fix(playlist): batch playlist writes past the GET URL limit (#1227) (#1235)

* fix(playlist): batch playlist writes past the GET URL limit (#1227)

Adding tracks failed past ~341 songs because the write path re-sent the
entire song list as createPlaylist.view?songId=<all> query params on a GET,
blowing past the server's ~8 KiB URL limit. Writes now append incrementally
via updatePlaylist.view?songIdToAdd=<batch> (and songIndexToRemove for
clears/removals) in 150-id batches, so there is no practical size cap. A
per-server in-memory membership cache removes the full getPlaylist refetch
on every dedup, fixing the "slow add on big playlists" report.

Layering detangle (keeps the new cache from adding dep-cruiser cycles):
- lib/api/subsonicPlaylists.ts is pure of the store again; cache invalidation
  on write failure moved to the feature callers' catch blocks.
- membership cache extracted to the core layer (src/store/playlistMembershipStore.ts)
  so offline/orbit/contextMenu/playlist read it directly instead of routing
  through the @/features/playlist barrel.
- severed the offline -> playlist-barrel edge (pinnedOfflineSync name fallback
  through the live playlist list was dead: nameless callers are all gated by
  isSourcePinnedOffline, where offline meta already carries the name).
- confirmAddAllDuplicates moved into the playlist feature; contextMenu submenus
  import the add/merge helpers via the playlist barrel, not deep paths.

dep-cruiser baseline regenerated: 742 -> 714 (net -28, all no-circular). The
churn in the baseline is path-shift of the frozen playerStore SCC (cover/
playback/orbit), not new coupling; the new playlist modules have zero violations.

* docs(changelog): record #1235 playlist URL-limit fix (changelog + credits)

* fix(playlist): seed membership cache from full list, not library-scoped view (#1235 review)

F1: runPlaylistLoad seeded the dedup membership cache from the
library-scope-filtered songs, so out-of-scope members looked "new" and
addTracksToPlaylistWithDedup/collectMergeSongIds could re-add them as
duplicates. Cache now holds the full unfiltered server list while the UI
still shows the filtered view; add a regression test.

Also documents the two accepted trade-offs flagged in review:
- F2: >batch updatePlaylist clears then appends non-atomically (URL-limit
  workaround); a mid-step failure truncates server state, cache invalidation
  lets the client re-read truth.
- F3: dedup read-modify-append isn't atomic across the await; rare missed
  dedup on concurrent adds, self-heals on next load.
This commit is contained in:
cucadmuh
2026-07-05 01:24:04 +03:00
committed by GitHub
parent 5e075b2c36
commit 32a21ec2c3
23 changed files with 879 additions and 10136 deletions
File diff suppressed because it is too large Load Diff
+7
View File
@@ -64,6 +64,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* Desktop builds no longer get stuck showing "offline" when WebKitGTK leaves `navigator.onLine` stuck at `false` while the server is actually reachable — the app now confirms with a real server probe instead of trusting that hint, so browse and playback keep working. Web builds are unchanged.
* Pending favorite/rating sync now flushes when the server actually becomes reachable again, rather than relying on a browser `online` event that may never fire on desktop.
### Playlists — add more than ~341 tracks; faster large-playlist edits
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1235](https://github.com/Psychotoxical/psysonic/pull/1235)**
* Adding tracks to a playlist no longer fails past ~341 songs — writes are sent to the server in batches instead of one oversized request, so playlists of any size build correctly.
* Adding and merging into large playlists is faster: playlist membership is cached in memory for de-duplication instead of re-fetching the whole playlist on every add.
## [1.49.0] - 2026-06-29
+1
View File
@@ -181,6 +181,7 @@ const CONTRIBUTOR_ENTRIES = [
'Playback — ReplayGain index prefetch, gapless playbar sync, library replayGainPeak column, live RG refresh after sync (PR #1231)',
'Artists browse — album vs track credit mode toggle, starred favorites in both modes, persisted credit mode, SQL letter-bucket filter (PR #1232)',
'Connection — ignore spurious WebKitGTK navigator.onLine offline hint on desktop; confirm via server probe; flush pending favorite/rating sync on real reachability (report: mikmik on Psysonic Discord, PR #1234)',
'Playlists — batch playlist writes past the GET URL limit (add >341 tracks), in-memory membership cache for fast dedup, offline↔playlist layering detangle (PR #1235)',
],
},
{
@@ -1,14 +1,11 @@
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ListMusic, Plus } from 'lucide-react';
import { getPlaylist, updatePlaylist } from '@/lib/api/subsonicPlaylists';
import type { SubsonicPlaylist } from '@/lib/api/subsonicTypes';
import { usePlaylistStore } from '@/features/playlist';
import { addTracksToPlaylistWithDedup, showAddTracksDedupToast } from '@/features/playlist';
import { showToast } from '@/lib/dom/toast';
import {
confirmAddAllDuplicates,
isSmartPlaylistName,
} from '@/features/contextMenu/utils/contextMenuHelpers';
import { isSmartPlaylistName } from '@/features/contextMenu/utils/contextMenuHelpers';
interface Props {
songIds: string[];
@@ -74,23 +71,9 @@ export function AddToPlaylistSubmenu({ songIds, resolveSongIds, onDone, dropDown
const ids = idsForAction();
setAdding(pl.id);
try {
const { songs } = await getPlaylist(pl.id);
const existingIds = new Set(songs.map((s) => s.id));
const newIds = ids.filter((id) => !existingIds.has(id));
if (newIds.length > 0) {
await updatePlaylist(pl.id, [...songs.map((s) => s.id), ...newIds]);
showToast(t('playlists.addSuccess', { count: newIds.length, playlist: pl.name }));
touchPlaylist(pl.id);
} else {
const accepted = await confirmAddAllDuplicates(pl.name, ids.length, t);
if (accepted) {
await updatePlaylist(pl.id, [...songs.map((s) => s.id), ...ids]);
showToast(t('playlists.addedAsDuplicates', { count: ids.length, playlist: pl.name }), 3000, 'info');
touchPlaylist(pl.id);
} else {
showToast(t('playlists.addAllSkipped', { count: ids.length, playlist: pl.name }), 3000, 'info');
}
}
const result = await addTracksToPlaylistWithDedup(pl.id, pl.name, ids, t);
showAddTracksDedupToast(t, pl.name, result);
if (result.outcome !== 'skipped') touchPlaylist(pl.id);
} catch {
showToast(t('playlists.addError'), 3000, 'error');
}
@@ -1,14 +1,12 @@
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ListMusic, Plus } from 'lucide-react';
import { resolveAlbum, resolveMediaServerId, resolvePlaylist } from '@/features/offline';
import { resolveAlbum, resolveMediaServerId } from '@/features/offline';
import type { SubsonicPlaylist } from '@/lib/api/subsonicTypes';
import { usePlaylistStore } from '@/features/playlist';
import { addTracksToPlaylistWithDedup, showAddTracksDedupToast } from '@/features/playlist';
import { showToast } from '@/lib/dom/toast';
import {
confirmAddAllDuplicates,
isSmartPlaylistName,
} from '@/features/contextMenu/utils/contextMenuHelpers';
import { isSmartPlaylistName } from '@/features/contextMenu/utils/contextMenuHelpers';
interface Props {
albumIds: string[];
@@ -39,46 +37,12 @@ export function MultiAlbumToPlaylistSubmenu({ albumIds, onDone, triggerId: _trig
}, [albumIds]);
const handleAddWithToast = async (pl: SubsonicPlaylist, songIds: string[]) => {
const { updatePlaylist } = await import('@/lib/api/subsonicPlaylists');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
try {
const serverId = resolveMediaServerId();
if (!serverId) return;
const resolved = await resolvePlaylist(serverId, pl.id);
if (!resolved) return;
const { songs: existingSongs } = resolved;
const existingIds = new Set(existingSongs.map((s) => s.id));
const newIds: string[] = [];
const duplicateIds: string[] = [];
for (const id of songIds) {
if (existingIds.has(id)) duplicateIds.push(id);
else newIds.push(id);
}
const addedCount = newIds.length;
const duplicateCount = duplicateIds.length;
if (addedCount > 0) {
await updatePlaylist(pl.id, [...existingSongs.map((s) => s.id), ...newIds]);
touchPlaylist(pl.id);
if (duplicateCount > 0) {
showToast(t('playlists.addPartial', { added: addedCount, skipped: duplicateCount, playlist: pl.name }), 4000, 'info');
} else {
showToast(t('playlists.addSuccess', { count: addedCount, playlist: pl.name }), 3000, 'info');
}
} else if (duplicateCount > 0) {
const accepted = await confirmAddAllDuplicates(pl.name, duplicateCount, t);
if (accepted) {
await updatePlaylist(pl.id, [...existingSongs.map((s) => s.id), ...songIds]);
touchPlaylist(pl.id);
showToast(t('playlists.addedAsDuplicates', { count: duplicateCount, playlist: pl.name }), 3000, 'info');
} else {
showToast(t('playlists.addAllSkipped', { count: duplicateCount, playlist: pl.name }), 4000, 'info');
}
}
const result = await addTracksToPlaylistWithDedup(pl.id, pl.name, songIds, t);
showAddTracksDedupToast(t, pl.name, result);
if (result.outcome !== 'skipped') touchPlaylist(pl.id);
} catch {
showToast(t('playlists.addError'), 4000, 'error');
}
@@ -1,15 +1,13 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ListMusic, Plus } from 'lucide-react';
import { resolveAlbum, resolveArtist, resolveMediaServerId, resolvePlaylist } from '@/features/offline';
import { resolveAlbum, resolveArtist, resolveMediaServerId } from '@/features/offline';
import { getPlaylists } from '@/lib/api/subsonicPlaylists';
import type { SubsonicPlaylist } from '@/lib/api/subsonicTypes';
import { usePlaylistStore } from '@/features/playlist';
import { addTracksToPlaylistWithDedup, showAddTracksDedupToast } from '@/features/playlist';
import { showToast } from '@/lib/dom/toast';
import {
confirmAddAllDuplicates,
isSmartPlaylistName,
} from '@/features/contextMenu/utils/contextMenuHelpers';
import { isSmartPlaylistName } from '@/features/contextMenu/utils/contextMenuHelpers';
interface Props {
artistIds: string[];
@@ -53,46 +51,12 @@ export function MultiArtistToPlaylistSubmenu({ artistIds, onDone, triggerId: _tr
}, [artistIds]);
const handleAddWithToast = async (pl: SubsonicPlaylist, songIds: string[]) => {
const { updatePlaylist } = await import('@/lib/api/subsonicPlaylists');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
try {
const serverId = resolveMediaServerId();
if (!serverId) return;
const resolved = await resolvePlaylist(serverId, pl.id);
if (!resolved) return;
const { songs: existingSongs } = resolved;
const existingIds = new Set(existingSongs.map((s) => s.id));
const newIds: string[] = [];
const duplicateIds: string[] = [];
for (const id of songIds) {
if (existingIds.has(id)) duplicateIds.push(id);
else newIds.push(id);
}
const addedCount = newIds.length;
const duplicateCount = duplicateIds.length;
if (addedCount > 0) {
await updatePlaylist(pl.id, [...existingSongs.map((s) => s.id), ...newIds]);
touchPlaylist(pl.id);
if (duplicateCount > 0) {
showToast(t('playlists.addPartial', { added: addedCount, skipped: duplicateCount, playlist: pl.name }), 4000, 'info');
} else {
showToast(t('playlists.addSuccess', { count: addedCount, playlist: pl.name }), 3000, 'info');
}
} else if (duplicateCount > 0) {
const accepted = await confirmAddAllDuplicates(pl.name, duplicateCount, t);
if (accepted) {
await updatePlaylist(pl.id, [...existingSongs.map((s) => s.id), ...songIds]);
touchPlaylist(pl.id);
showToast(t('playlists.addedAsDuplicates', { count: duplicateCount, playlist: pl.name }), 3000, 'info');
} else {
showToast(t('playlists.addAllSkipped', { count: duplicateCount, playlist: pl.name }), 4000, 'info');
}
}
const result = await addTracksToPlaylistWithDedup(pl.id, pl.name, songIds, t);
showAddTracksDedupToast(t, pl.name, result);
if (result.outcome !== 'skipped') touchPlaylist(pl.id);
} catch {
showToast(t('playlists.addError'), 4000, 'error');
}
@@ -1,7 +1,13 @@
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ListMusic, Plus } from 'lucide-react';
import { usePlaylistStore } from '@/features/playlist';
import {
usePlaylistStore,
addTracksToPlaylistWithDedup,
collectMergeSongIds,
resolvePlaylistSongIds,
} from '@/features/playlist';
import { usePlaylistMembershipStore } from '@/store/playlistMembershipStore';
import { showToast } from '@/lib/dom/toast';
import { isSmartPlaylistName } from '@/features/contextMenu/utils/contextMenuHelpers';
@@ -41,7 +47,7 @@ export function SinglePlaylistToPlaylistSubmenu({ playlist, onDone, triggerId }:
const handleCreate = async () => {
if (!newName.trim()) return;
const { createPlaylist } = await import('@/lib/api/subsonicPlaylists');
const createPlaylist = usePlaylistStore.getState().createPlaylist;
try {
const newPl = await createPlaylist(newName.trim(), []);
if (newPl?.id) {
@@ -55,15 +61,20 @@ export function SinglePlaylistToPlaylistSubmenu({ playlist, onDone, triggerId }:
};
const handleAddToNewPlaylist = async (targetId: string, targetName: string) => {
const { getPlaylist, updatePlaylist } = await import('@/lib/api/subsonicPlaylists');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
try {
const { songs: sourceSongs } = await getPlaylist(playlist.id);
if (sourceSongs.length > 0) {
await updatePlaylist(targetId, sourceSongs.map((s: { id: string }) => s.id));
const { getPlaylist } = await import('@/lib/api/subsonicPlaylists');
const sourceIds = await resolvePlaylistSongIds(playlist.id, async () => {
const { songs } = await getPlaylist(playlist.id);
return songs.map(s => s.id);
});
if (sourceIds.length > 0) {
const result = await addTracksToPlaylistWithDedup(targetId, targetName, sourceIds, t);
if (result.addedCount > 0) {
showToast(t('playlists.createAndAddSuccess', { count: result.addedCount, playlist: targetName }), 3000, 'info');
touchPlaylist(targetId);
showToast(t('playlists.createAndAddSuccess', { count: sourceSongs.length, playlist: targetName }), 3000, 'info');
}
}
onDone();
} catch {
@@ -73,22 +84,20 @@ export function SinglePlaylistToPlaylistSubmenu({ playlist, onDone, triggerId }:
};
const handleAdd = async (targetId: string, targetName: string) => {
const { getPlaylist, updatePlaylist } = await import('@/lib/api/subsonicPlaylists');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
try {
const { songs: targetSongs } = await getPlaylist(targetId);
const targetIds = new Set(targetSongs.map((s: { id: string }) => s.id));
const { songs: sourceSongs } = await getPlaylist(playlist.id);
const newSongs = sourceSongs.filter((s: { id: string }) => !targetIds.has(s.id));
if (newSongs.length > 0) {
newSongs.forEach((s: { id: string }) => targetIds.add(s.id));
await updatePlaylist(targetId, Array.from(targetIds));
touchPlaylist(targetId);
showToast(t('playlists.addToPlaylistSuccess', { count: newSongs.length, playlist: targetName }), 3000, 'info');
} else {
const { getPlaylist } = await import('@/lib/api/subsonicPlaylists');
const sourceIds = await resolvePlaylistSongIds(playlist.id, async () => {
const { songs } = await getPlaylist(playlist.id);
return songs.map(s => s.id);
});
const result = await addTracksToPlaylistWithDedup(targetId, targetName, sourceIds, t);
if (result.outcome === 'skipped') {
showToast(t('playlists.addToPlaylistNoNew', { playlist: targetName }), 3000, 'info');
} else {
showToast(t('playlists.addToPlaylistSuccess', { count: result.addedCount, playlist: targetName }), 3000, 'info');
touchPlaylist(targetId);
}
onDone();
} catch {
@@ -180,7 +189,7 @@ export function MultiPlaylistToPlaylistSubmenu({ playlists, onDone, triggerId }:
const handleCreate = async () => {
if (!newName.trim()) return;
const { createPlaylist } = await import('@/lib/api/subsonicPlaylists');
const createPlaylist = usePlaylistStore.getState().createPlaylist;
try {
const newPl = await createPlaylist(newName.trim(), []);
if (newPl?.id) {
@@ -194,61 +203,44 @@ export function MultiPlaylistToPlaylistSubmenu({ playlists, onDone, triggerId }:
};
const handleMergeToNewPlaylist = async (targetId: string, targetName: string) => {
const { getPlaylist, updatePlaylist } = await import('@/lib/api/subsonicPlaylists');
const { addSongsToPlaylist } = await import('@/lib/api/subsonicPlaylists');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
const membership = usePlaylistMembershipStore.getState();
try {
const targetIds = new Set<string>();
let totalAdded = 0;
for (const pl of playlists) {
const { songs } = await getPlaylist(pl.id);
const newSongs = songs.filter((s: { id: string }) => !targetIds.has(s.id));
if (newSongs.length > 0) {
newSongs.forEach((s: { id: string }) => targetIds.add(s.id));
totalAdded += newSongs.length;
}
}
if (totalAdded > 0) {
await updatePlaylist(targetId, Array.from(targetIds));
const idsToAdd = await collectMergeSongIds(targetId, playlists.map(p => p.id));
if (idsToAdd.length > 0) {
await addSongsToPlaylist(targetId, idsToAdd);
membership.appendPlaylistSongIds(targetId, idsToAdd);
touchPlaylist(targetId);
showToast(t('playlists.createAndAddSuccess', { count: totalAdded, playlist: targetName }), 3000, 'info');
showToast(t('playlists.createAndAddSuccess', { count: idsToAdd.length, playlist: targetName }), 3000, 'info');
}
onDone();
} catch {
membership.invalidatePlaylistSongIds(targetId);
showToast(t('playlists.mergeError'), 4000, 'error');
onDone();
}
};
const handleMerge = async (targetId: string, targetName: string) => {
const { getPlaylist, updatePlaylist } = await import('@/lib/api/subsonicPlaylists');
const { addSongsToPlaylist } = await import('@/lib/api/subsonicPlaylists');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
const membership = usePlaylistMembershipStore.getState();
try {
const { songs: targetSongs } = await getPlaylist(targetId);
const targetIds = new Set(targetSongs.map((s: { id: string }) => s.id));
let totalAdded = 0;
for (const pl of playlists) {
const { songs } = await getPlaylist(pl.id);
const newSongs = songs.filter((s: { id: string }) => !targetIds.has(s.id));
if (newSongs.length > 0) {
newSongs.forEach((s: { id: string }) => targetIds.add(s.id));
totalAdded += newSongs.length;
}
}
if (totalAdded > 0) {
await updatePlaylist(targetId, Array.from(targetIds));
const idsToAdd = await collectMergeSongIds(targetId, playlists.map(p => p.id));
if (idsToAdd.length > 0) {
await addSongsToPlaylist(targetId, idsToAdd);
membership.appendPlaylistSongIds(targetId, idsToAdd);
touchPlaylist(targetId);
showToast(t('playlists.mergeSuccess', { count: totalAdded, playlist: targetName }), 3000, 'info');
showToast(t('playlists.mergeSuccess', { count: idsToAdd.length, playlist: targetName }), 3000, 'info');
} else {
showToast(t('playlists.mergeNoNewSongs'), 3000, 'info');
}
onDone();
} catch {
membership.invalidatePlaylistSongIds(targetId);
showToast(t('playlists.mergeError'), 4000, 'error');
onDone();
}
@@ -2,12 +2,13 @@ import { useTranslation } from 'react-i18next';
import { Play, ListPlus, Radio, Heart, ChevronRight, ChevronsRight, User, Disc3, ListMusic, Info, Sparkles, Star, Trash2, HeartCrack, Share2, Orbit as OrbitIcon } from 'lucide-react';
import { useNavigateToAlbum } from '@/features/album';
import { useNavigateToArtist } from '@/features/artist';
import { resolveAlbum, resolveMediaServerId, resolvePlaylist } from '@/features/offline';
import { resolveAlbum, resolveMediaServerId } from '@/features/offline';
import { queueSongStar } from '@/features/playback/store/pendingStarSync';
import { getMusicNetworkRuntime, useEnrichmentPrimary } from '@/music-network';
import type { Track } from '@/lib/media/trackTypes';
import { useAuthStore } from '@/store/authStore';
import { usePlaylistStore } from '@/features/playlist';
import { usePlaylistMembershipStore } from '@/store/playlistMembershipStore';
import { songToTrack } from '@/lib/media/songToTrack';
import { showToast } from '@/lib/dom/toast';
import { suggestOrbitTrack, hostEnqueueToOrbit, evaluateOrbitSuggestGate, OrbitSuggestBlockedError } from '@/features/orbit';
@@ -178,21 +179,17 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
</div>
{offlinePolicy.canEditPlaylist && playlistId && playlistSongIndex !== undefined && (
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
const { updatePlaylist } = await import('@/lib/api/subsonicPlaylists');
const { removePlaylistSongsAtIndices } = await import('@/lib/api/subsonicPlaylists');
const { showToast } = await import('@/lib/dom/toast');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
const { touchPlaylist } = usePlaylistStore.getState();
const membership = usePlaylistMembershipStore.getState();
try {
const serverId = resolveMediaServerId();
if (!serverId) return;
const resolved = await resolvePlaylist(serverId, playlistId);
if (!resolved) return;
const { songs } = resolved;
const prevCount = songs.length;
const updatedIds = songs.filter((_, i) => i !== playlistSongIndex).map(s => s.id);
await updatePlaylist(playlistId, updatedIds, prevCount);
await removePlaylistSongsAtIndices(playlistId, [playlistSongIndex]);
membership.removePlaylistSongIdsAtIndices(playlistId, [playlistSongIndex]);
touchPlaylist(playlistId);
showToast(t('playlists.removeSuccess'), 3000, 'info');
} catch {
membership.invalidatePlaylistSongIds(playlistId);
showToast(t('playlists.removeError'), 4000, 'error');
}
})}>
@@ -1,5 +1,3 @@
import { useConfirmModalStore } from '@/store/confirmModalStore';
/** Psysonic smart playlists (Navidrome); not valid targets for manual add-to-playlist. */
export const SMART_PLAYLIST_PREFIX = 'psy-smart-';
@@ -25,18 +23,3 @@ export function shuffleArray<T>(arr: T[]): T[] {
return result;
}
/** Ask user before re-adding songs to a playlist when *all* selected ids are
* already present. Returns true caller should append them as duplicates,
* false caller should keep today's silent-skip toast behavior. */
export async function confirmAddAllDuplicates(
playlistName: string,
count: number,
t: (key: string, opts?: Record<string, unknown>) => string,
): Promise<boolean> {
return useConfirmModalStore.getState().request({
title: t('playlists.duplicateConfirmTitle'),
message: t('playlists.duplicateConfirmMessage', { count, playlist: playlistName }),
confirmLabel: t('playlists.duplicateConfirmAction'),
cancelLabel: t('common.cancel'),
});
}
@@ -7,7 +7,6 @@ import { useAuthStore } from '@/store/authStore';
import type { PinSource } from '@/store/localPlaybackStore';
import { useLocalPlaybackStore } from '@/store/localPlaybackStore';
import { useOfflineStore } from '@/features/offline/store/offlineStore';
import { usePlaylistStore } from '@/features/playlist';
import { isSmartPlaylistName } from '@/lib/format/playlistDetailHelpers';
import { getMediaDir } from '@/lib/media/mediaDir';
import { deleteMediaFile } from '@/lib/api/syncfs';
@@ -58,8 +57,11 @@ function offlineMeta(sourceId: string, serverId: string) {
}
function resolvePlaylistName(playlistId: string, serverId: string): string | undefined {
return offlineMeta(playlistId, serverId)?.name
?? usePlaylistStore.getState().playlists.find(p => p.id === playlistId)?.name;
// Only pinned playlists reach the nameless internal callers (all gated by
// isSourcePinnedOffline), so offline meta always carries the name here; external
// callers pass `name` explicitly. Avoid importing the playlist feature barrel —
// that edge closes offline↔playlist import cycles (see 2026-07 detangle task).
return offlineMeta(playlistId, serverId)?.name;
}
/** Smart playlists refresh from server rules — not eligible for manual offline cache/sync. */
+7 -5
View File
@@ -1,7 +1,8 @@
import { createPlaylist, deletePlaylist, getPlaylist, getPlaylists, updatePlaylist } from '@/lib/api/subsonicPlaylists';
import { createPlaylist, deletePlaylist, getPlaylist, getPlaylists, addSongsToPlaylist } from '@/lib/api/subsonicPlaylists';
import { getSong } from '@/lib/api/subsonicLibrary';
import { songToTrack } from '@/lib/media/songToTrack';
import { useAuthStore } from '@/store/authStore';
import { usePlaylistMembershipStore } from '@/store/playlistMembershipStore';
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import {
@@ -189,15 +190,16 @@ export async function suggestOrbitTrack(trackId: string): Promise<void> {
/**
* Append a track to an outbox playlist unless it's already there. Subsonic's
* playlist update replaces the song list wholesale, so we carry the existing
* ids along. Shared by the initial suggest and the guest tick's lost-update
* re-send (where a no-op append means the host already cleared + recorded it).
* playlist update replaces the song list wholesale on sweep, so we read the
* current server list before append. A no-op when the track is already present
* means the host already cleared + recorded it (lost-update re-send path).
*/
export async function ensureTrackInOutbox(outboxPlaylistId: string, trackId: string): Promise<void> {
const { songs } = await getPlaylist(outboxPlaylistId);
const ids = songs.map(s => s.id);
if (ids.includes(trackId)) return;
await updatePlaylist(outboxPlaylistId, [...ids, trackId], ids.length);
await addSongsToPlaylist(outboxPlaylistId, [trackId]);
usePlaylistMembershipStore.getState().setPlaylistSongIds(outboxPlaylistId, [...ids, trackId]);
}
/**
+1
View File
@@ -25,6 +25,7 @@ export * from './hooks/usePlaylistSuggestions';
export * from './store/playlistFolderStore';
export * from './store/playlistLayoutStore';
export * from './store/playlistStore';
export * from './utils/addTracksToPlaylistWithDedup';
export * from './utils/playlistBulkPlayActions';
export * from './utils/playlistDisplayedSongs';
export * from './utils/playlistFolders';
@@ -6,6 +6,7 @@ import { useTracklistColumns, type ColDef } from '@/lib/hooks/useTracklistColumn
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { useShallow } from 'zustand/react/shallow';
import { usePlaylistStore } from '@/features/playlist/store/playlistStore';
import { usePlaylistMembershipStore } from '@/store/playlistMembershipStore';
import { useOfflineStore } from '@/features/offline';
import { useLocalPlaybackStore } from '@/store/localPlaybackStore';
import { useAlbumOfflineState } from '@/features/album';
@@ -117,8 +118,11 @@ export default function PlaylistDetail() {
setSaving(true);
try {
await updatePlaylist(id, updatedSongs.map(s => s.id), prevCount);
if (id) touchPlaylist(id);
} catch { /* ignore: best-effort */ }
usePlaylistMembershipStore.getState().replacePlaylistSongIds(id, updatedSongs.map(s => s.id));
touchPlaylist(id);
} catch {
usePlaylistMembershipStore.getState().invalidatePlaylistSongIds(id);
}
setSaving(false);
}, [id, touchPlaylist]);
+19 -8
View File
@@ -1,11 +1,11 @@
import { getPlaylists } from '@/lib/api/subsonicPlaylists';
import { getPlaylists, createPlaylist as apiCreatePlaylist } from '@/lib/api/subsonicPlaylists';
import type { SubsonicPlaylist } from '@/lib/api/subsonicTypes';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { createPlaylist as apiCreatePlaylist } from '@/lib/api/subsonicPlaylists';
import { useAuthStore } from '@/store/authStore';
import { isOfflineBrowseActive } from '@/features/offline';
import { fetchOfflineBrowsablePlaylists } from '@/features/offline';
import { isOfflineBrowseActive, fetchOfflineBrowsablePlaylists } from '@/features/offline';
import { usePlaylistMembershipStore } from '@/store/playlistMembershipStore';
interface PlaylistStore {
recentIds: string[];
playlists: SubsonicPlaylist[];
@@ -30,10 +30,13 @@ export const usePlaylistStore = create<PlaylistStore>()(
recentIds: [id, ...s.recentIds.filter((x) => x !== id)].slice(0, 50),
lastModified: { ...s.lastModified, [id]: Date.now() },
})),
removeId: (id) =>
set((s) => ({ recentIds: s.recentIds.filter((x) => x !== id) })),
removeId: (id) => {
usePlaylistMembershipStore.getState().invalidatePlaylistSongIds(id);
set((s) => ({ recentIds: s.recentIds.filter((x) => x !== id) }));
},
fetchPlaylists: async () => {
set({ playlistsLoading: true });
usePlaylistMembershipStore.getState().clearAllPlaylistSongIds();
try {
const serverId = useAuthStore.getState().activeServerId;
if (isOfflineBrowseActive() && serverId) {
@@ -54,6 +57,7 @@ export const usePlaylistStore = create<PlaylistStore>()(
playlists: [...s.playlists, playlist],
recentIds: [playlist.id, ...s.recentIds.filter((x) => x !== playlist.id)].slice(0, 50),
}));
usePlaylistMembershipStore.getState().setPlaylistSongIds(playlist.id, songIds ?? []);
return playlist;
} catch {
return null;
@@ -65,6 +69,13 @@ export const usePlaylistStore = create<PlaylistStore>()(
}));
},
}),
{ name: 'psysonic_playlists_recent' }
)
{
name: 'psysonic_playlists_recent',
partialize: (state) => ({
recentIds: state.recentIds,
playlists: state.playlists,
lastModified: state.lastModified,
}),
},
),
);
@@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { addTracksToPlaylistWithDedup } from '@/features/playlist/utils/addTracksToPlaylistWithDedup';
import { usePlaylistMembershipStore } from '@/store/playlistMembershipStore';
const addSongsToPlaylistMock = vi.fn(async (_id: string, _ids: string[]) => {});
const getPlaylistMock = vi.fn(async (_id: string) => ({ playlist: { id: 'pl-1' }, songs: [{ id: 'a' }, { id: 'b' }] }));
const confirmMock = vi.fn(async () => false);
vi.mock('@/lib/api/subsonicPlaylists', () => ({
addSongsToPlaylist: (id: string, ids: string[]) => addSongsToPlaylistMock(id, ids),
getPlaylist: (id: string) => getPlaylistMock(id),
}));
vi.mock('@/store/confirmModalStore', () => ({
useConfirmModalStore: {
getState: () => ({ request: () => confirmMock() }),
},
}));
vi.mock('@/store/authStore', () => ({
useAuthStore: {
getState: () => ({ activeServerId: 'srv-1' }),
},
}));
describe('addTracksToPlaylistWithDedup', () => {
beforeEach(() => {
addSongsToPlaylistMock.mockClear();
getPlaylistMock.mockClear();
confirmMock.mockReset();
confirmMock.mockResolvedValue(false);
usePlaylistMembershipStore.setState({
songIdsByCacheKey: { 'srv-1:pl-1': ['a', 'b'] },
});
});
it('dedupes against cached ids without getPlaylist', async () => {
const result = await addTracksToPlaylistWithDedup('pl-1', 'Mix', ['b', 'c'], k => k);
expect(result).toMatchObject({ outcome: 'partial', addedCount: 1, skippedCount: 1 });
expect(getPlaylistMock).not.toHaveBeenCalled();
expect(addSongsToPlaylistMock).toHaveBeenCalledWith('pl-1', ['c']);
expect(usePlaylistMembershipStore.getState().getPlaylistSongIds('pl-1')).toEqual(['a', 'b', 'c']);
});
it('fetches membership once on cold cache and dedupes', async () => {
usePlaylistMembershipStore.setState({ songIdsByCacheKey: {} });
getPlaylistMock.mockResolvedValue({
playlist: { id: 'pl-2' },
songs: [{ id: 'x' }],
});
const result = await addTracksToPlaylistWithDedup('pl-2', 'Cold', ['x', 'y'], k => k);
expect(result).toMatchObject({ outcome: 'partial', addedCount: 1, skippedCount: 1 });
expect(getPlaylistMock).toHaveBeenCalledTimes(1);
expect(addSongsToPlaylistMock).toHaveBeenCalledWith('pl-2', ['y']);
expect(usePlaylistMembershipStore.getState().getPlaylistSongIds('pl-2')).toEqual(['x', 'y']);
});
it('invalidates cache when the write fails', async () => {
addSongsToPlaylistMock.mockRejectedValueOnce(new Error('boom'));
await expect(
addTracksToPlaylistWithDedup('pl-1', 'Mix', ['c'], k => k),
).rejects.toThrow('boom');
expect(usePlaylistMembershipStore.getState().getPlaylistSongIds('pl-1')).toBeUndefined();
});
});
@@ -0,0 +1,152 @@
import { addSongsToPlaylist, getPlaylist } from '@/lib/api/subsonicPlaylists';
import { useConfirmModalStore } from '@/store/confirmModalStore';
import { showToast } from '@/lib/dom/toast';
import { usePlaylistMembershipStore } from '@/store/playlistMembershipStore';
export type AddTracksDedupOutcome = 'added' | 'added_duplicates' | 'partial' | 'skipped';
export interface AddTracksDedupResult {
outcome: AddTracksDedupOutcome;
addedCount: number;
skippedCount: number;
}
/** Ask before re-adding songs when *all* selected ids are already present.
* Returns true append as duplicates, false keep the silent-skip behavior. */
export async function confirmAddAllDuplicates(
playlistName: string,
count: number,
t: (key: string, opts?: Record<string, unknown>) => string,
): Promise<boolean> {
return useConfirmModalStore.getState().request({
title: t('playlists.duplicateConfirmTitle'),
message: t('playlists.duplicateConfirmMessage', { count, playlist: playlistName }),
confirmLabel: t('playlists.duplicateConfirmAction'),
cancelLabel: t('common.cancel'),
});
}
/** Add tracks with dedup; membership from cache or one getPlaylist on cache miss (GET response, not long URL). */
export async function addTracksToPlaylistWithDedup(
playlistId: string,
playlistName: string,
trackIds: readonly string[],
t: (key: string, opts?: Record<string, unknown>) => string,
): Promise<AddTracksDedupResult> {
if (trackIds.length === 0) {
return { outcome: 'skipped', addedCount: 0, skippedCount: 0 };
}
// Dedup reads the membership snapshot, awaits the write, then appends. Concurrent
// adds to the same playlist could interleave on this read-modify-append; the risk is
// a rare missed dedup, self-healing on the next full load. Acceptable for a UI action.
const store = usePlaylistMembershipStore.getState();
const existingIds = new Set(
await resolvePlaylistSongIds(playlistId, async () => {
const { songs } = await getPlaylist(playlistId);
return songs.map(s => s.id);
}),
);
const newIds = trackIds.filter(id => !existingIds.has(id));
try {
if (newIds.length > 0) {
await addSongsToPlaylist(playlistId, newIds);
store.appendPlaylistSongIds(playlistId, newIds);
return {
outcome: newIds.length === trackIds.length ? 'added' : 'partial',
addedCount: newIds.length,
skippedCount: trackIds.length - newIds.length,
};
}
const accepted = await confirmAddAllDuplicates(playlistName, trackIds.length, t);
if (!accepted) {
return { outcome: 'skipped', addedCount: 0, skippedCount: trackIds.length };
}
await addSongsToPlaylist(playlistId, [...trackIds]);
store.appendPlaylistSongIds(playlistId, trackIds);
return { outcome: 'added_duplicates', addedCount: trackIds.length, skippedCount: 0 };
} catch (err) {
// A batched write may have partially landed — drop the cache so the next read refetches truth.
store.invalidatePlaylistSongIds(playlistId);
throw err;
}
}
export function showAddTracksDedupToast(
t: (key: string, opts?: Record<string, unknown>) => string,
playlistName: string,
result: AddTracksDedupResult,
): void {
switch (result.outcome) {
case 'added':
showToast(t('playlists.addSuccess', { count: result.addedCount, playlist: playlistName }));
break;
case 'partial':
showToast(
t('playlists.addPartial', {
added: result.addedCount,
skipped: result.skippedCount,
playlist: playlistName,
}),
4000,
'info',
);
break;
case 'added_duplicates':
showToast(
t('playlists.addedAsDuplicates', { count: result.addedCount, playlist: playlistName }),
3000,
'info',
);
break;
case 'skipped':
showToast(
t('playlists.addAllSkipped', { count: result.skippedCount, playlist: playlistName }),
3000,
'info',
);
break;
}
}
/** Resolve song ids for a playlist: in-memory cache first, network fallback (then cached). */
export async function resolvePlaylistSongIds(
playlistId: string,
fetch: () => Promise<readonly string[]>,
): Promise<readonly string[]> {
const cached = usePlaylistMembershipStore.getState().getPlaylistSongIds(playlistId);
if (cached !== undefined) return cached;
const ids = await fetch();
usePlaylistMembershipStore.getState().setPlaylistSongIds(playlistId, ids);
return ids;
}
/** Collect song ids to merge into target, using cached membership when available. */
export async function collectMergeSongIds(
targetPlaylistId: string,
sourcePlaylistIds: readonly string[],
): Promise<string[]> {
const targetIds = new Set(
await resolvePlaylistSongIds(targetPlaylistId, async () => {
const { songs } = await getPlaylist(targetPlaylistId);
return songs.map(s => s.id);
}),
);
const idsToAdd: string[] = [];
for (const sourceId of sourcePlaylistIds) {
const sourceIds = await resolvePlaylistSongIds(sourceId, async () => {
const { songs } = await getPlaylist(sourceId);
return songs.map(s => s.id);
});
for (const songId of sourceIds) {
if (!targetIds.has(songId)) {
targetIds.add(songId);
idsToAdd.push(songId);
}
}
}
return idsToAdd;
}
@@ -0,0 +1,64 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { runPlaylistLoad } from '@/features/playlist/utils/runPlaylistLoad';
import { usePlaylistMembershipStore } from '@/store/playlistMembershipStore';
const getPlaylistMock = vi.fn();
const filterMock = vi.fn();
vi.mock('@/lib/api/subsonicPlaylists', () => ({
getPlaylist: (id: string) => getPlaylistMock(id),
}));
vi.mock('@/lib/api/subsonicLibrary', () => ({
filterSongsToActiveLibrary: (songs: unknown) => filterMock(songs),
}));
vi.mock('@/features/offline', () => ({
isOfflineBrowseActive: () => false,
resolvePlaylist: vi.fn(),
}));
vi.mock('@/features/playlist/store/playlistStore', () => ({
usePlaylistStore: { getState: () => ({ playlists: [] }) },
}));
vi.mock('@/store/authStore', () => ({
useAuthStore: { getState: () => ({ activeServerId: 'srv-1' }) },
}));
function makeDeps(id: string) {
return {
id,
setLoading: vi.fn(),
setPlaylist: vi.fn(),
setSongs: vi.fn(),
setCustomCoverId: vi.fn(),
setRatings: vi.fn(),
setStarredSongs: vi.fn(),
};
}
describe('runPlaylistLoad membership seeding', () => {
beforeEach(() => {
getPlaylistMock.mockReset();
filterMock.mockReset();
usePlaylistMembershipStore.setState({ songIdsByCacheKey: {} });
});
it('seeds the membership cache from the full list, not the library-scoped view', async () => {
getPlaylistMock.mockResolvedValue({
playlist: { id: 'pl-1' },
songs: [{ id: 'a' }, { id: 'b' }, { id: 'c' }],
});
// Active library scope hides b and c from the displayed list.
filterMock.mockResolvedValue([{ id: 'a' }]);
const deps = makeDeps('pl-1');
await runPlaylistLoad(deps);
// Cache must hold the full server membership so dedup won't re-add b/c.
expect(usePlaylistMembershipStore.getState().getPlaylistSongIds('pl-1')).toEqual(['a', 'b', 'c']);
// The visible list is still the filtered subset.
expect(deps.setSongs).toHaveBeenCalledWith([{ id: 'a' }]);
});
});
@@ -4,6 +4,7 @@ import { filterSongsToActiveLibrary } from '@/lib/api/subsonicLibrary';
import type { SubsonicPlaylist, SubsonicSong } from '@/lib/api/subsonicTypes';
import { useAuthStore } from '@/store/authStore';
import { usePlaylistStore } from '@/features/playlist/store/playlistStore';
import { usePlaylistMembershipStore } from '@/store/playlistMembershipStore';
import { isOfflineBrowseActive } from '@/features/offline';
import { resolvePlaylist } from '@/features/offline';
@@ -21,6 +22,11 @@ function applyLoadedPlaylist(
deps: RunPlaylistLoadDeps,
playlist: SubsonicPlaylist,
songs: SubsonicSong[],
// The membership cache must hold the *full* server-side track list, not the
// library-scope-filtered view — otherwise dedup would treat out-of-scope
// members as new and re-add them as duplicates. Defaults to the shown songs
// (offline path, where the resolved list already is the full membership).
membershipIds: string[] = songs.map(s => s.id),
): void {
const { setPlaylist, setSongs, setCustomCoverId, setRatings, setStarredSongs } = deps;
setPlaylist(playlist);
@@ -34,6 +40,7 @@ function applyLoadedPlaylist(
});
setRatings(init);
setStarredSongs(starred);
usePlaylistMembershipStore.getState().setPlaylistSongIds(deps.id, membershipIds);
}
export async function runPlaylistLoad(deps: RunPlaylistLoadDeps): Promise<void> {
@@ -51,7 +58,7 @@ export async function runPlaylistLoad(deps: RunPlaylistLoadDeps): Promise<void>
const { playlist, songs } = await getPlaylist(id);
const filteredSongs = await filterSongsToActiveLibrary(songs);
applyLoadedPlaylist(deps, playlist, filteredSongs);
applyLoadedPlaylist(deps, playlist, filteredSongs, songs.map(s => s.id));
} catch {
const stub = usePlaylistStore.getState().playlists.find(p => p.id === id);
if (stub) {
@@ -1,8 +1,10 @@
import type React from 'react';
import type { TFunction } from 'i18next';
import { deletePlaylist, getPlaylist, updatePlaylist } from '@/lib/api/subsonicPlaylists';
import { deletePlaylist, addSongsToPlaylist } from '@/lib/api/subsonicPlaylists';
import type { SubsonicPlaylist } from '@/lib/api/subsonicTypes';
import { usePlaylistStore } from '@/features/playlist/store/playlistStore';
import { usePlaylistMembershipStore } from '@/store/playlistMembershipStore';
import { collectMergeSongIds } from '@/features/playlist/utils/addTracksToPlaylistWithDedup';
import { showToast } from '@/lib/dom/toast';
export interface RunPlaylistDeleteDeps {
@@ -82,29 +84,22 @@ export async function runPlaylistMergeSelected(deps: RunPlaylistMergeSelectedDep
const { targetPlaylist, selectedPlaylists, touchPlaylist, clearSelection, t } = deps;
if (selectedPlaylists.length === 0) return;
try {
const { songs: targetSongs } = await getPlaylist(targetPlaylist.id);
const targetIds = new Set(targetSongs.map(s => s.id));
let totalAdded = 0;
const sourceIds = selectedPlaylists
.filter(pl => pl.id !== targetPlaylist.id)
.map(pl => pl.id);
const idsToAdd = await collectMergeSongIds(targetPlaylist.id, sourceIds);
for (const pl of selectedPlaylists) {
if (pl.id === targetPlaylist.id) continue;
const { songs } = await getPlaylist(pl.id);
const newSongs = songs.filter(s => !targetIds.has(s.id));
if (newSongs.length > 0) {
newSongs.forEach(s => targetIds.add(s.id));
totalAdded += newSongs.length;
}
}
if (totalAdded > 0) {
await updatePlaylist(targetPlaylist.id, Array.from(targetIds));
if (idsToAdd.length > 0) {
await addSongsToPlaylist(targetPlaylist.id, idsToAdd);
usePlaylistMembershipStore.getState().appendPlaylistSongIds(targetPlaylist.id, idsToAdd);
touchPlaylist(targetPlaylist.id);
showToast(t('playlists.mergeSuccess', { count: totalAdded, playlist: targetPlaylist.name }), 3000, 'info');
showToast(t('playlists.mergeSuccess', { count: idsToAdd.length, playlist: targetPlaylist.name }), 3000, 'info');
} else {
showToast(t('playlists.mergeNoNewSongs'), 3000, 'info');
}
clearSelection();
} catch {
usePlaylistMembershipStore.getState().invalidatePlaylistSongIds(targetPlaylist.id);
showToast(t('playlists.mergeError'), 4000, 'error');
}
}
+103
View File
@@ -0,0 +1,103 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
PLAYLIST_SONG_ID_GET_BATCH,
addSongsToPlaylist,
chunkIndicesForSubsonicGet,
chunkRemovalIndicesForSubsonicGet,
chunkSongIdsForSubsonicGet,
removePlaylistSongsAtIndices,
updatePlaylist,
} from '@/lib/api/subsonicPlaylists';
const { apiMock } = vi.hoisted(() => {
const fn = vi.fn();
return { apiMock: fn };
});
vi.mock('@/lib/api/subsonicClient', () => ({
api: apiMock,
apiForServer: vi.fn(),
}));
vi.mock('@/features/offline', () => ({
schedulePinnedPlaylistSync: vi.fn(),
}));
describe('subsonicPlaylists batching', () => {
beforeEach(() => {
apiMock.mockReset();
apiMock.mockImplementation(async (endpoint: string) => {
if (endpoint === 'getPlaylist.view') {
return {
playlist: {
id: 'pl1',
entry: Array.from({ length: 400 }, (_, i) => ({ id: `existing-${i}` })),
},
};
}
return {};
});
});
it('chunks song ids for GET batching', () => {
const ids = Array.from({ length: 320 }, (_, i) => `track-${i}`);
const batches = chunkSongIdsForSubsonicGet(ids, 150);
expect(batches).toHaveLength(3);
expect(batches[0]).toHaveLength(150);
expect(batches[2]).toHaveLength(20);
});
it('chunks clear indices from the end', () => {
const batches = chunkIndicesForSubsonicGet(340, 150);
expect(batches).toHaveLength(3);
expect(batches[0][0]).toBe(190);
expect(batches[0][batches[0].length - 1]).toBe(339);
expect(batches[2]).toEqual(Array.from({ length: 40 }, (_, i) => i));
});
it('addSongsToPlaylist uses updatePlaylist.view with songIdToAdd only', async () => {
const ids = Array.from({ length: PLAYLIST_SONG_ID_GET_BATCH + 5 }, (_, i) => `s${i}`);
await addSongsToPlaylist('pl1', ids);
expect(apiMock).toHaveBeenCalledTimes(2);
const calls = apiMock.mock.calls as Array<[string, Record<string, unknown>?]>;
expect(calls[0]?.[0]).toBe('updatePlaylist.view');
expect(calls[0]?.[1]).toEqual({
playlistId: 'pl1',
songIdToAdd: ids.slice(0, PLAYLIST_SONG_ID_GET_BATCH),
});
expect(calls[1]?.[1]).toEqual({
playlistId: 'pl1',
songIdToAdd: ids.slice(PLAYLIST_SONG_ID_GET_BATCH),
});
});
it('chunks removal indices high-to-low', () => {
const indices = Array.from({ length: 200 }, (_, i) => i);
const batches = chunkRemovalIndicesForSubsonicGet(indices, 150);
expect(batches).toHaveLength(2);
expect(batches[0][0]).toBe(199);
expect(batches[0][batches[0].length - 1]).toBe(50);
expect(batches[1][0]).toBe(49);
expect(batches[1][batches[1].length - 1]).toBe(0);
});
it('removePlaylistSongsAtIndices removes high indices first', async () => {
const indices = Array.from({ length: 200 }, (_, i) => i);
await removePlaylistSongsAtIndices('pl1', indices);
expect(apiMock).toHaveBeenCalledTimes(2);
const calls = apiMock.mock.calls as Array<[string, Record<string, unknown>?]>;
const firstBatch = calls[0]?.[1]?.songIndexToRemove as number[];
expect(firstBatch[0]).toBe(199);
expect(firstBatch[firstBatch.length - 1]).toBe(50);
});
it('updatePlaylist clears then appends when replacing a large list', async () => {
const ids = Array.from({ length: 200 }, (_, i) => `s${i}`);
await updatePlaylist('pl1', ids, 400);
const calls = apiMock.mock.calls as Array<[string, Record<string, unknown>?]>;
const endpoints = calls.map(call => call[0]);
expect(endpoints.filter(e => e === 'updatePlaylist.view').length).toBeGreaterThan(0);
expect(endpoints.filter(e => e === 'createPlaylist.view')).toHaveLength(0);
expect(calls.some(call => call[1]?.songIdToAdd)).toBe(true);
});
});
+89 -10
View File
@@ -4,6 +4,57 @@ import { shouldAttemptSubsonicForServer } from '@/lib/network/subsonicNetworkGua
import { api, apiForServer } from '@/lib/api/subsonicClient';
import type { SubsonicPlaylist, SubsonicSong } from '@/lib/api/subsonicTypes';
/** Max song-id params per Subsonic GET call (auth + ~8 KiB URL ceiling). */
export const PLAYLIST_SONG_ID_GET_BATCH = 150;
export function chunkIndicesForSubsonicGet(count: number, batchSize = PLAYLIST_SONG_ID_GET_BATCH): number[][] {
if (count <= 0) return [];
const batches: number[][] = [];
let remaining = count;
while (remaining > 0) {
const size = Math.min(batchSize, remaining);
const start = remaining - size;
batches.push(Array.from({ length: size }, (_, i) => start + i));
remaining -= size;
}
return batches;
}
export function chunkSongIdsForSubsonicGet(ids: string[], batchSize = PLAYLIST_SONG_ID_GET_BATCH): string[][] {
if (ids.length === 0) return [];
const batches: string[][] = [];
for (let i = 0; i < ids.length; i += batchSize) {
batches.push(ids.slice(i, i + batchSize));
}
return batches;
}
/** Batch arbitrary removal indices high-to-low so earlier positions stay valid between calls. */
export function chunkRemovalIndicesForSubsonicGet(
indices: number[],
batchSize = PLAYLIST_SONG_ID_GET_BATCH,
): number[][] {
if (indices.length === 0) return [];
const sorted = [...indices].sort((a, b) => b - a);
const batches: number[][] = [];
for (let i = 0; i < sorted.length; i += batchSize) {
batches.push(sorted.slice(i, i + batchSize));
}
return batches;
}
function schedulePinnedPlaylistSync(playlistId: string): void {
void import('@/features/offline')
.then(m => m.schedulePinnedPlaylistSync(playlistId))
.catch(() => {});
}
async function clearPlaylistSongs(id: string, prevCount: number): Promise<void> {
for (const indices of chunkIndicesForSubsonicGet(prevCount)) {
await api('updatePlaylist.view', { playlistId: id, songIndexToRemove: indices });
}
}
export async function getPlaylists(includeOrbit = false): Promise<SubsonicPlaylist[]> {
const data = await api<{ playlists: { playlist: SubsonicPlaylist[] } }>('getPlaylists.view', { _t: Date.now() });
const all = data.playlists?.playlist ?? [];
@@ -45,21 +96,49 @@ export async function createPlaylist(name: string, songIds?: string[]): Promise<
return data.playlist;
}
/** Append tracks without re-sending the full playlist (avoids GET URL length limits). */
export async function addSongsToPlaylist(id: string, songIdsToAdd: string[]): Promise<void> {
if (songIdsToAdd.length === 0) return;
for (const batch of chunkSongIdsForSubsonicGet(songIdsToAdd)) {
await api('updatePlaylist.view', { playlistId: id, songIdToAdd: batch });
}
schedulePinnedPlaylistSync(id);
}
/** Remove tracks by 0-based playlist indices (batched for large playlists). */
export async function removePlaylistSongsAtIndices(id: string, indices: number[]): Promise<void> {
if (indices.length === 0) return;
for (const batch of chunkRemovalIndicesForSubsonicGet(indices)) {
await api('updatePlaylist.view', { playlistId: id, songIndexToRemove: batch });
}
schedulePinnedPlaylistSync(id);
}
export async function updatePlaylist(id: string, songIds: string[], prevCount = 0): Promise<void> {
if (songIds.length > 0) {
if (songIds.length <= PLAYLIST_SONG_ID_GET_BATCH) {
// createPlaylist with playlistId replaces the existing playlist's songs (Subsonic API 1.14+)
await api('createPlaylist.view', { playlistId: id, songId: songIds });
} else if (prevCount > 0) {
// Axios serialises empty arrays as no params — createPlaylist.view would leave songs unchanged.
// Use updatePlaylist.view with explicit index removal to clear the list instead.
await api('updatePlaylist.view', {
playlistId: id,
songIndexToRemove: Array.from({ length: prevCount }, (_, i) => i),
});
} else {
// Lists over the GET batch cap can't replace atomically (URL length limit),
// so we clear then re-append. A failure between the two steps leaves the
// server playlist truncated; the caller invalidates the membership cache so
// the client re-reads truth on next load. This is the unavoidable trade-off
// for supporting playlists larger than one request can carry.
let priorCount = prevCount;
if (priorCount <= 0) {
const { songs } = await getPlaylist(id);
priorCount = songs.length;
}
void import('@/features/offline')
.then(m => m.schedulePinnedPlaylistSync(id))
.catch(() => {});
if (priorCount > 0) {
await clearPlaylistSongs(id, priorCount);
}
await addSongsToPlaylist(id, songIds);
}
} else if (prevCount > 0) {
await clearPlaylistSongs(id, prevCount);
}
schedulePinnedPlaylistSync(id);
}
export async function updatePlaylistMeta(
+38
View File
@@ -0,0 +1,38 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { usePlaylistMembershipStore } from '@/store/playlistMembershipStore';
vi.mock('@/store/authStore', () => ({
useAuthStore: {
getState: () => ({ activeServerId: 'srv-1' }),
},
}));
describe('playlistMembershipStore', () => {
beforeEach(() => {
usePlaylistMembershipStore.setState({ songIdsByCacheKey: {} });
});
it('stores and reads ids scoped to active server', () => {
usePlaylistMembershipStore.getState().setPlaylistSongIds('pl-1', ['a', 'b']);
expect(usePlaylistMembershipStore.getState().getPlaylistSongIds('pl-1')).toEqual(['a', 'b']);
});
it('appends and removes by index', () => {
const store = usePlaylistMembershipStore.getState();
store.setPlaylistSongIds('pl-1', ['a', 'b', 'c']);
store.appendPlaylistSongIds('pl-1', ['d']);
store.removePlaylistSongIdsAtIndices('pl-1', [1]);
expect(usePlaylistMembershipStore.getState().getPlaylistSongIds('pl-1')).toEqual(['a', 'c', 'd']);
});
it('invalidate drops a single playlist; clearAll drops everything', () => {
const store = usePlaylistMembershipStore.getState();
store.setPlaylistSongIds('pl-1', ['a']);
store.setPlaylistSongIds('pl-2', ['b']);
store.invalidatePlaylistSongIds('pl-1');
expect(usePlaylistMembershipStore.getState().getPlaylistSongIds('pl-1')).toBeUndefined();
expect(usePlaylistMembershipStore.getState().getPlaylistSongIds('pl-2')).toEqual(['b']);
store.clearAllPlaylistSongIds();
expect(usePlaylistMembershipStore.getState().getPlaylistSongIds('pl-2')).toBeUndefined();
});
});
+69
View File
@@ -0,0 +1,69 @@
import { create } from 'zustand';
import { useAuthStore } from '@/store/authStore';
/**
* In-memory playlist song-id membership cache, keyed by `serverId:playlistId`.
*
* Lives in the core store layer (not the playlist feature) so `offline`, `orbit`,
* `contextMenu` and `playlist` can all read/write it directly without a cross-feature
* barrel dependency the membership cache is the one piece those features genuinely
* share, and routing it through `@/features/playlist` created import cycles.
*
* Not persisted: playlist membership must reflect the live server, so it is rebuilt
* from `getPlaylist`/`runPlaylistLoad` on demand and dropped on `fetchPlaylists`.
*/
interface PlaylistMembershipStore {
/** Song-id lists keyed by `serverId:playlistId`. */
songIdsByCacheKey: Record<string, readonly string[]>;
getPlaylistSongIds: (playlistId: string) => readonly string[] | undefined;
setPlaylistSongIds: (playlistId: string, songIds: readonly string[]) => void;
appendPlaylistSongIds: (playlistId: string, songIds: readonly string[]) => void;
replacePlaylistSongIds: (playlistId: string, songIds: readonly string[]) => void;
removePlaylistSongIdsAtIndices: (playlistId: string, indices: readonly number[]) => void;
invalidatePlaylistSongIds: (playlistId: string) => void;
clearAllPlaylistSongIds: () => void;
}
/** Scope membership to the active server — playlist ids are not globally unique. */
function cacheKey(playlistId: string): string {
const serverId = useAuthStore.getState().activeServerId ?? '';
return `${serverId}:${playlistId}`;
}
export const usePlaylistMembershipStore = create<PlaylistMembershipStore>()((set, get) => ({
songIdsByCacheKey: {},
getPlaylistSongIds: (playlistId) => get().songIdsByCacheKey[cacheKey(playlistId)],
setPlaylistSongIds: (playlistId, songIds) =>
set((s) => ({
songIdsByCacheKey: { ...s.songIdsByCacheKey, [cacheKey(playlistId)]: [...songIds] },
})),
appendPlaylistSongIds: (playlistId, songIds) => {
if (songIds.length === 0) return;
set((s) => {
const key = cacheKey(playlistId);
const prev = s.songIdsByCacheKey[key] ?? [];
return { songIdsByCacheKey: { ...s.songIdsByCacheKey, [key]: [...prev, ...songIds] } };
});
},
replacePlaylistSongIds: (playlistId, songIds) => get().setPlaylistSongIds(playlistId, songIds),
removePlaylistSongIdsAtIndices: (playlistId, indices) => {
if (indices.length === 0) return;
set((s) => {
const key = cacheKey(playlistId);
const prev = s.songIdsByCacheKey[key];
if (!prev) return s;
const remove = new Set(indices);
return {
songIdsByCacheKey: { ...s.songIdsByCacheKey, [key]: prev.filter((_, i) => !remove.has(i)) },
};
});
},
invalidatePlaylistSongIds: (playlistId) =>
set((s) => {
const key = cacheKey(playlistId);
if (!(key in s.songIdsByCacheKey)) return s;
const { [key]: _removed, ...rest } = s.songIdsByCacheKey;
return { songIdsByCacheKey: rest };
}),
clearAllPlaylistSongIds: () => set({ songIdsByCacheKey: {} }),
}));