Updating this website with what's playing in Plex
02/11/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,
]);
}
}