mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
* 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:
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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: {} }),
|
||||
}));
|
||||
Reference in New Issue
Block a user