feat(playlist-modal): route Plex tracks through the dedicated add path

Frontend half of the fix. LocalLibraryView no longer gates Plex
tracks out of "Add to playlist" — the whole add pipeline now routes
by source:

  * source === 'plex' → v2_playlist_add_plex_track(playlistId,
    ratingKey, position). ratingKey comes from track.file_path, which
    is where mapPlexTrack stashes it at load time.
  * source in {user, qobuz_purchase, qobuz_download} →
    v2_playlist_add_local_track(playlistId, localTrackId, position)
    (unchanged).
  * source === qobuz (Qobuz catalog track) →
    v2_add_tracks_to_playlist (unchanged).

Changes:
  * uiStore: new playlistModalPlexRatingKeys slot + getter + passed
    through openPlaylistModal (4th arg) and closePlaylistModal.
  * PlaylistModal: new plexRatingKeys prop, isPlexTracks derived flag,
    and branches in handleAddToPlaylist / handleDirectAddToPlaylist /
    handleCreateAndAdd that emit v2_playlist_add_plex_track. Duplicate
    detection (which only covers Qobuz) is skipped for Plex adds.
    tracks_count update for newly-created playlists now treats Plex
    like local — 0, since the Qobuz side doesn't own those tracks.
  * LocalLibraryView: adds onTrackAddPlexToPlaylist /
    onBulkAddPlexToPlaylist callbacks. handleBulkAddToPlaylist and
    handleAlbumBulkAddToPlaylist split the selection by source and
    emit the right callback per subset. The per-row plex gate at
    line 3509 is replaced with source-aware routing.
  * +page.svelte: new openAddPlexToPlaylist helper + wired the two
    new callbacks into the LocalLibraryView render.

No confusion with Qobuz tracks: Plex rating keys never touch the
Qobuz playlist API, and local-track ids never reach the Plex
command. The two namespaces stay physically separate at every layer.

cc
This commit is contained in:
vicrodh committed 2026-04-21 11:38:25 -06:00
1 parent 753487582a
commit d5ff51aba0
4 files changed
+101 -20

No files matched your search

