Skip to content

Commit be95181

Browse files
committed
feat: add Autoplay and Song Radio features with Plex Pass support
1 parent c06476e commit be95181

15 files changed

Lines changed: 313 additions & 14 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66

77
## [Unreleased]
88

9+
### Added
10+
11+
- Added Autoplay - once the queue is down to its last song or so, similar songs get added automatically so playback never just runs dry. Off by default; toggle it from the icon next to Queue in the player bar, and set how many songs get added per top-up in Settings. Works the same for local playback and casting, and keeps topping the queue up on its own server-side even if nothing's around to do it locally (e.g. a phone's screen is locked)
12+
- Added Song Radio, Artist Radio, and Autoplay support for Plex (needs an active Plex Pass on the account) - previously Navidrome/Subsonic and Jellyfin only
13+
914
### Changed
1015

1116
- Changed the Home view's two "discover" shelves for more headroom and a clearer distinction between them - the library-based one now shows up to 20 albums instead of 15 on wide screens, and both got clearer titles ("Discover in your library" / "New artists to explore") so it's obvious which is drawn from albums you already own and which is brand-new artist suggestions

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,11 +79,11 @@ Jellyfin and Plex can both be selected as a server type at login. Neither has a
7979
| Play history / Stats page | ✅ | ✅ | ✅ |
8080
| Favorites (heart icon) | ✅ | ✅ | ❌ |
8181
| Personal 1-5 star rating | ✅ | ❌ | ✅ |
82-
| Song/Artist Radio and Autoplay | ✅ | ✅ | ❌ |
82+
| Song/Artist Radio and Autoplay | ✅ | ✅ | ✅ (needs an active Plex Pass) |
8383
| Create playlists | ✅ | ✅ | ✅ |
8484
| Trigger a library rescan from Settings | ✅ | ❌ | ❌ |
8585

86-
A couple of these are real gaps to close, not things Plex is actually incapable of - Plex does have its own sonic-similarity/"Play Similar" mechanism, it just hasn't been bridged into `getSimilarSongs2.view` yet (unlike Jellyfin's InstantMix, which has). Favorites is the one genuine dead end: Plex's core Media Server API has no separate boolean favorite at all - the heart-shaped "Love" seen in Plexamp/mobile is backed by Plex Pass cloud sync (a different API this bridge doesn't talk to), and personal ratings already cover the "mark this" use case for Plex instead.
86+
Song/Artist Radio and Autoplay on Plex are bridged onto Plex's own Sonic Analysis feature (`/library/metadata/{id}/nearest`, the same thing "Play Similar Tracks" uses in every official Plex client) - but Sonic Analysis itself is a Plex Pass-gated feature server-side, confirmed against Plex's own support docs. Without an active Plex Pass, the buttons and the Autoplay toggle are still there, they just quietly return nothing instead of a real mix. Favorites is the one genuine dead end: Plex's core Media Server API has no separate boolean favorite at all - the heart-shaped "Love" seen in Plexamp/mobile is backed by a different, Plex Pass cloud sync API this bridge doesn't talk to, and personal ratings already cover the "mark this" use case for Plex instead.
8787

8888
### Remote Control (Electron) and the mobile web UI
8989

‎connect/media/plex.py‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,62 @@ def get_track(self, track_id: str) -> Track:
241241
album=item.get("parentTitle", ""),
242242
)
243243

