fix(playlist-detail): route plex tracks through v2_plex_play_track

Plex tracks added to a Qobuz playlist weren't playable:

  * Clicking the row hit onTrackPlay → playTrack with no source arg →
    backend treated the id as a Qobuz track id and rejected it with
    "invalid track id for remote handoff: -1000017964" (the negative
    plexDisplayId UI-only offset was the id we sent).
  * Auto-advancing from the previous (local) track mapped the plex
    DisplayTrack's negative id through Math.abs into the local-track
    queue, which then looked 1_000_017_964 up in local_tracks and
    errored with "Track not found".

Changes:

1. plexTrackToDisplay now uses Number(ratingKey) as id when the
   ratingKey parses as a positive integer, matching the shape Plex
   backend / LocalLibraryView already use (PlexCachedTrack.id =
   playback_track_id(rating_key) = the numeric rating_key for the
   common case). The negative-offset hash stays as the fallback for
   non-numeric rating keys — those still need UI-unique ids but are
   rare enough that no-play is an acceptable degradation.
2. isLocal stays true so the Qobuz context-builder and blacklist
   filter keep their existing semantics; the new isPlex flag drives
   routing at the dispatch sites.
3. PlaylistDetailView.Props gains onPlexTrackPlay (track) → void.
   handleTrackClick checks track.isPlex first and dispatches through
   it; handleTrackPlayNext / handleTrackPlayLater no-op on plex for
   now (backend queue doesn't understand plex yet).
4. removeTrackFromPlaylist checks track.isPlex first and calls
   v2_playlist_remove_plex_track with ratingKey: String(track.id) so
   the "Remove from playlist" menu item works on plex rows too.
5. +page.svelte gets handleDisplayPlexTrackPlay — mirrors
   handleLocalTrackPlay's plex branch (playTrack with source='plex',
   isLocal:false). Wired via onPlexTrackPlay on the PlaylistDetailView
   render.
6. buildQueueTracks filters plex out entirely. The local-track queue
   path in the backend looks ids up in local_tracks; plex ratingKeys
   aren't there and the mismatch was the source of the auto-advance
   "Track not found" toast. Plex tracks keep their direct-click
   playback; gapless auto-advance across plex will require backend
   queue awareness (deferred).

Known limitation carried forward: Play Next / Play Later / auto-
advance across plex tracks are no-ops until the backend queue learns
to cross-reference plex_cache_tracks. Direct click on a plex row
plays correctly.

cc
This commit is contained in:
vicrodh committed 2026-04-21 15:22:41 -06:00
1 parent f9a7891802
commit f9b7290e90
2 files changed
+89 -8

No files matched your search

