|
34 | 34 | Revisit only if /library/sections/{id}/genre turns out to carry real |
35 | 35 | per-genre counts cheaply (PLEX_PLAN.md flagged this as worth checking, |
36 | 36 | 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. |
37 | 49 |
|
38 | 50 | Field names below are this module's best understanding of Plex's |
39 | 51 | Metadata/MediaContainer JSON shape, not yet fully confirmed against a live |
@@ -291,6 +303,45 @@ async def get_song(params: dict, media: PlexClient) -> dict: |
291 | 303 | return {"song": _map_song(items[0])} |
292 | 304 |
|
293 | 305 |
|
| 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 | + |
294 | 345 | async def get_artists(_params: dict, media: PlexClient) -> dict: |
295 | 346 | section = await _music_section(media) |
296 | 347 | 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: |
592 | 643 | "getAlbumList2.view": get_album_list2, |
593 | 644 | "getAlbum.view": get_album, |
594 | 645 | "getSong.view": get_song, |
| 646 | + "getSimilarSongs2.view": get_similar_songs2, |
595 | 647 | "getArtists.view": get_artists, |
596 | 648 | "getArtist.view": get_artist, |
597 | 649 | "search3.view": search3, |
|
0 commit comments