244+
# /nearest + limit/maxDistance matches python-plexapi's own
245+
# Audio.sonicallySimilar() (the library every other unofficial Plex
246+
# client's equivalent feature is built on) — see
247+
# https://python-plexapi.readthedocs.io/en/latest/modules/audio.html.
248+
# Confirmed live against a real server (2026-08-20): the endpoint is
249+
# real (403, not 404), and per Plex's own support article
250+
# (https://support.plex.tv/articles/sonic-analysis-music/), "Sonic
251+
# analysis for music is a premium feature and requires an active Plex
252+
# Pass subscription for the Server admin account" — exactly the 403 a
253+
# non-Pass test account got back. The server itself having
254+
# musicAnalysis enabled isn't enough on its own without that
255+
# subscription. The success-path *response shape* was never actually
256+
# seen against a Pass-holding account, but there's no real reason to
257+
# expect it differs from the MediaContainer.Metadata[] list every other
258+
# Plex track-listing endpoint already confirmed uses (search, album/
259+
# playlist children — see media/plex_bridge.py's _map_song()) — Plex is
260+
# consistent about that shape everywhere else, so this reuses
261+
# get_track()'s own field mapping above rather than inventing a second
262+
# one. Worth a quick sanity check the first time this actually runs
263+
# against a Pass account, but not blocked on it.
264+
#
265+
# 403 specifically returns [] rather than raising — routes/stream.py's
266+
# _maybe_autoplay_topup() would otherwise log a real warning on every
267+
# single top-up attempt for every non-Pass Plex account, forever, for a
268+
# permanent/expected condition rather than a transient failure worth
269+
# flagging. Anything else (network error, malformed response, a genuine
270+
# server problem) still raises, same as get_track()/get_stream_url()
271+
# above — that caller's own except Exception already logs those.
272+
def get_similar_songs2(self, seed_id: str, count: int = 10) -> list[Track]:
273+
try:
274+
data = self._get(
275+
f"/library/metadata/{quote(str(seed_id), safe='')}/nearest",
276+
excludeFields="summary",
277+
limit=count,
278+
maxDistance=0.25,
279+
)
280+
except httpx.HTTPStatusError as e:
281+
if e.response.status_code == 403:
282+
logger.debug(
283+
f"[Plex] Sonic Analysis unavailable (no Plex Pass?) for {seed_id}: {e}"
284+
)
285+
return []
286+
raise
287+
items = data.get("MediaContainer", {}).get("Metadata", [])
288+
return [
289+
Track(
290+
id=str(item["ratingKey"]),
291+
title=item.get("title", "Unknown"),
292+
artist=item.get("grandparentTitle", "Unknown"),
293+
duration=int((item.get("duration") or 0) / 1000),
294+
cover_art_id=str(item.get("parentRatingKey") or item["ratingKey"]),
295+
album=item.get("parentTitle", ""),
296+
)
297+
for item in items
298+
]
299+
244300
def get_stream_url(self, track_id: str) -> str:
245301
# Direct play, not the universal transcode endpoint — PLEX_PLAN.md
246302
# calls for implementing this first and treating transcoding as a