@@ -217,6 +217,14 @@
onLocalTrackPlay?: (track: LocalLibraryTrack) => void;
onLocalTrackPlayNext?: (track: LocalLibraryTrack) => void;
onLocalTrackPlayLater?: (track: LocalLibraryTrack) => void;
/**
* Play a Plex track from the playlist. Fired when the user clicks
* a row whose isPlex flag is set. The track argument carries the
* DisplayTrack shape (including title/artist/album/artwork) plus
* id = Number(ratingKey) — the +page.svelte handler routes to
* v2_plex_play_track via playTrack with source='plex'.
*/
onPlexTrackPlay?: (track: DisplayTrack) => void;
onSetLocalQueue?: (trackIds: number[]) => void;
onPlaylistCountUpdate?: (playlistId: number, qobuzCount: number, localCount: number) => void;
onPlaylistUpdated?: () => void;
@@ -249,6 +257,7 @@
onLocalTrackPlay,
onLocalTrackPlayNext,
onLocalTrackPlayLater,
onPlexTrackPlay,
onSetLocalQueue,
onPlaylistCountUpdate,
onPlaylistUpdated,
@@ -1549,10 +1558,21 @@
return `${base}${path}${separator}X-Plex-Token=${encodeURIComponent(token)}`;
}
// Convert plex tracks to DisplayTrack format
// Convert plex tracks to DisplayTrack format.
// id: parse the ratingKey to a Number when possible — this matches
// the LocalLibraryView.PlexCachedTrack.id shape (playback_track_id
// on the backend) and is what v2_plex_play_track expects as its
// ratingKey arg (via String(track.id)). The negative-offset fallback
// is only used for non-numeric rating keys, which are rare and only
// need frontend uniqueness.
function plexTrackToDisplay(track: PlaylistPlexTrack, index: number): DisplayTrack {
const numericId = Number(track.ratingKey);
const idForUi =
Number.isFinite(numericId) && numericId > 0
? numericId
: plexDisplayId(track.ratingKey);
return {
id: plexDisplayId(track.ratingKey),
id: idForUi,
number: index + 1,
title: track.title,
artist: track.artist,
@@ -1563,8 +1583,8 @@
hires: (track.bit_depth && track.bit_depth >= 24) || track.sample_rate > 48000,
bitDepth: track.bit_depth,
samplingRate: track.sample_rate / 1000,
isLocal: true,
isPlex: true,
isLocal: true, // kept for context-builder / blacklist filters
isPlex: true, // drives plex-specific playback routing
artworkPath: track.artwork_path,
};
}
@@ -1783,8 +1803,16 @@
}
function buildQueueTracks(tracks: DisplayTrack[]) {
// Filter out blacklisted artists before building queue
// Filter out blacklisted artists AND plex tracks from the queue.
// Plex exclusion is a short-term safety: the local-track queue path
// in the backend looks every id up in local_tracks, and Plex rating
// keys don't live there — they'd crash auto-advance with
// "Track not found". Plex tracks in a playlist still play when
// clicked directly (handleTrackClick dispatches via isPlex below);
// gapless auto-advance to/from plex is deferred until the backend
// queue learns to cross-reference plex_cache_tracks.
const filteredTracks = tracks.filter(trk => {
if (trk.isPlex) return false;
if (trk.isLocal) return true; // Local tracks are never blacklisted
if (!trk.artistId) return true; // No artist ID, can't check blacklist
return !isArtistBlacklisted(trk.artistId);
@@ -1830,7 +1858,7 @@
.map(trk => trk.id);
const contextIndex = trackIds.indexOf(track.id);
if (contextIndex >= 0 && trackIds.length > 0) {
await setPlaybackContext(
'playlist',
@@ -1851,7 +1879,12 @@
console.error('Failed to set queue:', err);
}
if (track.isLocal && track.localTrackId) {
if (track.isPlex) {
// Plex tracks go through the dedicated plex handler — parent
// routes to playTrack with source='plex', which hits
// v2_plex_play_track(ratingKey=String(track.id)).
onPlexTrackPlay?.(track);
} else if (track.isLocal && track.localTrackId) {
// Handle local track play
const localTrack = localTracksMap.get(track.localTrackId);
if (localTrack && onLocalTrackPlay) {
@@ -1863,6 +1896,11 @@
}
function handleTrackPlayNext(track: DisplayTrack) {
// Plex Play Next / Later aren't wired on the backend queue yet —
// the queue's local-track path looks ids up in local_tracks, which
// doesn't cover plex rating keys. Drop silently so the menu item
// is effectively a no-op for plex until queue support lands.
if (track.isPlex) return;
if (track.isLocal && track.localTrackId) {
const localTrack = localTracksMap.get(track.localTrackId);
if (localTrack && onLocalTrackPlayNext) {
@@ -1874,6 +1912,7 @@
}
function handleTrackPlayLater(track: DisplayTrack) {
if (track.isPlex) return;
if (track.isLocal && track.localTrackId) {
const localTrack = localTracksMap.get(track.localTrackId);
if (localTrack && onLocalTrackPlayLater) {
@@ -1886,7 +1925,16 @@
async function removeTrackFromPlaylist(track: DisplayTrack) {
try {
if (track.isLocal && track.localTrackId) {
if (track.isPlex) {
// Plex tracks live in playlist_plex_tracks, keyed by ratingKey
// (string). track.id is Number(ratingKey) — stringify it back.
await invoke('v2_playlist_remove_plex_track', {
playlistId,
ratingKey: String(track.id),
});
await Promise.all([loadLocalTracks(), loadPlexTracks()]);
notifyParentOfCounts();
} else if (track.isLocal && track.localTrackId) {
// Remove local track
await invoke('v2_playlist_remove_local_track', { playlistId, localTrackId: track.localTrackId });
await Promise.all([loadLocalTracks(), loadPlexTracks()]);
+33
View File
@@ -3683,6 +3683,38 @@
});
}
/**
* Play a Plex-sourced DisplayTrack. Mirrors handleLocalTrackPlay's
* Plex branch: routes through playTrack with source='plex', which
* hits v2_plex_play_track(ratingKey=String(track.id)). track.id is
* already Number(ratingKey) by the time this callback fires — set
* in PlaylistDetailView.plexTrackToDisplay.
*/
function handleDisplayPlexTrackPlay(track: DisplayTrack): void {
console.log('Playing plex display track:', track.id, track.title);
const quality = track.bitDepth && track.samplingRate
? `${track.bitDepth}bit/${track.samplingRate}kHz`
: track.hires
? 'Hi-Res'
: '-';
playTrack(
{
id: track.id,
title: track.title,
artist: track.artist || 'Unknown Artist',
album: track.album || 'Playlist',
artwork: track.albumArt || '',
duration: track.durationSeconds,
quality,
bitDepth: track.bitDepth,
samplingRate: track.samplingRate,
isLocal: false,
source: 'plex',
},
{ isLocal: false, source: 'plex' },
);
}
/**
* Helper: Create context and play display track
*/
@@ -5926,6 +5958,7 @@
onLocalTrackPlay={handleLocalTrackPlay}
onLocalTrackPlayNext={queueLocalTrackNext}
onLocalTrackPlayLater={queueLocalTrackLater}
onPlexTrackPlay={handleDisplayPlexTrackPlay}
onSetLocalQueue={handleSetLocalQueue}
onPlaylistCountUpdate={(playlistId, qobuzCount, localCount) =>
sidebarRef?.updatePlaylistCounts(playlistId, qobuzCount, localCount)