Updating this website with what's playing in Plex

11/02/2024

I've had this idea kicking around in my head for a while, and when I looked at Luke's latest homepage, and how much cool stuff he has going on, I had to make it.

Plex doesn't really have much in the way of an API that it brags about, but it does have a web UI, and I do have network inspector. The structure is a little something like this:

  • Websocket client to connect to Plex and listen for new notifications
  • A couple of API endpoints in Craft to push data to

Simple enough? Fortunately my Plex server runs on a NUC in my office, so I set the client up as a little mini project to run with docker compose. This should keep API requests quick, and I hope it means the websocket client can be a bit more stable. I've only ever used websockets for a couple of things, and that was when I was in (relative) control of the client and the server, so I'm not sure how this looks in the long term.

I've implemented this in Ruby because that's my go-to language for pretty much any code-sketching like this. Performance is obviously always a concern when you're using Ruby, but it idles at 0% CPU on my NUC, and only jumps to like 3% when you ask it to do things. I can live with that. I also have docker compose configured to restart unless I stop it, so if it does crash I'll probably never even notice!

I'm reusing the GraphQL authentication token for requests to Craft, because I'm lazy. I also learned that when you check a token, it throws an exception if the token is invalid (which I usually hate as a pattern but it saved me a job here).

There's not much here that I want to add in terms of functionality. I don't even think I need the scrobbling (don't sue me, last.fm - I couldn't be bothered to think of a better name!) because Plex's stats are pretty good (even though you can't download them easily - more network snooping in my future, I feel).

Also in trying to quickly check and see if I can get stats more easily, I've just found that Plex supports webhooks (since at least 2019! How did I miss that) which renders all of this redundant anyway. Pretty annoying but will hugely simplify a rewrite so I'm not too upset about it! Still, enjoy some extremely redundant Ruby:

require "dotenv/load"
require 'async'
require 'async/http/endpoint'
require 'async/websocket/client'
require 'faraday'
require 'json'
require 'redis'

DOMAIN = ENV["PLEX"]
TOKENPARAM = "X-Plex-Token"
TOKEN = ENV["PLEX_TOKEN"]

WS_URL = "ws://#{DOMAIN}/:/websockets/notifications?#{TOKENPARAM}=#{TOKEN}"
# because this is async, I want Redis to store the 
# persistent state like whether scrobbling is blocked
# and what the last-posted state was
$redis = Redis.new(url: "redis://redis:6379/5")

# shorthand for logging to stdout because lazy
def log(category, message = nil)
  parts = [Time.now, category, message].compact
  $stdout.puts parts.join(' -- ')
end

# so that I can catch ctrl-c, everything gets wrapped in exception capture
# I don't usually do this but all the errors whilst I was developing 
# were annoying me
begin
  Async do |task|
    endpoint = Async::HTTP::Endpoint.parse(WS_URL, alpn_protocols: Async::HTTP::Protocol::HTTP11.names)

    Async::WebSocket::Client.connect(endpoint) do |connection|
      while message = connection.read
        parsed = message.parse
        # Plex websockets are extremely chatty. Fortunately the ones I
        # want are easy to target
        next unless parsed[:NotificationContainer][:type] == "playing"
        # Each notification also seems to support multiple payloads
        # but I seem to only need the first one for what I'm doing
        container = parsed[:NotificationContainer][:PlaySessionStateNotification]&.first
        # if you use &., always check for nil?
        next if container.nil?

        # the Plex API path for the currently-playing thing
        playing = container[:key]
        # playing/paused/stopped, as far as I can tell
        state = container[:state]
        # how far in we are - the current state seems to fire every 
        # 15 seconds or so
        offset = container[:viewOffset]

        if offset < 1000 # new track, we can scrobble
          log("SCROBBLE CLEAR")
          $redis.del("block-scrobble")
        end

        # hit the Plex API to find out what's playing
        url = "http://#{DOMAIN}#{playing}"
        data = Faraday.get(url, {TOKENPARAM => TOKEN}, Accept: "application/json")
        meta = JSON.parse(data.body)
        library = meta["MediaContainer"]["librarySectionTitle"]
        # I only care about music
        next unless library == "Music"

        track = meta["MediaContainer"]["Metadata"].first
        # I feel like the last.fm app used to just scrobble after 20
        # seconds, but I wanted it to happen only after 30% of the 
        # track is played
        acceptable_scrobble_offset = track['duration'] * 0.3
        next if track.nil?

        payload = {
          state: state,
          guid: track["guid"],
          track: track["title"],
          artist: track["originalTitle"] || track["grandparentTitle"],
          album: track["parentTitle"],
        }

        # we store the last payload in Redis when we send it so we 
        # can see if things have changed. Craft is slow and I don't 
        # want to be hammering my site when I listen to music
        last = JSON.parse($redis.get("last-payload") || '{}')
        # when we scrobble a track, we block future scrobbling until 
        # we're 1 second into the next track. This stops pauses 
        # from re-triggering scrobbling of a track that already happened
        #
        # I'm honestly not too sure about this logic. Plex seems to 
        # send a notification within 1 second of tracks changing 
        # so it feels safe, but I still don't trust it.
        block_scrobble = $redis.get("block-scrobble") == "1"

        # either the track changed or the play-state changed
        if last["guid"] != payload[:guid] || last["state"] != payload[:state]
          $redis.set('last-payload', JSON.dump(payload))
          response = Faraday.post(
            "#{ENV['CRAFT']}actions/jasper/plex/update",
            payload.to_json,
            "X-AUTH-TOKEN": ENV["CRAFT_TOKEN"],
            "Content-Type": "application/json"
          )

          log("CRAFT PUSH",response.body)
        end

        # we're playing, we haven't already scrobbled, and we've listened to enough!
        # I struggled with this logic a lot and it'll probably change but for now it makes sense
        if payload[:state] == "playing" && !block_scrobble && offset >= acceptable_scrobble_offset
          $redis.set("block-scrobble", "1")
          response = Faraday.post(
            "#{ENV['CRAFT']}actions/jasper/plex/scrobble",
            payload.to_json,
            "X-AUTH-TOKEN": ENV["CRAFT_TOKEN"],
            "Content-Type": "application/json"
          )

          log("SCROBBLE", JSON.dump(payload))
        end
      end
    end
  end