‎connect/media/plex_bridge.py‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,18 @@
3434
Revisit only if /library/sections/{id}/genre turns out to carry real
3535
per-genre counts cheaply (PLEX_PLAN.md flagged this as worth checking,
3636
not yet done).
37+
- getSimilarSongs2.view (Song/Artist Radio, and Autoplay's frontend-side
38+
top-up — see stores/playback.ts's maybeAutoplay()) maps onto Plex's own
39+
Sonic Analysis (`/library/metadata/{id}/nearest`) — see
40+
get_similar_songs2()'s own comment and PlexClient.get_similar_songs2()'s
41+
(media/plex.py) identical one for the full story: confirmed live
42+
(2026-08-20) that the endpoint is real, but Sonic Analysis itself is a
43+
Plex Pass-gated feature server-side (confirmed against Plex's own
44+
support docs), not something this bridge can work around — a
45+
non-Pass account gets a 403, translated here into an empty result
46+
rather than an error. services/capabilities.ts's songRadio is true for
47+
Plex accordingly, same as Jellyfin, even though it silently does
48+
nothing for a listener without Plex Pass.
3749
3850
Field names below are this module's best understanding of Plex's
3951
Metadata/MediaContainer JSON shape, not yet fully confirmed against a live
@@ -291,6 +303,45 @@ async def get_song(params: dict, media: PlexClient) -> dict:
291303
return {"song": _map_song(items[0])}
292304

293305

306+
async def get_similar_songs2(params: dict, media: PlexClient) -> dict:
307+
"""Song/Artist Radio + Autoplay's frontend-facing counterpart to
308+
PlexClient.get_similar_songs2() (media/plex.py) — same endpoint, same
309+
Plex-Pass caveat and unverified-response-shape note, see that method's
310+
own comment for the full explanation. Duplicated rather than shared
311+
because that one's synchronous (for connect's own internal Autoplay
312+
fallback, see routes/stream.py) and this one's async (this module's
313+
handlers all are, see _HANDLERS below) — same split as get_song() above
314+
vs. PlexClient.get_track().
315+
316+
Unlike that one, a 403 here surfaces as `plexPassRequired: true` in the
317+
response rather than silently coming back empty — this is the path an
318+
actual listener's own request takes (a Song/Artist Radio click, or
319+
stores/playback.ts's maybeAutoplay() running for a client that's
320+
actually online), so there's someone to actually tell. PlexClient's own
321+
version has no equivalent because it's connect's own background
322+
fallback for when nobody's around to tell in the first place (see
323+
routes/stream.py's _maybe_autoplay_topup())."""
324+
item_id = params.get("id", "")
325+
if not item_id:
326+
raise ValueError("getSimilarSongs2.view requires id")
327+
count = params.get("count", "50")
328+
try:
329+
data = await _px_get(
330+
media,
331+
f"/library/metadata/{_quote_id(item_id)}/nearest",
332+
excludeFields="summary",
333+
limit=count,
334+
maxDistance="0.25",
335+
)
336+
except httpx.HTTPStatusError as e:
337+
if e.response.status_code == 403:
338+
logger.debug(f"[Plex] Sonic Analysis unavailable (no Plex Pass?) for {item_id}: {e}")
339+
return {"similarSongs2": {"song": [], "plexPassRequired": True}}
340+
raise
341+
songs = data.get("MediaContainer", {}).get("Metadata", [])
342+
return {"similarSongs2": {"song": _map_all(_map_song, songs)}}
343+
344+
294345
async def get_artists(_params: dict, media: PlexClient) -> dict:
295346
section = await _music_section(media)
296347
data = await _px_get(media, f"/library/sections/{section}/all", type="8", sort="titleSort")
@@ -592,6 +643,7 @@ async def delete_playlist(params: dict, media: PlexClient) -> dict:
592643
"getAlbumList2.view": get_album_list2,
593644
"getAlbum.view": get_album,
594645
"getSong.view": get_song,
646+
"getSimilarSongs2.view": get_similar_songs2,
595647
"getArtists.view": get_artists,
596648
"getArtist.view": get_artist,
597649
"search3.view": search3,

‎connect/tests/test_plex.py‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,57 @@ def fake_get(url, **kwargs):
101101
_client().get_track("missing")
102102

103103

104+
# ── get_similar_songs2 (Sonic Analysis, Plex Pass-gated) ─────────────────────
105+
# See this method's own comment (media/plex.py) for the full story — confirmed
106+
# live (2026-08-20) that a non-Plex-Pass account gets a real 403 here.
107+
108+
109+
def test_get_similar_songs2_parses_items(monkeypatch):
110+
item = {
111+
"ratingKey": "9002",
112+
"title": "Similar Song",
113+
"grandparentTitle": "Artist B",
114+
"parentTitle": "Another Album",
115+
"parentRatingKey": "2002",
116+
"duration": 200_000,
117+
}
118+
119+
def fake_get(url, headers=None, params=None, timeout=None):
120+
assert url.endswith("/library/metadata/9001/nearest")
121+
assert params["limit"] == 10
122+
return httpx.Response(
123+
200,
124+
json={"MediaContainer": {"Metadata": [item]}},
125+
request=httpx.Request("GET", url),
126+
)
127+
128+
monkeypatch.setattr(httpx, "get", fake_get)
129+
songs = _client().get_similar_songs2("9001", count=10)
130+
assert len(songs) == 1
131+
assert songs[0].id == "9002"
132+
assert songs[0].artist == "Artist B"
133+
assert songs[0].duration == 200
134+
135+
136+
def test_get_similar_songs2_returns_empty_without_plex_pass(monkeypatch):
137+
def fake_get(url, **kwargs):
138+
request = httpx.Request("GET", url)
139+
return httpx.Response(403, request=request)
140+
141+
monkeypatch.setattr(httpx, "get", fake_get)
142+
assert _client().get_similar_songs2("9001") == []
143+
144+
145+
def test_get_similar_songs2_reraises_other_errors(monkeypatch):
146+
def fake_get(url, **kwargs):
147+
request = httpx.Request("GET", url)
148+
return httpx.Response(500, request=request)
149+
150+
monkeypatch.setattr(httpx, "get", fake_get)
151+
with pytest.raises(httpx.HTTPStatusError):
152+
_client().get_similar_songs2("9001")
153+
154+
104155
# ── get_stream_url resolves Media.Part.key ───────────────────────────────────
105156

106157

‎connect/tests/test_plex_bridge.py‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from unittest.mock import AsyncMock, MagicMock
44

5+
import httpx
56
import pytest
67

78
from core.session import DEFAULT_SESSION_ID, SessionState
@@ -209,6 +210,61 @@ def test_get_album_list2_returns_mapped_albums(client, plex_session, monkeypatch
209210
assert sum(1 for _method, url, _params in calls if url.endswith("/library/sections")) == 1
210211

211212

213+
def test_get_similar_songs2_returns_mapped_songs(client, plex_session, monkeypatch):
214+
fake_client, calls = _fake_px_client(
215+
{
216+
"/library/metadata/9001/nearest": {
217+
"MediaContainer": {
218+
"Metadata": [
219+
{
220+
"ratingKey": "9002",
221+
"title": "Similar Song",
222+
"grandparentTitle": "Artist B",
223+
"parentTitle": "Album B",
224+
"duration": 200_000,
225+
}
226+
]
227+
}
228+
},
229+
}
230+
)
231+
monkeypatch.setattr(plex_bridge, "_get_client", lambda: fake_client)
232+
233+
r = client.get("/rest/getSimilarSongs2.view", params={"id": "9001", "count": "10"})
234+
assert r.status_code == 200
235+
body = r.json()["subsonic-response"]
236+
assert body["status"] == "ok"
237+
songs = body["similarSongs2"]["song"]
238+
assert len(songs) == 1
239+
assert songs[0]["id"] == "9002"
240+
assert songs[0]["artist"] == "Artist B"
241+
assert "plexPassRequired" not in body["similarSongs2"]
242+
assert calls[0][2]["limit"] == "10"
243+
244+
245+
def test_get_similar_songs2_flags_plex_pass_required_on_403(client, plex_session, monkeypatch):
246+
"""Regression for a real live-server finding (2026-08-20): Plex's Sonic
247+
Analysis feature this endpoint bridges onto is Plex Pass-gated, and a
248+
non-Pass account gets a clean 403 back — surfaced to the frontend as
249+
plexPassRequired rather than a thrown error, so stores/playback.ts can
250+
tell the listener why instead of that just silently doing nothing (Song/
251+
Artist Radio) or logging a warning forever (Autoplay)."""
252+
253+
async def fake_request(method, url, headers=None, params=None):
254+
return httpx.Response(403, request=httpx.Request(method, url))
255+
256+
fake_client = MagicMock()
257+
fake_client.request = fake_request
258+
monkeypatch.setattr(plex_bridge, "_get_client", lambda: fake_client)
259+
260+
r = client.get("/rest/getSimilarSongs2.view", params={"id": "9001"})
261+
assert r.status_code == 200
262+
body = r.json()["subsonic-response"]
263+
assert body["status"] == "ok"
264+
assert body["similarSongs2"]["song"] == []
265+
assert body["similarSongs2"]["plexPassRequired"] is True
266+
267+
212268
def test_get_artist_derives_counts_instead_of_trusting_summary_fields(
213269
client, plex_session, monkeypatch
214270
):

‎src/renderer/src/i18n/locales/de.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ export default {
113113
songRadioError: 'Song-Radio konnte nicht gestartet werden.',
114114
artistRadio: 'Artist-Radio',
115115
artistRadioError: 'Artist-Radio konnte nicht gestartet werden.',
116+
plexPassRequired:
117+
'Dafür braucht es ein aktives Plex-Pass-Abo - Plex sperrt die eigene Sonic-Analysis-Funktion (Song-/Artist-Radio, Autoplay) selbst dahinter, das kann Beacon nicht umgehen.',
116118
viewOnService: 'Auf {service} ansehen',
117119
selected: 'ausgewählt',
118120
clearSelection: 'Auswahl aufheben',

‎src/renderer/src/i18n/locales/en.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@ export default {
115115
songRadioError: 'Could not start Song Radio.',
116116
artistRadio: 'Artist Radio',
117117
artistRadioError: 'Could not start Artist Radio.',
118+
plexPassRequired:
119+
"This needs an active Plex Pass subscription - it's what Plex's own Sonic Analysis feature (Song/Artist Radio, Autoplay) is gated behind on their side, not something Beacon can work around.",
118120
viewOnService: 'View on {service}',
119121
selected: 'selected',
120122
clearSelection: 'Clear selection',

‎src/renderer/src/i18n/locales/es.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ export default {
116116
songRadioError: 'No se pudo iniciar la radio de la canción.',
117117
artistRadio: 'Radio del artista',
118118
artistRadioError: 'No se pudo iniciar la radio del artista.',
119+
plexPassRequired:
120+
'Esto necesita una suscripción activa a Plex Pass - Plex bloquea su propia función Sonic Analysis (Radio de canción/artista, Autoplay) detrás de eso, Beacon no puede evitarlo.',
119121
viewOnService: 'Ver en {service}',
120122
selected: 'seleccionado(s)',
121123
clearSelection: 'Borrar selección',

‎src/renderer/src/i18n/locales/fr.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@ export default {
115115
songRadioError: 'Impossible de démarrer la radio du titre.',
116116
artistRadio: "Radio de l'artiste",
117117
artistRadioError: "Impossible de démarrer la radio de l'artiste.",
118+
plexPassRequired:
119+
"Ceci nécessite un abonnement Plex Pass actif - c'est Plex lui-même qui verrouille sa propre fonction Sonic Analysis (Radio par morceau/artiste, Autoplay) derrière cet abonnement, Beacon ne peut pas contourner ça.",
118120
viewOnService: 'Voir sur {service}',
119121
selected: 'sélectionné(s)',
120122
clearSelection: 'Effacer la sélection',

0 commit comments

Comments
 (0)