mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +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.
|
* 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.
|
* 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.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)',
|
'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)',
|
'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)',
|
'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 React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ListMusic, Plus } from 'lucide-react';
|
import { ListMusic, Plus } from 'lucide-react';
|
||||||
import { getPlaylist, updatePlaylist } from '@/lib/api/subsonicPlaylists';
|
|
||||||
import type { SubsonicPlaylist } from '@/lib/api/subsonicTypes';
|
import type { SubsonicPlaylist } from '@/lib/api/subsonicTypes';
|
||||||
import { usePlaylistStore } from '@/features/playlist';
|
import { usePlaylistStore } from '@/features/playlist';
|
||||||
|
import { addTracksToPlaylistWithDedup, showAddTracksDedupToast } from '@/features/playlist';
|
||||||
import { showToast } from '@/lib/dom/toast';
|
import { showToast } from '@/lib/dom/toast';
|
||||||
import {
|
import { isSmartPlaylistName } from '@/features/contextMenu/utils/contextMenuHelpers';
|
||||||
confirmAddAllDuplicates,
|
|
||||||
isSmartPlaylistName,
|
|
||||||
} from '@/features/contextMenu/utils/contextMenuHelpers';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
songIds: string[];
|
songIds: string[];
|
||||||
@@ -74,23 +71,9 @@ export function AddToPlaylistSubmenu({ songIds, resolveSongIds, onDone, dropDown
|
|||||||
const ids = idsForAction();
|
const ids = idsForAction();
|
||||||
setAdding(pl.id);
|
setAdding(pl.id);
|
||||||
try {
|
try {
|
||||||
const { songs } = await getPlaylist(pl.id);
|
const result = await addTracksToPlaylistWithDedup(pl.id, pl.name, ids, t);
|
||||||
const existingIds = new Set(songs.map((s) => s.id));
|
showAddTracksDedupToast(t, pl.name, result);
|
||||||
const newIds = ids.filter((id) => !existingIds.has(id));
|
if (result.outcome !== 'skipped') touchPlaylist(pl.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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
showToast(t('playlists.addError'), 3000, 'error');
|
showToast(t('playlists.addError'), 3000, 'error');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ListMusic, Plus } from 'lucide-react';
|
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 type { SubsonicPlaylist } from '@/lib/api/subsonicTypes';
|
||||||
import { usePlaylistStore } from '@/features/playlist';
|
import { usePlaylistStore } from '@/features/playlist';
|
||||||
|
import { addTracksToPlaylistWithDedup, showAddTracksDedupToast } from '@/features/playlist';
|
||||||
import { showToast } from '@/lib/dom/toast';
|
import { showToast } from '@/lib/dom/toast';
|
||||||
import {
|
import { isSmartPlaylistName } from '@/features/contextMenu/utils/contextMenuHelpers';
|
||||||
confirmAddAllDuplicates,
|
|
||||||
isSmartPlaylistName,
|
|
||||||
} from '@/features/contextMenu/utils/contextMenuHelpers';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
albumIds: string[];
|
albumIds: string[];
|
||||||
@@ -39,46 +37,12 @@ export function MultiAlbumToPlaylistSubmenu({ albumIds, onDone, triggerId: _trig
|
|||||||
}, [albumIds]);
|
}, [albumIds]);
|
||||||
|
|
||||||
const handleAddWithToast = async (pl: SubsonicPlaylist, songIds: string[]) => {
|
const handleAddWithToast = async (pl: SubsonicPlaylist, songIds: string[]) => {
|
||||||
const { updatePlaylist } = await import('@/lib/api/subsonicPlaylists');
|
|
||||||
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const serverId = resolveMediaServerId();
|
const result = await addTracksToPlaylistWithDedup(pl.id, pl.name, songIds, t);
|
||||||
if (!serverId) return;
|
showAddTracksDedupToast(t, pl.name, result);
|
||||||
const resolved = await resolvePlaylist(serverId, pl.id);
|
if (result.outcome !== 'skipped') touchPlaylist(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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
showToast(t('playlists.addError'), 4000, 'error');
|
showToast(t('playlists.addError'), 4000, 'error');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ListMusic, Plus } from 'lucide-react';
|
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 { getPlaylists } from '@/lib/api/subsonicPlaylists';
|
||||||
import type { SubsonicPlaylist } from '@/lib/api/subsonicTypes';
|
import type { SubsonicPlaylist } from '@/lib/api/subsonicTypes';
|
||||||
import { usePlaylistStore } from '@/features/playlist';
|
import { usePlaylistStore } from '@/features/playlist';
|
||||||
|
import { addTracksToPlaylistWithDedup, showAddTracksDedupToast } from '@/features/playlist';
|
||||||
import { showToast } from '@/lib/dom/toast';
|
import { showToast } from '@/lib/dom/toast';
|
||||||
import {
|
import { isSmartPlaylistName } from '@/features/contextMenu/utils/contextMenuHelpers';
|
||||||
confirmAddAllDuplicates,
|
|
||||||
isSmartPlaylistName,
|
|
||||||
} from '@/features/contextMenu/utils/contextMenuHelpers';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
artistIds: string[];
|
artistIds: string[];
|
||||||
@@ -53,46 +51,12 @@ export function MultiArtistToPlaylistSubmenu({ artistIds, onDone, triggerId: _tr
|
|||||||
}, [artistIds]);
|
}, [artistIds]);
|
||||||
|
|
||||||
const handleAddWithToast = async (pl: SubsonicPlaylist, songIds: string[]) => {
|
const handleAddWithToast = async (pl: SubsonicPlaylist, songIds: string[]) => {
|
||||||
const { updatePlaylist } = await import('@/lib/api/subsonicPlaylists');
|
|
||||||
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const serverId = resolveMediaServerId();
|
const result = await addTracksToPlaylistWithDedup(pl.id, pl.name, songIds, t);
|
||||||
if (!serverId) return;
|
showAddTracksDedupToast(t, pl.name, result);
|
||||||
const resolved = await resolvePlaylist(serverId, pl.id);
|
if (result.outcome !== 'skipped') touchPlaylist(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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
showToast(t('playlists.addError'), 4000, 'error');
|
showToast(t('playlists.addError'), 4000, 'error');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ListMusic, Plus } from 'lucide-react';
|
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 { showToast } from '@/lib/dom/toast';
|
||||||
import { isSmartPlaylistName } from '@/features/contextMenu/utils/contextMenuHelpers';
|
import { isSmartPlaylistName } from '@/features/contextMenu/utils/contextMenuHelpers';
|
||||||
|
|
||||||
@@ -41,7 +47,7 @@ export function SinglePlaylistToPlaylistSubmenu({ playlist, onDone, triggerId }:
|
|||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!newName.trim()) return;
|
if (!newName.trim()) return;
|
||||||
const { createPlaylist } = await import('@/lib/api/subsonicPlaylists');
|
const createPlaylist = usePlaylistStore.getState().createPlaylist;
|
||||||
try {
|
try {
|
||||||
const newPl = await createPlaylist(newName.trim(), []);
|
const newPl = await createPlaylist(newName.trim(), []);
|
||||||
if (newPl?.id) {
|
if (newPl?.id) {
|
||||||
@@ -55,15 +61,20 @@ export function SinglePlaylistToPlaylistSubmenu({ playlist, onDone, triggerId }:
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAddToNewPlaylist = async (targetId: string, targetName: string) => {
|
const handleAddToNewPlaylist = async (targetId: string, targetName: string) => {
|
||||||
const { getPlaylist, updatePlaylist } = await import('@/lib/api/subsonicPlaylists');
|
|
||||||
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { songs: sourceSongs } = await getPlaylist(playlist.id);
|
const { getPlaylist } = await import('@/lib/api/subsonicPlaylists');
|
||||||
if (sourceSongs.length > 0) {
|
const sourceIds = await resolvePlaylistSongIds(playlist.id, async () => {
|
||||||
await updatePlaylist(targetId, sourceSongs.map((s: { id: string }) => s.id));
|
const { songs } = await getPlaylist(playlist.id);
|
||||||
touchPlaylist(targetId);
|
return songs.map(s => s.id);
|
||||||
showToast(t('playlists.createAndAddSuccess', { count: sourceSongs.length, playlist: targetName }), 3000, 'info');
|
});
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
onDone();
|
onDone();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -73,22 +84,20 @@ export function SinglePlaylistToPlaylistSubmenu({ playlist, onDone, triggerId }:
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAdd = async (targetId: string, targetName: string) => {
|
const handleAdd = async (targetId: string, targetName: string) => {
|
||||||
const { getPlaylist, updatePlaylist } = await import('@/lib/api/subsonicPlaylists');
|
|
||||||
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { songs: targetSongs } = await getPlaylist(targetId);
|
const { getPlaylist } = await import('@/lib/api/subsonicPlaylists');
|
||||||
const targetIds = new Set(targetSongs.map((s: { id: string }) => s.id));
|
const sourceIds = await resolvePlaylistSongIds(playlist.id, async () => {
|
||||||
const { songs: sourceSongs } = await getPlaylist(playlist.id);
|
const { songs } = await getPlaylist(playlist.id);
|
||||||
const newSongs = sourceSongs.filter((s: { id: string }) => !targetIds.has(s.id));
|
return songs.map(s => s.id);
|
||||||
|
});
|
||||||
if (newSongs.length > 0) {
|
const result = await addTracksToPlaylistWithDedup(targetId, targetName, sourceIds, t);
|
||||||
newSongs.forEach((s: { id: string }) => targetIds.add(s.id));
|
if (result.outcome === 'skipped') {
|
||||||
await updatePlaylist(targetId, Array.from(targetIds));
|
|
||||||
touchPlaylist(targetId);
|
|
||||||
showToast(t('playlists.addToPlaylistSuccess', { count: newSongs.length, playlist: targetName }), 3000, 'info');
|
|
||||||
} else {
|
|
||||||
showToast(t('playlists.addToPlaylistNoNew', { playlist: targetName }), 3000, 'info');
|
showToast(t('playlists.addToPlaylistNoNew', { playlist: targetName }), 3000, 'info');
|
||||||
|
} else {
|
||||||
|
showToast(t('playlists.addToPlaylistSuccess', { count: result.addedCount, playlist: targetName }), 3000, 'info');
|
||||||
|
touchPlaylist(targetId);
|
||||||
}
|
}
|
||||||
onDone();
|
onDone();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -180,7 +189,7 @@ export function MultiPlaylistToPlaylistSubmenu({ playlists, onDone, triggerId }:
|
|||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!newName.trim()) return;
|
if (!newName.trim()) return;
|
||||||
const { createPlaylist } = await import('@/lib/api/subsonicPlaylists');
|
const createPlaylist = usePlaylistStore.getState().createPlaylist;
|
||||||
try {
|
try {
|
||||||
const newPl = await createPlaylist(newName.trim(), []);
|
const newPl = await createPlaylist(newName.trim(), []);
|
||||||
if (newPl?.id) {
|
if (newPl?.id) {
|
||||||
@@ -194,61 +203,44 @@ export function MultiPlaylistToPlaylistSubmenu({ playlists, onDone, triggerId }:
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleMergeToNewPlaylist = async (targetId: string, targetName: string) => {
|
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 touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
||||||
|
const membership = usePlaylistMembershipStore.getState();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const targetIds = new Set<string>();
|
const idsToAdd = await collectMergeSongIds(targetId, playlists.map(p => p.id));
|
||||||
let totalAdded = 0;
|
if (idsToAdd.length > 0) {
|
||||||
|
await addSongsToPlaylist(targetId, idsToAdd);
|
||||||
for (const pl of playlists) {
|
membership.appendPlaylistSongIds(targetId, idsToAdd);
|
||||||
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));
|
|
||||||
touchPlaylist(targetId);
|
touchPlaylist(targetId);
|
||||||
showToast(t('playlists.createAndAddSuccess', { count: totalAdded, playlist: targetName }), 3000, 'info');
|
showToast(t('playlists.createAndAddSuccess', { count: idsToAdd.length, playlist: targetName }), 3000, 'info');
|
||||||
}
|
}
|
||||||
onDone();
|
onDone();
|
||||||
} catch {
|
} catch {
|
||||||
|
membership.invalidatePlaylistSongIds(targetId);
|
||||||
showToast(t('playlists.mergeError'), 4000, 'error');
|
showToast(t('playlists.mergeError'), 4000, 'error');
|
||||||
onDone();
|
onDone();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMerge = async (targetId: string, targetName: string) => {
|
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 touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
||||||
|
const membership = usePlaylistMembershipStore.getState();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { songs: targetSongs } = await getPlaylist(targetId);
|
const idsToAdd = await collectMergeSongIds(targetId, playlists.map(p => p.id));
|
||||||
const targetIds = new Set(targetSongs.map((s: { id: string }) => s.id));
|
if (idsToAdd.length > 0) {
|
||||||
let totalAdded = 0;
|
await addSongsToPlaylist(targetId, idsToAdd);
|
||||||
|
membership.appendPlaylistSongIds(targetId, idsToAdd);
|
||||||
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));
|
|
||||||
touchPlaylist(targetId);
|
touchPlaylist(targetId);
|
||||||
showToast(t('playlists.mergeSuccess', { count: totalAdded, playlist: targetName }), 3000, 'info');
|
showToast(t('playlists.mergeSuccess', { count: idsToAdd.length, playlist: targetName }), 3000, 'info');
|
||||||
} else {
|
} else {
|
||||||
showToast(t('playlists.mergeNoNewSongs'), 3000, 'info');
|
showToast(t('playlists.mergeNoNewSongs'), 3000, 'info');
|
||||||
}
|
}
|
||||||
onDone();
|
onDone();
|
||||||
} catch {
|
} catch {
|
||||||
|
membership.invalidatePlaylistSongIds(targetId);
|
||||||
showToast(t('playlists.mergeError'), 4000, 'error');
|
showToast(t('playlists.mergeError'), 4000, 'error');
|
||||||
onDone();
|
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 { 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 { useNavigateToAlbum } from '@/features/album';
|
||||||
import { useNavigateToArtist } from '@/features/artist';
|
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 { queueSongStar } from '@/features/playback/store/pendingStarSync';
|
||||||
import { getMusicNetworkRuntime, useEnrichmentPrimary } from '@/music-network';
|
import { getMusicNetworkRuntime, useEnrichmentPrimary } from '@/music-network';
|
||||||
import type { Track } from '@/lib/media/trackTypes';
|
import type { Track } from '@/lib/media/trackTypes';
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
import { usePlaylistStore } from '@/features/playlist';
|
import { usePlaylistStore } from '@/features/playlist';
|
||||||
|
import { usePlaylistMembershipStore } from '@/store/playlistMembershipStore';
|
||||||
import { songToTrack } from '@/lib/media/songToTrack';
|
import { songToTrack } from '@/lib/media/songToTrack';
|
||||||
import { showToast } from '@/lib/dom/toast';
|
import { showToast } from '@/lib/dom/toast';
|
||||||
import { suggestOrbitTrack, hostEnqueueToOrbit, evaluateOrbitSuggestGate, OrbitSuggestBlockedError } from '@/features/orbit';
|
import { suggestOrbitTrack, hostEnqueueToOrbit, evaluateOrbitSuggestGate, OrbitSuggestBlockedError } from '@/features/orbit';
|
||||||
@@ -178,21 +179,17 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
|
|||||||
</div>
|
</div>
|
||||||
{offlinePolicy.canEditPlaylist && playlistId && playlistSongIndex !== undefined && (
|
{offlinePolicy.canEditPlaylist && playlistId && playlistSongIndex !== undefined && (
|
||||||
<div className="context-menu-item" style={{ color: 'var(--danger)' }} onClick={() => handleAction(async () => {
|
<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 { showToast } = await import('@/lib/dom/toast');
|
||||||
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
|
const { touchPlaylist } = usePlaylistStore.getState();
|
||||||
|
const membership = usePlaylistMembershipStore.getState();
|
||||||
try {
|
try {
|
||||||
const serverId = resolveMediaServerId();
|
await removePlaylistSongsAtIndices(playlistId, [playlistSongIndex]);
|
||||||
if (!serverId) return;
|
membership.removePlaylistSongIdsAtIndices(playlistId, [playlistSongIndex]);
|
||||||
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);
|
|
||||||
touchPlaylist(playlistId);
|
touchPlaylist(playlistId);
|
||||||
showToast(t('playlists.removeSuccess'), 3000, 'info');
|
showToast(t('playlists.removeSuccess'), 3000, 'info');
|
||||||
} catch {
|
} catch {
|
||||||
|
membership.invalidatePlaylistSongIds(playlistId);
|
||||||
showToast(t('playlists.removeError'), 4000, 'error');
|
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. */
|
/** Psysonic smart playlists (Navidrome); not valid targets for manual add-to-playlist. */
|
||||||
export const SMART_PLAYLIST_PREFIX = 'psy-smart-';
|
export const SMART_PLAYLIST_PREFIX = 'psy-smart-';
|
||||||
|
|
||||||
@@ -25,18 +23,3 @@ export function shuffleArray<T>(arr: T[]): T[] {
|
|||||||
return result;
|
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 type { PinSource } from '@/store/localPlaybackStore';
|
||||||
import { useLocalPlaybackStore } from '@/store/localPlaybackStore';
|
import { useLocalPlaybackStore } from '@/store/localPlaybackStore';
|
||||||
import { useOfflineStore } from '@/features/offline/store/offlineStore';
|
import { useOfflineStore } from '@/features/offline/store/offlineStore';
|
||||||
import { usePlaylistStore } from '@/features/playlist';
|
|
||||||
import { isSmartPlaylistName } from '@/lib/format/playlistDetailHelpers';
|
import { isSmartPlaylistName } from '@/lib/format/playlistDetailHelpers';
|
||||||
import { getMediaDir } from '@/lib/media/mediaDir';
|
import { getMediaDir } from '@/lib/media/mediaDir';
|
||||||
import { deleteMediaFile } from '@/lib/api/syncfs';
|
import { deleteMediaFile } from '@/lib/api/syncfs';
|
||||||
@@ -58,8 +57,11 @@ function offlineMeta(sourceId: string, serverId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolvePlaylistName(playlistId: string, serverId: string): string | undefined {
|
function resolvePlaylistName(playlistId: string, serverId: string): string | undefined {
|
||||||
return offlineMeta(playlistId, serverId)?.name
|
// Only pinned playlists reach the nameless internal callers (all gated by
|
||||||
?? usePlaylistStore.getState().playlists.find(p => p.id === playlistId)?.name;
|
// 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. */
|
/** 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 { getSong } from '@/lib/api/subsonicLibrary';
|
||||||
import { songToTrack } from '@/lib/media/songToTrack';
|
import { songToTrack } from '@/lib/media/songToTrack';
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
|
import { usePlaylistMembershipStore } from '@/store/playlistMembershipStore';
|
||||||
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
|
import { useOrbitStore } from '@/features/orbit/store/orbitStore';
|
||||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||||
import {
|
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
|
* 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
|
* playlist update replaces the song list wholesale on sweep, so we read the
|
||||||
* ids along. Shared by the initial suggest and the guest tick's lost-update
|
* current server list before append. A no-op when the track is already present
|
||||||
* re-send (where a no-op append means the host already cleared + recorded it).
|
* means the host already cleared + recorded it (lost-update re-send path).
|
||||||
*/
|
*/
|
||||||
export async function ensureTrackInOutbox(outboxPlaylistId: string, trackId: string): Promise<void> {
|
export async function ensureTrackInOutbox(outboxPlaylistId: string, trackId: string): Promise<void> {
|
||||||
const { songs } = await getPlaylist(outboxPlaylistId);
|
const { songs } = await getPlaylist(outboxPlaylistId);
|
||||||
const ids = songs.map(s => s.id);
|
const ids = songs.map(s => s.id);
|
||||||
if (ids.includes(trackId)) return;
|
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/playlistFolderStore';
|
||||||
export * from './store/playlistLayoutStore';
|
export * from './store/playlistLayoutStore';
|
||||||
export * from './store/playlistStore';
|
export * from './store/playlistStore';
|
||||||
|
export * from './utils/addTracksToPlaylistWithDedup';
|
||||||
export * from './utils/playlistBulkPlayActions';
|
export * from './utils/playlistBulkPlayActions';
|
||||||
export * from './utils/playlistDisplayedSongs';
|
export * from './utils/playlistDisplayedSongs';
|
||||||
export * from './utils/playlistFolders';
|
export * from './utils/playlistFolders';
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useTracklistColumns, type ColDef } from '@/lib/hooks/useTracklistColumn
|
|||||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { usePlaylistStore } from '@/features/playlist/store/playlistStore';
|
import { usePlaylistStore } from '@/features/playlist/store/playlistStore';
|
||||||
|
import { usePlaylistMembershipStore } from '@/store/playlistMembershipStore';
|
||||||
import { useOfflineStore } from '@/features/offline';
|
import { useOfflineStore } from '@/features/offline';
|
||||||
import { useLocalPlaybackStore } from '@/store/localPlaybackStore';
|
import { useLocalPlaybackStore } from '@/store/localPlaybackStore';
|
||||||
import { useAlbumOfflineState } from '@/features/album';
|
import { useAlbumOfflineState } from '@/features/album';
|
||||||
@@ -117,8 +118,11 @@ export default function PlaylistDetail() {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await updatePlaylist(id, updatedSongs.map(s => s.id), prevCount);
|
await updatePlaylist(id, updatedSongs.map(s => s.id), prevCount);
|
||||||
if (id) touchPlaylist(id);
|
usePlaylistMembershipStore.getState().replacePlaylistSongIds(id, updatedSongs.map(s => s.id));
|
||||||
} catch { /* ignore: best-effort */ }
|
touchPlaylist(id);
|
||||||
|
} catch {
|
||||||
|
usePlaylistMembershipStore.getState().invalidatePlaylistSongIds(id);
|
||||||
|
}
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}, [id, touchPlaylist]);
|
}, [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 type { SubsonicPlaylist } from '@/lib/api/subsonicTypes';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import { createPlaylist as apiCreatePlaylist } from '@/lib/api/subsonicPlaylists';
|
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
import { isOfflineBrowseActive } from '@/features/offline';
|
import { isOfflineBrowseActive, fetchOfflineBrowsablePlaylists } from '@/features/offline';
|
||||||
import { fetchOfflineBrowsablePlaylists } from '@/features/offline';
|
import { usePlaylistMembershipStore } from '@/store/playlistMembershipStore';
|
||||||
|
|
||||||
interface PlaylistStore {
|
interface PlaylistStore {
|
||||||
recentIds: string[];
|
recentIds: string[];
|
||||||
playlists: SubsonicPlaylist[];
|
playlists: SubsonicPlaylist[];
|
||||||
@@ -30,10 +30,13 @@ export const usePlaylistStore = create<PlaylistStore>()(
|
|||||||
recentIds: [id, ...s.recentIds.filter((x) => x !== id)].slice(0, 50),
|
recentIds: [id, ...s.recentIds.filter((x) => x !== id)].slice(0, 50),
|
||||||
lastModified: { ...s.lastModified, [id]: Date.now() },
|
lastModified: { ...s.lastModified, [id]: Date.now() },
|
||||||
})),
|
})),
|
||||||
removeId: (id) =>
|
removeId: (id) => {
|
||||||
set((s) => ({ recentIds: s.recentIds.filter((x) => x !== id) })),
|
usePlaylistMembershipStore.getState().invalidatePlaylistSongIds(id);
|
||||||
|
set((s) => ({ recentIds: s.recentIds.filter((x) => x !== id) }));
|
||||||
|
},
|
||||||
fetchPlaylists: async () => {
|
fetchPlaylists: async () => {
|
||||||
set({ playlistsLoading: true });
|
set({ playlistsLoading: true });
|
||||||
|
usePlaylistMembershipStore.getState().clearAllPlaylistSongIds();
|
||||||
try {
|
try {
|
||||||
const serverId = useAuthStore.getState().activeServerId;
|
const serverId = useAuthStore.getState().activeServerId;
|
||||||
if (isOfflineBrowseActive() && serverId) {
|
if (isOfflineBrowseActive() && serverId) {
|
||||||
@@ -54,6 +57,7 @@ export const usePlaylistStore = create<PlaylistStore>()(
|
|||||||
playlists: [...s.playlists, playlist],
|
playlists: [...s.playlists, playlist],
|
||||||
recentIds: [playlist.id, ...s.recentIds.filter((x) => x !== playlist.id)].slice(0, 50),
|
recentIds: [playlist.id, ...s.recentIds.filter((x) => x !== playlist.id)].slice(0, 50),
|
||||||
}));
|
}));
|
||||||
|
usePlaylistMembershipStore.getState().setPlaylistSongIds(playlist.id, songIds ?? []);
|
||||||
return playlist;
|
return playlist;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
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 type { SubsonicPlaylist, SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
import { usePlaylistStore } from '@/features/playlist/store/playlistStore';
|
import { usePlaylistStore } from '@/features/playlist/store/playlistStore';
|
||||||
|
import { usePlaylistMembershipStore } from '@/store/playlistMembershipStore';
|
||||||
import { isOfflineBrowseActive } from '@/features/offline';
|
import { isOfflineBrowseActive } from '@/features/offline';
|
||||||
import { resolvePlaylist } from '@/features/offline';
|
import { resolvePlaylist } from '@/features/offline';
|
||||||
|
|
||||||
@@ -21,6 +22,11 @@ function applyLoadedPlaylist(
|
|||||||
deps: RunPlaylistLoadDeps,
|
deps: RunPlaylistLoadDeps,
|
||||||
playlist: SubsonicPlaylist,
|
playlist: SubsonicPlaylist,
|
||||||
songs: SubsonicSong[],
|
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 {
|
): void {
|
||||||
const { setPlaylist, setSongs, setCustomCoverId, setRatings, setStarredSongs } = deps;
|
const { setPlaylist, setSongs, setCustomCoverId, setRatings, setStarredSongs } = deps;
|
||||||
setPlaylist(playlist);
|
setPlaylist(playlist);
|
||||||
@@ -34,6 +40,7 @@ function applyLoadedPlaylist(
|
|||||||
});
|
});
|
||||||
setRatings(init);
|
setRatings(init);
|
||||||
setStarredSongs(starred);
|
setStarredSongs(starred);
|
||||||
|
usePlaylistMembershipStore.getState().setPlaylistSongIds(deps.id, membershipIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runPlaylistLoad(deps: RunPlaylistLoadDeps): Promise<void> {
|
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 { playlist, songs } = await getPlaylist(id);
|
||||||
const filteredSongs = await filterSongsToActiveLibrary(songs);
|
const filteredSongs = await filterSongsToActiveLibrary(songs);
|
||||||
applyLoadedPlaylist(deps, playlist, filteredSongs);
|
applyLoadedPlaylist(deps, playlist, filteredSongs, songs.map(s => s.id));
|
||||||
} catch {
|
} catch {
|
||||||
const stub = usePlaylistStore.getState().playlists.find(p => p.id === id);
|
const stub = usePlaylistStore.getState().playlists.find(p => p.id === id);
|
||||||
if (stub) {
|
if (stub) {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import type { TFunction } from 'i18next';
|
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 type { SubsonicPlaylist } from '@/lib/api/subsonicTypes';
|
||||||
import { usePlaylistStore } from '@/features/playlist/store/playlistStore';
|
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';
|
import { showToast } from '@/lib/dom/toast';
|
||||||
|
|
||||||
export interface RunPlaylistDeleteDeps {
|
export interface RunPlaylistDeleteDeps {
|
||||||
@@ -82,29 +84,22 @@ export async function runPlaylistMergeSelected(deps: RunPlaylistMergeSelectedDep
|
|||||||
const { targetPlaylist, selectedPlaylists, touchPlaylist, clearSelection, t } = deps;
|
const { targetPlaylist, selectedPlaylists, touchPlaylist, clearSelection, t } = deps;
|
||||||
if (selectedPlaylists.length === 0) return;
|
if (selectedPlaylists.length === 0) return;
|
||||||
try {
|
try {
|
||||||
const { songs: targetSongs } = await getPlaylist(targetPlaylist.id);
|
const sourceIds = selectedPlaylists
|
||||||
const targetIds = new Set(targetSongs.map(s => s.id));
|
.filter(pl => pl.id !== targetPlaylist.id)
|
||||||
let totalAdded = 0;
|
.map(pl => pl.id);
|
||||||
|
const idsToAdd = await collectMergeSongIds(targetPlaylist.id, sourceIds);
|
||||||
|
|
||||||
for (const pl of selectedPlaylists) {
|
if (idsToAdd.length > 0) {
|
||||||
if (pl.id === targetPlaylist.id) continue;
|
await addSongsToPlaylist(targetPlaylist.id, idsToAdd);
|
||||||
const { songs } = await getPlaylist(pl.id);
|
usePlaylistMembershipStore.getState().appendPlaylistSongIds(targetPlaylist.id, idsToAdd);
|
||||||
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));
|
|
||||||
touchPlaylist(targetPlaylist.id);
|
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 {
|
} else {
|
||||||
showToast(t('playlists.mergeNoNewSongs'), 3000, 'info');
|
showToast(t('playlists.mergeNoNewSongs'), 3000, 'info');
|
||||||
}
|
}
|
||||||
clearSelection();
|
clearSelection();
|
||||||
} catch {
|
} catch {
|
||||||
|
usePlaylistMembershipStore.getState().invalidatePlaylistSongIds(targetPlaylist.id);
|
||||||
showToast(t('playlists.mergeError'), 4000, 'error');
|
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 { api, apiForServer } from '@/lib/api/subsonicClient';
|
||||||
import type { SubsonicPlaylist, SubsonicSong } from '@/lib/api/subsonicTypes';
|
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[]> {
|
export async function getPlaylists(includeOrbit = false): Promise<SubsonicPlaylist[]> {
|
||||||
const data = await api<{ playlists: { playlist: SubsonicPlaylist[] } }>('getPlaylists.view', { _t: Date.now() });
|
const data = await api<{ playlists: { playlist: SubsonicPlaylist[] } }>('getPlaylists.view', { _t: Date.now() });
|
||||||
const all = data.playlists?.playlist ?? [];
|
const all = data.playlists?.playlist ?? [];
|
||||||
@@ -45,21 +96,49 @@ export async function createPlaylist(name: string, songIds?: string[]): Promise<
|
|||||||
return data.playlist;
|
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> {
|
export async function updatePlaylist(id: string, songIds: string[], prevCount = 0): Promise<void> {
|
||||||
if (songIds.length > 0) {
|
if (songIds.length > 0) {
|
||||||
// createPlaylist with playlistId replaces the existing playlist's songs (Subsonic API 1.14+)
|
if (songIds.length <= PLAYLIST_SONG_ID_GET_BATCH) {
|
||||||
await api('createPlaylist.view', { playlistId: id, songId: songIds });
|
// createPlaylist with playlistId replaces the existing playlist's songs (Subsonic API 1.14+)
|
||||||
|
await api('createPlaylist.view', { playlistId: id, songId: songIds });
|
||||||
|
} 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;
|
||||||
|
}
|
||||||
|
if (priorCount > 0) {
|
||||||
|
await clearPlaylistSongs(id, priorCount);
|
||||||
|
}
|
||||||
|
await addSongsToPlaylist(id, songIds);
|
||||||
|
}
|
||||||
} else if (prevCount > 0) {
|
} else if (prevCount > 0) {
|
||||||
// Axios serialises empty arrays as no params — createPlaylist.view would leave songs unchanged.
|
await clearPlaylistSongs(id, prevCount);
|
||||||
// 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),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
void import('@/features/offline')
|
schedulePinnedPlaylistSync(id);
|
||||||
.then(m => m.schedulePinnedPlaylistSync(id))
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updatePlaylistMeta(
|
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