rescue Interrupt
  $stdout.puts "Exiting..."
end

I've now killed the websocket client and updated my custom Craft API endpoint to support the Plex webhook directly. Don't feel stupid at all. I still maintain that was a cool approach.

It's worth noting for anyone who doesn't always work this way, developing against webhooks can be a bit annoying if you don't have something to spoof those payloads with. For that reason, I'll always put a bit of request inspection in my code, trigger a webhook, then set up RapidAPI (or Postman if you use that. When I started using Rapid it was called Paw and I didn't even hear of Postman until recently) in the same way, so I can replay it over and over easily. Much nicer than triggering webhooks as you can actually see what you're doing without inspecting to a file or something.

<?php

namespace modules\controllers;

use Craft;
use craft\web\Controller;
use craft\elements\GlobalSet;

class PlexController extends Controller
{
    protected array|bool|int $allowAnonymous = true;

    public function beforeAction($actionId):bool
    {
        # I'm using an API token so I don't really care
        # about CSRF. I feel like I only even _think_ about
        # CSRF when it's being annoying, but that probably
        # just means it works really well
        $this->enableCsrfValidation = false;

        return parent::beforeAction($actionId);
    }

    public function actionUpdate()
    {
        $request = Craft::$app->getRequest();
        $auth = $request->getQueryParam('X-TOKEN');
        $verified = Craft::$app->getGql()->getTokenByAccessToken($auth);

        # getting the access token throws an exception on failure
        # but I don't trust it
        if (!$verified) {
            return $this->asJson([
                'success' => false,
            ]);
        }

        $payloadData = $request->getBodyParam('payload');
        # I hate JSON as stdClass and I will fight you over this
        $payload = json_decode($payloadData, true);
        $meta = $payload['Metadata'];

        # I don't want to change my frontend just because webhook
        # events are named slightly differently to the websocket ones
        $playback = [
            'media.stop' => 'stopped',
            'media.resume' => 'playing',
            'media.play' => 'playing',
            'media.pause' => 'paused',
        ];

        # I only care about music, and the events that I've configured
        # I'll add scrobbling later if I can't programmatically get the Plex
        # stats I want
        if ($meta['librarySectionTitle'] !== 'Music' || !isset($playback[$payload['event']])) {
            return $this->asJson([
                'success' => false,
                'payload' => $payload,
            ]);
        }

        $newState = $playback[$payload['event']];

        $global = GlobalSet::find()->handle('nowplaying')->one();
        $persisted = false;
        # only update if the track or playback state have changed
        if ($global->plexGuid != $meta['guid'] || $global->playbackState != $newState) {
            # Plex's nomenclature is a bit opaque but `originalTitle` when it exists,
            # seems to contain the track's personnel if it's different from the album artist
            # but it's not always there. If it's `VOLA feat. Anders Fridén` then I want
            # that rather than just VOLA
            $artist = isset($meta['originalTitle']) ? $meta['originalTitle'] : $meta['grandparentTitle'];

            $global->setFieldValues([
                'artist' => $artist,
                'album' => $meta['parentTitle'],
                'track' => $meta['title'],
                'plexGuid' => $meta['guid'],
                'playbackState' => $newState,
            ]);

            Craft::$app->elements->saveElement($global);
            $persisted = true;
        }

        return $this->asJson([
            'success' => true,
            'payload' => $payload,
            'persisted' => $persisted,
        ]);
    }
}
In a game where you can play as a pangolin, how are you going to play as anything other than a... Clingy puppy is clingy