feat(offline): unify local playback, offline library, and favorites sync (#1008)

* feat(local-playback): LP-1 media layout and download_track_local

Add library-index-backed path builder in psysonic-core and a unified
Tauri download command that writes under media/{cache|library}/ with
layout fingerprints; legacy hot/offline commands unchanged for now.

* feat(local-playback): LP-2 localPlaybackStore and media tier Rust helpers

Add unified Zustand index with legacy offline/hot-cache import, media_layout
TS mirror, and Rust commands for tier size/purge/delete/promote.

* feat(local-playback): LP-3 wire prefetch and playback to unified index

Route downloads through download_track_local, delegate hot/offline shims
to localPlaybackStore, and update resolve/promote/prefetch plus key rewrite.

* feat(local-playback): LP-4–LP-6 offline UI, invalidation, and mediaDir

Offline Library loads pinned groups via library index; sync-idle invalidates
stale paths; Settings uses a single mediaDir with cache/library tier sizes.

* feat(local-playback): migrate legacy offline files to media/library layout

Move flat psysonic-offline downloads into nested media/library paths using
library index metadata, with retry on sync-idle when tracks are not yet indexed.

* feat(local-playback): simplify offline disk migration and restore Offline Library UI

Scan psysonic-offline on disk and relocate by library track id; restore pinSource
and cover art for migrated pins; add find_live_by_id for segment/key resolution.

* feat(local-playback): disk-first offline reconcile and fast library tier discovery

Reconcile library-tier index against on-disk files using candidate track IDs
instead of scanning the full catalog. Refresh Offline Library from disk on
open and focus so deleted folders drop out of the UI. Add Rust discover/prune
helpers and wire album/server reconcile through the unified path.

* fix(offline): resolve local playback URLs across server index-key variants

Offline Library play failed when library-tier files were indexed under a
host key while playback looked up only the active profile UUID. Use
findLocalPlaybackEntry for URL resolution, pin queueServerId to the card
server, and build play queues from tracks that still have on-disk bytes.

* fix(offline): playlist cards, playback from Offline Library, and local URL routing

Show playlists with name and quad/custom cover instead of the first track's
album artist. Build play queues with library-batch fallback and offline-only
server switch. Prefer library-tier URLs in playTrack; add playback-unavailable
toast and missing trackToSong import.

* fix(cache): ephemeral disk reconcile, empty-dir prune, and Storage UI

Sweep media/cache after eviction (orphan files, stale index, empty folders).
Settings: split media folder from cover cache; in-browser image cache lives
under Cover art cache with aligned columns; clear only IndexedDB images.

* feat(offline): show library disk usage in Offline Library header

Query media/library tier size on reconcile and display it in a right-aligned
stat block beside the page title and album count.

* fix(cache): defer unindexed hot-cache eviction; drop legacy offline size cap

Reconcile ephemeral cache without deleting files from other app instances;
evict unindexed hot-cache files oldest-first only when over hotCacheMaxMb.
Remove the hidden maxCacheMb gate and offline-full banner on album pages.

* feat(offline): play-all cache card and stable Offline Library grid rows

Add a shuffle-and-play card for all on-disk library pins plus hot-cache
tracks when buffering is enabled. Fix virtual row height for offline cards
and reserve the year line so grid rows no longer overlap.

* feat(offline): queue-cache grid card limited to media/cache

Replace the full-width play-all banner with a playlist-style grid tile.
Shuffle/enqueue only ephemeral hot-cache tracks when buffering is enabled,
not offline library pins.

* chore(licenses): regenerate bundled OSS list for 1.48.0-dev

Set GPL-3.0-or-later on workspace crates and extend cargo-about accepted
licenses so generate-licenses.mjs runs; refresh src/data/licenses.json.

* feat(favorites): auto-sync starred tracks into separate media/favorites tier

Keep manual Offline Library in media/library/ and favorites offline in
favorite-auto/index + media/favorites/ so toggling sync cannot purge
user-pinned bytes; playback resolves library before favorites.

* feat(favorites): compact offline toggle with disk icon and sync semaphore

Move control to the page header (disk + switch, tooltips); show red/yellow/green
LED when enabled instead of the full-width save-offline card.

* fix(favorites): trigger offline sync on star/unstar from anywhere

Hook star/unstar API so favorites offline reconcile runs globally (songs,
albums, artists); optimistic unstar removes local bytes; drop Favorites-page-only sync.

* fix(cache): skip hot-cache prefetch when favorites or library bytes exist

Treat favorite-auto tier like offline library for prefetch, stream promote,
and same-track replay so synced favorites are not duplicated in media/cache.

* fix(favorites): reconcile offline files on merged track union only

Dedupe artist/album/song stars into one target set per track id; drop eager
unstar deletes so overlapping favorites do not remove bytes still needed.

* feat(offline): add Favorites card to Offline Library

Mirror queue-cache card for favorite-auto tier with play, enqueue, and
navigation to Favorites on card click.

* feat(offline): show library+favorites disk total with icon breakdown

Sum media/library and media/favorites in the On disk widget and open an
icon popover on hover with per-tier sizes for screen readers and sighted users.

* feat(favorites): enable offline Favorites tab when auto-save is on

Keep Favorites in the sidebar when disconnected, land on /favorites without
manual pins, and load starred rows from the local library index.

* feat(favorites): cross-server offline browse with per-server covers

When auto-save is on, Favorites merges starred items from every indexed
server and syncs each server independently. Detail links carry ?server=
for offline album/artist pages; cover art resolves disk cache by entity
serverId instead of the active server only.

* feat(playback): mixed-server queue scope and cross-server favorites sync

Per-ref server identity for playback (URL index key in queue refs, profile
UUID for API): trackServerScope, playbackServer helpers, gapless/scrobble/covers
by playing ref. Remove cross-server enqueue block; remap queueItems on URL
remigration.

Favorites: star/unstar and favorite-auto sync target the owning serverId
(not only active); queueSongStar passes server through pending sync.

* fix(offline): suppress Subsonic calls during favorites and local playback

Add reachability guards so offline favorites browse, album detail, queue
sync, scrobble, and Now Playing metadata skip network when the server is
down or the track plays from psysonic-local. Load starred albums/tracks
from the library index only (not the full artist table), refresh favorites
from index first, and pass server scope in favorites navigation.

* fix(queue): export share and playlist save for active server only

Mixed-server queues now filter queue refs by the browsed server profile
before copying a share link or saving/updating a playlist from the toolbar.

* fix(offline): complete album pins and resume interrupted downloads

Prefer full getAlbum track lists when online so partial library index
does not truncate offline pins; refresh songs before pin from album
detail. Resume incomplete persisted pins after reconcile and reconnect,
cancel in-flight work on delete, and chunk library batch fetches past
100 refs.

* fix(offline): pin queue, queued UI, and re-pin after remove

Serialize album and playlist offline pins so parallel enqueue no longer
drops in-flight work. Show an explicit queued state on album and playlist
actions with dequeue on repeat click, sidebar tooltips for long labels,
and clear stale cancel flags so Make available offline works after remove.

* fix(offline): remove Offline Library cards without full page reload

Optimistically drop the deleted card from local grid state, show the
loading spinner only on first visit, and ignore stale disk refreshes so
pin updates no longer flash the whole library view.

* fix(offline): artist discography pin state and queue handling

Detect cached/queued/downloading from persisted album pins instead of
ephemeral bulkProgress, skip already offline or in-flight albums when
enqueueing discography, and show the correct hero button after revisit.

* fix(local-playback): address LP-1 review handoff (B1, M1–M7)

Harden media path sanitization and tier containment, align Rust/TS layout
fingerprints, serialize per-track downloads, and fix favorites re-enable,
multi-server debounce, prev-track promote key, now-playing reachability,
and ephemeral prefetch soft-skip for unindexed tracks.

* fix(offline): cancel in-flight favorites downloads on unstar

Abort Rust streams with the real favorites downloadId when sync is
rescheduled or disabled, and drop completed bytes that no longer belong
in the starred set so unstar does not leave orphan files on disk.

* fix(build): resolve TypeScript errors blocking prod nix build

Align mediaLayout with LibraryTrackDto camelCase, extend analysis-sync
reasons, fix OfflineLibrary grid cover typing, and tighten vitest mocks
so `tsc && vite build` passes under the flake beforeBuildCommand.

* fix(rust): satisfy clippy too_many_arguments for CI

Bundle offline-library analysis and local path/migration helpers into
parameter structs so `cargo clippy -D warnings` passes on the branch.

* fix(settings): show correct hot-cache track count in Buffering section

Count ephemeral localPlayback rows instead of prefix-matching index keys,
which always missed host:port server segments and showed zero tracks.

* fix(media-layout): align truncation threshold on code points (M1)

Rust sanitize_and_truncate_segment now uses char count like TS so long
non-ASCII metadata does not diverge layout fingerprints; add Cyrillic
parity tests and clarify ephemeral cold-miss doc on download_track_local.

* fix(test): use numeric cachedAt in hotCacheStore count test

Align test fixture with LocalPlaybackEntry type so tsc passes in CI.

* docs: add CHANGELOG and credits for offline experience PR #1008

* feat(offline): auto-sync manually cached playlists when track list changes

Re-download new tracks and prune removed ones for playlist pins only, triggered
from updatePlaylist, playlist detail load, smart-playlist polling, and reconnect.

* docs: note cached-playlist sync in CHANGELOG and credits for PR #1008

* fix(offline): exclude smart playlists from manual offline cache and sync

Hide cache-offline for psy-smart-* playlists, block download/sync paths, and
document the distinction in CHANGELOG.

* feat(offline): auto-sync cached albums and artist discographies

Generalize pinned playlist reconcile into pinnedOfflineSync so manually
pinned albums and artist discographies re-download added tracks and prune
removed ones on reopen, reconnect, and catalog changes.

* feat(offline): split pinned sync triggers by pin kind

Album and artist pins reconcile after library index sync and reconnect;
regular playlists reconcile hourly and on in-app playlist edits only.
Remove reconcile-on-open for album, artist, and playlist detail views.

* fix(offline): address PR #1008 review (N1, tests, pin queue)

Scope playlist reconcile to the owning server via getPlaylistForServer.
Add artist discography and mixed-server playlist tests; dedupe pending
sync jobs; skip pinTasks overwrite during active downloads.
This commit is contained in:
cucadmuh
2026-06-06 22:43:03 +03:00
committed by GitHub
parent 40db6b08d2
commit 2d3c723a6e
228 changed files with 11551 additions and 1534 deletions
+66
View File
@@ -0,0 +1,66 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { reconcileEphemeralCache } from './ephemeralTierReconcile';
import { useLocalPlaybackStore } from '../../store/localPlaybackStore';
vi.mock('@tauri-apps/api/core', () => ({
invoke: vi.fn(),
}));
import { invoke } from '@tauri-apps/api/core';
describe('reconcileEphemeralCache', () => {
beforeEach(() => {
vi.mocked(invoke).mockReset();
useLocalPlaybackStore.setState({ entries: {} });
});
it('drops stale index rows and prunes empty dirs without deleting unindexed files', async () => {
useLocalPlaybackStore.setState({
entries: {
'srv:gone': {
serverIndexKey: 'srv',
trackId: 'gone',
localPath: '/media/cache/srv/gone.flac',
layoutFingerprint: '',
sizeBytes: 1,
tier: 'ephemeral',
cachedAt: 1,
suffix: 'flac',
},
'srv:keep': {
serverIndexKey: 'srv',
trackId: 'keep',
localPath: '/media/cache/srv/keep.flac',
layoutFingerprint: '',
sizeBytes: 2,
tier: 'ephemeral',
cachedAt: 2,
suffix: 'flac',
},
},
});
vi.mocked(invoke).mockImplementation(async (cmd, args) => {
if (cmd === 'probe_media_files') {
const paths = (args as { localPaths: string[] }).localPaths;
return paths.map(p => p.endsWith('keep.flac'));
}
if (cmd === 'prune_empty_media_tier_dirs') return undefined;
throw new Error(`unexpected invoke ${cmd}`);
});
const result = await reconcileEphemeralCache();
expect(result).toEqual({ removedStaleIndex: 1 });
expect(useLocalPlaybackStore.getState().entries['srv:keep']).toBeDefined();
expect(useLocalPlaybackStore.getState().entries['srv:gone']).toBeUndefined();
expect(invoke).not.toHaveBeenCalledWith(
'prune_orphan_ephemeral_cache_files',
expect.anything(),
);
expect(invoke).toHaveBeenCalledWith('prune_empty_media_tier_dirs', {
tier: 'ephemeral',
mediaDir: null,
});
});
});
+67
View File
@@ -0,0 +1,67 @@
import { invoke } from '@tauri-apps/api/core';
import { useLocalPlaybackStore } from '../../store/localPlaybackStore';
import { parseLocalPlaybackEntryKey } from '../../store/localPlaybackKeys';
import { getMediaDir } from '../media/mediaDir';
export interface EphemeralReconcileResult {
removedStaleIndex: number;
}
/** On-disk byte total under `{media}/cache/` (all instances sharing the media dir). */
export async function getEphemeralDiskBytes(mediaDir: string | null): Promise<number> {
return invoke<number>('get_media_tier_size', { tier: 'ephemeral', mediaDir }).catch(() => 0);
}
/**
* Delete cache files not in `keepPaths`, oldest mtime first, until tier size ≤ `maxBytes`.
* Used when dev/prod share one media dir and another instance's bytes are not in this index.
*/
export async function evictEphemeralOrphansToFit(
maxBytes: number,
mediaDir: string | null,
keepPaths: string[],
): Promise<string[]> {
return invoke<string[]>('evict_ephemeral_cache_orphans_to_fit', {
keepPaths,
maxBytes,
mediaDir,
}).catch(() => []);
}
/**
* Index↔disk sync without evicting unindexed files (safe when dev + prod share `media/cache/`):
* - drop index rows whose files are gone
* - prune empty directories under `{media}/cache/`
*
* Unindexed on-disk files are removed only from `evictEphemeralToFit` when over budget.
*/
export async function reconcileEphemeralCache(): Promise<EphemeralReconcileResult> {
const lp = useLocalPlaybackStore.getState();
const mediaDir = getMediaDir();
const ephemeral = Object.entries(lp.entries).filter(([, e]) => e.tier === 'ephemeral');
const paths = ephemeral.map(([, e]) => e.localPath);
const existsFlags =
paths.length > 0
? await invoke<boolean[]>('probe_media_files', { localPaths: paths }).catch(() =>
paths.map(() => false),
)
: [];
let removedStaleIndex = 0;
ephemeral.forEach(([key, entry], i) => {
if (existsFlags[i]) {
return;
}
const parsed = parseLocalPlaybackEntryKey(key);
if (parsed) {
lp.removeEntry(parsed.trackId, parsed.serverIndexKey, 'reconcile-missing-bytes');
removedStaleIndex += 1;
}
});
await invoke('prune_empty_media_tier_dirs', { tier: 'ephemeral', mediaDir }).catch(() => {});
return { removedStaleIndex };
}
+5
View File
@@ -32,6 +32,11 @@ describe('estimateRowHeightPx', () => {
it('playlist behaves like album', () => {
expect(estimateRowHeightPx(200, 'playlist')).toBe(308);
});
it('offline is taller than album for the track-count footer', () => {
expect(estimateRowHeightPx(200, 'offline')).toBe(340);
expect(estimateRowHeightPx(200, 'album')).toBe(308);
});
});
});
+3 -1
View File
@@ -31,13 +31,15 @@ export function computeCellWidthPx(containerWidthPx: number, columnCount: number
return (containerWidthPx - (c - 1) * CARD_GRID_GAP_PX) / c;
}
export type CardGridRowHeightVariant = 'artist' | 'album' | 'playlist' | 'composer';
export type CardGridRowHeightVariant = 'artist' | 'album' | 'playlist' | 'offline' | 'composer';
const VARIANT: Record<CardGridRowHeightVariant, { extra: number; min: number; max: number }> = {
artist: { extra: 72, min: 200, max: 520 },
/** Cover scales with cell width; ~108px headroom matches prior ~288px row at ~180px tiles. */
album: { extra: 108, min: 260, max: 560 },
playlist: { extra: 108, min: 260, max: 560 },
/** Offline Library cards: album metadata + track-count footer + row gap in virtual rows. */
offline: { extra: 140, min: 290, max: 580 },
/** Text-only composer tiles: no imagery → fixed intrinsic height, does not
* scale with cell width like the image variants. min === max pins it. */
composer: { extra: 0, min: 88, max: 88 },
@@ -55,7 +55,11 @@ export async function runArtistToggleStar(deps: RunArtistToggleStarDeps): Promis
const currentlyStarred = isStarred;
setIsStarred(!currentlyStarred);
try {
const meta = { name: artist.name, albumCount: artist.albumCount };
const meta = {
serverId: artist.serverId,
name: artist.name,
albumCount: artist.albumCount,
};
if (currentlyStarred) await unstar(artist.id, 'artist', meta);
else await star(artist.id, 'artist', meta);
} catch (e) {
+4 -3
View File
@@ -3,12 +3,13 @@
* occasionally repeat the same album/song id; duplicate React keys then warn and
* break reconciliation.
*/
export function dedupeById<T extends { id: string }>(items: T[]): T[] {
export function dedupeById<T extends { id: string; serverId?: string }>(items: T[]): T[] {
const seen = new Set<string>();
const out: T[] = [];
for (const item of items) {
if (seen.has(item.id)) continue;
seen.add(item.id);
const key = item.serverId ? `${item.serverId}:${item.id}` : item.id;
if (seen.has(key)) continue;
seen.add(key);
out.push(item);
}
return out;
+4 -2
View File
@@ -139,8 +139,10 @@ export async function putBlob(key: string, blob: Blob): Promise<void> {
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
});
const maxBytes = useAuthStore.getState().maxCacheMb * 1024 * 1024;
scheduleEvictDiskIfNeeded(maxBytes);
const maxCacheMb = useAuthStore.getState().maxCacheMb;
if (maxCacheMb > 0) {
scheduleEvictDiskIfNeeded(maxCacheMb * 1024 * 1024);
}
} catch {
// Ignore write errors
}
+1
View File
@@ -205,6 +205,7 @@ export function trackToSong(t: LibraryTrackDto): SubsonicSong {
if (t.bpmSource === 'analysis' || t.bpmSource === 'tag') {
merged.localBpmSource = t.bpmSource;
}
if (t.serverId) merged.serverId = t.serverId;
return merged;
}
+2
View File
@@ -12,6 +12,8 @@ type TrackPatch = {
/** Optional metadata on star/unstar (album/artist); not mirrored into the local index. */
export type StarPatchMeta = {
/** Owning saved-server profile (cross-server favorites / `?server=` detail). */
serverId?: string;
name?: string;
artist?: string;
artistId?: string;
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { toQueueItemRefs } from './queueItemRef';
import type { Track } from '../../store/playerStoreTypes';
describe('toQueueItemRefs', () => {
it('uses per-track serverId when present', () => {
const queue: Track[] = [
{ id: 't1', title: 'A', artist: '', album: '', albumId: '', duration: 1, serverId: 'srv-a' },
{ id: 't2', title: 'B', artist: '', album: '', albumId: '', duration: 1, serverId: 'srv-b' },
];
const refs = toQueueItemRefs('fallback', queue);
expect(refs[0].serverId).not.toBe(refs[1].serverId);
expect(refs[0].trackId).toBe('t1');
expect(refs[1].trackId).toBe('t2');
});
});
+3 -1
View File
@@ -1,4 +1,5 @@
import type { QueueItemRef, Track } from '../../store/playerStoreTypes';
import { stampTrackServerId } from '../playback/trackServerScope';
import { canonicalQueueServerKey } from '../server/serverIndexKey';
/**
@@ -12,8 +13,9 @@ import { canonicalQueueServerKey } from '../server/serverIndexKey';
* circular dependency.
*/
export function toQueueItemRefs(serverId: string, queue: Track[]): QueueItemRef[] {
const canonicalId = canonicalQueueServerKey(serverId);
return queue.map(t => {
const scoped = stampTrackServerId(t, serverId);
const canonicalId = canonicalQueueServerKey(scoped.serverId ?? serverId);
const ref: QueueItemRef = { serverId: canonicalId, trackId: t.id };
if (t.autoAdded) ref.autoAdded = true;
if (t.radioAdded) ref.radioAdded = true;
+3 -3
View File
@@ -65,13 +65,13 @@ describe('queueTrackResolver', () => {
expect(getCachedTrack(ref('t2'))?.playNextAdded).toBe(true);
});
it('falls back to network getSong when the index is not ready', async () => {
it('falls back to network getSongForServer when the index is not ready', async () => {
notReady();
const spy = vi.spyOn(subsonic, 'getSong').mockResolvedValue({
const spy = vi.spyOn(subsonic, 'getSongForServer').mockResolvedValue({
id: 't9', title: 'Net Song', album: 'A', duration: 1,
} as never);
await resolveBatch([ref('t9')]);
expect(spy).toHaveBeenCalledWith('t9');
expect(spy).toHaveBeenCalledWith('s1', 't9');
expect(getCachedTrack(ref('t9'))?.title).toBe('Net Song');
});
+13 -4
View File
@@ -1,8 +1,9 @@
import { libraryGetTracksBatch, type TrackRefDto } from '../../api/library';
import { getSong } from '../../api/subsonicLibrary';
import { getSongForServer } from '../../api/subsonicLibrary';
import { usePlayerStore } from '../../store/playerStore';
import type { QueueItemRef, Track } from '../../store/playerStoreTypes';
import { songToTrack } from '../playback/songToTrack';
import { resolveServerIdForIndexKey } from '../server/serverLookup';
import { canonicalQueueServerKey } from '../server/serverIndexKey';
import { trackToSong } from './advancedSearchLocal';
import { libraryIsReady } from './libraryReady';
@@ -158,7 +159,11 @@ export async function resolveBatch(refs: QueueItemRef[]): Promise<void> {
try {
const dtos = await libraryGetTracksBatch(chunk);
for (const d of dtos) {
const track = carryFlags(songToTrack(trackToSong(d)), refByTrack.get(d.id));
const profileId = resolveServerIdForIndexKey(serverId) || serverId;
const track = carryFlags(
{ ...songToTrack(trackToSong(d)), serverId: d.serverId ?? profileId },
refByTrack.get(d.id),
);
cacheSet(refKey({ serverId, trackId: d.id }), track);
stillMissing.delete(d.id);
changed = true;
@@ -168,11 +173,15 @@ export async function resolveBatch(refs: QueueItemRef[]): Promise<void> {
}
// Network fallback (P8) for refs the index couldn't serve.
const profileId = resolveServerIdForIndexKey(serverId) || serverId;
for (const trackId of stillMissing) {
try {
const song = await getSong(trackId);
const song = await getSongForServer(profileId, trackId);
if (song) {
const track = carryFlags(songToTrack(song), refByTrack.get(trackId));
const track = carryFlags(
{ ...songToTrack(song), serverId: profileId },
refByTrack.get(trackId),
);
cacheSet(refKey({ serverId, trackId }), track);
changed = true;
}
+7
View File
@@ -0,0 +1,7 @@
import { useAuthStore } from '../../store/authStore';
/** Resolved user media root `M` (empty → Rust default `{app_data}/media/`). */
export function getMediaDir(): string | null {
const dir = useAuthStore.getState().mediaDir?.trim();
return dir || null;
}
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import { layoutFingerprintFromLibraryTrack, sanitizeAndTruncateSegment } from './mediaLayout';
// Keep in sync with `short_hash` in `src-tauri/crates/psysonic-core/src/media_layout.rs`.
function shortHash(s: string): string {
let h = 0;
for (let i = 0; i < s.length; i++) {
h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
}
return (h >>> 0).toString(16).padStart(8, '0');
}
describe('mediaLayout', () => {
it('shortHash parity anchor matches Rust imul-31 UTF-16', () => {
expect(shortHash('Radiohead')).toBe('3da68c3b');
});
it('does not truncate Cyrillic segments under 120 code points (byte length may exceed 120)', () => {
const segment = 'а'.repeat(100);
expect([...segment].length).toBe(100);
expect(Buffer.byteLength(segment, 'utf8')).toBeGreaterThan(120);
expect(sanitizeAndTruncateSegment(segment)).toBe(segment);
});
it('truncates long Cyrillic segments with the same hash suffix as Rust', () => {
const segment = 'а'.repeat(130);
expect(sanitizeAndTruncateSegment(segment)).toBe(
`${'а'.repeat(111)}_eef20600`,
);
});
it('layout fingerprint uses truncated hash for very long segments', () => {
const longName = 'A'.repeat(200);
const track = {
serverId: 'srv',
id: 't1',
title: 'Song',
artist: longName,
album: 'Album',
albumArtist: null,
trackNumber: 1,
discNumber: 1,
durationSec: 180,
suffix: 'mp3',
rawJson: null,
syncedAt: 0,
};
const fp = layoutFingerprintFromLibraryTrack(track);
expect(fp).toContain('_');
expect(fp.length).toBeLessThan(600);
});
});
+97
View File
@@ -0,0 +1,97 @@
import type { LibraryTrackDto } from '../../api/library';
const MAX_SEGMENT_LEN = 120;
const FORBIDDEN = /[\\/:*?"<>|\u0000-\u001f\u007f]/g;
const WINDOWS_RESERVED = new Set([
'CON', 'PRN', 'AUX', 'NUL',
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9',
]);
/** Keep path-segment rules aligned with `psysonic_core::cover_cache_layout::sanitize_path_segment`. */
function sanitizeSegment(segment: string): string {
let trimmed = segment.trim().replace(/[. ]+$/, '');
if (!trimmed) return '_';
const cleaned = trimmed.replace(FORBIDDEN, '_');
if (!cleaned || cleaned === '.' || cleaned === '..') return '_';
if (WINDOWS_RESERVED.has(cleaned.toUpperCase())) return `_${cleaned}`;
return cleaned;
}
function shortHash(s: string): string {
let h = 0;
for (let i = 0; i < s.length; i++) {
h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
}
return (h >>> 0).toString(16).padStart(8, '0').slice(0, 8);
}
function sanitizeAndTruncate(segment: string, maxLen: number): string {
const sanitized = sanitizeSegment(segment);
const chars = [...sanitized];
if (chars.length <= maxLen) return sanitized;
const hash = shortHash(segment);
const keep = maxLen - 1 - hash.length;
return `${chars.slice(0, keep).join('')}_${hash}`;
}
function variousArtistsLabel(s: string): boolean {
return s.trim().toLowerCase().includes('various artists');
}
function trackIsCompilation(track: Pick<LibraryTrackDto, 'artist' | 'rawJson'>): boolean {
if (variousArtistsLabel(track.artist ?? '')) return true;
const raw = track.rawJson;
if (!raw || typeof raw !== 'object') return false;
const obj = raw as Record<string, unknown>;
if (obj.isCompilation === true) return true;
if (obj.compilation === true || obj.compilation === 1 || obj.compilation === '1') return true;
const releaseTypes = obj.releaseTypes;
if (Array.isArray(releaseTypes)) {
return releaseTypes.some(
rt => typeof rt === 'string' && rt.toLowerCase() === 'compilation',
);
}
return false;
}
function artistFolderSegment(
track: Pick<LibraryTrackDto, 'artist' | 'albumArtist' | 'rawJson'>,
): string {
const artist = (track.artist ?? '').trim();
const albumArtist = (track.albumArtist ?? '').trim();
const chosen = !artist || trackIsCompilation(track)
? (albumArtist || 'Various Artists')
: artist;
return sanitizeAndTruncate(chosen, MAX_SEGMENT_LEN);
}
function trackFilenameStem(track: Pick<LibraryTrackDto, 'title' | 'trackNumber' | 'discNumber'>): string {
const title = (track.title ?? '').trim() || 'Unknown Title';
const trackN = Math.max(0, track.trackNumber ?? 0);
const discN = Math.max(0, track.discNumber ?? 1);
if (discN > 1) {
return `${String(discN).padStart(2, '0')}-${String(trackN).padStart(2, '0')} - ${title}`;
}
return `${String(trackN).padStart(2, '0')} - ${title}`;
}
/** Parity-tested with `sanitize_and_truncate_segment` in `media_layout.rs`. */
export function sanitizeAndTruncateSegment(segment: string): string {
return sanitizeAndTruncate(segment, MAX_SEGMENT_LEN);
}
/** Stable fingerprint — keep in sync with `psysonic_core::media_layout::layout_fingerprint`. */
export function layoutFingerprintFromLibraryTrack(
track: LibraryTrackDto,
suffix?: string,
): string {
const artistSeg = artistFolderSegment(track);
const albumSeg = sanitizeAndTruncate((track.album ?? '').trim() || 'Unknown Album', MAX_SEGMENT_LEN);
const stem = trackFilenameStem(track);
const ext = (suffix ?? track.suffix ?? '').trim();
const trackN = track.trackNumber ?? 0;
const discN = track.discNumber ?? 0;
const albumArtist = (track.albumArtist ?? '').trim();
return `artist=${artistSeg}|album_artist=${albumArtist}|album=${albumSeg}|title=${(track.title ?? '').trim()}|track=${trackN}|disc=${discN}|stem=${stem}|suffix=${ext}`;
}
@@ -0,0 +1,90 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore } from '../../store/authStore';
import { useOfflineStore } from '../../store/offlineStore';
import { useLocalPlaybackStore } from '../../store/localPlaybackStore';
import {
entryNeedsFileRelocation,
restoreOfflineLibraryPinSources,
} from './legacyOfflineFileMigration';
import type { LocalPlaybackEntry } from '../../store/localPlaybackStore';
function entry(overrides: Partial<LocalPlaybackEntry>): LocalPlaybackEntry {
return {
serverIndexKey: 'srv',
trackId: 't1',
localPath: '/old/path',
layoutFingerprint: '',
sizeBytes: 0,
tier: 'library',
cachedAt: 1,
suffix: 'mp3',
...overrides,
};
}
describe('entryNeedsFileRelocation', () => {
it('detects psysonic-offline flat paths', () => {
expect(entryNeedsFileRelocation(entry({
localPath: '/home/u/.local/share/psysonic-offline/host/t1.mp3',
}))).toBe(true);
});
it('skips paths already under media/library', () => {
expect(entryNeedsFileRelocation(entry({
localPath: '/home/u/.local/share/media/library/host/Artist/Album/01 - Song.mp3',
}))).toBe(false);
});
it('skips ephemeral tier', () => {
expect(entryNeedsFileRelocation(entry({
tier: 'ephemeral',
localPath: '/home/u/psysonic-offline/host/t1.mp3',
}))).toBe(false);
});
});
describe('restoreOfflineLibraryPinSources', () => {
beforeEach(() => {
vi.stubGlobal('localStorage', {
getItem: () => null,
setItem: () => {},
});
useAuthStore.setState({
servers: [{ id: 'srv-uuid', name: 'Home', url: 'http://music.test', username: 'u', password: 'p' }],
activeServerId: 'srv-uuid',
});
useOfflineStore.setState({
albums: {
'music.test:al-1': {
id: 'al-1',
serverId: 'music.test',
name: 'My Album',
artist: 'Artist',
trackIds: ['t1', 't2'],
type: 'album',
},
},
});
useLocalPlaybackStore.setState({
entries: {
'music.test:t1': {
serverIndexKey: 'music.test',
trackId: 't1',
localPath: '/media/library/music.test/Artist/My Album/01.mp3',
layoutFingerprint: 'fp',
sizeBytes: 100,
tier: 'library',
cachedAt: 1,
suffix: 'mp3',
},
},
});
});
it('attaches pinSource from offline albums for library entries', () => {
expect(restoreOfflineLibraryPinSources()).toBe(1);
const e = useLocalPlaybackStore.getState().entries['music.test:t1'];
expect(e.pinSource).toEqual({ kind: 'album', sourceId: 'al-1', displayName: 'My Album' });
expect(useLocalPlaybackStore.getState().listPinnedGroups()).toHaveLength(1);
});
});
@@ -0,0 +1,230 @@
import { invoke } from '@tauri-apps/api/core';
import { libraryGetTracksBatch } from '../../api/library';
import { useAuthStore } from '../../store/authStore';
import { useOfflineStore, type OfflineAlbumMeta } from '../../store/offlineStore';
import { useLocalPlaybackStore, type LocalPlaybackEntry, type PinSource } from '../../store/localPlaybackStore';
import { localPlaybackEntryKey } from '../../store/localPlaybackKeys';
import { importLegacyLocalPlayback } from '../../store/localPlaybackMigration';
import { getMediaDir } from '../media/mediaDir';
import { resolveServerIdForIndexKey } from '../server/serverLookup';
import { resolveIndexKey } from '../server/serverIndexKey';
interface LegacyOfflineMigrationResult {
trackId: string;
serverIndexKey: string;
path: string;
size: number;
layoutFingerprint: string;
relocated: boolean;
skippedReason?: string | null;
}
type PersistCapableStore = {
persist: {
hasHydrated: () => boolean;
onFinishHydration: (fn: () => void) => () => void;
};
};
type LegacyOfflineBlob = {
state?: {
albums?: Record<string, OfflineAlbumMeta>;
};
};
function waitForStoreHydration(store: PersistCapableStore): Promise<void> {
if (store.persist.hasHydrated()) return Promise.resolve();
return new Promise(resolve => {
const unsub = store.persist.onFinishHydration(() => {
unsub();
resolve();
});
});
}
function migrationDebug(payload: Record<string, unknown>): void {
if (useAuthStore.getState().loggingMode !== 'debug') return;
void invoke('frontend_debug_log', {
scope: 'legacy-offline-migration',
message: JSON.stringify(payload),
}).catch(() => {});
}
function resolveIndexKeyForServerId(serverId: string): string {
const trimmed = serverId.trim();
if (!trimmed) return trimmed;
const servers = useAuthStore.getState().servers;
const byId = servers.find(s => s.id === trimmed);
if (byId) return resolveIndexKey(byId.id) || trimmed;
return resolveIndexKey(trimmed) || trimmed;
}
function collectLegacyOfflineAlbums(): OfflineAlbumMeta[] {
const merged: Record<string, OfflineAlbumMeta> = {
...useOfflineStore.getState().albums,
};
try {
const raw = localStorage.getItem('psysonic-offline');
if (raw) {
const blob = JSON.parse(raw) as LegacyOfflineBlob;
Object.assign(merged, blob.state?.albums ?? {});
}
} catch { /* ignore */ }
return Object.values(merged);
}
function pinSourceForTrack(
serverIndexKey: string,
trackId: string,
albums: OfflineAlbumMeta[],
): PinSource | undefined {
for (const album of albums) {
if (!album.trackIds.includes(trackId)) continue;
const albumKey = resolveIndexKeyForServerId(album.serverId);
if (albumKey !== serverIndexKey && album.serverId !== serverIndexKey) continue;
return {
kind: album.type ?? 'album',
sourceId: album.id,
displayName: album.name,
};
}
return undefined;
}
/** True when the file still uses the flat legacy offline layout (not under `media/…/library/`). */
export function entryNeedsFileRelocation(entry: LocalPlaybackEntry): boolean {
if (entry.tier !== 'library' || !entry.localPath.trim()) return false;
const normalized = entry.localPath.replace(/\\/g, '/');
if (normalized.includes('/media/library/')) return false;
if (normalized.includes('/psysonic-offline/')) return true;
return !normalized.includes('/library/');
}
/** Offline Library cards need `pinSource` — restore from legacy albums + persist import. */
export function restoreOfflineLibraryPinSources(): number {
const servers = useAuthStore.getState().servers;
const albums = collectLegacyOfflineAlbums();
const fromPersist = importLegacyLocalPlayback(servers);
const store = useLocalPlaybackStore.getState();
const merged = { ...store.entries };
let updated = 0;
for (const entry of Object.values(merged)) {
if (entry.tier !== 'library' || entry.pinSource) continue;
const key = localPlaybackEntryKey(entry.serverIndexKey, entry.trackId);
const legacy = fromPersist[key];
const pin = legacy?.pinSource
?? pinSourceForTrack(entry.serverIndexKey, entry.trackId, albums);
if (!pin) continue;
merged[key] = { ...entry, pinSource: pin };
updated += 1;
}
if (updated > 0) {
useLocalPlaybackStore.setState({ entries: merged });
}
return updated;
}
/** Group orphan library pins by album metadata when legacy cards are gone. */
export async function inferPinSourcesFromLibraryIndex(): Promise<number> {
const needs = Object.values(useLocalPlaybackStore.getState().entries)
.filter(e => e.tier === 'library' && !e.pinSource);
if (needs.length === 0) return 0;
const refs = needs.map(e => ({
serverId: resolveServerIdForIndexKey(e.serverIndexKey) || e.serverIndexKey,
trackId: e.trackId,
}));
const tracks = await libraryGetTracksBatch(refs);
const byRef = new Map(tracks.map(t => [`${t.serverId}:${t.id}`, t]));
let updated = 0;
for (const entry of needs) {
const serverId = resolveServerIdForIndexKey(entry.serverIndexKey) || entry.serverIndexKey;
const dto = byRef.get(`${serverId}:${entry.trackId}`);
if (!dto?.albumId) continue;
useLocalPlaybackStore.getState().upsertEntry({
...entry,
pinSource: {
kind: 'album',
sourceId: dto.albumId,
displayName: dto.album,
},
});
updated += 1;
}
return updated;
}
function applyMigrationResults(results: LegacyOfflineMigrationResult[]): number {
let relocated = 0;
for (const r of results) {
if (!r.path || r.skippedReason === 'library_track_not_found') continue;
const key = localPlaybackEntryKey(r.serverIndexKey, r.trackId);
const prev = useLocalPlaybackStore.getState().entries[key];
useLocalPlaybackStore.getState().upsertEntry({
serverIndexKey: r.serverIndexKey,
trackId: r.trackId,
localPath: r.path,
layoutFingerprint: r.layoutFingerprint || prev?.layoutFingerprint || '',
sizeBytes: r.size || prev?.sizeBytes || 0,
tier: 'library',
cachedAt: prev?.cachedAt ?? Date.now(),
pinSource: prev?.pinSource,
suffix: prev?.suffix ?? 'mp3',
});
if (r.relocated) relocated += 1;
}
return relocated;
}
/**
* Scan flat `psysonic-offline/{segment}/{trackId}.ext`, keep tracks that still
* exist in the library index, move them under `media/library/…`, then restore
* Offline Library grouping (`pinSource`).
*/
export async function runLegacyOfflineFileMigration(serverIndexKey?: string): Promise<number> {
await waitForStoreHydration(useAuthStore as unknown as PersistCapableStore);
await waitForStoreHydration(useLocalPlaybackStore as unknown as PersistCapableStore);
await waitForStoreHydration(useOfflineStore as unknown as PersistCapableStore);
const customOfflineDir = useAuthStore.getState().offlineDownloadDir?.trim() || null;
let relocated = 0;
try {
const results = await invoke<LegacyOfflineMigrationResult[]>('migrate_legacy_offline_disk', {
mediaDir: getMediaDir(),
customOfflineDir,
serverIndexKeyFilter: serverIndexKey ?? null,
});
relocated = applyMigrationResults(results);
migrationDebug({
event: 'disk-migrate',
serverIndexKey: serverIndexKey ?? null,
scanned: results.length,
relocated,
results: results.map(r => ({
trackId: r.trackId,
relocated: r.relocated,
skippedReason: r.skippedReason ?? null,
})),
});
} catch (err) {
migrationDebug({
event: 'disk-migrate-error',
serverIndexKey: serverIndexKey ?? null,
error: err instanceof Error ? err.message : String(err),
});
}
const fromAlbums = restoreOfflineLibraryPinSources();
const fromIndex = await inferPinSourcesFromLibraryIndex();
migrationDebug({
event: 'pin-source-restore',
fromAlbums,
fromIndex,
groups: useLocalPlaybackStore.getState().listPinnedGroups().length,
});
return relocated;
}
@@ -0,0 +1,30 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { useAuthStore } from '../../store/authStore';
import { appendServerQuery, readDetailServerId } from './detailServerScope';
describe('detailServerScope', () => {
beforeEach(() => {
useAuthStore.setState({
activeServerId: 'srv-active',
servers: [
{ id: 'srv-a', name: 'A', url: 'https://a.test', username: 'u', password: 'p' },
{ id: 'srv-b', name: 'B', url: 'https://b.test', username: 'u', password: 'p' },
],
});
});
it('readDetailServerId prefers valid ?server= over fallback', () => {
const params = new URLSearchParams('server=srv-b&lossless=1');
expect(readDetailServerId(params, 'srv-active')).toBe('srv-b');
});
it('readDetailServerId falls back when server param is unknown', () => {
const params = new URLSearchParams('server=missing');
expect(readDetailServerId(params, 'srv-active')).toBe('srv-active');
});
it('appendServerQuery merges with existing query parts', () => {
expect(appendServerQuery('lossless=1', 'srv-a')).toBe('lossless=1&server=srv-a');
expect(appendServerQuery(undefined, 'srv-a')).toBe('server=srv-a');
});
});
+25
View File
@@ -0,0 +1,25 @@
import { useAuthStore } from '../../store/authStore';
/** Resolve `?server=` on album/artist detail routes; falls back when absent or unknown. */
export function readDetailServerId(
searchParams: URLSearchParams,
fallback: string | null | undefined,
): string | null {
const raw = searchParams.get('server');
if (!raw) return fallback ?? null;
const servers = useAuthStore.getState().servers;
if (servers.some(s => s.id === raw)) return raw;
return fallback ?? null;
}
/** Append or merge `server=` into an existing album/artist link query string. */
export function appendServerQuery(
base: string | undefined,
serverId: string | undefined,
): string | undefined {
if (!serverId) return base;
const serverPart = `server=${encodeURIComponent(serverId)}`;
if (!base) return serverPart;
const normalized = base.startsWith('?') ? base.slice(1) : base;
return `${normalized}&${serverPart}`;
}
@@ -0,0 +1,36 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
getActiveServerReachable,
isActiveServerReachable,
onActiveServerBecameReachable,
setActiveServerReachable,
} from './activeServerReachability';
describe('activeServerReachability', () => {
beforeEach(() => {
setActiveServerReachable(null);
});
it('isActiveServerReachable requires an explicit successful probe', () => {
expect(isActiveServerReachable()).toBe(false);
setActiveServerReachable(true);
expect(isActiveServerReachable()).toBe(true);
setActiveServerReachable(false);
expect(isActiveServerReachable()).toBe(false);
});
it('exposes the last probe result', () => {
setActiveServerReachable(true);
expect(getActiveServerReachable()).toBe(true);
});
it('onActiveServerBecameReachable fires only on false/null → true', () => {
const listener = vi.fn();
onActiveServerBecameReachable(listener);
setActiveServerReachable(false);
setActiveServerReachable(true);
expect(listener).toHaveBeenCalledTimes(1);
setActiveServerReachable(true);
expect(listener).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,32 @@
/**
* Active-server reachability snapshot maintained by `useConnectionStatus`.
* Non-hook code (queue sync, favorites refresh) uses this to avoid noisy
* network attempts while the browser or Subsonic endpoint is down.
*/
let activeServerReachable: boolean | null = null;
const reachableListeners = new Set<() => void>();
/** Fires when the active server transitions to reachable (`false`/`null` → `true`). */
export function onActiveServerBecameReachable(listener: () => void): () => void {
reachableListeners.add(listener);
return () => reachableListeners.delete(listener);
}
export function setActiveServerReachable(ok: boolean | null): void {
const wasReachable = activeServerReachable === true;
activeServerReachable = ok;
if (!wasReachable && ok === true) {
for (const listener of reachableListeners) listener();
}
}
export function getActiveServerReachable(): boolean | null {
return activeServerReachable;
}
/** True only when the browser is online and the last active-server probe succeeded. */
export function isActiveServerReachable(): boolean {
if (typeof navigator !== 'undefined' && !navigator.onLine) return false;
return activeServerReachable === true;
}
@@ -0,0 +1,40 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { setActiveServerReachable } from './activeServerReachability';
import { shouldAttemptSubsonicForServer } from './subsonicNetworkGuard';
const resolvePlaybackUrlMock = vi.fn((_trackId: string, _serverId?: string) =>
'https://music.test/stream',
);
vi.mock('../playback/resolvePlaybackUrl', () => ({
resolvePlaybackUrl: (trackId: string, serverId?: string) =>
resolvePlaybackUrlMock(trackId, serverId),
}));
describe('shouldAttemptSubsonicForServer', () => {
beforeEach(() => {
setActiveServerReachable(null);
resolvePlaybackUrlMock.mockReturnValue('https://music.test/stream');
});
it('returns false without a server id', () => {
expect(shouldAttemptSubsonicForServer('')).toBe(false);
});
it('returns false when the active server probe failed', () => {
setActiveServerReachable(false);
expect(shouldAttemptSubsonicForServer('srv-1', 't1')).toBe(false);
});
it('returns false when the track resolves to a local playback url', () => {
setActiveServerReachable(true);
resolvePlaybackUrlMock.mockReturnValue('psysonic-local:///favorites/t1.flac');
expect(shouldAttemptSubsonicForServer('srv-1', 't1')).toBe(false);
expect(resolvePlaybackUrlMock).toHaveBeenCalledWith('t1', 'srv-1');
});
it('returns true for stream playback when the active server is reachable', () => {
setActiveServerReachable(true);
expect(shouldAttemptSubsonicForServer('srv-1', 't1')).toBe(true);
});
});
+24
View File
@@ -0,0 +1,24 @@
import { useAuthStore } from '../../store/authStore';
import { resolvePlaybackUrl } from '../playback/resolvePlaybackUrl';
import { isActiveServerReachable } from './activeServerReachability';
/**
* Whether a Subsonic API call for `serverId` is worth attempting.
* Skips when the browser or active server is down, or when the track already
* plays from a local `psysonic-local://` URL (offline / favorite-auto bytes).
*/
export function shouldAttemptSubsonicForServer(serverId: string, trackId?: string): boolean {
if (!serverId) return false;
if (typeof navigator !== 'undefined' && !navigator.onLine) return false;
if (trackId) {
const url = resolvePlaybackUrl(trackId, serverId);
if (url.startsWith('psysonic-local://')) return false;
}
return isActiveServerReachable();
}
/** Convenience for call sites that only know the active server id. */
export function shouldAttemptSubsonicForActiveServer(): boolean {
const activeId = useAuthStore.getState().activeServerId;
return activeId ? shouldAttemptSubsonicForServer(activeId) : false;
}
@@ -0,0 +1,239 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore } from '../../store/authStore';
import { useLocalPlaybackStore } from '../../store/localPlaybackStore';
import {
favoritesOfflineBrowseEnabled,
hasOfflineBrowsingContent,
isOfflineSidebarLibraryNavAllowed,
loadStarredFromLibraryIndex,
mergeStarredFromServers,
resolveAlbumForServer,
} from './favoritesOfflineBrowse';
const isActiveServerReachableMock = vi.fn(() => true);
const shouldAttemptSubsonicForServerMock = vi.fn((_serverId: string, _trackId?: string) => true);
vi.mock('../network/activeServerReachability', () => ({
isActiveServerReachable: () => isActiveServerReachableMock(),
}));
vi.mock('../network/subsonicNetworkGuard', () => ({
shouldAttemptSubsonicForServer: (serverId: string, trackId?: string) =>
shouldAttemptSubsonicForServerMock(serverId, trackId),
}));
const getAlbumForServerMock = vi.fn();
const libraryAdvancedSearchMock = vi.fn();
const libraryGetTracksByAlbumMock = vi.fn();
vi.mock('../../api/subsonicLibrary', () => ({
getAlbumForServer: (...args: unknown[]) => getAlbumForServerMock(...args),
}));
vi.mock('../../api/library', () => ({
libraryAdvancedSearch: (...args: unknown[]) => libraryAdvancedSearchMock(...args),
libraryGetTracksByAlbum: (...args: unknown[]) => libraryGetTracksByAlbumMock(...args),
}));
describe('favoritesOfflineBrowse', () => {
beforeEach(() => {
isActiveServerReachableMock.mockReturnValue(true);
shouldAttemptSubsonicForServerMock.mockReturnValue(true);
getAlbumForServerMock.mockReset();
libraryGetTracksByAlbumMock.mockReset();
libraryAdvancedSearchMock.mockReset();
useAuthStore.setState({
favoritesOfflineEnabled: false,
activeServerId: 'srv-1',
servers: [{ id: 'srv-1', name: 'A', url: 'https://a.test', username: 'u', password: 'p' }],
});
useLocalPlaybackStore.setState({ entries: {} });
});
it('favoritesOfflineBrowseEnabled requires setting and at least one indexed server', () => {
expect(favoritesOfflineBrowseEnabled()).toBe(false);
useAuthStore.setState({ favoritesOfflineEnabled: true });
expect(favoritesOfflineBrowseEnabled()).toBe(true);
useAuthStore.setState({ servers: [] });
expect(favoritesOfflineBrowseEnabled()).toBe(false);
useAuthStore.setState({
favoritesOfflineEnabled: true,
activeServerId: null,
servers: [{ id: 'srv-2', name: 'B', url: 'https://b.test', username: 'u', password: 'p' }],
});
expect(favoritesOfflineBrowseEnabled()).toBe(true);
});
it('mergeStarredFromServers tags serverId and dedupes per server', () => {
const merged = mergeStarredFromServers([
{
serverId: 'srv-1',
starred: {
albums: [{ id: 'alb-1', name: 'A', artist: 'X', artistId: 'art-1', songCount: 1, duration: 1 }],
artists: [],
songs: [{ id: 't-1', title: 'S', artist: 'X', album: 'A', albumId: 'alb-1', duration: 1 }],
},
},
{
serverId: 'srv-2',
starred: {
albums: [{ id: 'alb-1', name: 'B', artist: 'Y', artistId: 'art-2', songCount: 1, duration: 1 }],
artists: [],
songs: [{ id: 't-1', title: 'S2', artist: 'Y', album: 'B', albumId: 'alb-1', duration: 1 }],
},
},
]);
expect(merged.albums).toHaveLength(2);
expect(merged.albums.map(a => a.serverId)).toEqual(['srv-1', 'srv-2']);
expect(merged.songs).toHaveLength(2);
expect(merged.songs.map(s => s.serverId)).toEqual(['srv-1', 'srv-2']);
});
it('isOfflineSidebarLibraryNavAllowed keeps only favorites when offline', () => {
expect(isOfflineSidebarLibraryNavAllowed('favorites', true)).toBe(true);
expect(isOfflineSidebarLibraryNavAllowed('favorites', false)).toBe(false);
expect(isOfflineSidebarLibraryNavAllowed('albums', true)).toBe(false);
});
it('loadStarredFromLibraryIndex omits artist entity (no artist.starred_at in index)', async () => {
libraryAdvancedSearchMock.mockResolvedValue({
albums: [{ id: 'alb-1', name: 'A', artist: 'X', artistId: 'art-1', serverId: 'srv-1' }],
artists: [{ id: 'art-99', name: 'Not A Favorite', serverId: 'srv-1' }],
tracks: [{ id: 't-1', title: 'S', artist: 'X', album: 'A', albumId: 'alb-1', durationSec: 1, serverId: 'srv-1' }],
});
const starred = await loadStarredFromLibraryIndex('srv-1');
expect(libraryAdvancedSearchMock).toHaveBeenCalledWith(expect.objectContaining({
entityTypes: ['album', 'track'],
starredOnly: true,
}));
expect(starred.artists).toEqual([]);
expect(starred.songs).toHaveLength(1);
});
it('resolveAlbumForServer uses library index when network fails', async () => {
useAuthStore.setState({ favoritesOfflineEnabled: true });
shouldAttemptSubsonicForServerMock.mockReturnValue(true);
getAlbumForServerMock.mockRejectedValue(new Error('Network Error'));
libraryGetTracksByAlbumMock.mockResolvedValue([
{
id: 't1',
title: 'Track',
artist: 'Artist',
album: 'Album',
albumId: 'alb-1',
artistId: 'art-1',
durationSec: 200,
serverId: 'srv-1',
},
]);
libraryAdvancedSearchMock.mockResolvedValue({
albums: [{
id: 'alb-1',
name: 'Album',
artist: 'Artist',
artistId: 'art-1',
serverId: 'srv-1',
}],
artists: [],
tracks: [],
});
const result = await resolveAlbumForServer('srv-1', 'alb-1');
expect(result?.album.id).toBe('alb-1');
expect(result?.songs).toHaveLength(1);
expect(getAlbumForServerMock).toHaveBeenCalledWith('srv-1', 'alb-1');
});
it('resolveAlbumForServer prefers full network album over partial library index', async () => {
useAuthStore.setState({ favoritesOfflineEnabled: true });
shouldAttemptSubsonicForServerMock.mockReturnValue(true);
libraryGetTracksByAlbumMock.mockResolvedValue([
{
id: 't1',
title: 'Indexed only',
artist: 'Artist',
album: 'Album',
albumId: 'alb-1',
durationSec: 100,
serverId: 'srv-1',
},
]);
getAlbumForServerMock.mockResolvedValue({
album: { id: 'alb-1', name: 'Album', artist: 'Artist', artistId: 'art-1', songCount: 3, duration: 600 },
songs: [
{ id: 't1', title: 'One', artist: 'Artist', album: 'Album', albumId: 'alb-1', duration: 200 },
{ id: 't2', title: 'Two', artist: 'Artist', album: 'Album', albumId: 'alb-1', duration: 200 },
{ id: 't3', title: 'Three', artist: 'Artist', album: 'Album', albumId: 'alb-1', duration: 200 },
],
});
const result = await resolveAlbumForServer('srv-1', 'alb-1');
expect(getAlbumForServerMock).toHaveBeenCalledWith('srv-1', 'alb-1');
expect(result?.songs).toHaveLength(3);
expect(result?.songs.map(s => s.id)).toEqual(['t1', 't2', 't3']);
});
it('resolveAlbumForServer uses library index when server is unreachable', async () => {
useAuthStore.setState({ favoritesOfflineEnabled: true });
shouldAttemptSubsonicForServerMock.mockReturnValue(false);
libraryGetTracksByAlbumMock.mockResolvedValue([
{
id: 't1',
title: 'Offline track',
artist: 'Artist',
album: 'Album',
albumId: 'alb-1',
durationSec: 200,
serverId: 'srv-1',
},
]);
libraryAdvancedSearchMock.mockResolvedValue({
albums: [{
id: 'alb-1',
name: 'Album',
artist: 'Artist',
artistId: 'art-1',
serverId: 'srv-1',
}],
artists: [],
tracks: [],
});
const result = await resolveAlbumForServer('srv-1', 'alb-1');
expect(result?.songs).toHaveLength(1);
expect(getAlbumForServerMock).not.toHaveBeenCalled();
});
it('resolveAlbumForServer falls back to network when index misses', async () => {
useAuthStore.setState({ favoritesOfflineEnabled: true });
isActiveServerReachableMock.mockReturnValue(true);
libraryGetTracksByAlbumMock.mockResolvedValue([]);
getAlbumForServerMock.mockResolvedValue({
album: { id: 'alb-2', name: 'Net', artist: 'A', artistId: 'a1', songCount: 1, duration: 1 },
songs: [{ id: 't2', title: 'T', artist: 'A', album: 'Net', albumId: 'alb-2', duration: 1 }],
});
const result = await resolveAlbumForServer('srv-1', 'alb-2');
expect(result?.album.id).toBe('alb-2');
expect(getAlbumForServerMock).toHaveBeenCalledWith('srv-1', 'alb-2');
});
it('hasOfflineBrowsingContent includes favorite-auto bytes when browse is enabled', () => {
expect(hasOfflineBrowsingContent({})).toBe(false);
useAuthStore.setState({ favoritesOfflineEnabled: true });
useLocalPlaybackStore.setState({
entries: {
'a.test:t1': {
serverIndexKey: 'a.test',
trackId: 't1',
localPath: '/fav/t1.mp3',
layoutFingerprint: 'fp',
sizeBytes: 1,
tier: 'favorite-auto',
cachedAt: 1,
suffix: 'mp3',
},
},
});
expect(hasOfflineBrowsingContent({})).toBe(true);
});
});
+252
View File
@@ -0,0 +1,252 @@
import { getStarredForServer } from '../../api/subsonicStarRating';
import { isActiveServerReachable } from '../network/activeServerReachability';
import { shouldAttemptSubsonicForServer } from '../network/subsonicNetworkGuard';
import { getAlbumForServer } from '../../api/subsonicLibrary';
import { libraryAdvancedSearch, libraryGetTracksByAlbum } from '../../api/library';
import type {
StarredResults,
SubsonicAlbum,
SubsonicArtist,
SubsonicSong,
} from '../../api/subsonicTypes';
import { useAuthStore } from '../../store/authStore';
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
import type { OfflineAlbumMeta } from '../../store/offlineStore';
import {
albumToAlbum,
artistToArtist,
trackToSong,
} from '../library/advancedSearchLocal';
import { dedupeById } from '../dedupeById';
import { countFavoriteAutoTracks, hasAnyOfflineAlbums } from './offlineLibraryHelpers';
/** Saved servers with a local library index (cross-server favorites scope). */
export function favoritesServerIds(): string[] {
const { servers } = useAuthStore.getState();
const idx = useLibraryIndexStore.getState();
return idx.indexedServerIds(servers.map(s => s.id));
}
/** Favorites page may be browsed offline when auto-save is enabled and any index exists. */
export function favoritesOfflineBrowseEnabled(): boolean {
const auth = useAuthStore.getState();
if (!auth.favoritesOfflineEnabled) return false;
return favoritesServerIds().length > 0;
}
export function isOfflineSidebarLibraryNavAllowed(
navId: string,
favoritesOfflineBrowse: boolean,
): boolean {
if (navId === 'favorites') return favoritesOfflineBrowse;
return false;
}
/** Any offline browsing surface: manual pins and/or saved favorite-auto bytes. */
export function hasOfflineBrowsingContent(
offlineAlbums: Record<string, OfflineAlbumMeta>,
): boolean {
if (hasAnyOfflineAlbums(offlineAlbums)) return true;
if (favoritesOfflineBrowseEnabled() && countFavoriteAutoTracks() > 0) return true;
return false;
}
function tagStarredWithServer(starred: StarredResults, serverId: string): StarredResults {
const withServer = <T extends { id: string }>(items: T[]): (T & { serverId: string })[] =>
items.map(item => ({ ...item, serverId }));
return {
artists: withServer(starred.artists),
albums: withServer(starred.albums),
songs: withServer(starred.songs),
};
}
/** Merge starred lists from multiple servers; dedupe by `serverId:id`. */
export function mergeStarredFromServers(
entries: { serverId: string; starred: StarredResults }[],
): StarredResults {
const artists: SubsonicArtist[] = [];
const albums: SubsonicAlbum[] = [];
const songs: SubsonicSong[] = [];
for (const { serverId, starred } of entries) {
const tagged = tagStarredWithServer(starred, serverId);
artists.push(...tagged.artists);
albums.push(...tagged.albums);
songs.push(...tagged.songs);
}
return {
artists: dedupeById(artists),
albums: dedupeById(albums),
songs: dedupeById(songs),
};
}
export async function loadStarredFromLibraryIndex(serverId: string): Promise<StarredResults> {
// Artist-level favorites are network-only today (`artist` has no `starred_at`;
// `starredOnly` on artists would return the whole artist table). Songs/albums
// use track/album stars in the index.
const response = await libraryAdvancedSearch({
serverId,
entityTypes: ['album', 'track'],
starredOnly: true,
limit: 10_000,
});
return {
artists: [],
albums: response.albums.map(albumToAlbum),
songs: response.tracks.map(trackToSong),
};
}
export async function loadStarredFromAllLibraryIndexes(): Promise<StarredResults> {
const serverIds = favoritesServerIds();
const entries = await Promise.all(
serverIds.map(async serverId => {
try {
const starred = await loadStarredFromLibraryIndex(serverId);
return { serverId, starred };
} catch {
return { serverId, starred: { artists: [], albums: [], songs: [] } satisfies StarredResults };
}
}),
);
return mergeStarredFromServers(entries);
}
/** Online starred merge with per-server local index fallback. */
export async function loadStarredFromAllServersOnline(): Promise<StarredResults> {
if (!isActiveServerReachable()) {
return loadStarredFromAllLibraryIndexes();
}
const serverIds = favoritesServerIds();
const entries = await Promise.all(
serverIds.map(async serverId => {
try {
const starred = await getStarredForServer(serverId);
return { serverId, starred };
} catch {
try {
const starred = await loadStarredFromLibraryIndex(serverId);
return { serverId, starred };
} catch {
return { serverId, starred: { artists: [], albums: [], songs: [] } satisfies StarredResults };
}
}
}),
);
return mergeStarredFromServers(entries);
}
/**
* Album detail / play / offline pin: use the network album when reachable so the
* track list is complete. The library index may only contain a subset (e.g.
* starred tracks or a partial sync) — never prefer that over `getAlbum` online.
* When the server is unreachable, fall back to the index when favorites-offline
* browsing is enabled.
*/
export async function resolveAlbumForServer(
serverId: string,
albumId: string,
): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null> {
const favoritesOffline = useAuthStore.getState().favoritesOfflineEnabled;
const networkAllowed = shouldAttemptSubsonicForServer(serverId);
if (networkAllowed) {
try {
const data = await getAlbumForServer(serverId, albumId);
return { album: data.album, songs: data.songs };
} catch {
/* fall through to library index */
}
} else if (!favoritesOffline) {
return null;
}
try {
return await loadAlbumFromLibraryIndex(serverId, albumId);
} catch {
return null;
}
}
export async function loadAlbumFromLibraryIndex(
serverId: string,
albumId: string,
): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null> {
const tracks = await libraryGetTracksByAlbum(serverId, albumId);
if (tracks.length === 0) return null;
const songs = tracks.map(trackToSong);
const albumSearch = await libraryAdvancedSearch({
serverId,
entityTypes: ['album'],
restrictAlbumIds: [albumId],
limit: 1,
});
const albumDto = albumSearch.albums[0];
if (albumDto) {
const album = albumToAlbum(albumDto);
return {
album: {
...album,
serverId,
songCount: songs.length,
duration: songs.reduce((sum, s) => sum + (s.duration ?? 0), 0),
},
songs: songs.map(s => ({ ...s, serverId })),
};
}
const first = tracks[0];
return {
album: {
id: albumId,
name: first.album ?? albumId,
artist: first.artist ?? '',
artistId: first.artistId ?? '',
songCount: songs.length,
duration: songs.reduce((sum, s) => sum + (s.duration ?? 0), 0),
coverArt: first.coverArtId ?? albumId,
year: first.year ?? undefined,
genre: first.genre ?? undefined,
starred: first.starredAt != null ? new Date(first.starredAt).toISOString() : undefined,
serverId,
},
songs: songs.map(s => ({ ...s, serverId })),
};
}
export async function loadArtistFromLibraryIndex(
serverId: string,
artistId: string,
): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] } | null> {
const response = await libraryAdvancedSearch({
serverId,
entityTypes: ['album', 'artist'],
limit: 10_000,
});
const albums = response.albums
.filter(a => a.artistId === artistId)
.map(albumToAlbum)
.map(a => ({ ...a, serverId }));
const artistDto = response.artists.find(a => a.id === artistId);
if (!artistDto && albums.length === 0) return null;
const artist = artistDto
? { ...artistToArtist(artistDto), serverId }
: {
id: artistId,
name: albums[0]?.artist ?? artistId,
albumCount: albums.length,
serverId,
};
return {
artist: {
...artist,
albumCount: albums.length,
},
albums,
};
}
@@ -0,0 +1,8 @@
import { describe, expect, it } from 'vitest';
import { FAVORITES_OFFLINE_JOB_ID } from './favoritesOfflineConstants';
describe('favoritesOfflineConstants', () => {
it('uses a stable virtual job id', () => {
expect(FAVORITES_OFFLINE_JOB_ID).toBe('__favorites__');
});
});
@@ -0,0 +1,2 @@
/** Virtual batch id for favorites offline download jobs (sidebar progress). */
export const FAVORITES_OFFLINE_JOB_ID = '__favorites__';
@@ -0,0 +1,130 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { SubsonicSong } from '../../api/subsonicTypes';
import { useAuthStore } from '../../store/authStore';
import { useOfflineJobStore } from '../../store/offlineJobStore';
import { FAVORITES_OFFLINE_JOB_ID } from './favoritesOfflineConstants';
import {
mergeStarredSongsUnion,
onFavoritesOfflineStarChange,
} from './favoritesOfflineSync';
const getStarredForServerMock = vi.fn(async (_serverId: string) => ({
artists: [],
albums: [],
songs: [{ id: 't1', title: 'T', artist: 'A', album: 'Al', albumId: 'al-1', duration: 1 }],
}));
const isActiveServerReachableMock = vi.fn(() => true);
vi.mock('../network/activeServerReachability', () => ({
isActiveServerReachable: () => isActiveServerReachableMock(),
}));
vi.mock('../../api/subsonicStarRating', () => ({
getStarredForServer: (serverId: string) => getStarredForServerMock(serverId),
}));
vi.mock('../../api/subsonicLibrary', () => ({
getAlbumForServer: vi.fn(async () => ({ songs: [] })),
}));
vi.mock('../../api/subsonicArtists', () => ({
getArtistForServer: vi.fn(async () => ({ albums: [] })),
}));
const invokeMock = vi.fn(async (_cmd: string, _args?: unknown) => ({}));
vi.mock('@tauri-apps/api/core', () => ({
invoke: (cmd: string, args?: unknown) => invokeMock(cmd, args),
}));
function song(id: string): SubsonicSong {
return {
id,
title: `Track ${id}`,
artist: 'Artist',
album: 'Album',
albumId: 'al-1',
duration: 180,
};
}
describe('onFavoritesOfflineStarChange', () => {
beforeEach(() => {
vi.useFakeTimers();
isActiveServerReachableMock.mockReturnValue(true);
getStarredForServerMock.mockClear();
invokeMock.mockClear();
useOfflineJobStore.setState({ jobs: [], pinQueue: [], bulkProgress: {} });
useAuthStore.setState({
favoritesOfflineEnabled: true,
activeServerId: 'srv-a',
servers: [
{ id: 'srv-a', name: 'A', url: 'https://a.test', username: 'u', password: 'p' },
{ id: 'srv-b', name: 'B', url: 'https://b.test', username: 'u', password: 'p' },
],
});
});
afterEach(() => {
vi.useRealTimers();
});
it('does not schedule sync while the active server is unreachable', async () => {
isActiveServerReachableMock.mockReturnValue(false);
onFavoritesOfflineStarChange('t1', 'song', true, 'srv-b');
await vi.advanceTimersByTimeAsync(700);
expect(getStarredForServerMock).not.toHaveBeenCalled();
});
it('schedules sync for the explicit server, not only the active one', async () => {
onFavoritesOfflineStarChange('t1', 'song', true, 'srv-b');
await vi.advanceTimersByTimeAsync(700);
expect(getStarredForServerMock).toHaveBeenCalledWith('srv-b');
expect(getStarredForServerMock).not.toHaveBeenCalledWith('srv-a');
});
it('aborts in-flight favorites Rust downloads when a star change reschedules sync', async () => {
useOfflineJobStore.setState({
jobs: [{
trackId: 't1',
albumId: FAVORITES_OFFLINE_JOB_ID,
albumName: 'Favorites',
trackTitle: 'T',
trackIndex: 0,
totalTracks: 1,
status: 'downloading',
downloadId: 'favorites-111',
}],
pinQueue: [],
bulkProgress: {},
});
onFavoritesOfflineStarChange('t2', 'song', false, 'srv-a');
expect(invokeMock).toHaveBeenCalledWith(
'cancel_offline_downloads',
{ downloadIds: ['favorites-111'] },
);
expect(useOfflineJobStore.getState().jobs).toEqual([]);
});
});
describe('mergeStarredSongsUnion', () => {
it('dedupes the same track from direct song, album, and artist stars', () => {
const shared = song('t-shared');
const union = mergeStarredSongsUnion(
[shared, song('t-solo')],
[[shared, song('t-album-only')]],
[[shared, song('t-artist-only')]],
);
expect(union.map(s => s.id).sort()).toEqual([
't-album-only',
't-artist-only',
't-shared',
't-solo',
]);
});
it('returns empty when nothing is starred', () => {
expect(mergeStarredSongsUnion([], [], [])).toEqual([]);
});
});
+399
View File
@@ -0,0 +1,399 @@
import { libraryUpsertSongsFromApi } from '../../api/library';
import { librarySqlServerId } from '../../api/coverCache';
import { getAlbumForServer } from '../../api/subsonicLibrary';
import { getArtistForServer } from '../../api/subsonicArtists';
import { getStarredForServer } from '../../api/subsonicStarRating';
import { buildStreamUrlForServer } from '../../api/subsonicStreamUrl';
import type { SubsonicSong } from '../../api/subsonicTypes';
import { invoke } from '@tauri-apps/api/core';
import i18n from '../../i18n';
import { useAuthStore } from '../../store/authStore';
import { cancelledDownloads, useOfflineJobStore } from '../../store/offlineJobStore';
import { useFavoritesOfflineSyncStore } from '../../store/favoritesOfflineSyncStore';
import { useLocalPlaybackStore } from '../../store/localPlaybackStore';
import { getMediaDir } from '../media/mediaDir';
import { resolveIndexKey, serverIndexKeyForProfile } from '../server/serverIndexKey';
import { FAVORITES_OFFLINE_JOB_ID } from './favoritesOfflineConstants';
import { isActiveServerReachable } from '../network/activeServerReachability';
import { favoritesServerIds, loadAlbumFromLibraryIndex } from './favoritesOfflineBrowse';
import {
entryBelongsToServer,
hasLocalLibraryBytes,
hasLocalFavoriteAutoBytes,
} from './offlineLibraryHelpers';
const CONCURRENCY = 2;
const DEBOUNCE_MS = 600;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
/** Accumulates server ids across debounced calls; `'all'` means fan-out to every server. */
let pendingSyncServerIds: Set<string> | 'all' = new Set();
let runToken = 0;
/** Rust cancellation key for the active favorites batch (`download_track_local`). */
let activeFavoritesDownloadId: string | null = null;
function rustDownloadIdsForFavoritesJobs(): string[] {
const fromJobs = useOfflineJobStore
.getState()
.jobs.filter(j => j.albumId === FAVORITES_OFFLINE_JOB_ID && j.downloadId)
.map(j => j.downloadId);
const ids = new Set(fromJobs);
if (activeFavoritesDownloadId) ids.add(activeFavoritesDownloadId);
return [...ids];
}
/** Abort in-flight favorites transfers and invalidate the current JS batch loop. */
function cancelInFlightFavoritesDownloads(): void {
runToken += 1;
cancelledDownloads.add(FAVORITES_OFFLINE_JOB_ID);
const downloadIds = rustDownloadIdsForFavoritesJobs();
if (downloadIds.length > 0) {
invoke('cancel_offline_downloads', { downloadIds }).catch(() => {});
for (const id of downloadIds) {
invoke('clear_offline_cancel', { downloadId: id }).catch(() => {});
}
}
activeFavoritesDownloadId = null;
useOfflineJobStore.setState(state => ({
jobs: state.jobs.filter(j => j.albumId !== FAVORITES_OFFLINE_JOB_ID),
}));
useFavoritesOfflineSyncStore.getState().setRunning(false);
}
function serverIndexKeyForSync(serverId: string): string {
const server = useAuthStore.getState().servers.find(s => s.id === serverId);
if (server) return serverIndexKeyForProfile(server) || resolveIndexKey(serverId) || serverId;
return resolveIndexKey(serverId) || serverId;
}
function librarySqlScope(serverId: string): string {
return librarySqlServerId(serverId);
}
/**
* Union of all tracks implied by starred songs, albums, and artists (deduped by track id).
* File/index lifecycle keys off this set — never per-entity pin — so overlapping stars
* (artist + song on the same album) share one `favorite-auto` row per track.
*/
export function mergeStarredSongsUnion(
directSongs: SubsonicSong[],
albumTrackLists: SubsonicSong[][],
artistAlbumTrackLists: SubsonicSong[][],
): SubsonicSong[] {
const byId = new Map<string, SubsonicSong>();
for (const song of directSongs) byId.set(song.id, song);
for (const songs of albumTrackLists) {
for (const song of songs) byId.set(song.id, song);
}
for (const songs of artistAlbumTrackLists) {
for (const song of songs) byId.set(song.id, song);
}
return [...byId.values()];
}
/** Collect every starred track (direct songs + album/artist expansion) for one server. */
export async function collectStarredSongs(serverId: string): Promise<SubsonicSong[]> {
const starred = await getStarredForServer(serverId);
const albumTrackLists: SubsonicSong[][] = [];
for (const album of starred.albums) {
try {
const detail = await getAlbumForServer(serverId, album.id);
albumTrackLists.push(detail.songs);
} catch {
try {
const local = await loadAlbumFromLibraryIndex(serverId, album.id);
if (local) albumTrackLists.push(local.songs);
} catch {
// skip unavailable album
}
}
}
const artistAlbumTrackLists: SubsonicSong[][] = [];
for (const artist of starred.artists) {
try {
const detail = await getArtistForServer(serverId, artist.id);
for (const alb of detail.albums ?? []) {
try {
const albumDetail = await getAlbumForServer(serverId, alb.id);
artistAlbumTrackLists.push(albumDetail.songs);
} catch {
try {
const local = await loadAlbumFromLibraryIndex(serverId, alb.id);
if (local) artistAlbumTrackLists.push(local.songs);
} catch {
// skip album
}
}
}
} catch {
// skip unavailable artist
}
}
return mergeStarredSongsUnion(starred.songs, albumTrackLists, artistAlbumTrackLists);
}
function pendingFavoriteAutoSongs(songs: SubsonicSong[], serverId: string): SubsonicSong[] {
return songs.filter(s => !hasLocalLibraryBytes(s.id, serverId) && !hasLocalFavoriteAutoBytes(s.id, serverId));
}
async function pruneOrphanFavoriteAuto(
serverId: string,
targetIds: Set<string>,
mediaDir: string | null,
): Promise<void> {
const lp = useLocalPlaybackStore.getState();
for (const entry of Object.values(lp.entries)) {
if (entry.tier !== 'favorite-auto') continue;
if (!entryBelongsToServer(entry, serverId)) continue;
if (targetIds.has(entry.trackId)) continue;
await invoke('delete_media_file', { localPath: entry.localPath, mediaDir }).catch(() => {});
lp.removeEntry(entry.trackId, entry.serverIndexKey, 'favorite-unstar-prune');
}
await invoke('prune_empty_media_tier_dirs', { tier: 'favorite-auto', mediaDir }).catch(() => {});
}
export async function disableFavoritesOfflineSync(): Promise<void> {
useAuthStore.getState().setFavoritesOfflineEnabled(false);
cancelInFlightFavoritesDownloads();
const mediaDir = getMediaDir();
await useLocalPlaybackStore.getState().purgeFavoriteAutoDisk(mediaDir);
useFavoritesOfflineSyncStore.getState().setTargetTrackIds([]);
useFavoritesOfflineSyncStore.getState().setLastError(null);
}
export function scheduleFavoritesOfflineSync(serverId?: string): void {
if (!useAuthStore.getState().favoritesOfflineEnabled) return;
if (!isActiveServerReachable()) return;
cancelInFlightFavoritesDownloads();
if (serverId) {
if (pendingSyncServerIds !== 'all') {
pendingSyncServerIds.add(serverId);
}
} else {
pendingSyncServerIds = 'all';
}
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
debounceTimer = null;
const serverIds = pendingSyncServerIds === 'all'
? favoritesServerIds()
: [...pendingSyncServerIds];
pendingSyncServerIds = new Set();
void runFavoritesOfflineSyncBatch(serverIds);
}, DEBOUNCE_MS);
}
/**
* Called after any successful star/unstar (song, album, or artist).
* Deletions run only inside {@link runFavoritesOfflineSync} via {@link pruneOrphanFavoriteAuto}
* against the merged track union — never eager per-entity removes (avoids deleting a file
* that is still required because the same track is starred via artist/album).
*/
export function onFavoritesOfflineStarChange(
_id: string,
_type: 'song' | 'album' | 'artist',
_starred: boolean,
serverId?: string,
): void {
const auth = useAuthStore.getState();
if (!auth.favoritesOfflineEnabled) return;
const target = serverId ?? auth.activeServerId;
if (!target) return;
scheduleFavoritesOfflineSync(target);
}
async function runFavoritesOfflineSyncBatch(serverIds: string[]): Promise<void> {
const auth = useAuthStore.getState();
if (!auth.favoritesOfflineEnabled || serverIds.length === 0) return;
const token = ++runToken;
const syncStore = useFavoritesOfflineSyncStore.getState();
syncStore.setRunning(true);
syncStore.setLastError(null);
try {
for (const serverId of serverIds) {
if (token !== runToken) return;
await runFavoritesOfflineSyncOneServer(serverId, token);
}
} finally {
if (token === runToken) {
syncStore.setRunning(false);
}
}
}
async function runFavoritesOfflineSyncOneServer(serverId: string, token: number): Promise<void> {
const auth = useAuthStore.getState();
if (!auth.favoritesOfflineEnabled) return;
const syncStore = useFavoritesOfflineSyncStore.getState();
const jobStore = useOfflineJobStore;
const serverIndexKey = serverIndexKeyForSync(serverId);
const libraryServerId = librarySqlScope(serverId);
const mediaDir = getMediaDir();
const albumName = i18n.t('favorites.offlineJobName');
try {
const allSongs = await collectStarredSongs(serverId);
if (token !== runToken) return;
const targetIds = new Set(allSongs.map(s => s.id));
syncStore.setTargetTrackIds([...targetIds]);
await pruneOrphanFavoriteAuto(serverId, targetIds, mediaDir);
if (token !== runToken) return;
await libraryUpsertSongsFromApi(libraryServerId, allSongs).catch(() => {});
const pending = pendingFavoriteAutoSongs(allSongs, serverId);
if (pending.length === 0) {
jobStore.setState(state => ({
jobs: state.jobs.filter(j => j.albumId !== FAVORITES_OFFLINE_JOB_ID),
}));
return;
}
if (token !== runToken) return;
cancelledDownloads.delete(FAVORITES_OFFLINE_JOB_ID);
const downloadId = `favorites-${Date.now()}`;
activeFavoritesDownloadId = downloadId;
jobStore.setState(state => ({
jobs: [
...state.jobs.filter(j => j.albumId !== FAVORITES_OFFLINE_JOB_ID),
...pending.map((s, i) => ({
trackId: s.id,
albumId: FAVORITES_OFFLINE_JOB_ID,
albumName,
trackTitle: s.title,
trackIndex: i,
totalTracks: pending.length,
status: 'queued' as const,
downloadId,
})),
],
}));
for (let i = 0; i < pending.length; i += CONCURRENCY) {
if (token !== runToken || cancelledDownloads.has(FAVORITES_OFFLINE_JOB_ID)) {
cancelledDownloads.delete(FAVORITES_OFFLINE_JOB_ID);
jobStore.setState(state => ({
jobs: state.jobs.filter(j => j.albumId !== FAVORITES_OFFLINE_JOB_ID),
}));
invoke('cancel_offline_downloads', { downloadIds: [downloadId] }).catch(() => {});
invoke('clear_offline_cancel', { downloadId }).catch(() => {});
activeFavoritesDownloadId = null;
return;
}
const batch = pending.slice(i, i + CONCURRENCY);
const batchIds = new Set(batch.map(s => s.id));
jobStore.setState(state => ({
jobs: state.jobs.map(j =>
j.albumId === FAVORITES_OFFLINE_JOB_ID && batchIds.has(j.trackId)
? { ...j, status: 'downloading' }
: j,
),
}));
await Promise.all(
batch.map(async song => {
const suffix = song.suffix || 'mp3';
if (cancelledDownloads.has(FAVORITES_OFFLINE_JOB_ID)) {
return { song, error: 'CANCELLED' };
}
if (hasLocalLibraryBytes(song.id, serverId) || hasLocalFavoriteAutoBytes(song.id, serverId)) {
return { song, error: null };
}
try {
const res = await invoke<{ path: string; size: number; layoutFingerprint: string }>(
'download_track_local',
{
tier: 'favorite-auto',
trackId: song.id,
serverIndexKey,
libraryServerId,
url: buildStreamUrlForServer(serverId, song.id),
suffix,
mediaDir,
downloadId,
},
);
if (
token !== runToken
|| cancelledDownloads.has(FAVORITES_OFFLINE_JOB_ID)
|| !targetIds.has(song.id)
) {
await invoke('delete_media_file', { localPath: res.path, mediaDir }).catch(() => {});
return { song, error: 'CANCELLED' };
}
useLocalPlaybackStore.getState().upsertEntry({
serverIndexKey,
trackId: song.id,
localPath: res.path,
sizeBytes: res.size,
layoutFingerprint: res.layoutFingerprint,
tier: 'favorite-auto',
suffix,
});
return { song, error: null };
} catch (err) {
const msg = typeof err === 'string' ? err : (err instanceof Error ? err.message : 'error');
if (msg === 'CANCELLED') return { song, error: 'CANCELLED' };
return { song, error: msg };
}
}),
).then(results => {
jobStore.setState(state => ({
jobs: state.jobs.map(j => {
if (j.albumId !== FAVORITES_OFFLINE_JOB_ID) return j;
const hit = results.find(r => r.song.id === j.trackId);
if (!hit) return j;
if (hit.error === 'CANCELLED') return j;
return {
...j,
status: hit.error ? ('error' as const) : ('done' as const),
};
}),
}));
});
}
if (token === runToken) {
jobStore.setState(state => ({
jobs: state.jobs.filter(
j => j.albumId !== FAVORITES_OFFLINE_JOB_ID || (j.status !== 'done' && j.status !== 'error'),
),
}));
if (activeFavoritesDownloadId === downloadId) {
invoke('clear_offline_cancel', { downloadId }).catch(() => {});
activeFavoritesDownloadId = null;
}
await invoke('prune_empty_media_tier_dirs', { tier: 'favorite-auto', mediaDir }).catch(() => {});
}
} catch (err) {
if (token === runToken) {
const msg = err instanceof Error ? err.message : String(err);
syncStore.setLastError(msg);
}
}
}
/** Run an initial sync when the setting is enabled (app start / server change). */
export function initFavoritesOfflineSync(): () => void {
const runIfEnabled = () => {
if (useAuthStore.getState().favoritesOfflineEnabled) {
scheduleFavoritesOfflineSync();
}
};
runIfEnabled();
return useAuthStore.subscribe((state, prev) => {
if (state.favoritesOfflineEnabled && !prev.favoritesOfflineEnabled) {
runIfEnabled();
}
});
}
+272
View File
@@ -0,0 +1,272 @@
import { libraryUpsertSongsFromApi } from '../../api/library';
import { librarySqlServerId } from '../../api/coverCache';
import type { SubsonicSong } from '../../api/subsonicTypes';
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../../store/authStore';
import type { LocalPlaybackEntry, PinSource } from '../../store/localPlaybackStore';
import { useLocalPlaybackStore } from '../../store/localPlaybackStore';
import { getMediaDir } from '../media/mediaDir';
import { resolveIndexKey, serverIndexKeyForProfile } from '../server/serverIndexKey';
import {
entryBelongsToServer,
findLocalPlaybackEntry,
indexKeyBelongsToServer,
} from './offlineLibraryHelpers';
interface LibraryTrackProbeResult {
path: string;
size: number;
layoutFingerprint: string;
exists: boolean;
}
interface LibraryTierDiskHit {
trackId: string;
path: string;
size: number;
layoutFingerprint: string;
suffix: string;
}
export interface LibraryTierReconcileResult {
syncedFromDisk: number;
removedStaleIndex: number;
orphansRemoved: number;
}
function serverIndexKeyForServerId(serverId: string): string {
const server = useAuthStore.getState().servers.find(s => s.id === serverId);
if (server) {
return serverIndexKeyForProfile(server) || resolveIndexKey(serverId) || serverId;
}
return resolveIndexKey(serverId) || serverId;
}
function collectCandidateTrackIds(serverId: string, extraTrackIds: string[] = []): string[] {
const lp = useLocalPlaybackStore.getState();
const ids = new Set(extraTrackIds);
for (const entry of libraryEntriesForServer(serverId)) {
ids.add(entry.trackId);
}
for (const group of lp.listPinnedGroups()) {
if (!indexKeyBelongsToServer(group.serverIndexKey, serverId)) continue;
for (const trackId of group.trackIds) ids.add(trackId);
}
return [...ids];
}
function libraryEntriesForServer(serverId: string): LocalPlaybackEntry[] {
return Object.values(useLocalPlaybackStore.getState().entries).filter(
e => e.tier === 'library' && entryBelongsToServer(e, serverId),
);
}
function upsertFromProbe(
probe: LibraryTrackProbeResult,
serverIndexKey: string,
serverId: string,
trackId: string,
suffix: string,
pinSource?: PinSource,
): void {
const lp = useLocalPlaybackStore.getState();
const existing = findLocalPlaybackEntry(trackId, serverId);
if (existing && existing.serverIndexKey !== serverIndexKey) {
lp.removeEntry(trackId, existing.serverIndexKey, 'reconcile-key-normalize');
}
lp.upsertEntry({
serverIndexKey,
trackId,
localPath: probe.path,
sizeBytes: probe.size,
layoutFingerprint: probe.layoutFingerprint,
tier: 'library',
pinSource: pinSource ?? existing?.pinSource,
suffix,
});
}
async function discoverLibraryTierHits(
serverId: string,
candidateTrackIds: string[],
): Promise<LibraryTierDiskHit[]> {
const serverIndexKey = serverIndexKeyForServerId(serverId);
const libraryServerId = librarySqlServerId(serverId);
try {
return await invoke<LibraryTierDiskHit[]>('discover_library_tier_on_disk', {
serverIndexKey,
libraryServerId,
candidateTrackIds,
mediaDir: getMediaDir(),
});
} catch {
return [];
}
}
async function importLibraryTierFromDisk(
serverId: string,
candidateTrackIds: string[],
): Promise<{
hits: LibraryTierDiskHit[];
imported: number;
hitByTrackId: Map<string, LibraryTierDiskHit>;
}> {
const serverIndexKey = serverIndexKeyForServerId(serverId);
const hits = await discoverLibraryTierHits(serverId, candidateTrackIds);
const hitByTrackId = new Map(hits.map(hit => [hit.trackId, hit]));
let imported = 0;
for (const hit of hits) {
const existing = findLocalPlaybackEntry(hit.trackId, serverId);
if (
existing
&& existing.localPath === hit.path
&& existing.layoutFingerprint === hit.layoutFingerprint
&& existing.sizeBytes === hit.size
&& existing.serverIndexKey === serverIndexKey
) {
continue;
}
upsertFromProbe(
{
path: hit.path,
size: hit.size,
layoutFingerprint: hit.layoutFingerprint,
exists: true,
},
serverIndexKey,
serverId,
hit.trackId,
hit.suffix || 'mp3',
existing?.pinSource,
);
imported += 1;
}
return { hits, imported, hitByTrackId };
}
/**
* Bidirectional library-tier reconcile for one server scope:
* - index row without bytes at canonical path → drop index row
* - bytes at canonical path without index → upsert index row
* - on-disk files not in the kept set → delete (orphan cleanup)
*/
/** Directory-first sweep for every configured server profile. */
export async function reconcileAllLibraryTiersFromDisk(): Promise<void> {
for (const server of useAuthStore.getState().servers) {
await reconcileLibraryTierForServer(server.id);
}
}
export async function reconcileLibraryTierForServer(
serverId: string,
): Promise<LibraryTierReconcileResult> {
const serverIndexKey = serverIndexKeyForServerId(serverId);
const lp = useLocalPlaybackStore.getState();
const keepPaths = new Set<string>();
let syncedFromDisk = 0;
let removedStaleIndex = 0;
const candidates = collectCandidateTrackIds(serverId);
const diskImport = await importLibraryTierFromDisk(serverId, candidates);
syncedFromDisk += diskImport.imported;
for (const hit of diskImport.hits) {
keepPaths.add(hit.path);
}
for (const entry of libraryEntriesForServer(serverId)) {
const hit = diskImport.hitByTrackId.get(entry.trackId);
if (hit) {
keepPaths.add(hit.path);
continue;
}
lp.removeEntry(entry.trackId, entry.serverIndexKey, 'reconcile-missing-bytes');
removedStaleIndex += 1;
}
let orphansRemoved = 0;
try {
const removed = await invoke<string[]>('prune_orphan_library_tier_files', {
serverIndexKey,
keepPaths: [...keepPaths],
mediaDir: getMediaDir(),
});
orphansRemoved = removed.length;
} catch {
orphansRemoved = 0;
}
return { syncedFromDisk, removedStaleIndex, orphansRemoved };
}
/** Album-scoped reconcile: sync index ↔ disk for the current track list, then prune orphans. */
export async function reconcileLibraryTierForAlbum(
serverId: string,
songs: SubsonicSong[],
pinSource?: PinSource,
): Promise<LibraryTierReconcileResult> {
const serverIndexKey = serverIndexKeyForServerId(serverId);
const libraryServerId = librarySqlServerId(serverId);
const lp = useLocalPlaybackStore.getState();
const keepPaths = new Set<string>();
let syncedFromDisk = 0;
let removedStaleIndex = 0;
await libraryUpsertSongsFromApi(libraryServerId, songs).catch(() => {});
const candidates = collectCandidateTrackIds(serverId, songs.map(song => song.id));
const diskImport = await importLibraryTierFromDisk(serverId, candidates);
for (const song of songs) {
const hit = diskImport.hitByTrackId.get(song.id);
const existing = findLocalPlaybackEntry(song.id, serverId);
if (hit) {
keepPaths.add(hit.path);
const effectivePin = pinSource ?? existing?.pinSource;
if (
!existing
|| existing.localPath !== hit.path
|| existing.layoutFingerprint !== hit.layoutFingerprint
|| existing.serverIndexKey !== serverIndexKey
) {
upsertFromProbe(
{
path: hit.path,
size: hit.size,
layoutFingerprint: hit.layoutFingerprint,
exists: true,
},
serverIndexKey,
serverId,
song.id,
hit.suffix || song.suffix || 'mp3',
effectivePin,
);
syncedFromDisk += 1;
}
continue;
}
if (existing) {
lp.removeEntry(song.id, existing.serverIndexKey, 'reconcile-album-missing-bytes');
removedStaleIndex += 1;
}
}
for (const hit of diskImport.hits) {
keepPaths.add(hit.path);
}
let orphansRemoved = 0;
try {
const removed = await invoke<string[]>('prune_orphan_library_tier_files', {
serverIndexKey,
keepPaths: [...keepPaths],
mediaDir: getMediaDir(),
});
orphansRemoved = removed.length;
} catch {
orphansRemoved = 0;
}
return { syncedFromDisk, removedStaleIndex, orphansRemoved };
}
+350 -40
View File
@@ -1,68 +1,220 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore } from '../../store/authStore';
import { useLocalPlaybackStore } from '../../store/localPlaybackStore';
import { useOfflineStore } from '../../store/offlineStore';
import { switchActiveServer } from '../server/switchActiveServer';
import {
buildOfflineTracksForAlbum,
ensureServerForOfflineAlbum,
buildOfflineCacheQueueTracks,
countFavoriteAutoTracks,
buildTracksForOfflineCard,
ensureServerForOfflineCard,
hasAnyOfflineAlbums,
hydrateOfflineLibraryCards,
isOfflinePinComplete,
pendingOfflinePinSongs,
offlineAlbumCoverScope,
offlineTrackCount,
type OfflineLibraryCard,
} from './offlineLibraryHelpers';
import * as libraryApi from '../../api/library';
import { coverStorageKey } from '../../cover/storageKeys';
import { resolveCoverDisplayTier } from '../../cover/tiers';
import type { OfflineAlbumMeta, OfflineTrackMeta } from '../../store/offlineStore';
vi.mock('../server/switchActiveServer', () => ({
switchActiveServer: vi.fn(async () => true),
}));
vi.mock('../../api/library', async importOriginal => {
const actual = await importOriginal<typeof import('../../api/library')>();
const libraryGetTracksBatch = vi.fn();
return {
...actual,
libraryGetTracksBatch,
libraryGetTracksBatchChunked: async (refs: Parameters<typeof actual.libraryGetTracksBatch>[0]) => {
if (refs.length === 0) return [];
const out: Awaited<ReturnType<typeof actual.libraryGetTracksBatch>> = [];
for (let i = 0; i < refs.length; i += actual.LIBRARY_TRACKS_BATCH_LIMIT) {
const chunk = refs.slice(i, i + actual.LIBRARY_TRACKS_BATCH_LIMIT);
const batch = await libraryGetTracksBatch(chunk).catch(() => []);
out.push(...batch);
}
return out;
},
libraryGetTrack: vi.fn(),
};
});
describe('offlineLibraryHelpers', () => {
beforeEach(() => {
useAuthStore.setState({
servers: [{ id: 'a', name: 'Home', url: 'http://a.test', username: 'u', password: 'p' }],
activeServerId: 'a',
});
useLocalPlaybackStore.setState({ entries: {} });
});
it('hasAnyOfflineAlbums is true when any album exists', () => {
it('countFavoriteAutoTracks counts favorite-auto tier rows only', () => {
expect(countFavoriteAutoTracks()).toBe(0);
useLocalPlaybackStore.setState({
entries: {
'a.test:t1': {
serverIndexKey: 'a.test',
trackId: 't1',
localPath: '/fav/t1.mp3',
layoutFingerprint: 'fp',
sizeBytes: 1,
tier: 'favorite-auto',
cachedAt: 1,
suffix: 'mp3',
},
'a.test:t2': {
serverIndexKey: 'a.test',
trackId: 't2',
localPath: '/lib/t2.mp3',
layoutFingerprint: 'fp',
sizeBytes: 1,
tier: 'library',
cachedAt: 1,
suffix: 'mp3',
},
},
});
expect(countFavoriteAutoTracks()).toBe(1);
});
it('pendingOfflinePinSongs skips already pinned tracks', () => {
useLocalPlaybackStore.setState({
entries: {
'a.test:t1': {
serverIndexKey: 'a.test',
trackId: 't1',
localPath: '/x',
layoutFingerprint: 'fp',
sizeBytes: 1,
tier: 'library',
cachedAt: 1,
suffix: 'mp3',
},
},
});
expect(pendingOfflinePinSongs([{ id: 't1' }, { id: 't2' }], 'a')).toEqual([{ id: 't2' }]);
});
it('isOfflinePinComplete with songIds finds entries stored under server UUID', () => {
useLocalPlaybackStore.setState({
entries: {
'a:t1': {
serverIndexKey: 'a',
trackId: 't1',
localPath: '/x',
layoutFingerprint: 'fp',
sizeBytes: 1,
tier: 'library',
cachedAt: 1,
suffix: 'mp3',
},
},
});
expect(isOfflinePinComplete('al1', 'a', ['t1'])).toBe(true);
});
it('isOfflinePinComplete checks localPlaybackStore pins by index key', () => {
useLocalPlaybackStore.setState({
entries: {
'a.test:t1': {
serverIndexKey: 'a.test',
trackId: 't1',
localPath: '/x',
layoutFingerprint: 'fp',
sizeBytes: 1,
tier: 'library',
cachedAt: 1,
suffix: 'mp3',
pinSource: { kind: 'album', sourceId: 'al1' },
},
},
});
useOfflineStore.setState({
albums: {
'a.test:al1': {
id: 'al1',
serverId: 'a.test',
name: 'Al',
artist: 'Ar',
trackIds: ['t1'],
},
},
});
expect(isOfflinePinComplete('al1', 'a')).toBe(true);
});
it('hasAnyOfflineAlbums is true when pinned groups exist', () => {
expect(hasAnyOfflineAlbums({})).toBe(false);
expect(hasAnyOfflineAlbums({
'a:al1': { id: 'al1', serverId: 'a', name: 'X', artist: 'Y', trackIds: [] },
})).toBe(true);
useLocalPlaybackStore.setState({
entries: {
'a.test:t1': {
serverIndexKey: 'a.test',
trackId: 't1',
localPath: '/x',
layoutFingerprint: '',
sizeBytes: 1,
tier: 'library',
cachedAt: 1,
suffix: 'mp3',
pinSource: { kind: 'album', sourceId: 'al1' },
},
},
});
expect(hasAnyOfflineAlbums({})).toBe(true);
});
it('buildOfflineTracksForAlbum uses album serverId in track keys', () => {
const album: OfflineAlbumMeta = {
id: 'al1', serverId: 'a', name: 'Al', artist: 'Ar', trackIds: ['t1', 't2'],
it('offlineTrackCount counts pinned tracks on the card', () => {
const card: OfflineLibraryCard = {
serverIndexKey: 'a.test',
pinSource: { kind: 'album', sourceId: 'al1' },
trackIds: ['t1', 't2'],
name: 'Al',
artist: 'Ar',
};
const tracks: Record<string, OfflineTrackMeta> = {
'a:t1': {
id: 't1', serverId: 'a', localPath: '/x.flac', title: 'One', artist: 'Ar',
album: 'Al', albumId: 'al1', suffix: 'flac', duration: 100, cachedAt: '2026-01-01',
useLocalPlaybackStore.setState({
entries: {
'a.test:t1': {
serverIndexKey: 'a.test',
trackId: 't1',
localPath: '/x',
layoutFingerprint: '',
sizeBytes: 1,
tier: 'library',
cachedAt: 1,
suffix: 'mp3',
},
},
'b:t2': {
id: 't2', serverId: 'b', localPath: '/y.flac', title: 'Wrong', artist: 'Ar',
album: 'Al', albumId: 'al1', suffix: 'flac', duration: 100, cachedAt: '2026-01-01',
},
};
const built = buildOfflineTracksForAlbum(album, tracks);
expect(built).toHaveLength(1);
expect(built[0]?.title).toBe('One');
expect(offlineTrackCount(album, tracks)).toBe(1);
});
expect(offlineTrackCount(card)).toBe(1);
});
it('offlineAlbumCoverScope is null when server profile is missing', () => {
const album: OfflineAlbumMeta = {
id: 'al1', serverId: 'gone', name: 'Al', artist: 'Ar', coverArt: 'ca1', trackIds: [],
const card: OfflineLibraryCard = {
serverIndexKey: 'gone',
pinSource: { kind: 'album', sourceId: 'al1' },
trackIds: [],
name: 'Al',
artist: 'Ar',
coverArt: 'ca1',
};
expect(offlineAlbumCoverScope(album)).toBeNull();
expect(offlineAlbumCoverScope(card)).toBeNull();
});
it('offlineAlbumCoverScope uses host index key compatible with disk cache', () => {
const album: OfflineAlbumMeta = {
id: 'al1', serverId: 'a', name: 'Al', artist: 'Ar', coverArt: 'ca1', trackIds: [],
const card: OfflineLibraryCard = {
serverIndexKey: 'a.test',
pinSource: { kind: 'album', sourceId: 'al1' },
trackIds: [],
name: 'Al',
artist: 'Ar',
coverArt: 'ca1',
};
const scope = offlineAlbumCoverScope(album);
const scope = offlineAlbumCoverScope(card);
expect(scope).toMatchObject({ kind: 'server', serverId: 'a' });
const tier = resolveCoverDisplayTier(300, { surface: 'dense' });
expect(coverStorageKey(scope!, { cacheKind: 'album', cacheEntityId: 'ca1' }, tier)).toBe(
@@ -70,16 +222,171 @@ describe('offlineLibraryHelpers', () => {
);
});
it('ensureServerForOfflineAlbum skips switch when already active', async () => {
it('ensureServerForOfflineCard skips switch when already active', async () => {
vi.mocked(switchActiveServer).mockClear();
const album: OfflineAlbumMeta = {
id: 'al1', serverId: 'a', name: 'Al', artist: 'Ar', trackIds: [],
const card: OfflineLibraryCard = {
serverIndexKey: 'a.test',
pinSource: { kind: 'album', sourceId: 'al1' },
trackIds: [],
name: 'Al',
artist: 'Ar',
};
await expect(ensureServerForOfflineAlbum(album)).resolves.toBe(true);
await expect(ensureServerForOfflineCard(card)).resolves.toBe(true);
expect(switchActiveServer).not.toHaveBeenCalled();
});
it('ensureServerForOfflineAlbum switches when album is on another server', async () => {
it('hydrateOfflineLibraryCards falls back to albumId when coverArtId is missing', async () => {
vi.mocked(libraryApi.libraryGetTracksBatch).mockResolvedValueOnce([{
serverId: 'a',
id: 't1',
title: 'Song',
album: 'Al',
albumId: 'al-1',
durationSec: 100,
syncedAt: 1,
rawJson: {},
}]);
const cards = await hydrateOfflineLibraryCards([{
serverIndexKey: 'a.test',
pinSource: { kind: 'album', sourceId: 'al-1', displayName: 'Al' },
trackIds: ['t1'],
}]);
expect(cards[0]?.coverArt).toBe('al-1');
});
it('hydrateOfflineLibraryCards uses playlist name, quad cover, and no artist line', async () => {
vi.mocked(libraryApi.libraryGetTracksBatch).mockResolvedValueOnce([
{
serverId: 'a',
id: 't1',
title: 'Song A',
artist: 'Artist A',
album: 'Album A',
albumId: 'al-a',
coverArtId: 'cov-a',
durationSec: 100,
syncedAt: 1,
rawJson: {},
},
{
serverId: 'a',
id: 't2',
title: 'Song B',
artist: 'Artist B',
album: 'Album B',
albumId: 'al-b',
coverArtId: 'cov-b',
durationSec: 100,
syncedAt: 1,
rawJson: {},
},
]);
const cards = await hydrateOfflineLibraryCards([{
serverIndexKey: 'a.test',
pinSource: { kind: 'playlist', sourceId: 'pl-1', displayName: 'My Mix' },
trackIds: ['t1', 't2'],
}]);
expect(cards[0]?.name).toBe('My Mix');
expect(cards[0]?.artist).toBe('');
expect(cards[0]?.coverArt).toBeUndefined();
expect(cards[0]?.coverQuadIds).toEqual(['cov-a', 'cov-b', 'cov-a', 'cov-b']);
});
it('hydrateOfflineLibraryCards uses legacy offline album coverArt', async () => {
vi.mocked(libraryApi.libraryGetTracksBatch).mockResolvedValueOnce([]);
useOfflineStore.setState({
albums: {
'a.test:al-1': {
id: 'al-1',
serverId: 'a.test',
name: 'Al',
artist: 'Ar',
coverArt: 'legacy-cover',
trackIds: ['t1'],
},
},
});
const cards = await hydrateOfflineLibraryCards([{
serverIndexKey: 'a.test',
pinSource: { kind: 'album', sourceId: 'al-1' },
trackIds: ['t1'],
}]);
expect(cards[0]?.coverArt).toBe('legacy-cover');
});
it('buildTracksForOfflineCard falls back to local index when library batch misses', async () => {
vi.mocked(libraryApi.libraryGetTracksBatch).mockResolvedValueOnce([]);
vi.mocked(libraryApi.libraryGetTrack).mockResolvedValue(null as never);
useLocalPlaybackStore.setState({
entries: {
'a.test:t1': {
serverIndexKey: 'a.test',
trackId: 't1',
localPath: '/media/library/a.test/Artist/Album/track.mp3',
layoutFingerprint: 'fp',
sizeBytes: 1000,
tier: 'library',
cachedAt: 1,
suffix: 'mp3',
pinSource: { kind: 'album', sourceId: 'al1' },
},
},
});
const tracks = await buildTracksForOfflineCard({
serverIndexKey: 'a.test',
pinSource: { kind: 'album', sourceId: 'al1', displayName: 'Album' },
trackIds: ['t1'],
name: 'Album',
artist: 'Artist',
});
expect(tracks).toHaveLength(1);
expect(tracks[0]?.id).toBe('t1');
expect(tracks[0]?.suffix).toBe('mp3');
});
it('buildOfflineCacheQueueTracks includes only ephemeral cache tracks', async () => {
const hotDto = {
id: 'hot1',
serverId: 'a',
title: 'Hot',
artist: 'Ar',
album: 'Al',
albumId: 'al2',
duration: 120,
suffix: 'flac',
};
vi.mocked(libraryApi.libraryGetTracksBatch).mockResolvedValueOnce([hotDto] as never);
useLocalPlaybackStore.setState({
entries: {
'a.test:t1': {
serverIndexKey: 'a.test',
trackId: 't1',
localPath: '/media/library/a.test/Artist/Album/one.mp3',
layoutFingerprint: 'fp',
sizeBytes: 1000,
tier: 'library',
cachedAt: 1,
suffix: 'mp3',
pinSource: { kind: 'album', sourceId: 'al1' },
},
'a.test:hot1': {
serverIndexKey: 'a.test',
trackId: 'hot1',
localPath: '/media/cache/a.test/hot1.flac',
layoutFingerprint: 'fp2',
sizeBytes: 2000,
tier: 'ephemeral',
cachedAt: 2,
suffix: 'flac',
},
},
});
const { tracks, queueServerIndexKey } = await buildOfflineCacheQueueTracks();
expect(queueServerIndexKey).toBe('a.test');
expect(tracks.map(t => t.id)).toEqual(['hot1']);
});
it('ensureServerForOfflineCard switches when card is on another server', async () => {
useAuthStore.setState({
servers: [
{ id: 'a', name: 'Home', url: 'http://a.test', username: 'u', password: 'p' },
@@ -87,12 +394,15 @@ describe('offlineLibraryHelpers', () => {
],
activeServerId: 'b',
});
const album: OfflineAlbumMeta = {
id: 'al1', serverId: 'a', name: 'Al', artist: 'Ar', trackIds: [],
const card: OfflineLibraryCard = {
serverIndexKey: 'a.test',
pinSource: { kind: 'album', sourceId: 'al1' },
trackIds: [],
name: 'Al',
artist: 'Ar',
};
await expect(ensureServerForOfflineAlbum(album)).resolves.toBe(true);
expect(switchActiveServer).toHaveBeenCalledWith(
expect.objectContaining({ id: 'a' }),
);
await expect(ensureServerForOfflineCard(card)).resolves.toBe(true);
expect(useAuthStore.getState().activeServerId).toBe('a');
expect(switchActiveServer).not.toHaveBeenCalled();
});
});
+567 -48
View File
@@ -1,47 +1,358 @@
import type { LibraryTrackDto } from '../../api/library';
import { libraryGetTrack, libraryGetTracksBatchChunked } from '../../api/library';
import type { SubsonicSong } from '../../api/subsonicTypes';
import type { CoverServerScope } from '../../cover/types';
import { useAuthStore } from '../../store/authStore';
import type { OfflineAlbumMeta, OfflineTrackMeta } from '../../store/offlineStore';
import { switchActiveServer } from '../server/switchActiveServer';
import type { LocalPlaybackEntry, PinnedGroup, PinSource } from '../../store/localPlaybackStore';
import { useLocalPlaybackStore } from '../../store/localPlaybackStore';
import { useOfflineStore, type OfflineAlbumMeta } from '../../store/offlineStore';
import { resolveTrackCoverArtId, trackToSong } from '../library/advancedSearchLocal';
import { canonicalQueueServerKey, resolveIndexKey } from '../server/serverIndexKey';
import type { Track } from '../../store/playerStoreTypes';
import { findServerByIdOrIndexKey, resolveServerIdForIndexKey } from '../server/serverLookup';
import { serverIndexKeyForProfile } from '../server/serverIndexKey';
export function hasAnyOfflineAlbums(albums: Record<string, OfflineAlbumMeta>): boolean {
return Object.keys(albums).length > 0;
export interface OfflineLibraryCard {
serverIndexKey: string;
pinSource: PinSource;
trackIds: string[];
name: string;
artist: string;
coverArt?: string;
/** 2×2 collage when the playlist has no single custom cover. */
coverQuadIds?: (string | null)[];
year?: number;
}
export function buildOfflineTracksForAlbum(
album: OfflineAlbumMeta,
tracks: Record<string, OfflineTrackMeta>,
): Track[] {
const { serverId } = album;
return album.trackIds.flatMap(tid => {
const t = tracks[`${serverId}:${tid}`];
if (!t) return [];
return [{
id: t.id,
title: t.title,
artist: t.artist,
album: t.album,
albumId: t.albumId,
artistId: t.artistId,
duration: t.duration,
coverArt: t.coverArt,
track: undefined,
year: t.year,
bitRate: t.bitRate,
suffix: t.suffix,
genre: t.genre,
replayGainTrackDb: t.replayGainTrackDb,
replayGainAlbumDb: t.replayGainAlbumDb,
replayGainPeak: t.replayGainPeak,
}];
export function resolveOfflineAlbumMeta(
albumId: string,
serverId: string,
): OfflineAlbumMeta | undefined {
const albums = useOfflineStore.getState().albums;
const server = useAuthStore.getState().servers.find(s => s.id === serverId);
const indexKey = server ? serverIndexKeyForProfile(server) : serverId;
return albums[`${indexKey}:${albumId}`] ?? albums[`${serverId}:${albumId}`];
}
function serverIndexKeysForServerId(serverId: string): string[] {
const servers = useAuthStore.getState().servers;
const server = servers.find(s => s.id === serverId);
const keys = new Set<string>();
if (server) {
const profileKey = serverIndexKeyForProfile(server);
if (profileKey) keys.add(profileKey);
keys.add(server.id);
}
keys.add(resolveIndexKey(serverId));
keys.add(serverId);
return [...keys].filter(Boolean);
}
export function entryBelongsToServer(entry: LocalPlaybackEntry, serverId: string): boolean {
return serverIndexKeysForServerId(serverId).includes(entry.serverIndexKey);
}
export function indexKeyBelongsToServer(serverIndexKey: string, serverId: string): boolean {
return serverIndexKeysForServerId(serverId).includes(serverIndexKey);
}
/** Resolve a library-tier row across legacy UUID / URL index-key variants. */
export function findLocalPlaybackEntry(
trackId: string,
serverId: string,
): LocalPlaybackEntry | null {
const lp = useLocalPlaybackStore.getState();
for (const key of serverIndexKeysForServerId(serverId)) {
const hit = lp.getEntry(trackId, key);
if (hit?.tier === 'library') return hit;
}
for (const entry of Object.values(lp.entries)) {
if (entry.trackId !== trackId || entry.tier !== 'library') continue;
if (entryBelongsToServer(entry, serverId)) return entry;
}
return null;
}
/** Index cache; run {@link reconcileLibraryTierForAlbum} / server reconcile so rows match disk. */
export function hasLocalLibraryBytes(trackId: string, serverId: string): boolean {
return !!findLocalPlaybackEntry(trackId, serverId)?.localPath;
}
/** Resolve a `favorite-auto` tier row across index-key variants. */
export function findFavoriteAutoEntry(
trackId: string,
serverId: string,
): LocalPlaybackEntry | null {
const lp = useLocalPlaybackStore.getState();
for (const key of serverIndexKeysForServerId(serverId)) {
const hit = lp.getEntry(trackId, key);
if (hit?.tier === 'favorite-auto') return hit;
}
for (const entry of Object.values(lp.entries)) {
if (entry.trackId !== trackId || entry.tier !== 'favorite-auto') continue;
if (entryBelongsToServer(entry, serverId)) return entry;
}
return null;
}
export function hasLocalFavoriteAutoBytes(trackId: string, serverId: string): boolean {
return !!findFavoriteAutoEntry(trackId, serverId)?.localPath;
}
/** Manual offline library or favorites auto-sync — skip redundant hot-cache prefetch/promote. */
export function hasLocalPersistentPlaybackBytes(trackId: string, serverId: string): boolean {
return hasLocalLibraryBytes(trackId, serverId) || hasLocalFavoriteAutoBytes(trackId, serverId);
}
/** Resolve `psysonic-local://` across legacy UUID / host index-key variants. */
export function findLocalPlaybackUrl(
trackId: string,
serverId: string,
tier: 'library' | 'ephemeral' | 'favorite-auto',
): string | null {
if (tier === 'library') {
const entry = findLocalPlaybackEntry(trackId, serverId);
if (entry?.localPath) return `psysonic-local://${entry.localPath}`;
return null;
}
if (tier === 'favorite-auto') {
const entry = findFavoriteAutoEntry(trackId, serverId);
if (entry?.localPath) return `psysonic-local://${entry.localPath}`;
return null;
}
const lp = useLocalPlaybackStore.getState();
for (const key of serverIndexKeysForServerId(serverId)) {
const url = lp.getLocalUrl(trackId, key, 'ephemeral');
if (url) return url;
}
return null;
}
/** Songs that still need a library-tier pin (used to skip redundant downloads). */
export function pendingOfflinePinSongs<T extends { id: string }>(
songs: T[],
serverId: string,
): T[] {
return songs.filter(s => !hasLocalLibraryBytes(s.id, serverId));
}
/** True when every track in the offline pin group has local library-tier bytes. */
export function isOfflinePinComplete(
albumId: string,
serverId: string,
songIds?: string[],
): boolean {
if (songIds?.length) {
return songIds.every(tid => hasLocalLibraryBytes(tid, serverId));
}
const server = useAuthStore.getState().servers.find(s => s.id === serverId);
const indexKey = server ? (serverIndexKeyForProfile(server) || serverId) : serverId;
const meta = resolveOfflineAlbumMeta(albumId, serverId);
const groupTrackIds = useLocalPlaybackStore.getState()
.listPinnedGroups(indexKey)
.find(g => g.pinSource.sourceId === albumId)?.trackIds;
const trackIds = meta?.trackIds.length
? meta.trackIds
: (groupTrackIds ?? []);
if (trackIds.length === 0) return false;
return trackIds.every(tid => hasLocalLibraryBytes(tid, serverId));
}
/** @deprecated Use {@link reconcileLibraryTierForAlbum} from `./libraryTierReconcile`. */
export async function syncAlbumLibraryTierFromDisk(
albumId: string,
serverId: string,
songs: SubsonicSong[],
pinSource?: PinSource,
): Promise<number> {
const { reconcileLibraryTierForAlbum } = await import('./libraryTierReconcile');
const result = await reconcileLibraryTierForAlbum(serverId, songs, pinSource ?? {
kind: 'album',
sourceId: albumId,
});
return result.syncedFromDisk;
}
/** @deprecated Use {@link listOfflineLibraryCards}. */
export function hasAnyOfflineAlbums(albums: Record<string, OfflineAlbumMeta>): boolean {
if (Object.keys(albums).length > 0) return true;
return useLocalPlaybackStore.getState().listPinnedGroups().length > 0;
}
export function libraryDtoToTrack(dto: LibraryTrackDto): Track {
const song = trackToSong(dto);
return {
id: song.id,
title: song.title,
artist: song.artist ?? '',
album: song.album,
albumId: song.albumId ?? '',
artistId: song.artistId,
duration: song.duration ?? 0,
coverArt: song.coverArt,
discNumber: song.discNumber,
track: song.track,
year: song.year,
bitRate: song.bitRate,
suffix: song.suffix,
genre: song.genre,
replayGainTrackDb: dto.replayGainTrackDb ?? undefined,
replayGainAlbumDb: dto.replayGainAlbumDb ?? undefined,
size: song.size,
serverId: dto.serverId,
};
}
function legacyCoverForPinnedGroup(group: PinnedGroup): string | undefined {
const albums: OfflineAlbumMeta[] = [...Object.values(useOfflineStore.getState().albums)];
try {
const raw = localStorage.getItem('psysonic-offline');
if (raw) {
const blob = JSON.parse(raw) as { state?: { albums?: Record<string, OfflineAlbumMeta> } };
albums.push(...Object.values(blob.state?.albums ?? {}));
}
} catch { /* ignore */ }
const pinKind = group.pinSource.kind ?? 'album';
for (const album of albums) {
if (album.id !== group.pinSource.sourceId) continue;
if (album.type && album.type !== pinKind) continue;
const albumKey = resolveIndexKey(album.serverId);
if (albumKey !== group.serverIndexKey && album.serverId !== group.serverIndexKey) continue;
const cover = album.coverArt?.trim();
if (cover) return cover;
}
return undefined;
}
function buildPlaylistCoverQuad(
trackIds: string[],
byId: Map<string, LibraryTrackDto>,
libraryServerId: string,
): (string | null)[] {
const seen = new Set<string>();
const covers: string[] = [];
for (const trackId of trackIds) {
const dto = byId.get(`${libraryServerId}:${trackId}`);
if (!dto) continue;
const cover = resolveTrackCoverArtId(dto);
if (!cover || seen.has(cover)) continue;
seen.add(cover);
covers.push(cover);
if (covers.length >= 4) break;
}
if (covers.length === 0) return [];
return Array.from({ length: 4 }, (_, i) => covers[i % covers.length] ?? null);
}
export async function hydrateOfflineLibraryCards(
groups: PinnedGroup[],
): Promise<OfflineLibraryCard[]> {
if (groups.length === 0) return [];
const refs = groups.flatMap(g =>
g.trackIds.map(trackId => ({
serverId: resolveServerIdForIndexKey(g.serverIndexKey) || g.serverIndexKey,
trackId,
})),
);
const tracks = await libraryGetTracksBatchChunked(refs);
const byId = new Map(tracks.map(t => [`${t.serverId}:${t.id}`, t]));
return groups.map(group => {
const libraryServerId = resolveServerIdForIndexKey(group.serverIndexKey) || group.serverIndexKey;
const first = group.trackIds
.map(tid => byId.get(`${libraryServerId}:${tid}`))
.find(Boolean);
const pinKind = group.pinSource.kind ?? 'album';
const pinnedMeta = resolveOfflineAlbumMeta(group.pinSource.sourceId, libraryServerId);
const legacyCover = legacyCoverForPinnedGroup(group);
const displayName = pinKind === 'album'
? (group.pinSource.displayName
?? first?.album
?? first?.title
?? group.pinSource.sourceId)
: (group.pinSource.displayName ?? group.pinSource.sourceId);
const artist = pinKind === 'artist'
? (group.pinSource.displayName ?? first?.artist ?? '')
: pinKind === 'playlist'
? ''
: (first?.artist ?? first?.albumArtist ?? '');
let coverArt: string | undefined;
let coverQuadIds: (string | null)[] | undefined;
if (pinKind === 'playlist') {
coverArt = pinnedMeta?.coverArt?.trim() || legacyCover || undefined;
if (!coverArt) {
const quad = buildPlaylistCoverQuad(group.trackIds, byId, libraryServerId);
if (quad.some(Boolean)) coverQuadIds = quad;
}
} else {
coverArt = pinnedMeta?.coverArt?.trim()
|| legacyCover
|| (first ? resolveTrackCoverArtId(first) : undefined);
}
return {
serverIndexKey: group.serverIndexKey,
pinSource: group.pinSource,
trackIds: group.trackIds,
name: displayName,
artist,
coverArt,
coverQuadIds,
year: pinKind === 'album' ? (first?.year ?? undefined) : undefined,
};
});
}
/** Server scope for offline album covers — same host index key as main library disk cache. */
export function offlineAlbumCoverScope(album: OfflineAlbumMeta): CoverServerScope | null {
if (!album.coverArt) return null;
const server = findServerByIdOrIndexKey(album.serverId);
function fallbackTrackFromLocalEntry(
trackId: string,
serverId: string,
card: OfflineLibraryCard,
): Track | null {
const entry = findLocalPlaybackEntry(trackId, serverId);
if (!entry?.localPath) return null;
return {
id: trackId,
title: card.pinSource.displayName ?? card.name,
artist: card.artist,
album: card.name,
albumId: card.pinSource.kind === 'album' ? card.pinSource.sourceId : '',
duration: 0,
coverArt: card.coverArt,
suffix: entry.suffix ?? 'mp3',
size: entry.sizeBytes,
};
}
export async function ensureServerForOfflineIndexKey(serverIndexKey: string): Promise<boolean> {
const { activeServerId, servers } = useAuthStore.getState();
const resolved = resolveServerIdForIndexKey(serverIndexKey) || serverIndexKey;
if (resolved === activeServerId) return true;
const server = servers.find(s => s.id === resolved)
?? findServerByIdOrIndexKey(serverIndexKey);
if (!server) return false;
const auth = useAuthStore.getState();
auth.setActiveServer(server.id);
auth.setLoggedIn(true);
return true;
}
function listEphemeralCacheEntries(): LocalPlaybackEntry[] {
return Object.values(useLocalPlaybackStore.getState().entries)
.filter(e => e.tier === 'ephemeral' && e.localPath)
.sort((a, b) => (b.lastPlayedAt ?? b.cachedAt) - (a.lastPlayedAt ?? a.cachedAt));
}
/** Indexed ephemeral rows with on-disk bytes under `{media}/cache/`. */
export function countEphemeralCacheTracks(): number {
return listEphemeralCacheEntries().length;
}
export function ephemeralCacheCoverScope(): CoverServerScope | null {
const entry = listEphemeralCacheEntries()[0];
if (!entry) return null;
const server = findServerByIdOrIndexKey(entry.serverIndexKey);
if (!server) return null;
return {
kind: 'server',
@@ -52,20 +363,228 @@ export function offlineAlbumCoverScope(album: OfflineAlbumMeta): CoverServerScop
};
}
/** Switch active server when it differs from the album's source server (for offline play). */
export async function ensureServerForOfflineAlbum(album: OfflineAlbumMeta): Promise<boolean> {
const { activeServerId, servers } = useAuthStore.getState();
const resolved = resolveServerIdForIndexKey(album.serverId);
if (resolved === activeServerId) return true;
const server = servers.find(s => s.id === resolved)
?? findServerByIdOrIndexKey(album.serverId);
if (!server) return false;
return switchActiveServer(server);
/** Up to four cover IDs for a playlist-style collage from hot-cache tracks. */
export async function collectEphemeralCacheCoverQuad(): Promise<(string | null)[]> {
const ephemeral = listEphemeralCacheEntries();
if (ephemeral.length === 0) return [null, null, null, null];
const refs = ephemeral.slice(0, 16).map(e => ({
serverId: resolveServerIdForIndexKey(e.serverIndexKey) || e.serverIndexKey,
trackId: e.trackId,
}));
const dtos = await libraryGetTracksBatchChunked(refs);
const covers: string[] = [];
for (const dto of dtos) {
const cover = resolveTrackCoverArtId(dto);
if (cover && !covers.includes(cover)) covers.push(cover);
if (covers.length >= 4) break;
}
return Array.from({ length: 4 }, (_, i) => covers[i] ?? null);
}
export function offlineTrackCount(
album: OfflineAlbumMeta,
tracks: Record<string, OfflineTrackMeta>,
): number {
return album.trackIds.filter(tid => !!tracks[`${album.serverId}:${tid}`]).length;
function listFavoriteAutoEntries(): LocalPlaybackEntry[] {
return Object.values(useLocalPlaybackStore.getState().entries)
.filter(e => e.tier === 'favorite-auto' && e.localPath)
.sort((a, b) => (b.lastPlayedAt ?? b.cachedAt) - (a.lastPlayedAt ?? a.cachedAt));
}
/** Indexed favorite-auto rows with on-disk bytes under `{media}/favorites/`. */
export function countFavoriteAutoTracks(): number {
return listFavoriteAutoEntries().length;
}
export function favoriteAutoCoverScope(): CoverServerScope | null {
const entry = listFavoriteAutoEntries()[0];
if (!entry) return null;
const server = findServerByIdOrIndexKey(entry.serverIndexKey);
if (!server) return null;
return {
kind: 'server',
serverId: server.id,
url: server.url,
username: server.username,
password: server.password,
};
}
export type OfflineCoverQuadCell = {
coverArtId: string;
serverId: string;
} | null;
/** Up to four cover cells for a collage from favorites-tier tracks (per-server scope). */
export async function collectFavoriteAutoCoverQuad(): Promise<OfflineCoverQuadCell[]> {
const favorites = listFavoriteAutoEntries();
if (favorites.length === 0) return [null, null, null, null];
const refs = favorites.slice(0, 16).map(e => ({
serverId: resolveServerIdForIndexKey(e.serverIndexKey) || e.serverIndexKey,
trackId: e.trackId,
}));
const dtos = await libraryGetTracksBatchChunked(refs);
const cells: { coverArtId: string; serverId: string }[] = [];
const seen = new Set<string>();
for (const dto of dtos) {
const coverArtId = resolveTrackCoverArtId(dto);
if (!coverArtId) continue;
const dedupeKey = `${dto.serverId}:${coverArtId}`;
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
cells.push({ coverArtId, serverId: dto.serverId });
if (cells.length >= 4) break;
}
return Array.from({ length: 4 }, (_, i) => cells[i] ?? null);
}
/** Playable tracks under `{media}/favorites/` only (favorite-auto tier). */
export async function buildOfflineFavoritesQueueTracks(): Promise<{
tracks: Track[];
queueServerIndexKey: string | null;
}> {
const favorites = listFavoriteAutoEntries();
if (favorites.length === 0) {
return { tracks: [], queueServerIndexKey: null };
}
const refs = favorites.map(e => ({
serverId: resolveServerIdForIndexKey(e.serverIndexKey) || e.serverIndexKey,
trackId: e.trackId,
}));
const dtos = await libraryGetTracksBatchChunked(refs);
const dtoById = new Map(dtos.map(d => [`${d.serverId}:${d.id}`, d]));
const tracks: Track[] = [];
let queueServerIndexKey: string | null = null;
for (const entry of favorites) {
const serverId = resolveServerIdForIndexKey(entry.serverIndexKey) || entry.serverIndexKey;
const dto = dtoById.get(`${serverId}:${entry.trackId}`);
if (dto) {
tracks.push(libraryDtoToTrack(dto));
} else {
tracks.push({
id: entry.trackId,
title: entry.trackId,
artist: '',
album: '',
albumId: '',
duration: 0,
suffix: entry.suffix,
size: entry.sizeBytes,
serverId,
});
}
queueServerIndexKey ??= entry.serverIndexKey;
}
return { tracks, queueServerIndexKey };
}
/** Playable tracks under `{media}/cache/` only (ephemeral / hot-cache tier). */
export async function buildOfflineCacheQueueTracks(): Promise<{
tracks: Track[];
queueServerIndexKey: string | null;
}> {
const ephemeral = listEphemeralCacheEntries();
if (ephemeral.length === 0) {
return { tracks: [], queueServerIndexKey: null };
}
const refs = ephemeral.map(e => ({
serverId: resolveServerIdForIndexKey(e.serverIndexKey) || e.serverIndexKey,
trackId: e.trackId,
}));
const dtos = await libraryGetTracksBatchChunked(refs);
const dtoById = new Map(dtos.map(d => [`${d.serverId}:${d.id}`, d]));
const tracks: Track[] = [];
let queueServerIndexKey: string | null = null;
for (const entry of ephemeral) {
const serverId = resolveServerIdForIndexKey(entry.serverIndexKey) || entry.serverIndexKey;
const dto = dtoById.get(`${serverId}:${entry.trackId}`);
if (dto) {
tracks.push(libraryDtoToTrack(dto));
} else {
tracks.push({
id: entry.trackId,
title: entry.trackId,
artist: '',
album: '',
albumId: '',
duration: 0,
suffix: entry.suffix,
size: entry.sizeBytes,
serverId,
});
}
queueServerIndexKey ??= entry.serverIndexKey;
}
return { tracks, queueServerIndexKey };
}
export async function buildTracksForOfflineCard(card: OfflineLibraryCard): Promise<Track[]> {
const serverId = resolveServerIdForIndexKey(card.serverIndexKey) || card.serverIndexKey;
const localTrackIds = card.trackIds.filter(tid => hasLocalLibraryBytes(tid, serverId));
if (localTrackIds.length === 0) return [];
const refs = localTrackIds.map(trackId => ({ serverId, trackId }));
const dtos = await libraryGetTracksBatchChunked(refs);
const dtoById = new Map(dtos.map(d => [d.id, d]));
const order = new Map(localTrackIds.map((id, i) => [id, i]));
const tracks: Track[] = [];
for (const trackId of localTrackIds) {
const dto = dtoById.get(trackId);
if (dto) {
tracks.push(libraryDtoToTrack(dto));
continue;
}
const single = await libraryGetTrack(serverId, trackId).catch(() => null);
if (single) {
tracks.push(libraryDtoToTrack(single));
continue;
}
const fallback = fallbackTrackFromLocalEntry(trackId, serverId, card);
if (fallback) tracks.push(fallback);
}
return tracks.sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0));
}
/** @deprecated */
export function buildOfflineTracksForAlbum(
album: OfflineAlbumMeta,
tracks: Record<string, never>,
): Track[] {
void tracks;
return [];
}
export function offlineAlbumCoverScope(card: Pick<OfflineLibraryCard, 'serverIndexKey' | 'coverArt'>): CoverServerScope | null {
if (!card.coverArt) return null;
const server = findServerByIdOrIndexKey(card.serverIndexKey);
if (!server) return null;
return {
kind: 'server',
serverId: server.id,
url: server.url,
username: server.username,
password: server.password,
};
}
/** Offline play only needs the library index + on-disk bytes — no live server ping. */
export async function ensureServerForOfflineCard(card: OfflineLibraryCard): Promise<boolean> {
return ensureServerForOfflineIndexKey(card.serverIndexKey);
}
export function offlineQueueServerKeyForCard(card: OfflineLibraryCard): string {
return canonicalQueueServerKey(card.serverIndexKey);
}
export function offlineTrackCount(card: OfflineLibraryCard): number {
const serverId = resolveServerIdForIndexKey(card.serverIndexKey) || card.serverIndexKey;
return card.trackIds.filter(tid => hasLocalLibraryBytes(tid, serverId)).length;
}
export function offlineLibraryCardKey(card: OfflineLibraryCard): string {
return `${card.serverIndexKey}:${card.pinSource.kind}:${card.pinSource.sourceId}`;
}
+196
View File
@@ -0,0 +1,196 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { cancelledDownloads, useOfflineJobStore } from '../../store/offlineJobStore';
import {
clearOfflinePinTasks,
dequeueOfflinePin,
enqueueOfflinePin,
isAlbumPinQueued,
registerOfflinePinExecutor,
} from './offlinePinQueue';
describe('offlinePinQueue', () => {
beforeEach(() => {
cancelledDownloads.clear();
clearOfflinePinTasks();
useOfflineJobStore.setState({ jobs: [], pinQueue: [], bulkProgress: {} });
registerOfflinePinExecutor(async () => {});
});
it('dequeues a queued album without affecting an active download', async () => {
const gate = { unblock: undefined as (() => void) | undefined };
registerOfflinePinExecutor(async () => {
await new Promise<void>(resolve => {
gate.unblock = () => resolve();
});
});
enqueueOfflinePin({
albumId: 'alb-1',
albumName: 'One',
albumArtist: 'A',
coverArt: undefined,
year: undefined,
songs: [],
serverId: 'srv',
type: 'album',
});
enqueueOfflinePin({
albumId: 'alb-2',
albumName: 'Two',
albumArtist: 'B',
coverArt: undefined,
year: undefined,
songs: [],
serverId: 'srv',
type: 'album',
});
await vi.waitFor(() => expect(isAlbumPinQueued('alb-2')).toBe(true));
expect(dequeueOfflinePin('alb-2')).toBe(true);
expect(isAlbumPinQueued('alb-2')).toBe(false);
expect(useOfflineJobStore.getState().pinQueue).toHaveLength(1);
gate.unblock?.();
await vi.waitFor(() => expect(useOfflineJobStore.getState().pinQueue).toHaveLength(0));
});
it('allows re-enqueue after cancelDownload (e.g. remove offline cache)', async () => {
const ran: string[] = [];
registerOfflinePinExecutor(async task => {
ran.push(task.albumId);
});
const task = {
albumId: 'alb-1',
albumName: 'One',
albumArtist: 'A',
coverArt: undefined,
year: undefined,
songs: [],
serverId: 'srv',
type: 'album' as const,
};
enqueueOfflinePin(task);
await vi.waitFor(() => expect(ran).toEqual(['alb-1']));
useOfflineJobStore.getState().cancelDownload('alb-1');
expect(cancelledDownloads.has('alb-1')).toBe(true);
enqueueOfflinePin(task);
await vi.waitFor(() => expect(ran).toEqual(['alb-1', 'alb-1']));
});
it('clears stale cancel flag when enqueueOfflinePin runs', async () => {
cancelledDownloads.add('alb-1');
const ran: string[] = [];
registerOfflinePinExecutor(async task => {
ran.push(task.albumId);
});
enqueueOfflinePin({
albumId: 'alb-1',
albumName: 'One',
albumArtist: 'A',
coverArt: undefined,
year: undefined,
songs: [],
serverId: 'srv',
type: 'album',
});
await vi.waitFor(() => expect(ran).toEqual(['alb-1']));
expect(cancelledDownloads.has('alb-1')).toBe(false);
});
it('dedupes duplicate album ids in the queue', () => {
const task = {
albumId: 'alb-1',
albumName: 'One',
albumArtist: 'A',
coverArt: undefined,
year: undefined,
songs: [],
serverId: 'srv',
type: 'album' as const,
};
expect(enqueueOfflinePin(task)).toBe(true);
expect(enqueueOfflinePin(task)).toBe(false);
expect(useOfflineJobStore.getState().pinQueue).toHaveLength(1);
});
it('does not replace the in-flight task when a download is active', async () => {
let capturedTrackIds: string[] = [];
const gate = { unblock: undefined as (() => void) | undefined };
registerOfflinePinExecutor(async task => {
capturedTrackIds = task.songs.map(s => s.id);
await new Promise<void>(resolve => {
gate.unblock = () => resolve();
});
});
const base = {
albumId: 'alb-1',
albumName: 'One',
albumArtist: 'A',
coverArt: undefined,
year: undefined,
serverId: 'srv',
type: 'album' as const,
};
enqueueOfflinePin({ ...base, songs: [{ id: 't1', title: 't1', artist: 'A', album: 'Al', albumId: 'alb-1', duration: 1 }] });
await vi.waitFor(() => {
expect(useOfflineJobStore.getState().pinQueue[0]?.status).toBe('downloading');
});
expect(enqueueOfflinePin({
...base,
songs: [
{ id: 't1', title: 't1', artist: 'A', album: 'Al', albumId: 'alb-1', duration: 1 },
{ id: 't2', title: 't2', artist: 'A', album: 'Al', albumId: 'alb-1', duration: 1 },
],
})).toBe(false);
gate.unblock?.();
await vi.waitFor(() => expect(capturedTrackIds).toEqual(['t1']));
});
it('processes albums one after another', async () => {
const order: string[] = [];
const gate = { unblock: undefined as (() => void) | undefined };
registerOfflinePinExecutor(async task => {
order.push(task.albumId);
await new Promise<void>(resolve => {
gate.unblock = () => resolve();
});
});
enqueueOfflinePin({
albumId: 'alb-1',
albumName: 'One',
albumArtist: 'A',
coverArt: undefined,
year: undefined,
songs: [],
serverId: 'srv',
type: 'album',
});
enqueueOfflinePin({
albumId: 'alb-2',
albumName: 'Two',
albumArtist: 'B',
coverArt: undefined,
year: undefined,
songs: [],
serverId: 'srv',
type: 'album',
});
await vi.waitFor(() => expect(order).toEqual(['alb-1']));
expect(useOfflineJobStore.getState().pinQueue.some(p => p.albumId === 'alb-2' && p.status === 'queued')).toBe(true);
gate.unblock?.();
await vi.waitFor(() => expect(order).toEqual(['alb-1', 'alb-2']));
});
});
+147
View File
@@ -0,0 +1,147 @@
import type { SubsonicSong } from '../../api/subsonicTypes';
import type { PinSource } from '../../store/localPlaybackStore';
import {
cancelledDownloads,
useOfflineJobStore,
type OfflinePinQueueEntry,
} from '../../store/offlineJobStore';
export type OfflinePinKind = PinSource['kind'];
export interface OfflinePinTask {
albumId: string;
albumName: string;
albumArtist: string;
coverArt: string | undefined;
year: number | undefined;
songs: SubsonicSong[];
serverId: string;
type: OfflinePinKind;
/** When set, bump `bulkProgress[groupId].done` after each album finishes. */
artistProgressGroupId?: string;
}
type OfflinePinExecutor = (task: OfflinePinTask) => Promise<void>;
const pinTasks = new Map<string, OfflinePinTask>();
let executor: OfflinePinExecutor | null = null;
let queueDraining = false;
export function registerOfflinePinExecutor(fn: OfflinePinExecutor): void {
executor = fn;
}
export function clearOfflinePinTasks(): void {
pinTasks.clear();
}
export function removeOfflinePinTask(albumId: string): void {
pinTasks.delete(albumId);
}
/** True when the album is waiting in the pin queue (not actively downloading). */
export function isAlbumPinQueued(albumId: string): boolean {
return useOfflineJobStore.getState().pinQueue.some(
p => p.albumId === albumId && p.status === 'queued',
);
}
/** Remove a queued pin before download starts. No-op if already downloading. */
export function dequeueOfflinePin(albumId: string): boolean {
const store = useOfflineJobStore.getState();
const entry = store.pinQueue.find(p => p.albumId === albumId);
if (!entry || entry.status !== 'queued') return false;
cancelledDownloads.add(albumId);
removeOfflinePinTask(albumId);
store.removePinFromQueue(albumId);
return true;
}
function isPinAlreadyScheduled(albumId: string): boolean {
const { pinQueue } = useOfflineJobStore.getState();
return pinQueue.some(p => p.albumId === albumId);
}
/**
* Queue a library-tier pin. Duplicate album/playlist/artist ids coalesce to one
* entry; the queue drains one album at a time so parallel pins do not evict each other.
*/
export function enqueueOfflinePin(task: OfflinePinTask): boolean {
cancelledDownloads.delete(task.albumId);
const store = useOfflineJobStore.getState();
const existing = store.pinQueue.find(p => p.albumId === task.albumId);
if (existing?.status === 'downloading') {
return false;
}
pinTasks.set(task.albumId, task);
if (existing?.status === 'queued') {
scheduleOfflinePinQueue();
return true;
}
if (isPinAlreadyScheduled(task.albumId)) {
return false;
}
const entry: OfflinePinQueueEntry = {
albumId: task.albumId,
albumName: task.albumName,
pinKind: task.type,
status: 'queued',
queuedAt: Date.now(),
};
useOfflineJobStore.setState(state => ({
pinQueue: [...state.pinQueue, entry],
}));
scheduleOfflinePinQueue();
return true;
}
export function scheduleOfflinePinQueue(): void {
void drainOfflinePinQueue();
}
async function drainOfflinePinQueue(): Promise<void> {
if (queueDraining || !executor) return;
queueDraining = true;
try {
while (true) {
const store = useOfflineJobStore.getState();
const next = store.pinQueue.find(p => p.status === 'queued');
if (!next) break;
if (cancelledDownloads.has(next.albumId)) {
store.removePinFromQueue(next.albumId);
pinTasks.delete(next.albumId);
continue;
}
const task = pinTasks.get(next.albumId);
if (!task) {
store.removePinFromQueue(next.albumId);
continue;
}
store.setPinQueueStatus(next.albumId, 'downloading');
try {
await executor(task);
} catch {
/* per-track errors are recorded on jobs; continue queue */
} finally {
if (task.artistProgressGroupId) {
store.bumpBulkProgressDone(task.artistProgressGroupId);
}
store.removePinFromQueue(next.albumId);
pinTasks.delete(next.albumId);
}
}
} finally {
queueDraining = false;
const stillQueued = useOfflineJobStore.getState().pinQueue.some(p => p.status === 'queued');
if (stillQueued) {
void drainOfflinePinQueue();
}
}
}
+485
View File
@@ -0,0 +1,485 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { SubsonicSong } from '../../api/subsonicTypes';
import { useAuthStore } from '../../store/authStore';
import { useLocalPlaybackStore } from '../../store/localPlaybackStore';
import { useOfflineJobStore } from '../../store/offlineJobStore';
import { useOfflineStore } from '../../store/offlineStore';
import {
isManualOfflinePlaylist,
isPlaylistPinnedOffline,
isSourcePinnedOffline,
schedulePinnedAlbumSync,
schedulePinnedPlaylistSync,
scheduleSyncPinnedAlbumsAndArtists,
syncAllPinnedPlaylists,
syncPinnedArtistIfNeeded,
} from './pinnedOfflineSync';
import { SMART_PREFIX } from '../componentHelpers/playlistDetailHelpers';
const getPlaylistMock = vi.fn();
const getAlbumForServerMock = vi.fn();
const getArtistForServerMock = vi.fn();
const filterSongsMock = vi.fn(async (songs: SubsonicSong[]) => songs);
const isReachableMock = vi.fn(() => true);
const enqueueMock = vi.fn((_task: unknown) => true);
const invokeMock = vi.fn(async (_cmd: string, _args?: unknown) => ({}));
vi.mock('../network/activeServerReachability', () => ({
isActiveServerReachable: () => isReachableMock(),
onActiveServerBecameReachable: () => () => {},
}));
vi.mock('../../api/subsonicPlaylists', () => ({
getPlaylistForServer: (serverId: string, id: string) => getPlaylistMock(serverId, id),
}));
vi.mock('../../api/subsonicLibrary', () => ({
getAlbumForServer: (serverId: string, id: string) => getAlbumForServerMock(serverId, id),
filterSongsToServerLibrary: (songs: SubsonicSong[]) => filterSongsMock(songs),
}));
vi.mock('../../api/subsonicArtists', () => ({
getArtistForServer: (serverId: string, artistId: string) => getArtistForServerMock(serverId, artistId),
}));
vi.mock('../../api/library', () => ({
libraryGetTracksByAlbum: vi.fn(async () => []),
subscribeLibrarySyncIdle: vi.fn(async () => () => {}),
}));
vi.mock('./offlinePinQueue', async (importOriginal) => {
const actual = await importOriginal<typeof import('./offlinePinQueue')>();
return {
...actual,
enqueueOfflinePin: (task: unknown) => enqueueMock(task),
};
});
vi.mock('@tauri-apps/api/core', () => ({
invoke: (cmd: string, args?: unknown) => invokeMock(cmd, args),
}));
function song(id: string): SubsonicSong {
return {
id,
title: id,
artist: 'A',
album: 'Al',
albumId: 'al-1',
duration: 100,
};
}
function seedAuth(): void {
useAuthStore.setState({
activeServerId: 'srv-a',
servers: [{ id: 'srv-a', name: 'A', url: 'https://a.test', username: 'u', password: 'p' }],
});
}
describe('isPlaylistPinnedOffline', () => {
beforeEach(() => {
useOfflineStore.setState({ albums: {} });
useLocalPlaybackStore.setState({ entries: {} });
seedAuth();
});
it('returns true when offline meta marks a playlist pin', () => {
useOfflineStore.setState({
albums: {
'a.test:pl-1': {
id: 'pl-1',
serverId: 'a.test',
name: 'Mix',
artist: '',
trackIds: ['t1'],
type: 'playlist',
},
},
});
expect(isPlaylistPinnedOffline('pl-1', 'srv-a')).toBe(true);
});
it('returns false for uncached playlists', () => {
expect(isPlaylistPinnedOffline('pl-9', 'srv-a')).toBe(false);
});
});
describe('isManualOfflinePlaylist', () => {
beforeEach(() => seedAuth());
it('rejects smart playlist names', () => {
expect(isManualOfflinePlaylist('pl-1', 'srv-a', `${SMART_PREFIX}Jazz`)).toBe(false);
});
it('allows regular playlist names', () => {
expect(isManualOfflinePlaylist('pl-1', 'srv-a', 'Road mix')).toBe(true);
});
});
describe('schedulePinnedPlaylistSync', () => {
beforeEach(() => {
vi.useFakeTimers();
isReachableMock.mockReturnValue(true);
getPlaylistMock.mockReset();
enqueueMock.mockReset();
invokeMock.mockClear();
useOfflineJobStoreReset();
useOfflineStore.setState({
albums: {
'a.test:pl-1': {
id: 'pl-1',
serverId: 'a.test',
name: 'Road mix',
artist: '',
trackIds: ['t1'],
type: 'playlist',
},
},
});
useLocalPlaybackStore.setState({
entries: {
'a.test:t1': {
serverIndexKey: 'a.test',
trackId: 't1',
localPath: '/media/library/a.test/a/al/t1.mp3',
layoutFingerprint: 'fp',
sizeBytes: 1000,
tier: 'library',
cachedAt: 1,
suffix: 'mp3',
pinSource: { kind: 'playlist', sourceId: 'pl-1', displayName: 'Road mix' },
},
},
});
seedAuth();
getPlaylistMock.mockResolvedValue({
playlist: { id: 'pl-1', name: 'Road mix', songCount: 1 },
songs: [song('t2')],
});
});
afterEach(() => {
vi.useRealTimers();
});
it('does nothing when the playlist is not cached offline', async () => {
schedulePinnedPlaylistSync('pl-9');
await vi.advanceTimersByTimeAsync(700);
expect(getPlaylistMock).not.toHaveBeenCalled();
});
it('does not sync smart playlists even when previously cached', async () => {
useOfflineStore.setState({
albums: {
'a.test:pl-smart': {
id: 'pl-smart',
serverId: 'a.test',
name: `${SMART_PREFIX}Daily`,
artist: '',
trackIds: ['t1'],
type: 'playlist',
},
},
});
schedulePinnedPlaylistSync('pl-smart');
await vi.advanceTimersByTimeAsync(700);
expect(getPlaylistMock).not.toHaveBeenCalled();
});
it('prunes removed tracks and enqueues downloads for the new list', async () => {
schedulePinnedPlaylistSync('pl-1');
await vi.advanceTimersByTimeAsync(700);
expect(getPlaylistMock).toHaveBeenCalledWith('srv-a', 'pl-1');
expect(invokeMock).toHaveBeenCalledWith(
'delete_media_file',
expect.objectContaining({ localPath: '/media/library/a.test/a/al/t1.mp3' }),
);
expect(useLocalPlaybackStore.getState().entries['a.test:t1']).toBeUndefined();
expect(enqueueMock).toHaveBeenCalledWith(
expect.objectContaining({
albumId: 'pl-1',
type: 'playlist',
songs: [expect.objectContaining({ id: 't2' })],
}),
);
expect(useOfflineStore.getState().albums['a.test:pl-1']?.trackIds).toEqual(['t2']);
});
});
describe('scheduleSyncPinnedAlbumsAndArtists', () => {
beforeEach(() => {
vi.useFakeTimers();
isReachableMock.mockReturnValue(true);
getAlbumForServerMock.mockReset();
enqueueMock.mockReset();
useOfflineJobStoreReset();
useOfflineStore.setState({
albums: {
'a.test:al-1': {
id: 'al-1',
serverId: 'a.test',
name: 'Album',
artist: 'Artist',
trackIds: ['t1'],
type: 'album',
},
},
});
seedAuth();
getAlbumForServerMock.mockResolvedValue({
album: { id: 'al-1', name: 'Album', artist: 'Artist', coverArt: 'c1' },
songs: [song('t2')],
});
});
afterEach(() => {
vi.useRealTimers();
});
it('reconciles cached albums after a debounced library-scope trigger', async () => {
scheduleSyncPinnedAlbumsAndArtists('srv-a');
await vi.advanceTimersByTimeAsync(700);
expect(getAlbumForServerMock).toHaveBeenCalledWith('srv-a', 'al-1');
expect(useOfflineStore.getState().albums['a.test:al-1']?.trackIds).toEqual(['t2']);
});
});
describe('schedulePinnedAlbumSync', () => {
beforeEach(() => {
vi.useFakeTimers();
isReachableMock.mockReturnValue(true);
getAlbumForServerMock.mockReset();
enqueueMock.mockReset();
invokeMock.mockClear();
useOfflineJobStoreReset();
useOfflineStore.setState({
albums: {
'a.test:al-1': {
id: 'al-1',
serverId: 'a.test',
name: 'Album',
artist: 'Artist',
trackIds: ['t1'],
type: 'album',
},
},
});
useLocalPlaybackStore.setState({
entries: {
'a.test:t1': {
serverIndexKey: 'a.test',
trackId: 't1',
localPath: '/media/library/a.test/a/al/t1.mp3',
layoutFingerprint: 'fp',
sizeBytes: 1000,
tier: 'library',
cachedAt: 1,
suffix: 'mp3',
pinSource: { kind: 'album', sourceId: 'al-1', displayName: 'Album' },
},
},
});
seedAuth();
getAlbumForServerMock.mockResolvedValue({
album: { id: 'al-1', name: 'Album', artist: 'Artist', coverArt: 'c1' },
songs: [song('t2')],
});
});
afterEach(() => {
vi.useRealTimers();
});
it('reconciles a cached album against the live track list', async () => {
expect(isSourcePinnedOffline('al-1', 'srv-a', 'album')).toBe(true);
schedulePinnedAlbumSync('al-1');
await vi.advanceTimersByTimeAsync(700);
expect(getAlbumForServerMock).toHaveBeenCalledWith('srv-a', 'al-1');
expect(enqueueMock).toHaveBeenCalledWith(
expect.objectContaining({
albumId: 'al-1',
type: 'album',
songs: [expect.objectContaining({ id: 't2' })],
}),
);
expect(useOfflineStore.getState().albums['a.test:al-1']?.trackIds).toEqual(['t2']);
});
});
function seedArtistAlbumPin(albumId: string, trackId: string, serverIndexKey = 'a.test'): void {
useOfflineStore.setState(state => ({
albums: {
...state.albums,
[`${serverIndexKey}:${albumId}`]: {
id: albumId,
serverId: serverIndexKey,
name: `Album ${albumId}`,
artist: 'Artist',
trackIds: [trackId],
type: 'artist',
},
},
}));
useLocalPlaybackStore.setState(state => ({
entries: {
...state.entries,
[`${serverIndexKey}:${trackId}`]: {
serverIndexKey,
trackId,
localPath: `/media/library/${serverIndexKey}/a/${albumId}/${trackId}.mp3`,
layoutFingerprint: 'fp',
sizeBytes: 1000,
tier: 'library',
cachedAt: 1,
suffix: 'mp3',
pinSource: { kind: 'artist', sourceId: albumId },
},
},
}));
}
describe('syncPinnedArtistIfNeeded', () => {
beforeEach(() => {
isReachableMock.mockReturnValue(true);
getArtistForServerMock.mockReset();
getAlbumForServerMock.mockReset();
enqueueMock.mockReset();
invokeMock.mockClear();
useOfflineJobStoreReset();
useOfflineStore.setState({ albums: {} });
useLocalPlaybackStore.setState({ entries: {} });
seedAuth();
});
it('prunes albums removed from the live artist catalog', async () => {
seedArtistAlbumPin('al-1', 't1');
seedArtistAlbumPin('al-2', 't2');
getArtistForServerMock.mockResolvedValue({
artist: { id: 'art-1', name: 'Artist' },
albums: [{ id: 'al-1', name: 'One', artist: 'Artist' }],
});
getAlbumForServerMock.mockImplementation(async (_sid: string, id: string) => ({
album: { id, name: id, artist: 'Artist' },
songs: [song(id === 'al-1' ? 't1' : 't9')],
}));
await syncPinnedArtistIfNeeded('art-1', 'srv-a');
expect(useOfflineStore.getState().albums['a.test:al-2']).toBeUndefined();
expect(invokeMock).toHaveBeenCalledWith(
'delete_media_file',
expect.objectContaining({ localPath: '/media/library/a.test/a/al-2/t2.mp3' }),
);
expect(useLocalPlaybackStore.getState().entries['a.test:t2']).toBeUndefined();
});
it('auto-downloads a new album when the full discography scope was pinned', async () => {
seedArtistAlbumPin('al-1', 't1');
seedArtistAlbumPin('al-2', 't2');
getArtistForServerMock.mockResolvedValue({
artist: { id: 'art-1', name: 'Artist' },
albums: [
{ id: 'al-1', name: 'One', artist: 'Artist' },
{ id: 'al-2', name: 'Two', artist: 'Artist' },
{ id: 'al-3', name: 'Three', artist: 'Artist' },
],
});
getAlbumForServerMock.mockImplementation(async (_sid: string, id: string) => ({
album: { id, name: id, artist: 'Artist' },
songs: [song(`track-${id}`)],
}));
await syncPinnedArtistIfNeeded('art-1', 'srv-a', ['al-1', 'al-2']);
expect(enqueueMock).toHaveBeenCalledWith(
expect.objectContaining({
albumId: 'al-3',
type: 'artist',
artistProgressGroupId: 'art-1',
}),
);
});
});
describe('syncAllPinnedPlaylists', () => {
beforeEach(() => {
isReachableMock.mockReturnValue(true);
getPlaylistMock.mockReset();
enqueueMock.mockReset();
useOfflineJobStoreReset();
useOfflineStore.setState({
albums: {
'b.test:pl-b': {
id: 'pl-b',
serverId: 'b.test',
name: 'Remote mix',
artist: '',
trackIds: ['tb1'],
type: 'playlist',
},
},
});
useAuthStore.setState({
activeServerId: 'srv-a',
servers: [
{ id: 'srv-a', name: 'A', url: 'https://a.test', username: 'u', password: 'p' },
{ id: 'srv-b', name: 'B', url: 'https://b.test', username: 'u', password: 'p' },
],
});
getPlaylistMock.mockResolvedValue({
playlist: { id: 'pl-b', name: 'Remote mix', songCount: 1 },
songs: [song('tb2')],
});
});
it('fetches each cached playlist from its owning server, not the active server', async () => {
await syncAllPinnedPlaylists();
expect(getPlaylistMock).toHaveBeenCalledWith('srv-b', 'pl-b');
expect(useOfflineStore.getState().albums['b.test:pl-b']?.trackIds).toEqual(['tb2']);
});
});
describe('schedulePinnedPlaylistSync dedupe', () => {
beforeEach(() => {
vi.useFakeTimers();
isReachableMock.mockReturnValue(true);
getPlaylistMock.mockReset();
enqueueMock.mockReset();
useOfflineJobStoreReset();
useOfflineStore.setState({
albums: {
'a.test:pl-1': {
id: 'pl-1',
serverId: 'a.test',
name: 'Road mix',
artist: '',
trackIds: ['t1'],
type: 'playlist',
},
},
});
seedAuth();
getPlaylistMock.mockResolvedValue({
playlist: { id: 'pl-1', name: 'Road mix', songCount: 1 },
songs: [song('t1')],
});
});
afterEach(() => {
vi.useRealTimers();
});
it('coalesces duplicate schedule calls in one debounce window', async () => {
schedulePinnedPlaylistSync('pl-1');
schedulePinnedPlaylistSync('pl-1');
await vi.advanceTimersByTimeAsync(700);
expect(getPlaylistMock).toHaveBeenCalledTimes(1);
});
});
function useOfflineJobStoreReset(): void {
useOfflineJobStore.setState({ jobs: [], pinQueue: [], bulkProgress: {} });
}
+642
View File
@@ -0,0 +1,642 @@
import { libraryGetTracksByAlbum, subscribeLibrarySyncIdle } from '../../api/library';
import { getAlbumForServer, filterSongsToServerLibrary } from '../../api/subsonicLibrary';
import { getPlaylistForServer } from '../../api/subsonicPlaylists';
import { getArtistForServer } from '../../api/subsonicArtists';
import type { SubsonicSong } from '../../api/subsonicTypes';
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../../store/authStore';
import type { PinSource } from '../../store/localPlaybackStore';
import { useLocalPlaybackStore } from '../../store/localPlaybackStore';
import { useOfflineStore } from '../../store/offlineStore';
import { usePlaylistStore } from '../../store/playlistStore';
import { isSmartPlaylistName } from '../componentHelpers/playlistDetailHelpers';
import { getMediaDir } from '../media/mediaDir';
import {
isActiveServerReachable,
onActiveServerBecameReachable,
} from '../network/activeServerReachability';
import { resolveIndexKey, serverIndexKeyForProfile } from '../server/serverIndexKey';
import { resolveServerIdForIndexKey } from '../server/serverLookup';
import { findLocalPlaybackEntry } from './offlineLibraryHelpers';
import { enqueueOfflinePin } from './offlinePinQueue';
export type OfflinePinKind = PinSource['kind'];
const DEBOUNCE_MS = 600;
const RETRY_WHILE_DOWNLOADING_MS = 2500;
/** Cached regular playlists reconcile on this interval (and on in-app edits). */
const PLAYLIST_SYNC_INTERVAL_MS = 60 * 60 * 1000;
let playlistDebounceTimer: ReturnType<typeof setTimeout> | null = null;
let albumArtistDebounceTimer: ReturnType<typeof setTimeout> | null = null;
const pendingPlaylistJobs: { sourceId: string; serverId: string }[] = [];
const pendingAlbumJobs: { sourceId: string; serverId: string }[] = [];
const pendingArtistJobs: { artistId: string; serverId: string; albumIds?: string[] }[] = [];
/** Empty set entry means all servers; otherwise profile ids from library idle. */
const pendingAlbumArtistServers = new Set<string | null>();
const retryTimers = new Map<string, ReturnType<typeof setTimeout>>();
let playlistSyncInterval: ReturnType<typeof setInterval> | null = null;
let stopLibraryIdle: (() => void) | null = null;
function serverIndexKeyForOffline(serverId: string): string {
const server = useAuthStore.getState().servers.find(s => s.id === serverId);
if (server) return serverIndexKeyForProfile(server) || resolveIndexKey(serverId) || serverId;
return resolveIndexKey(serverId) || serverId;
}
function belongsToProfile(metaServerKey: string, profileServerId: string): boolean {
const indexKey = serverIndexKeyForOffline(profileServerId);
return metaServerKey === profileServerId
|| metaServerKey === indexKey
|| resolveServerIdForIndexKey(metaServerKey) === profileServerId;
}
function offlineMeta(sourceId: string, serverId: string) {
const indexKey = serverIndexKeyForOffline(serverId);
const albums = useOfflineStore.getState().albums;
return albums[`${indexKey}:${sourceId}`] ?? albums[`${serverId}:${sourceId}`];
}
function resolvePlaylistName(playlistId: string, serverId: string): string | undefined {
return offlineMeta(playlistId, serverId)?.name
?? usePlaylistStore.getState().playlists.find(p => p.id === playlistId)?.name;
}
/** Smart playlists refresh from server rules — not eligible for manual offline cache/sync. */
export function isManualOfflinePlaylist(playlistId: string, serverId: string, name?: string): boolean {
const resolved = name ?? resolvePlaylistName(playlistId, serverId);
return !resolved || !isSmartPlaylistName(resolved);
}
/** True when a source was manually cached offline with the given pin kind. */
export function isSourcePinnedOffline(
sourceId: string,
serverId: string,
kind: OfflinePinKind,
): boolean {
const meta = offlineMeta(sourceId, serverId);
if (meta?.type === kind) return true;
const indexKey = serverIndexKeyForOffline(serverId);
const group = useLocalPlaybackStore.getState()
.listPinnedGroups(indexKey)
.find(g => g.pinSource.kind === kind && g.pinSource.sourceId === sourceId);
return (group?.trackIds.length ?? 0) > 0;
}
/** @deprecated Use {@link isSourcePinnedOffline} with kind `playlist`. */
export function isPlaylistPinnedOffline(playlistId: string, serverId: string): boolean {
return isSourcePinnedOffline(playlistId, serverId, 'playlist');
}
function trackStillNeededByOtherPin(
trackId: string,
serverIndexKey: string,
exceptKind: OfflinePinKind,
exceptSourceId: string,
): boolean {
for (const group of useLocalPlaybackStore.getState().listPinnedGroups(serverIndexKey)) {
if (group.pinSource.kind === exceptKind && group.pinSource.sourceId === exceptSourceId) continue;
if (group.trackIds.includes(trackId)) return true;
}
return false;
}
async function pruneRemovedPinTracks(
sourceId: string,
serverId: string,
kind: OfflinePinKind,
keepIds: Set<string>,
): Promise<void> {
const indexKey = serverIndexKeyForOffline(serverId);
const lp = useLocalPlaybackStore.getState();
const mediaDir = getMediaDir();
const group = lp.listPinnedGroups(indexKey)
.find(g => g.pinSource.kind === kind && g.pinSource.sourceId === sourceId);
const previousIds = group?.trackIds ?? offlineMeta(sourceId, serverId)?.trackIds ?? [];
for (const trackId of previousIds) {
if (keepIds.has(trackId)) continue;
if (trackStillNeededByOtherPin(trackId, indexKey, kind, sourceId)) continue;
const entry = findLocalPlaybackEntry(trackId, serverId);
if (!entry?.localPath || entry.tier !== 'library') continue;
if (entry.pinSource?.kind !== kind || entry.pinSource.sourceId !== sourceId) continue;
await invoke('delete_media_file', { localPath: entry.localPath, mediaDir }).catch(() => {});
lp.removeEntry(trackId, entry.serverIndexKey, `${kind}-sync-prune`);
}
}
function dedupeSongs(songs: SubsonicSong[]): SubsonicSong[] {
const seen = new Set<string>();
return songs.filter(s => {
if (seen.has(s.id)) return false;
seen.add(s.id);
return true;
});
}
function updateOfflineMeta(
sourceId: string,
serverId: string,
kind: OfflinePinKind,
patch: {
name: string;
albumArtist: string;
coverArt?: string;
year?: number;
trackIds: string[];
},
): void {
const indexKey = serverIndexKeyForOffline(serverId);
useOfflineStore.setState(state => {
const key = `${indexKey}:${sourceId}`;
const legacyKey = `${serverId}:${sourceId}`;
const existing = state.albums[key] ?? state.albums[legacyKey];
const nextAlbums = { ...state.albums };
delete nextAlbums[legacyKey];
nextAlbums[key] = {
...(existing ?? {
id: sourceId,
serverId: indexKey,
artist: patch.albumArtist,
}),
id: sourceId,
serverId: indexKey,
name: patch.name,
artist: patch.albumArtist,
coverArt: patch.coverArt ?? existing?.coverArt,
year: patch.year ?? existing?.year,
trackIds: patch.trackIds,
type: kind,
};
return { albums: nextAlbums };
});
}
function scheduleRetryWhileDownloading(
sourceId: string,
serverId: string,
kind: OfflinePinKind,
): void {
const key = `${serverId}:${kind}:${sourceId}`;
const prev = retryTimers.get(key);
if (prev) clearTimeout(prev);
retryTimers.set(key, setTimeout(() => {
retryTimers.delete(key);
void syncPinnedSourceIfNeeded(sourceId, serverId, kind);
}, RETRY_WHILE_DOWNLOADING_MS));
}
interface SyncPinOptions {
prefetchedSongs?: SubsonicSong[];
name?: string;
albumArtist?: string;
coverArt?: string;
year?: number;
artistProgressGroupId?: string;
/** Download even when the source is not pinned yet (new album in a fully cached discography). */
allowUnpinned?: boolean;
}
/**
* Refresh a manually cached pin: download new tracks, drop removed ones,
* update persisted offline metadata.
*/
export async function syncPinnedSourceIfNeeded(
sourceId: string,
serverId: string,
kind: OfflinePinKind,
options: SyncPinOptions = {},
): Promise<void> {
if (!isActiveServerReachable()) return;
const alreadyPinned = isSourcePinnedOffline(sourceId, serverId, kind);
if (!alreadyPinned && !options.allowUnpinned) return;
if (kind === 'playlist' && !isManualOfflinePlaylist(sourceId, serverId, options.name)) return;
let songs = options.prefetchedSongs;
let displayName = options.name ?? offlineMeta(sourceId, serverId)?.name ?? sourceId;
let albumArtist = options.albumArtist ?? offlineMeta(sourceId, serverId)?.artist ?? '';
let coverArt = options.coverArt ?? offlineMeta(sourceId, serverId)?.coverArt;
let year = options.year ?? offlineMeta(sourceId, serverId)?.year;
if (!songs) {
try {
if (kind === 'playlist') {
const data = await getPlaylistForServer(serverId, sourceId);
displayName = data.playlist.name;
coverArt = data.playlist.coverArt ?? coverArt;
songs = await filterSongsToServerLibrary(data.songs, serverId);
} else {
const data = await getAlbumForServer(serverId, sourceId);
displayName = data.album.name;
albumArtist = data.album.artist ?? albumArtist;
coverArt = data.album.coverArt ?? coverArt;
year = data.album.year ?? year;
songs = await filterSongsToServerLibrary(data.songs, serverId);
}
} catch {
return;
}
} else {
songs = await filterSongsToServerLibrary(songs, serverId);
}
const unique = dedupeSongs(songs);
const keepIds = new Set(unique.map(s => s.id));
await pruneRemovedPinTracks(sourceId, serverId, kind, keepIds);
updateOfflineMeta(sourceId, serverId, kind, {
name: displayName,
albumArtist,
coverArt,
year,
trackIds: unique.map(s => s.id),
});
const offline = useOfflineStore.getState();
if (offline.isAlbumDownloading(sourceId)) {
scheduleRetryWhileDownloading(sourceId, serverId, kind);
return;
}
const enqueued = enqueueOfflinePin({
albumId: sourceId,
albumName: displayName,
albumArtist,
coverArt,
year,
songs: unique,
serverId,
type: kind,
artistProgressGroupId: options.artistProgressGroupId,
});
if (!enqueued && offline.isAlbumDownloading(sourceId)) {
scheduleRetryWhileDownloading(sourceId, serverId, kind);
}
}
/** @deprecated Use {@link syncPinnedSourceIfNeeded} with kind `playlist`. */
export async function syncPinnedPlaylistIfNeeded(
playlistId: string,
serverId?: string,
prefetchedSongs?: SubsonicSong[],
): Promise<void> {
const sid = serverId ?? useAuthStore.getState().activeServerId;
if (!sid) return;
await syncPinnedSourceIfNeeded(playlistId, sid, 'playlist', { prefetchedSongs });
}
export async function syncPinnedAlbumIfNeeded(
albumId: string,
serverId?: string,
prefetchedSongs?: SubsonicSong[],
): Promise<void> {
const sid = serverId ?? useAuthStore.getState().activeServerId;
if (!sid) return;
await syncPinnedSourceIfNeeded(albumId, sid, 'album', { prefetchedSongs });
}
/** Any album in the artist discography was cached with type `artist`. */
export function isArtistDiscographyPinnedOffline(
serverId: string,
albumIds: string[],
): boolean {
return albumIds.some(id => isSourcePinnedOffline(id, serverId, 'artist'));
}
function listPinnedArtistAlbumIds(serverId: string): string[] {
const ids = new Set<string>();
for (const meta of Object.values(useOfflineStore.getState().albums)) {
if (meta.type !== 'artist') continue;
if (!belongsToProfile(meta.serverId, serverId)) continue;
ids.add(meta.id);
}
for (const group of useLocalPlaybackStore.getState().listPinnedGroups()) {
if (group.pinSource.kind !== 'artist') continue;
if (!belongsToProfile(group.serverIndexKey, serverId)) continue;
ids.add(group.pinSource.sourceId);
}
return [...ids];
}
/**
* Reconcile a cached artist discography: refresh pinned albums, drop albums
* removed from the catalog, and fetch new albums when the scope was fully cached.
* When every album in the known scope is already pinned, newly released albums
* download automatically (intended “keep discography complete” UX).
*/
export async function syncPinnedArtistIfNeeded(
artistId: string,
serverId?: string,
knownAlbumIds?: string[],
): Promise<void> {
if (!isActiveServerReachable()) return;
const sid = serverId ?? useAuthStore.getState().activeServerId;
if (!sid || !artistId) return;
const pinnedBefore = listPinnedArtistAlbumIds(sid);
const scopeIds = knownAlbumIds ?? pinnedBefore;
if (!isArtistDiscographyPinnedOffline(sid, scopeIds) && pinnedBefore.length === 0) return;
let liveAlbumIds: string[] = [];
try {
const { albums } = await getArtistForServer(sid, artistId);
liveAlbumIds = albums.map(a => a.id);
} catch {
return;
}
const scopeFullyPinned = scopeIds.length > 0
&& scopeIds.every(id => isSourcePinnedOffline(id, sid, 'artist'));
const liveSet = new Set(liveAlbumIds);
for (const oldAlbumId of pinnedBefore) {
if (liveSet.has(oldAlbumId)) continue;
await pruneRemovedPinTracks(oldAlbumId, sid, 'artist', new Set());
const indexKey = serverIndexKeyForOffline(sid);
useOfflineStore.setState(state => {
const albums = { ...state.albums };
delete albums[`${indexKey}:${oldAlbumId}`];
delete albums[`${sid}:${oldAlbumId}`];
return { albums };
});
}
for (const albumId of liveAlbumIds) {
const shouldSync = isSourcePinnedOffline(albumId, sid, 'artist')
|| (scopeFullyPinned && pinnedBefore.length > 0);
if (!shouldSync) continue;
await syncPinnedSourceIfNeeded(albumId, sid, 'artist', {
artistProgressGroupId: artistId,
allowUnpinned: !isSourcePinnedOffline(albumId, sid, 'artist'),
});
}
}
function pushUniquePlaylistJob(sourceId: string, serverId: string): void {
if (pendingPlaylistJobs.some(j => j.sourceId === sourceId && j.serverId === serverId)) return;
pendingPlaylistJobs.push({ sourceId, serverId });
}
function pushUniqueAlbumJob(sourceId: string, serverId: string): void {
if (pendingAlbumJobs.some(j => j.sourceId === sourceId && j.serverId === serverId)) return;
pendingAlbumJobs.push({ sourceId, serverId });
}
function pushUniqueArtistJob(artistId: string, serverId: string, albumIds?: string[]): void {
if (pendingArtistJobs.some(j => j.artistId === artistId && j.serverId === serverId)) return;
pendingArtistJobs.push({ artistId, serverId, albumIds });
}
function flushPendingPlaylistJobs(): void {
playlistDebounceTimer = null;
const jobs = [...pendingPlaylistJobs];
pendingPlaylistJobs.length = 0;
for (const job of jobs) {
void syncPinnedSourceIfNeeded(job.sourceId, job.serverId, 'playlist');
}
}
function flushPendingAlbumArtistJobs(): void {
albumArtistDebounceTimer = null;
const albums = [...pendingAlbumJobs];
const artists = [...pendingArtistJobs];
const servers = [...pendingAlbumArtistServers];
pendingAlbumJobs.length = 0;
pendingArtistJobs.length = 0;
pendingAlbumArtistServers.clear();
for (const job of albums) {
void syncPinnedSourceIfNeeded(job.sourceId, job.serverId, 'album');
}
for (const job of artists) {
void syncPinnedArtistIfNeeded(job.artistId, job.serverId, job.albumIds);
}
if (servers.length > 0) {
for (const serverId of servers) {
void syncAllPinnedAlbumsAndArtists(serverId ?? undefined);
}
}
}
function scheduleDebouncedPlaylistSync(): void {
if (playlistDebounceTimer) clearTimeout(playlistDebounceTimer);
playlistDebounceTimer = setTimeout(flushPendingPlaylistJobs, DEBOUNCE_MS);
}
function scheduleDebouncedAlbumArtistSync(): void {
if (albumArtistDebounceTimer) clearTimeout(albumArtistDebounceTimer);
albumArtistDebounceTimer = setTimeout(flushPendingAlbumArtistJobs, DEBOUNCE_MS);
}
function metaMatchesServer(metaServerKey: string, serverId?: string): boolean {
if (!serverId) return true;
return belongsToProfile(metaServerKey, serverId);
}
async function groupPinnedArtistAlbumsByArtistId(
serverId: string,
albumIds: Iterable<string>,
): Promise<Map<string, string[]>> {
const byArtist = new Map<string, string[]>();
for (const albumId of albumIds) {
try {
const tracks = await libraryGetTracksByAlbum(serverId, albumId);
const artistId = tracks[0]?.artistId;
if (!artistId) continue;
const list = byArtist.get(artistId) ?? [];
list.push(albumId);
byArtist.set(artistId, list);
} catch {
// index row missing — fall back to per-album reconcile below
}
}
return byArtist;
}
export function schedulePinnedPlaylistSync(playlistId: string, serverId?: string): void {
const sid = serverId ?? useAuthStore.getState().activeServerId;
if (!playlistId || !sid) return;
if (!isSourcePinnedOffline(playlistId, sid, 'playlist')) return;
if (!isManualOfflinePlaylist(playlistId, sid)) return;
if (!isActiveServerReachable()) return;
pushUniquePlaylistJob(playlistId, sid);
scheduleDebouncedPlaylistSync();
}
export function schedulePinnedAlbumSync(albumId: string, serverId?: string): void {
const sid = serverId ?? useAuthStore.getState().activeServerId;
if (!albumId || !sid) return;
if (!isSourcePinnedOffline(albumId, sid, 'album')) return;
if (!isActiveServerReachable()) return;
pushUniqueAlbumJob(albumId, sid);
scheduleDebouncedAlbumArtistSync();
}
export function schedulePinnedArtistSync(
artistId: string,
serverId?: string,
albumIds?: string[],
): void {
const sid = serverId ?? useAuthStore.getState().activeServerId;
if (!sid || !artistId) return;
if (!isArtistDiscographyPinnedOffline(sid, albumIds ?? listPinnedArtistAlbumIds(sid))) return;
if (!isActiveServerReachable()) return;
pushUniqueArtistJob(artistId, sid, albumIds);
scheduleDebouncedAlbumArtistSync();
}
/** Reconcile every cached album pin and artist discography (optionally one server). */
export async function syncAllPinnedAlbumsAndArtists(serverId?: string): Promise<void> {
if (!isActiveServerReachable()) return;
const seenAlbums = new Set<string>();
const artistAlbumIdsByServer = new Map<string, Set<string>>();
const albumJobs: { sourceId: string; serverId: string }[] = [];
const consider = (kind: OfflinePinKind, sourceId: string, metaServerKey: string) => {
if (kind === 'playlist') return;
const sid = resolveServerIdForIndexKey(metaServerKey) || metaServerKey;
if (!metaMatchesServer(metaServerKey, serverId) && !metaMatchesServer(sid, serverId)) return;
if (kind === 'album') {
const dedupe = `${sid}:${sourceId}`;
if (seenAlbums.has(dedupe)) return;
seenAlbums.add(dedupe);
albumJobs.push({ sourceId, serverId: sid });
return;
}
if (kind === 'artist') {
const set = artistAlbumIdsByServer.get(sid) ?? new Set<string>();
set.add(sourceId);
artistAlbumIdsByServer.set(sid, set);
}
};
for (const meta of Object.values(useOfflineStore.getState().albums)) {
consider(meta.type ?? 'album', meta.id, meta.serverId);
}
for (const group of useLocalPlaybackStore.getState().listPinnedGroups()) {
consider(group.pinSource.kind, group.pinSource.sourceId, group.serverIndexKey);
}
for (const job of albumJobs) {
await syncPinnedSourceIfNeeded(job.sourceId, job.serverId, 'album');
}
for (const [sid, albumIds] of artistAlbumIdsByServer) {
const byArtist = await groupPinnedArtistAlbumsByArtistId(sid, albumIds);
const assignedAlbums = new Set<string>();
for (const [artistId, ids] of byArtist) {
ids.forEach(id => assignedAlbums.add(id));
await syncPinnedArtistIfNeeded(artistId, sid, ids);
}
for (const albumId of albumIds) {
if (assignedAlbums.has(albumId)) continue;
await syncPinnedSourceIfNeeded(albumId, sid, 'artist');
}
}
}
/** Reconcile every manually cached regular playlist (optionally one server). */
export async function syncAllPinnedPlaylists(serverId?: string): Promise<void> {
if (!isActiveServerReachable()) return;
const seen = new Set<string>();
const jobs: { sourceId: string; serverId: string }[] = [];
for (const meta of Object.values(useOfflineStore.getState().albums)) {
if (meta.type !== 'playlist') continue;
if (isSmartPlaylistName(meta.name)) continue;
const sid = resolveServerIdForIndexKey(meta.serverId) || meta.serverId;
if (!metaMatchesServer(meta.serverId, serverId) && !metaMatchesServer(sid, serverId)) continue;
const dedupe = `${sid}:${meta.id}`;
if (seen.has(dedupe)) continue;
seen.add(dedupe);
jobs.push({ sourceId: meta.id, serverId: sid });
}
for (const group of useLocalPlaybackStore.getState().listPinnedGroups()) {
if (group.pinSource.kind !== 'playlist') continue;
if (isSmartPlaylistName(group.pinSource.displayName ?? '')) continue;
const sid = resolveServerIdForIndexKey(group.serverIndexKey) || group.serverIndexKey;
if (!metaMatchesServer(group.serverIndexKey, serverId) && !metaMatchesServer(sid, serverId)) continue;
const dedupe = `${sid}:${group.pinSource.sourceId}`;
if (seen.has(dedupe)) continue;
seen.add(dedupe);
jobs.push({ sourceId: group.pinSource.sourceId, serverId: sid });
}
for (const job of jobs) {
if (!isManualOfflinePlaylist(job.sourceId, job.serverId)) continue;
await syncPinnedSourceIfNeeded(job.sourceId, job.serverId, 'playlist');
}
}
/** @deprecated Use {@link syncAllPinnedAlbumsAndArtists} + {@link syncAllPinnedPlaylists}. */
export async function syncAllPinnedOffline(): Promise<void> {
await syncAllPinnedAlbumsAndArtists();
await syncAllPinnedPlaylists();
}
export function scheduleSyncPinnedAlbumsAndArtists(serverId?: string): void {
if (!isActiveServerReachable()) return;
pendingAlbumArtistServers.add(serverId ?? null);
scheduleDebouncedAlbumArtistSync();
}
/** @deprecated Use {@link scheduleSyncPinnedAlbumsAndArtists}. */
export function scheduleSyncAllPinnedOffline(): void {
scheduleSyncPinnedAlbumsAndArtists();
void syncAllPinnedPlaylists();
}
/** @deprecated Use hourly {@link syncAllPinnedPlaylists}. */
export function scheduleSyncAllPinnedPlaylists(): void {
if (!isActiveServerReachable()) return;
void syncAllPinnedPlaylists();
}
function onLibraryBecameIdle(serverIndexKey: string, kind: string, ok: boolean): void {
if (!ok) return;
if (kind !== 'initial_sync' && kind !== 'delta_sync') return;
if (!isActiveServerReachable()) return;
const serverId = resolveServerIdForIndexKey(serverIndexKey);
scheduleSyncPinnedAlbumsAndArtists(serverId);
}
export function initPinnedOfflineSync(): () => void {
void subscribeLibrarySyncIdle(payload => {
onLibraryBecameIdle(payload.serverId, payload.kind, payload.ok);
}).then(unlisten => {
stopLibraryIdle = unlisten;
});
playlistSyncInterval = setInterval(() => {
if (isActiveServerReachable()) void syncAllPinnedPlaylists();
}, PLAYLIST_SYNC_INTERVAL_MS);
const stopReachable = onActiveServerBecameReachable(() => {
scheduleSyncPinnedAlbumsAndArtists();
});
return () => {
if (playlistDebounceTimer) clearTimeout(playlistDebounceTimer);
if (albumArtistDebounceTimer) clearTimeout(albumArtistDebounceTimer);
if (playlistSyncInterval) clearInterval(playlistSyncInterval);
stopLibraryIdle?.();
stopLibraryIdle = null;
for (const t of retryTimers.values()) clearTimeout(t);
retryTimers.clear();
stopReachable();
};
}
/** @deprecated Use {@link initPinnedOfflineSync}. */
export function initPinnedPlaylistOfflineSync(): () => void {
return initPinnedOfflineSync();
}
@@ -0,0 +1,98 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore } from '../../store/authStore';
import { useOfflineStore } from '../../store/offlineStore';
import { resumeIncompleteOfflinePins } from './resumeIncompleteOfflinePins';
const isActiveServerReachableMock = vi.fn(() => true);
const isOfflinePinCompleteMock = vi.fn((_albumId: string, _serverId: string) => false);
const resolveAlbumForServerMock = vi.fn();
const downloadAlbumMock = vi.fn();
vi.mock('../network/activeServerReachability', () => ({
isActiveServerReachable: () => isActiveServerReachableMock(),
onActiveServerBecameReachable: () => () => {},
}));
vi.mock('./offlineLibraryHelpers', () => ({
isOfflinePinComplete: (albumId: string, serverId: string) =>
isOfflinePinCompleteMock(albumId, serverId),
}));
vi.mock('./favoritesOfflineBrowse', () => ({
resolveAlbumForServer: (serverId: string, albumId: string) =>
resolveAlbumForServerMock(serverId, albumId),
}));
vi.mock('../../api/library', () => ({
libraryGetTracksBatchChunked: vi.fn(async () => []),
LIBRARY_TRACKS_BATCH_LIMIT: 100,
}));
describe('resumeIncompleteOfflinePins', () => {
beforeEach(() => {
isActiveServerReachableMock.mockReturnValue(true);
isOfflinePinCompleteMock.mockReturnValue(false);
resolveAlbumForServerMock.mockReset();
downloadAlbumMock.mockReset();
useAuthStore.setState({
activeServerId: 'srv-1',
servers: [{ id: 'srv-1', name: 'A', url: 'https://a.test', username: 'u', password: 'p' }],
});
useOfflineStore.setState({
albums: {
'srv-1:alb-1': {
id: 'alb-1',
serverId: 'srv-1',
name: 'Album',
artist: 'Artist',
trackIds: ['t1', 't2'],
type: 'album',
},
},
});
useOfflineStore.setState({
downloadAlbum: downloadAlbumMock as never,
isAlbumDownloading: () => false,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('skips when the active server is unreachable', async () => {
isActiveServerReachableMock.mockReturnValue(false);
await resumeIncompleteOfflinePins();
expect(downloadAlbumMock).not.toHaveBeenCalled();
});
it('skips pins that are already complete on disk', async () => {
isOfflinePinCompleteMock.mockReturnValue(true);
await resumeIncompleteOfflinePins();
expect(downloadAlbumMock).not.toHaveBeenCalled();
});
it('re-queues downloadAlbum for incomplete persisted pins', async () => {
resolveAlbumForServerMock.mockResolvedValue({
album: { id: 'alb-1', name: 'Album', artist: 'Artist', artistId: 'a1', songCount: 2, duration: 2 },
songs: [
{ id: 't1', title: 'One', artist: 'Artist', album: 'Album', albumId: 'alb-1', duration: 1 },
{ id: 't2', title: 'Two', artist: 'Artist', album: 'Album', albumId: 'alb-1', duration: 1 },
],
});
await resumeIncompleteOfflinePins();
expect(downloadAlbumMock).toHaveBeenCalledWith(
'alb-1',
'Album',
'Artist',
undefined,
undefined,
expect.arrayContaining([
expect.objectContaining({ id: 't1' }),
expect.objectContaining({ id: 't2' }),
]),
'srv-1',
'album',
);
});
});
@@ -0,0 +1,100 @@
import { libraryGetTracksBatchChunked } from '../../api/library';
import { getPlaylist } from '../../api/subsonicPlaylists';
import type { SubsonicSong } from '../../api/subsonicTypes';
import { useAuthStore } from '../../store/authStore';
import type { OfflineAlbumMeta } from '../../store/offlineStore';
import { useOfflineStore } from '../../store/offlineStore';
import { trackToSong } from '../library/advancedSearchLocal';
import { isActiveServerReachable, onActiveServerBecameReachable } from '../network/activeServerReachability';
import { shouldAttemptSubsonicForServer } from '../network/subsonicNetworkGuard';
import { resolveServerIdForIndexKey } from '../server/serverLookup';
import { isOfflinePinComplete } from './offlineLibraryHelpers';
import { resolveAlbumForServer } from './favoritesOfflineBrowse';
const DEBOUNCE_MS = 800;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
async function songsForOfflineMeta(
meta: OfflineAlbumMeta,
profileServerId: string,
): Promise<SubsonicSong[]> {
if (meta.type === 'playlist' && shouldAttemptSubsonicForServer(profileServerId)) {
const activeId = useAuthStore.getState().activeServerId;
if (activeId === profileServerId) {
try {
const { songs } = await getPlaylist(meta.id);
if (songs.length > 0) return songs;
} catch {
/* fall through */
}
}
} else if (meta.type !== 'playlist') {
try {
const resolved = await resolveAlbumForServer(profileServerId, meta.id);
if (resolved?.songs.length) return resolved.songs;
} catch {
/* fall through */
}
}
const refs = meta.trackIds.map(trackId => ({ serverId: profileServerId, trackId }));
const dtos = await libraryGetTracksBatchChunked(refs).catch(() => []);
return dtos.map(trackToSong);
}
/**
* Re-queue library-tier pins that still lack on-disk bytes (e.g. user left the
* album page mid-download, or the app restarted while jobs were in memory only).
*/
export async function resumeIncompleteOfflinePins(): Promise<void> {
if (!isActiveServerReachable()) return;
const offline = useOfflineStore.getState();
const metas = Object.values(offline.albums);
for (const meta of metas) {
if (!meta.trackIds?.length) continue;
const profileServerId = resolveServerIdForIndexKey(meta.serverId) || meta.serverId;
if (offline.isAlbumDownloading(meta.id)) continue;
if (isOfflinePinComplete(meta.id, profileServerId, meta.trackIds)) continue;
const songs = await songsForOfflineMeta(meta, profileServerId);
if (songs.length === 0) continue;
void offline.downloadAlbum(
meta.id,
meta.name,
meta.artist,
meta.coverArt,
meta.year,
songs,
profileServerId,
meta.type ?? 'album',
);
}
}
export function scheduleResumeIncompleteOfflinePins(): void {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
debounceTimer = null;
void resumeIncompleteOfflinePins();
}, DEBOUNCE_MS);
}
/** App start, server switch, and reconnect → finish interrupted offline pins. */
export function initResumeIncompleteOfflinePins(): () => void {
scheduleResumeIncompleteOfflinePins();
const stopReachable = onActiveServerBecameReachable(() => scheduleResumeIncompleteOfflinePins());
const stopAuth = useAuthStore.subscribe((state, prev) => {
if (state.activeServerId !== prev.activeServerId) {
scheduleResumeIncompleteOfflinePins();
}
});
return () => {
if (debounceTimer) clearTimeout(debounceTimer);
stopReachable();
stopAuth();
};
}
+12 -6
View File
@@ -1,16 +1,22 @@
import { getAlbum } from '../../api/subsonicLibrary';
import { usePlayerStore } from '../../store/playerStore';
import { resolveAlbumForServer } from '../offline/favoritesOfflineBrowse';
import { songToTrack } from './songToTrack';
import { useOrbitStore } from '../../store/orbitStore';
import { fadeOut } from './fadeOut';
import type { Track } from '../../store/playerStoreTypes';
import { shuffleArray } from './shuffleArray';
async function fetchAlbumTracks(albumId: string): Promise<Track[]> {
const albumData = await getAlbum(albumId);
async function fetchAlbumTracks(albumId: string, serverId?: string): Promise<Track[]> {
const albumData = serverId
? await resolveAlbumForServer(serverId, albumId)
: await getAlbum(albumId).then(d => ({ album: d.album, songs: d.songs }));
if (!albumData) throw new Error(`Album ${albumId} not available`);
const albumGenre = albumData.album.genre;
const ownerServerId = serverId ?? albumData.album.serverId;
return albumData.songs.map(s => {
const track = songToTrack(s);
if (ownerServerId) track.serverId = ownerServerId;
if (!track.genre && albumGenre) track.genre = albumGenre;
return track;
});
@@ -44,10 +50,10 @@ async function startAlbumPlayback(tracks: Track[]): Promise<void> {
usePlayerStore.getState().playTrack(tracks[0], tracks);
}
export async function playAlbum(albumId: string): Promise<void> {
await startAlbumPlayback(await fetchAlbumTracks(albumId));
export async function playAlbum(albumId: string, opts?: { serverId?: string }): Promise<void> {
await startAlbumPlayback(await fetchAlbumTracks(albumId, opts?.serverId));
}
export async function playAlbumShuffled(albumId: string): Promise<void> {
await startAlbumPlayback(shuffleArray(await fetchAlbumTracks(albumId)));
export async function playAlbumShuffled(albumId: string, opts?: { serverId?: string }): Promise<void> {
await startAlbumPlayback(shuffleArray(await fetchAlbumTracks(albumId, opts?.serverId)));
}
+33 -1
View File
@@ -3,9 +3,11 @@ import { useAuthStore } from '../../store/authStore';
import { usePlayerStore } from '../../store/playerStore';
import {
bindQueueServerForPlayback,
bindQueueServerForTracks,
clearQueueServerForPlayback,
ensurePlaybackServerActive,
getPlaybackServerId,
playbackCacheKeyForTrack,
playbackCoverArtForId,
playbackServerDiffersFromActive,
prepareActiveServerForNewMix,
@@ -41,6 +43,19 @@ describe('playbackServer', () => {
expect(getPlaybackServerId()).toBe('a');
});
it('getPlaybackServerId follows the playing ref in a mixed-server queue', () => {
useAuthStore.setState({ activeServerId: 'a' });
usePlayerStore.setState({
queueItems: [
{ serverId: 'a.test', trackId: 't1' },
{ serverId: 'b.test', trackId: 't2' },
],
queueServerId: 'a.test',
queueIndex: 1,
});
expect(getPlaybackServerId()).toBe('b');
});
it('getPlaybackServerId falls back to active when queue is empty', () => {
clearQueueServerForPlayback();
usePlayerStore.setState({ queueItems: [] });
@@ -88,7 +103,8 @@ describe('playbackServer', () => {
useAuthStore.setState({ activeServerId: 'b' });
usePlayerStore.setState({ queueServerId: null });
expect(shouldHandoffQueueToActiveServer()).toBe(true);
expect(playbackServerDiffersFromActive()).toBe(false);
// Playing ref still owns playback even when queueServerId is unpinned.
expect(playbackServerDiffersFromActive()).toBe(true);
});
it('shouldHandoffQueueToActiveServer when queue server differs from active', () => {
@@ -123,4 +139,20 @@ describe('playbackServer', () => {
expect(shouldBindQueueServerForPlay(prevRefs, next, next)).toBe(true);
expect(shouldBindQueueServerForPlay(prevRefs, sameTrack, undefined)).toBe(false);
});
it('bindQueueServerForTracks pins the first track server, not the active server', () => {
useAuthStore.setState({ activeServerId: 'a' });
bindQueueServerForTracks([
{ id: 't1', title: 'T', artist: '', album: '', albumId: '', duration: 1, serverId: 'b' },
]);
expect(usePlayerStore.getState().queueServerId).toBe('b.test');
});
it('playbackCacheKeyForTrack prefers track.serverId over queue server', () => {
usePlayerStore.setState({ queueServerId: 'a.test' });
const key = playbackCacheKeyForTrack({
id: 't1', title: 'T', artist: '', album: '', albumId: '', duration: 1, serverId: 'b',
});
expect(key).toBe('b.test');
});
});
+100 -16
View File
@@ -4,32 +4,59 @@ import { coverEntryToRef, resolveAlbumCoverEntry } from '../../cover/resolveEntr
import { coverStorageKeyFromRef } from '../../cover/storageKeys';
import { resolveCoverDisplayTier } from '../../cover/tiers';
import { useAuthStore } from '../../store/authStore';
import type { ServerProfile } from '../../store/authStoreTypes';
import { usePlayerStore } from '../../store/playerStore';
import { switchActiveServer } from '../server/switchActiveServer';
import { sameQueueTrackId } from './queueIdentity';
import type { QueueItemRef, Track } from '../../store/playerStoreTypes';
import { resolveServerIdForIndexKey } from '../server/serverLookup';
import { findServerByIdOrIndexKey, resolveServerIdForIndexKey } from '../server/serverLookup';
import {
canonicalQueueServerKey,
resolveIndexKey,
serverIndexKeyForProfile,
serverIndexKeyFromUrl,
} from '../server/serverIndexKey';
import {
activeServerProfileId,
isMultiServerQueue,
profileIdFromQueueRef,
queueItemRefAt,
stampTrackServerIds,
} from './trackServerScope';
/** Server that owns the current queue / stream URLs (may differ from the browsed server). */
function playbackServerFromRef(ref: QueueItemRef): ServerProfile | undefined {
const profileId = profileIdFromQueueRef(ref);
return findServerByIdOrIndexKey(profileId) ?? findServerByIdOrIndexKey(ref.serverId);
}
/** Profile id for the currently playing queue item (mixed-server safe). */
export function getPlaybackServerId(): string {
const playingRef = queueItemRefAt();
if (playingRef?.serverId) {
const server = playbackServerFromRef(playingRef);
if (server) return server.id;
}
const { queueServerId, queueItems } = usePlayerStore.getState();
if ((queueItems?.length ?? 0) > 0 && queueServerId) {
return resolveServerIdForIndexKey(queueServerId);
const resolved = resolveServerIdForIndexKey(queueServerId);
const server = findServerByIdOrIndexKey(resolved);
return server?.id ?? resolved;
}
return useAuthStore.getState().activeServerId ?? '';
return activeServerProfileId() ?? '';
}
export function getPlaybackIndexKey(): string {
const playingRef = queueItemRefAt();
if (playingRef?.serverId) {
const server = playbackServerFromRef(playingRef);
if (server) return serverIndexKeyForProfile(server) || server.id;
return playbackCacheKeyForRef(playingRef);
}
const { queueServerId, queueItems } = usePlayerStore.getState();
if ((queueItems?.length ?? 0) > 0 && queueServerId) {
return resolveIndexKey(queueServerId);
}
const activeId = useAuthStore.getState().activeServerId ?? '';
const activeId = activeServerProfileId() ?? '';
if (!activeId) return '';
const server = useAuthStore.getState().servers.find(s => s.id === activeId);
return server ? serverIndexKeyFromUrl(server.url) || activeId : activeId;
@@ -48,15 +75,62 @@ export function getPlaybackCacheServerKey(): string {
export function bindQueueServerForPlayback(): void {
const sid = useAuthStore.getState().activeServerId;
if (!sid) return;
const server = useAuthStore.getState().servers.find(s => s.id === sid);
// Canonical index key on writes so mixed-server queues stay unambiguous —
// every ref/queue-level server identifier follows the same shape that the
// library index already uses. Falls back to the raw id when the server
// profile cannot be resolved (e.g. tests with a stubbed auth store).
const canonical = server ? serverIndexKeyForProfile(server) || sid : sid;
bindQueueServerId(sid);
}
/** Pin queue playback to an explicit server profile or index key. */
export function bindQueueServerId(serverId: string): void {
const server = findServerByIdOrIndexKey(serverId);
const canonical = server
? serverIndexKeyForProfile(server) || server.id
: canonicalQueueServerKey(serverId) || serverId;
usePlayerStore.setState({ queueServerId: canonical });
}
/**
* Pin the queue-level server anchor from incoming tracks.
* Per-item `QueueItemRef.serverId` remains authoritative for mixed-server queues;
* `queueServerId` is the first track's bucket (legacy hot-cache / sync hints).
*/
export function bindQueueServerForTracks(tracks: Track[]): void {
const scoped = stampTrackServerIds(tracks);
const sid = scoped[0]?.serverId ?? activeServerProfileId();
if (!sid) return;
bindQueueServerId(sid);
}
export function playbackCacheKeyForRef(ref: QueueItemRef | null | undefined): string {
if (ref?.serverId) {
return canonicalQueueServerKey(ref.serverId) || ref.serverId;
}
return getPlaybackCacheServerKey();
}
export function playbackProfileIdForRef(ref: QueueItemRef | null | undefined): string {
const key = playbackCacheKeyForRef(ref);
if (!key) return getPlaybackServerId();
return resolveServerIdForIndexKey(key) || key;
}
export function playbackCacheKeyForTrack(
track: Track,
ref?: QueueItemRef | null,
): string {
if (ref?.serverId) return playbackCacheKeyForRef(ref);
if (track.serverId) {
return canonicalQueueServerKey(track.serverId) || track.serverId;
}
return getPlaybackCacheServerKey();
}
export function playbackProfileIdForTrack(
track: Track,
ref?: QueueItemRef | null,
): string {
const key = playbackCacheKeyForTrack(track, ref);
return resolveServerIdForIndexKey(key) || key || getPlaybackServerId();
}
/**
* Bind `queueServerId` via {@link bindQueueServerForPlayback} when it is still
* null, then return the (now-bound) server identifier. Call this synchronously
@@ -71,9 +145,10 @@ export function bindQueueServerForPlayback(): void {
* Idempotent: no-op when already pinned. Returns `''` when no active server is
* available to pin (e.g. unit tests without an authed store).
*/
export function ensureQueueServerPinned(): string {
export function ensureQueueServerPinned(tracks?: Track[]): string {
if (usePlayerStore.getState().queueServerId == null) {
bindQueueServerForPlayback();
if (tracks?.length) bindQueueServerForTracks(tracks);
else bindQueueServerForPlayback();
}
return usePlayerStore.getState().queueServerId ?? '';
}
@@ -83,11 +158,20 @@ export function clearQueueServerForPlayback(): void {
}
export function playbackServerDiffersFromActive(): boolean {
const activeSid = activeServerProfileId();
if (!activeSid) return false;
const playingRef = queueItemRefAt();
if (playingRef?.serverId) {
const playbackSid = getPlaybackServerId();
return Boolean(playbackSid) && playbackSid !== activeSid;
}
const { queueServerId, queueItems } = usePlayerStore.getState();
if ((queueItems?.length ?? 0) === 0 || !queueServerId) return false;
const activeSid = useAuthStore.getState().activeServerId;
const resolvedQueue = resolveServerIdForIndexKey(queueServerId);
return !!activeSid && resolvedQueue !== activeSid;
return resolveServerIdForIndexKey(queueServerId) !== activeSid;
}
export function queueIsMultiServer(): boolean {
return isMultiServerQueue(usePlayerStore.getState().queueItems);
}
/**
+64 -50
View File
@@ -1,14 +1,25 @@
/**
* `resolvePlaybackUrl` precedence + `streamUrlTrackId` parser tests (Phase F3).
*
* Precedence pinned by the function: offline → hot cache → HTTP stream.
* Refactors that reorder this break playback for users with offline /
* hot-cache entries.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { LocalPlaybackEntry } from '@/store/localPlaybackStore';
import { useOfflineStore } from '@/store/offlineStore';
import { useHotCacheStore } from '@/store/hotCacheStore';
const { getLocalUrlMock, entriesMock } = vi.hoisted(() => ({
getLocalUrlMock: vi.fn(),
entriesMock: {} as Record<string, LocalPlaybackEntry>,
}));
vi.mock('@/store/localPlaybackStore', () => ({
useLocalPlaybackStore: {
getState: () => ({
getLocalUrl: getLocalUrlMock,
getEntry: (trackId: string, serverIndexKey: string) =>
entriesMock[`${serverIndexKey}:${trackId}`] ?? null,
entries: entriesMock,
}),
subscribe: vi.fn(),
},
}));
import {
getPlaybackSourceKind,
@@ -18,12 +29,24 @@ import {
import { useAuthStore } from '@/store/authStore';
import { resetAuthStore } from '@/test/helpers/storeReset';
function seedLibraryEntry(trackId: string, serverIndexKey: string, localPath: string): void {
entriesMock[`${serverIndexKey}:${trackId}`] = {
serverIndexKey,
trackId,
localPath,
layoutFingerprint: '',
sizeBytes: 1,
tier: 'library',
cachedAt: 1,
suffix: 'flac',
};
}
beforeEach(() => {
resetAuthStore();
// Reset the offline + hot-cache store getLocalUrl mocks before each test.
vi.spyOn(useOfflineStore.getState(), 'getLocalUrl').mockReturnValue(null);
vi.spyOn(useHotCacheStore.getState(), 'getLocalUrl').mockReturnValue(null);
// Set up an active server so buildStreamUrl works.
Object.keys(entriesMock).forEach(k => delete entriesMock[k]);
getLocalUrlMock.mockReset();
getLocalUrlMock.mockReturnValue(null);
const id = useAuthStore.getState().addServer({
name: 'Test', url: 'https://music.example.com', username: 'alice', password: 'pw',
});
@@ -31,15 +54,31 @@ beforeEach(() => {
});
describe('resolvePlaybackUrl — precedence', () => {
it('returns the offline URL when present (1st priority)', () => {
vi.mocked(useOfflineStore.getState().getLocalUrl).mockReturnValue('psysonic-local://offline/track-1.flac');
vi.mocked(useHotCacheStore.getState().getLocalUrl).mockReturnValue('psysonic-local://hot/track-1.flac');
expect(resolvePlaybackUrl('track-1', 'srv-1')).toBe('psysonic-local://offline/track-1.flac');
it('returns the library-tier URL when present (1st priority)', () => {
seedLibraryEntry('track-1', 'srv-1', '/library/track-1.flac');
expect(resolvePlaybackUrl('track-1', 'srv-1')).toBe('psysonic-local:///library/track-1.flac');
});
it('falls through to the hot-cache URL when offline is absent (2nd priority)', () => {
vi.mocked(useOfflineStore.getState().getLocalUrl).mockReturnValue(null);
vi.mocked(useHotCacheStore.getState().getLocalUrl).mockReturnValue('psysonic-local://hot/track-1.flac');
it('returns favorite-auto URL when library is absent (2nd priority)', () => {
entriesMock['srv-1:track-1'] = {
serverIndexKey: 'srv-1',
trackId: 'track-1',
localPath: '/favorites/track-1.flac',
layoutFingerprint: '',
sizeBytes: 1,
tier: 'favorite-auto',
cachedAt: 1,
suffix: 'flac',
};
expect(resolvePlaybackUrl('track-1', 'srv-1')).toBe('psysonic-local:///favorites/track-1.flac');
});
it('falls through to ephemeral cache when library and favorites are absent (3rd priority)', () => {
getLocalUrlMock.mockImplementation(
(_tid: string, _sid: string, tier?: string) => (
tier === 'ephemeral' ? 'psysonic-local://hot/track-1.flac' : null
),
);
expect(resolvePlaybackUrl('track-1', 'srv-1')).toBe('psysonic-local://hot/track-1.flac');
});
@@ -48,22 +87,20 @@ describe('resolvePlaybackUrl — precedence', () => {
expect(url).toMatch(/^https:\/\/music\.example\.com\/rest\/stream\.view\?/);
expect(url).toContain('id=track-1');
});
it('forwards trackId + serverId to both stores so per-server entries scope correctly', () => {
resolvePlaybackUrl('track-7', 'srv-3');
expect(useOfflineStore.getState().getLocalUrl).toHaveBeenCalledWith('track-7', 'srv-3');
expect(useHotCacheStore.getState().getLocalUrl).toHaveBeenCalledWith('track-7', 'srv-3');
});
});
describe('getPlaybackSourceKind', () => {
it('returns "offline" when the offline store has the track', () => {
vi.mocked(useOfflineStore.getState().getLocalUrl).mockReturnValue('psysonic-local://offline/t1.flac');
it('returns "offline" when the library tier has the track', () => {
seedLibraryEntry('t1', 'srv-1', '/library/t1.flac');
expect(getPlaybackSourceKind('t1', 'srv-1')).toBe('offline');
});
it('returns "hot" when only the hot-cache has the track', () => {
vi.mocked(useHotCacheStore.getState().getLocalUrl).mockReturnValue('psysonic-local://hot/t1.flac');
it('returns "hot" when only ephemeral cache has the track', () => {
getLocalUrlMock.mockImplementation(
(_tid: string, _sid: string, tier?: string) => (
tier === 'ephemeral' ? 'psysonic-local://hot/t1.flac' : null
),
);
expect(getPlaybackSourceKind('t1', 'srv-1')).toBe('hot');
});
@@ -74,10 +111,6 @@ describe('getPlaybackSourceKind', () => {
it('returns "hot" when the engine reported a preload for this trackId (RAM-loaded)', () => {
expect(getPlaybackSourceKind('t1', 'srv-1', 't1')).toBe('hot');
});
it('returns "stream" when the engine preload hint is for a different track', () => {
expect(getPlaybackSourceKind('t1', 'srv-1', 'other-track')).toBe('stream');
});
});
describe('streamUrlTrackId', () => {
@@ -89,23 +122,4 @@ describe('streamUrlTrackId', () => {
it('returns null for URLs that are not stream.view', () => {
expect(streamUrlTrackId('https://music.example.com/rest/getCoverArt.view?id=cover')).toBeNull();
});
it('returns null when the URL has no query string', () => {
expect(streamUrlTrackId('https://music.example.com/rest/stream.view')).toBeNull();
});
it('returns null when stream.view URL lacks an id param', () => {
expect(streamUrlTrackId('https://music.example.com/rest/stream.view?u=alice')).toBeNull();
});
it('decodes URL-encoded id values', () => {
expect(streamUrlTrackId('https://x/rest/stream.view?id=AC%2FDC%20Back')).toBe('AC/DC Back');
});
it('falls back to manual query parsing when URL constructor would throw', () => {
// Relative path — `new URL(...)` requires a base, so the function's
// manual fallback parses the query directly.
const url = '/rest/stream.view?id=relative-track&u=u';
expect(streamUrlTrackId(url)).toBe('relative-track');
});
});
+19 -22
View File
@@ -1,6 +1,6 @@
import { buildStreamUrlForServer } from '../../api/subsonicStreamUrl';
import { useOfflineStore } from '../../store/offlineStore';
import { useHotCacheStore } from '../../store/hotCacheStore';
import { findLocalPlaybackUrl } from '../offline/offlineLibraryHelpers';
import { resolveServerIdForIndexKey } from '../server/serverLookup';
import { getPlaybackCacheServerKey, getPlaybackServerId } from './playbackServer';
/** Same resolution order as {@link resolvePlaybackUrl} — for UI hints only. */
@@ -32,6 +32,10 @@ export function streamUrlTrackId(url: string): string | null {
return null;
}
function resolvePlaybackProfileId(serverIdOrKey: string): string {
return resolveServerIdForIndexKey(serverIdOrKey) || serverIdOrKey || getPlaybackServerId();
}
/**
* @param enginePreloadedTrackId — song id for which `audio_preload` finished into the engine RAM slot
* (parsed from `audio:preload-ready` payload URL).
@@ -41,15 +45,10 @@ export function getPlaybackSourceKind(
serverId: string,
enginePreloadedTrackId: string | null = null,
): PlaybackSourceKind {
const legacySid = getPlaybackServerId();
const offline =
useOfflineStore.getState().getLocalUrl(trackId, serverId)
|| (legacySid && legacySid !== serverId ? useOfflineStore.getState().getLocalUrl(trackId, legacySid) : null);
if (offline) return 'offline';
const hot =
useHotCacheStore.getState().getLocalUrl(trackId, serverId)
|| (legacySid && legacySid !== serverId ? useHotCacheStore.getState().getLocalUrl(trackId, legacySid) : null);
if (hot) return 'hot';
const profileId = resolvePlaybackProfileId(serverId);
if (findLocalPlaybackUrl(trackId, profileId, 'library')) return 'offline';
if (findLocalPlaybackUrl(trackId, profileId, 'favorite-auto')) return 'offline';
if (findLocalPlaybackUrl(trackId, profileId, 'ephemeral')) return 'hot';
const resolved = resolvePlaybackUrl(trackId, serverId);
if (
!resolved.startsWith('psysonic-local://')
@@ -61,17 +60,15 @@ export function getPlaybackSourceKind(
return 'stream';
}
/** Offline library → hot playback cache → HTTP stream. */
/** Pinned library → favorites auto → ephemeral cache → HTTP stream. */
export function resolvePlaybackUrl(trackId: string, serverId?: string): string {
const sid = serverId && serverId.length > 0 ? serverId : getPlaybackCacheServerKey();
const legacySid = getPlaybackServerId();
const offline =
useOfflineStore.getState().getLocalUrl(trackId, sid)
|| (legacySid && legacySid !== sid ? useOfflineStore.getState().getLocalUrl(trackId, legacySid) : null);
if (offline) return offline;
const hot =
useHotCacheStore.getState().getLocalUrl(trackId, sid)
|| (legacySid && legacySid !== sid ? useHotCacheStore.getState().getLocalUrl(trackId, legacySid) : null);
const cacheKey = serverId && serverId.length > 0 ? serverId : getPlaybackCacheServerKey();
const profileId = resolvePlaybackProfileId(cacheKey);
const pinned = findLocalPlaybackUrl(trackId, profileId, 'library');
if (pinned) return pinned;
const favorites = findLocalPlaybackUrl(trackId, profileId, 'favorite-auto');
if (favorites) return favorites;
const hot = findLocalPlaybackUrl(trackId, profileId, 'ephemeral');
if (hot) return hot;
return buildStreamUrlForServer(sid, trackId);
return buildStreamUrlForServer(profileId, trackId);
}
+3
View File
@@ -1,5 +1,7 @@
import type { SubsonicSong } from '../../api/subsonicTypes';
import type { Track } from '../../store/playerStoreTypes';
import { activeServerProfileId } from './trackServerScope';
export function songToTrack(song: SubsonicSong): Track {
return {
id: song.id,
@@ -25,5 +27,6 @@ export function songToTrack(song: SubsonicSong): Track {
samplingRate: song.samplingRate,
bitDepth: song.bitDepth,
size: song.size,
serverId: song.serverId ?? activeServerProfileId(),
};
}
@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { useAuthStore } from '../../store/authStore';
import { usePlayerStore } from '../../store/playerStore';
import type { Track } from '../../store/playerStoreTypes';
import {
activeServerQueueTrackIds,
filterQueueRefsForActiveServer,
isMultiServerQueue,
queueItemRefAt,
stampTrackServerId,
} from './trackServerScope';
const baseTrack = (): Track => ({
id: 't1',
title: 'T',
artist: 'A',
album: 'Al',
albumId: 'al1',
duration: 1,
});
describe('trackServerScope', () => {
beforeEach(() => {
useAuthStore.setState({
activeServerId: 'srv-active',
servers: [{ id: 'srv-active', name: 'A', url: 'https://a.test', username: 'u', password: 'p' }],
});
usePlayerStore.setState({ queueItems: [], queueIndex: 0 });
});
it('stampTrackServerId defaults to active server', () => {
expect(stampTrackServerId(baseTrack()).serverId).toBe('srv-active');
});
it('stampTrackServerId keeps an explicit serverId', () => {
expect(stampTrackServerId({ ...baseTrack(), serverId: 'srv-b' }).serverId).toBe('srv-b');
});
it('queueItemRefAt tolerates missing queueItems (partial store mocks)', () => {
usePlayerStore.setState({ queueItems: undefined as unknown as [], queueIndex: 0 });
expect(queueItemRefAt()).toBeNull();
});
it('queueItemRefAt returns the playing ref', () => {
usePlayerStore.setState({
queueItems: [{ serverId: 'a.test', trackId: 't1' }],
queueIndex: 0,
});
expect(queueItemRefAt()?.trackId).toBe('t1');
});
it('filterQueueRefsForActiveServer keeps only the active server bucket', () => {
useAuthStore.setState({
activeServerId: 'srv-a',
servers: [
{ id: 'srv-a', name: 'A', url: 'https://a.test', username: 'u', password: 'p' },
{ id: 'srv-b', name: 'B', url: 'https://b.test', username: 'u', password: 'p' },
],
});
const mixed = [
{ serverId: 'a.test', trackId: 't1' },
{ serverId: 'b.test', trackId: 't2' },
{ serverId: 'srv-a', trackId: 't3' },
];
expect(filterQueueRefsForActiveServer(mixed).map(r => r.trackId)).toEqual(['t1', 't3']);
expect(activeServerQueueTrackIds(mixed)).toEqual(['t1', 't3']);
});
it('isMultiServerQueue detects mixed refs', () => {
expect(isMultiServerQueue([
{ serverId: 'a.test', trackId: 't1' },
{ serverId: 'b.test', trackId: 't2' },
])).toBe(true);
expect(isMultiServerQueue([
{ serverId: 'a.test', trackId: 't1' },
{ serverId: 'a.test', trackId: 't2' },
])).toBe(false);
});
});
+70
View File
@@ -0,0 +1,70 @@
import type { QueueItemRef, Track } from '../../store/playerStoreTypes';
import { useAuthStore } from '../../store/authStore';
import { usePlayerStore } from '../../store/playerStore';
import { canonicalQueueServerKey } from '../server/serverIndexKey';
import { resolveServerIdForIndexKey } from '../server/serverLookup';
/** Active saved-server profile id (auth UUID), when logged in. */
export function activeServerProfileId(): string | undefined {
return useAuthStore.getState().activeServerId ?? undefined;
}
/**
* Ensure every track carries an owning server before it enters the queue.
* Explicit `track.serverId` wins; otherwise `fallbackServerId`, then active server.
*/
export function stampTrackServerId(track: Track, fallbackServerId?: string): Track {
const serverId = track.serverId ?? fallbackServerId ?? activeServerProfileId();
if (!serverId || track.serverId === serverId) {
return serverId && !track.serverId ? { ...track, serverId } : track;
}
return { ...track, serverId };
}
export function stampTrackServerIds(tracks: Track[], fallbackServerId?: string): Track[] {
return tracks.map(t => stampTrackServerId(t, fallbackServerId));
}
/** Canonical queue ref at `index`, or the currently playing slot. */
export function queueItemRefAt(index?: number): QueueItemRef | null {
const { queueItems, queueIndex } = usePlayerStore.getState();
if (!queueItems?.length) return null;
const idx = index ?? queueIndex;
if (idx < 0 || idx >= queueItems.length) return null;
return queueItems[idx] ?? null;
}
/** True when queue refs resolve to more than one server bucket. */
export function isMultiServerQueue(refs: QueueItemRef[]): boolean {
const keys = new Set<string>();
for (const ref of refs) {
if (!ref.serverId) continue;
keys.add(canonicalQueueServerKey(ref.serverId) || ref.serverId);
if (keys.size > 1) return true;
}
return false;
}
export function profileIdFromQueueRef(ref: QueueItemRef | null | undefined): string {
if (!ref?.serverId) return '';
return resolveServerIdForIndexKey(ref.serverId) || ref.serverId;
}
function queueRefProfileId(ref: QueueItemRef): string {
const fromRef = profileIdFromQueueRef(ref);
if (fromRef) return fromRef;
const pin = usePlayerStore.getState().queueServerId;
if (pin) return resolveServerIdForIndexKey(pin) || pin;
return activeServerProfileId() ?? '';
}
/** Queue refs that belong to the browsed (active) server — for export/save on mixed queues. */
export function filterQueueRefsForActiveServer(refs: QueueItemRef[]): QueueItemRef[] {
const activeId = activeServerProfileId();
if (!activeId) return [];
return refs.filter(ref => queueRefProfileId(ref) === activeId);
}
export function activeServerQueueTrackIds(refs: QueueItemRef[]): string[] {
return filterQueueRefsForActiveServer(refs).map(r => r.trackId);
}
@@ -1,15 +1,15 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { useAnalysisStrategyStore } from '../../store/analysisStrategyStore';
import { useCoverStrategyStore } from '../../store/coverStrategyStore';
import { useHotCacheStore } from '../../store/hotCacheStore';
import { useLocalPlaybackStore } from '../../store/localPlaybackStore';
import { useOfflineStore } from '../../store/offlineStore';
import { usePlayerStore } from '../../store/playerStore';
import { rewriteFrontendStoreKeysForRemap } from './rewriteFrontendStoreKeys';
describe('rewriteFrontendStoreKeysForRemap', () => {
beforeEach(() => {
useOfflineStore.setState({ tracks: {}, albums: {} });
useHotCacheStore.setState({ entries: {} });
useOfflineStore.setState({ albums: {} });
useLocalPlaybackStore.setState({ entries: {} });
useAnalysisStrategyStore.setState({
strategyByServer: {},
advancedParallelismByServer: {},
@@ -19,42 +19,70 @@ describe('rewriteFrontendStoreKeysForRemap', () => {
});
it('no-ops on empty remap list', async () => {
useOfflineStore.setState({
tracks: { 'old:t1': { serverId: 'old' } as never },
albums: {},
useLocalPlaybackStore.setState({
entries: {
'old:t1': {
serverIndexKey: 'old',
trackId: 't1',
localPath: '/x',
layoutFingerprint: '',
sizeBytes: 1,
tier: 'ephemeral',
cachedAt: 1,
suffix: 'mp3',
},
},
});
await rewriteFrontendStoreKeysForRemap([]);
expect(useOfflineStore.getState().tracks).toHaveProperty('old:t1');
expect(useLocalPlaybackStore.getState().entries).toHaveProperty('old:t1');
});
it('no-ops when oldKey === newKey', async () => {
useOfflineStore.setState({
tracks: { 'same:t1': { serverId: 'same' } as never },
albums: {},
useLocalPlaybackStore.setState({
entries: {
'same:t1': {
serverIndexKey: 'same',
trackId: 't1',
localPath: '/x',
layoutFingerprint: '',
sizeBytes: 1,
tier: 'ephemeral',
cachedAt: 1,
suffix: 'mp3',
},
},
});
await rewriteFrontendStoreKeysForRemap([{ oldKey: 'same', newKey: 'same' }]);
expect(useOfflineStore.getState().tracks).toHaveProperty('same:t1');
expect(useLocalPlaybackStore.getState().entries).toHaveProperty('same:t1');
});
it('rewrites offline tracks + albums under the new key', async () => {
it('rewrites offline albums under the new key', async () => {
useOfflineStore.setState({
tracks: { 'old:t1': { serverId: 'old' } as never },
albums: { 'old:al-1': { serverId: 'old' } as never },
albums: { 'old:al-1': { serverId: 'old', id: 'al-1', name: 'X', artist: 'Y', trackIds: [] } },
});
await rewriteFrontendStoreKeysForRemap([{ oldKey: 'old', newKey: 'new' }]);
const state = useOfflineStore.getState();
expect(state.tracks).toHaveProperty('new:t1');
expect(state.tracks).not.toHaveProperty('old:t1');
expect(state.albums).toHaveProperty('new:al-1');
expect(state.albums).not.toHaveProperty('old:al-1');
});
it('rewrites hot-cache entries under the new key', async () => {
useHotCacheStore.setState({
entries: { 'old:t1': { trackId: 't1' } as never },
it('rewrites local playback entries under the new key', async () => {
useLocalPlaybackStore.setState({
entries: {
'old:t1': {
serverIndexKey: 'old',
trackId: 't1',
localPath: '/x',
layoutFingerprint: '',
sizeBytes: 1,
tier: 'ephemeral',
cachedAt: 1,
suffix: 'mp3',
},
},
});
await rewriteFrontendStoreKeysForRemap([{ oldKey: 'old', newKey: 'new' }]);
const entries = useHotCacheStore.getState().entries;
const entries = useLocalPlaybackStore.getState().entries;
expect(entries).toHaveProperty('new:t1');
expect(entries).not.toHaveProperty('old:t1');
});
@@ -88,6 +116,22 @@ describe('rewriteFrontendStoreKeysForRemap', () => {
expect(usePlayerStore.getState().queueServerId).toBe('new');
});
it('repoints queueItems serverId when refs match the old key', async () => {
usePlayerStore.setState({
queueServerId: 'old',
queueItems: [
{ serverId: 'old', trackId: 't1' },
{ serverId: 'other', trackId: 't2' },
],
queueIndex: 0,
});
await rewriteFrontendStoreKeysForRemap([{ oldKey: 'old', newKey: 'new' }]);
const s = usePlayerStore.getState();
expect(s.queueServerId).toBe('new');
expect(s.queueItems[0]).toEqual({ serverId: 'new', trackId: 't1' });
expect(s.queueItems[1]).toEqual({ serverId: 'other', trackId: 't2' });
});
it('leaves queueServerId untouched when it is bound to a different server', async () => {
usePlayerStore.setState({ queueServerId: 'other' });
await rewriteFrontendStoreKeysForRemap([{ oldKey: 'old', newKey: 'new' }]);
@@ -95,18 +139,33 @@ describe('rewriteFrontendStoreKeysForRemap', () => {
});
it('does not clobber an existing entry under the new key', async () => {
useOfflineStore.setState({
tracks: {
'old:t1': { serverId: 'old', tag: 'from-old' } as never,
'new:t1': { serverId: 'new', tag: 'from-new' } as never,
useLocalPlaybackStore.setState({
entries: {
'old:t1': {
serverIndexKey: 'old',
trackId: 't1',
localPath: '/old',
layoutFingerprint: '',
sizeBytes: 1,
tier: 'ephemeral',
cachedAt: 1,
suffix: 'mp3',
},
'new:t1': {
serverIndexKey: 'new',
trackId: 't1',
localPath: '/new',
layoutFingerprint: '',
sizeBytes: 1,
tier: 'ephemeral',
cachedAt: 1,
suffix: 'mp3',
},
},
albums: {},
});
await rewriteFrontendStoreKeysForRemap([{ oldKey: 'old', newKey: 'new' }]);
const tracks = useOfflineStore.getState().tracks as unknown as Record<string, { tag: string }>;
// Existing destination preserved — same prefer-existing semantics as
// the disk-side cover bucket merge.
expect(tracks['new:t1']?.tag).toBe('from-new');
expect(tracks).not.toHaveProperty('old:t1');
const entries = useLocalPlaybackStore.getState().entries;
expect(entries['new:t1']?.localPath).toBe('/new');
expect(entries).not.toHaveProperty('old:t1');
});
});
+30 -30
View File
@@ -1,7 +1,7 @@
import type { ServerProfile } from '../../store/authStoreTypes';
import { useAnalysisStrategyStore } from '../../store/analysisStrategyStore';
import { useCoverStrategyStore } from '../../store/coverStrategyStore';
import { useHotCacheStore } from '../../store/hotCacheStore';
import { useLocalPlaybackStore } from '../../store/localPlaybackStore';
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
import { useOfflineStore } from '../../store/offlineStore';
import { usePlayerStore } from '../../store/playerStore';
@@ -27,21 +27,6 @@ function buildMappings(servers: ServerProfile[]): Mapping[] {
function rewriteOfflineStoreKeys(mappings: Mapping[]): void {
const map = new Map(mappings.map(mapping => [mapping.legacyId, mapping.indexKey]));
useOfflineStore.setState((state) => {
const tracks = { ...state.tracks };
for (const [key, meta] of Object.entries(state.tracks)) {
const i = key.indexOf(':');
if (i <= 0) continue;
const legacyId = key.slice(0, i);
const trackId = key.slice(i + 1);
const indexKey = map.get(legacyId);
if (!indexKey) continue;
const nextKey = `${indexKey}:${trackId}`;
if (!tracks[nextKey]) {
tracks[nextKey] = { ...meta, serverId: indexKey };
}
delete tracks[key];
}
const albums = { ...state.albums };
for (const [key, meta] of Object.entries(state.albums)) {
const i = key.indexOf(':');
@@ -56,13 +41,13 @@ function rewriteOfflineStoreKeys(mappings: Mapping[]): void {
}
delete albums[key];
}
return { tracks, albums };
return { albums };
});
}
function rewriteHotCacheStoreKeys(mappings: Mapping[]): void {
function rewriteLocalPlaybackStoreKeys(mappings: Mapping[]): void {
const map = new Map(mappings.map(mapping => [mapping.legacyId, mapping.indexKey]));
useHotCacheStore.setState((state) => {
useLocalPlaybackStore.setState((state) => {
const entries = { ...state.entries };
for (const [key, entry] of Object.entries(state.entries)) {
const i = key.indexOf(':');
@@ -73,7 +58,7 @@ function rewriteHotCacheStoreKeys(mappings: Mapping[]): void {
if (!indexKey) continue;
const nextKey = `${indexKey}:${trackId}`;
if (!entries[nextKey]) {
entries[nextKey] = entry;
entries[nextKey] = { ...entry, serverIndexKey: indexKey };
}
delete entries[key];
}
@@ -111,7 +96,7 @@ export async function rewriteFrontendStoreKeys(servers: ServerProfile[]): Promis
const mappings = buildMappings(servers);
if (mappings.length === 0) return;
rewriteOfflineStoreKeys(mappings);
rewriteHotCacheStoreKeys(mappings);
rewriteLocalPlaybackStoreKeys(mappings);
rewriteAnalysisStrategyStoreKeys(mappings);
// Keep migration explicit: Zustand persist writes the current state snapshot.
useAnalysisStrategyStore.getState().migrateServerOverrides(servers);
@@ -126,9 +111,8 @@ export async function rewriteFrontendStoreKeys(servers: ServerProfile[]): Promis
* `cover_cache_rename_server_bucket` has moved the disk bucket — without
* this step the in-memory zustand state would still point at the old keys.
*
* Player queue `queueServerId` is included here: if the queue is currently
* bound to a remapped index key, it gets re-pointed at the new one so the
* queue keeps playing through the rename instead of looking unbound.
* Player queue `queueServerId` and per-item `queueItems[].serverId` are
* included here so mixed-server playback keeps resolving through the rename.
*/
export async function rewriteFrontendStoreKeysForRemap(
remaps: ReadonlyArray<{ oldKey: string; newKey: string }>,
@@ -139,16 +123,32 @@ export async function rewriteFrontendStoreKeysForRemap(
if (mappings.length === 0) return;
rewriteOfflineStoreKeys(mappings);
rewriteHotCacheStoreKeys(mappings);
rewriteLocalPlaybackStoreKeys(mappings);
rewriteAnalysisStrategyStoreKeys(mappings);
// Player queue: queueServerId is a single string, not a keyed map.
// Player queue: queueServerId + per-item refs may carry remapped index keys.
const queueRemap = new Map(mappings.map(m => [m.legacyId, m.indexKey]));
usePlayerStore.setState(state => {
if (!state.queueServerId) return state;
const next = queueRemap.get(state.queueServerId);
if (!next) return state;
return { ...state, queueServerId: next };
let queueServerId = state.queueServerId;
if (queueServerId) {
const next = queueRemap.get(queueServerId);
if (next) queueServerId = next;
}
let queueItems = state.queueItems;
if (queueItems.length > 0) {
let changed = queueServerId !== state.queueServerId;
const nextItems = queueItems.map(ref => {
const nextServerId = queueRemap.get(ref.serverId);
if (!nextServerId) return ref;
changed = true;
return { ...ref, serverId: nextServerId };
});
if (changed) queueItems = nextItems;
}
if (queueServerId === state.queueServerId && queueItems === state.queueItems) {
return state;
}
return { queueServerId, queueItems };
});
// The analysis/cover strategy stores carry per-server-id maps that the