Physical Music Gallery

Overview

I like streaming music over Spotify or locally using Plex Media Server, but I miss the physicality of being able to leaf through a collection of albums. I'm not much of a vinyl record person, and I didn't want to build up a collection of CDs again and, besides, I wanted something that felt a bit magical when you used it. So I created the Physical Album Player:

How it works

Each album is a photo-glossy print of an album cover stuck to a 5x5" sheet of acrylic, with an NFC (RFID) sticker on the back:

When you pick an album and hold it up to the iPad, a hidden Raspberry Pi with an NFC hat detects and reads the NFC sticker, then instructs either Spotify or Plex to play that album on the iPad:

You could skip the iPad entirely and just run Spotify or Plex from the Raspberry Pi, but I wanted to be able to see the artwork, pick a track and interact with the album without any friction.

If you want a no-code version and you have a phone with an NFC reader (the latest iPhones do), you can simply hold your phone up to an NFC album and configure it to open the Spotify share link for that album, playing it straight from your phone.

Code

A Python script runs on the Raspberry Pi and scans for an NFC tag indefinitely. When it detects one it tries to match it to a known album, and if successful instructs a media player to start playing that album. You can play music you host locally on a Plex Media Server, or stream it from Spotify.

The NFC reader is a PN532-based NFC HAT connected to the Pi over UART. The HAT ships with a Python library (imported as pn532 below) — drop a local copy of it in a subfolder next to your script, then import it along with a few other libraries we'll need:


import json
import time
from datetime import datetime
import signal
import sys
import RPi.GPIO as GPIO
from pn532 import *
from plexapi.server import PlexServer
from plexapi.myplex import MyPlexAccount

Next, for each album you make, you need to track its NFC UID. Each NFC sticker has a unique identifier, so when the reader sees one that matches you know which album to play. I just define a JSON object inside the script and update it whenever I add an album, but you could keep it in a separate JSON file that's reloaded periodically if you'd rather.

You really only need the NFC UID and the text to search Spotify/Plex for, but I also store the album's name and artist so I can print them to the terminal. The result_index field tells the player which result to pick when several albums match the search string — usually the first one, but occasionally you need a later result when titles are similar.


    data = {
        "albums": [
            {
                "artist": "José González",
                "album_title": "Veneer",
                "search": "jose gonzalez veneer",
                "result_index": 0,
                "uid": b'\x04\xd8\xde\n\x00\x00\x01'
            },
            {
                "artist": "Cinematic Orchestra",
                "album_title": "Ma Fleur",
                "search": "Ma Fleur Cinematic Orchestra",
                "result_index": 0,
                "uid": b'\x04\xac\xd1\n\x00\x00\x01'
            }
        ]
    }

Then we define functions to initialize the NFC reader and connect to Plex. (The style.* references are just a small ANSI-colour helper for pretty terminal output.)


def initializePlex():
    print(f'{style.YELLOW}Connecting to Plex server... {style.RESET}', end='', flush=True)

    # Better to put these credentials in environment variables than to hard-code them
    account = MyPlexAccount("username here", "password here")
    plex = account.resource("Phenom").connect()  # returns a PlexServer instance

    print(f'{style.WHITE}[{style.GREEN}✓{style.WHITE}] {style.BLUE}{plex}{style.RESET}')

    client_name = "AlbumIpad"
    print(f'{style.YELLOW}Connecting to Plex client: {client_name}... {style.RESET}', end='', flush=True)
    client = plex.client(client_name)
    print(f'{style.WHITE}[{style.GREEN}✓{style.WHITE}] {style.BLUE}{client}{style.RESET}')

    return plex, client

def initializeNfcReader():
    print(f'{style.YELLOW}Connecting to NFC reader... {style.RESET}', end='', flush=True)
    pn532 = PN532_UART(debug=False, reset=20)
    ic, ver, rev, support = pn532.get_firmware_version()
    print(f'{style.WHITE}[{style.GREEN}✓{style.WHITE}] {style.BLUE}PN532 firmware v {ver}.{rev}{style.RESET}')

    # Configure PN532 to communicate with MiFare cards
    pn532.SAM_configuration()

    return pn532

Finally, the main loop listens for an NFC UID indefinitely. When it reads one, it first checks whether it's the same tag already sitting on the reader and ignores it if so (otherwise the album would restart over and over). If it's a new tag that matches an album in the JSON data, it searches for the album and tells the client to play it.


while True:

    nfcReader = initializeNfcReader()
    plexServer, plexClient = initializePlex()

    current_album_uid = None

    while True:
        try:
            # Check if an album is near the NFC reader
            last_read_uid = nfcReader.read_passive_target(timeout=0.1)

            # Loop again if no album is found
            if last_read_uid is None:
                if current_album_uid is not None:
                    print(f'{style.WHITE}No album detected{style.RESET}')
                current_album_uid = None
                continue

            # Only act when a different tag is placed on the reader
            if last_read_uid != current_album_uid:
                current_album_uid = last_read_uid
                matched = False
                for album in data.get("albums"):
                    if str(last_read_uid) == str(album["uid"]):
                        matched = True
                        print(f'{style.BLUE}{album["artist"]}{style.WHITE} - {style.CYAN}{album["album_title"]}{style.RESET}')
                        search_results = plexServer.search(album["search"])
                        search_result = search_results[album["result_index"]]
                        plexClient.playMedia(search_result)
                if not matched:
                    print(f'{style.WHITE}Unknown album: {style.YELLOW}{last_read_uid}{style.RESET}')

        except KeyboardInterrupt:
            print(f'{style.RED} Exiting{style.RESET}')
            GPIO.cleanup()
            sys.exit(0)

        except Exception as e:
            template = "An exception of type {0} occurred:\n{1!r}"
            print(template.format(type(e).__name__, e.args))
            GPIO.cleanup()
            raise

And there you have it! I've had a lot of fun discovering albums with my son.