+42 -7
View File
@@ -36,6 +36,7 @@
onDelete?: (playlistId: number) => void;
isHidden?: boolean;
isLocalTracks?: boolean;
plexRatingKeys?: string[];
currentFolderId?: string | null;
}
@@ -50,9 +51,12 @@
onDelete,
isHidden = false,
isLocalTracks = false,
plexRatingKeys = [],
currentFolderId = null
}: Props = $props();
const isPlexTracks = $derived(plexRatingKeys.length > 0);
// Form state
let name = $state('');
let description = $state('');
@@ -400,13 +404,16 @@
}
async function handleAddToPlaylist() {
if (!selectedPlaylistId || trackIds.length === 0) {
const hasIds = trackIds.length > 0 || plexRatingKeys.length > 0;
if (!selectedPlaylistId || !hasIds) {
error = $t('playlist.selectPlaylist');
return;
}
// For pending playlists and local tracks add directly
if (selectedPlaylistId < 0 || isLocalTracks) {
// For pending playlists, local tracks, and Plex tracks add directly.
// Duplicate detection below only covers Qobuz tracks, so anything
// non-Qobuz bypasses it.
if (selectedPlaylistId < 0 || isLocalTracks || isPlexTracks) {
await handleDirectAddToPlaylist();
return;
}
@@ -442,7 +449,8 @@
}
async function handleDirectAddToPlaylist() {
if (!selectedPlaylistId || trackIds.length === 0) {
const hasIds = trackIds.length > 0 || plexRatingKeys.length > 0;
if (!selectedPlaylistId || !hasIds) {
error = $t('playlist.selectPlaylist');
return;
}
@@ -454,7 +462,7 @@
// Check if this is a pending playlist (negative ID)
if (selectedPlaylistId < 0) {
const pendingId = -selectedPlaylistId; // Convert back to positive
const qobuzTrackIds = isLocalTracks ? [] : trackIds;
const qobuzTrackIds = isLocalTracks || isPlexTracks ? [] : trackIds;
// For local tracks, fetch file paths instead of using IDs
let localTrackPaths: string[] = [];
@@ -471,6 +479,22 @@
qobuzTrackIds,
localTrackPaths
});
} else if (isPlexTracks) {
// Regular playlist with Plex tracks — route through the dedicated
// plex command which stores the rating key in playlist_plex_tracks.
// Never hits the Qobuz API.
const playlist = userPlaylists.find(p => p.id === selectedPlaylistId);
const qobuzCount = playlist?.tracks_count ?? 0;
const localCount = localTrackCounts.get(selectedPlaylistId!) ?? 0;
const startPosition = qobuzCount + localCount;
for (let i = 0; i < plexRatingKeys.length; i++) {
await invoke('v2_playlist_add_plex_track', {
playlistId: selectedPlaylistId,
ratingKey: plexRatingKeys[i],
position: startPosition + i
});
}
} else if (isLocalTracks) {
// Regular playlist with local tracks
// Get current total count (Qobuz + local) to append at correct position
@@ -596,7 +620,16 @@
});
// Then add tracks
if (trackIds.length > 0) {
if (isPlexTracks && plexRatingKeys.length > 0) {
// Plex tracks — dedicated command, never hits Qobuz API
for (let i = 0; i < plexRatingKeys.length; i++) {
await invoke('v2_playlist_add_plex_track', {
playlistId: newPlaylist.id,
ratingKey: plexRatingKeys[i],
position: i
});
}
} else if (trackIds.length > 0) {
if (isLocalTracks) {
// Add local tracks one by one
for (let i = 0; i < trackIds.length; i++) {
@@ -614,7 +647,9 @@
void logPlaylistAdd(trackIds, newPlaylist.id);
}
// Update tracks_count to reflect added tracks (API returns 0 at creation)
newPlaylist.tracks_count = isLocalTracks ? 0 : trackIds.length;
// Local and Plex tracks live outside the Qobuz playlist, so the Qobuz
// count stays at 0 — they're surfaced via localTrackCounts separately.
newPlaylist.tracks_count = isLocalTracks || isPlexTracks ? 0 : trackIds.length;
}
onSuccess?.(newPlaylist);
@@ -234,6 +234,8 @@
onTrackPlayLater?: (track: LocalTrack) => void;
onTrackAddToPlaylist?: (trackId: number) => void;
onBulkAddToPlaylist?: (trackIds: number[]) => void;
onTrackAddPlexToPlaylist?: (ratingKey: string) => void;
onBulkAddPlexToPlaylist?: (ratingKeys: string[]) => void;
onSetLocalQueue?: (trackIds: number[]) => void;
activeTrackId?: number | null;
isPlaybackActive?: boolean;
@@ -249,6 +251,8 @@
onTrackPlayLater,
onTrackAddToPlaylist,
onBulkAddToPlaylist,
onTrackAddPlexToPlaylist,
onBulkAddPlexToPlaylist,
onSetLocalQueue,
activeTrackId = null,
isPlaybackActive = false
@@ -575,12 +579,17 @@
}
function handleBulkAddToPlaylist() {
// Plex tracks can't be added to playlists (matches per-row guard at line ~3306)
const ids = selectedLocalTracks()
.filter(trk => trk.source !== 'plex')
.map(trk => trk.id);
if (ids.length === 0) return;
onBulkAddToPlaylist?.(ids);
// Split selection by source: local_tracks-backed rows (user / qobuz
// purchases / qobuz downloads) route through v2_playlist_add_local_track;
// Plex rows route through v2_playlist_add_plex_track with their
// rating key (stored in file_path at map time). Each path stays within
// its own namespace so nothing bleeds into the Qobuz playlist API.
const selected = selectedLocalTracks();
const localIds = selected.filter(trk => trk.source !== 'plex').map(trk => trk.id);
const plexRatingKeys = selected.filter(trk => trk.source === 'plex').map(trk => trk.file_path);
if (localIds.length === 0 && plexRatingKeys.length === 0) return;
if (localIds.length > 0) onBulkAddToPlaylist?.(localIds);
if (plexRatingKeys.length > 0) onBulkAddPlexToPlaylist?.(plexRatingKeys);
trackSelectMode = false;
selectedTrackIds = new Set();
}
@@ -669,15 +678,20 @@
selectedAlbumIds = new Set();
}
/** Bulk-add selected albums' tracks to a playlist. Albums → tracks →
* playlist flow, skipping Plex tracks (mirrors per-row guard). */
/** Bulk-add selected albums' tracks to a playlist. Splits local rows
* from Plex rows so each goes through the right backend path — see
* handleBulkAddToPlaylist for the same split on the tracks-tab side. */
async function handleAlbumBulkAddToPlaylist() {
const picked = selectedAlbums();
if (picked.length === 0) return;
const tracksFlat = await resolveAlbumsTracks(picked);
const ids = tracksFlat.filter((trk) => trk.source !== 'plex').map((trk) => trk.id);
if (ids.length === 0) return;
onBulkAddToPlaylist?.(ids);
const localIds = tracksFlat.filter((trk) => trk.source !== 'plex').map((trk) => trk.id);
const plexRatingKeys = tracksFlat
.filter((trk) => trk.source === 'plex')
.map((trk) => trk.file_path);
if (localIds.length === 0 && plexRatingKeys.length === 0) return;
if (localIds.length > 0) onBulkAddToPlaylist?.(localIds);
if (plexRatingKeys.length > 0) onBulkAddPlexToPlaylist?.(plexRatingKeys);
albumSelectMode = false;
selectedAlbumIds = new Set();
}
@@ -3506,7 +3520,9 @@
onPlayNow: () => handleTrackPlay(track),
onPlayNext: onTrackPlayNext ? () => onTrackPlayNext(track) : undefined,
onPlayLater: onTrackPlayLater ? () => onTrackPlayLater(track) : undefined,
onAddToPlaylist: onTrackAddToPlaylist && track.source !== 'plex' ? () => onTrackAddToPlaylist(track.id) : undefined
onAddToPlaylist: track.source === 'plex'
? (onTrackAddPlexToPlaylist ? () => onTrackAddPlexToPlaylist(track.file_path) : undefined)
: (onTrackAddToPlaylist ? () => onTrackAddToPlaylist(track.id) : undefined)
}}
/>
{/each}
+20 -1
View File
@@ -53,6 +53,12 @@ let isPlaylistModalOpen = false;
let playlistModalMode: 'create' | 'edit' | 'addTrack' = 'create';
let playlistModalTrackIds: number[] = [];
let playlistModalTracksAreLocal = false;
// Parallel identifier list for Plex tracks. Plex's native id is the
// rating key (TEXT), not the numeric id used by Qobuz / local tracks,
// so it lives in its own array. When non-empty the modal routes adds
// through v2_playlist_add_plex_track instead of the local / Qobuz
// paths.
let playlistModalPlexRatingKeys: string[] = [];
let isPlaylistImportOpen = false;
// Listeners
@@ -282,11 +288,21 @@ export function getPlaylistModalTracksAreLocal(): boolean {
return playlistModalTracksAreLocal;
}
export function openPlaylistModal(mode: 'create' | 'edit' | 'addTrack', trackIds: number[] = [], isLocal = false): void {
export function getPlaylistModalPlexRatingKeys(): string[] {
return playlistModalPlexRatingKeys;
}
export function openPlaylistModal(
mode: 'create' | 'edit' | 'addTrack',
trackIds: number[] = [],
isLocal = false,
plexRatingKeys: string[] = [],
): void {
isPlaylistModalOpen = true;
playlistModalMode = mode;
playlistModalTrackIds = trackIds;
playlistModalTracksAreLocal = isLocal;
playlistModalPlexRatingKeys = plexRatingKeys;
notifyListeners();
}
@@ -294,6 +310,7 @@ export function closePlaylistModal(): void {
isPlaylistModalOpen = false;
playlistModalTrackIds = [];
playlistModalTracksAreLocal = false;
playlistModalPlexRatingKeys = [];
notifyListeners();
}
@@ -367,6 +384,7 @@ export interface UIState {
playlistModalMode: 'create' | 'edit' | 'addTrack';
playlistModalTrackIds: number[];
playlistModalTracksAreLocal: boolean;
playlistModalPlexRatingKeys: string[];
isPlaylistImportOpen: boolean;
miniPlayer: MiniPlayerState;
}
@@ -382,6 +400,7 @@ export function getUIState(): UIState {
playlistModalMode,
playlistModalTrackIds,
playlistModalTracksAreLocal,
playlistModalPlexRatingKeys,
isPlaylistImportOpen,
miniPlayer: getMiniPlayerState()
};
+11
View File
@@ -965,6 +965,7 @@
let playlistModalMode = $state<'create' | 'edit' | 'addTrack'>('create');
let playlistModalTrackIds = $state<number[]>([]);
let playlistModalTracksAreLocal = $state(false);
let playlistModalPlexRatingKeys = $state<string[]>([]);
let playlistModalEditPlaylist = $state<{ id: number; name: string; tracks_count: number } | undefined>(undefined);
let playlistModalEditIsHidden = $state(false);
let playlistModalEditCurrentFolderId = $state<string | null>(null);
@@ -3799,6 +3800,12 @@
openPlaylistModal('addTrack', trackIds, isLocal);
}
function openAddPlexToPlaylist(ratingKeys: string[]) {
clearPlaylistEditContext();
userPlaylists = sidebarRef?.getPlaylists() ?? [];
openPlaylistModal('addTrack', [], false, ratingKeys);
}
function handlePlaylistCreated(playlist?: { id: number; name: string; tracks_count: number }) {
const trackCount = playlistModalTrackIds.length;
const isLocal = playlistModalTracksAreLocal;
@@ -4609,6 +4616,7 @@
playlistModalMode = uiState.playlistModalMode;
playlistModalTrackIds = uiState.playlistModalTrackIds;
playlistModalTracksAreLocal = uiState.playlistModalTracksAreLocal;
playlistModalPlexRatingKeys = uiState.playlistModalPlexRatingKeys;
if (wasPlaylistModalOpen && !uiState.isPlaylistModalOpen) {
clearPlaylistEditContext();
}
@@ -5878,6 +5886,8 @@
onTrackPlayLater={queueLocalTrackLater}
onTrackAddToPlaylist={(trackId) => openAddToPlaylist([trackId], true)}
onBulkAddToPlaylist={(trackIds) => openAddToPlaylist(trackIds, true)}
onTrackAddPlexToPlaylist={(ratingKey) => openAddPlexToPlaylist([ratingKey])}
onBulkAddPlexToPlaylist={(ratingKeys) => openAddPlexToPlaylist(ratingKeys)}
onSetLocalQueue={handleSetLocalQueue}
onQobuzArtistClick={handleArtistClick}
activeTrackId={currentTrack?.id ?? null}
@@ -6588,6 +6598,7 @@
playlist={playlistModalMode === 'edit' ? playlistModalEditPlaylist : undefined}
trackIds={playlistModalTrackIds}
isLocalTracks={playlistModalTracksAreLocal}
plexRatingKeys={playlistModalPlexRatingKeys}
isHidden={playlistModalMode === 'edit' ? playlistModalEditIsHidden : false}
currentFolderId={playlistModalMode === 'edit' ? playlistModalEditCurrentFolderId : null}
{userPlaylists}