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
+4 -1
View File
@@ -1,7 +1,10 @@
export type AnalysisStorageChangedReason =
| 'offline-delete'
| 'hotcache-delete'
| 'hotcache-purge';
| 'hotcache-purge'
| 'local-playback-delete'
| 'offline-purge'
| 'favorites-offline-purge';
export type AnalysisStorageChangedDetail = {
trackId?: string | null;
+21 -9
View File
@@ -16,7 +16,9 @@ import { bumpPerfCounter } from '../utils/perf/perfTelemetry';
import {
getPlaybackCacheServerKey,
getPlaybackIndexKey,
getPlaybackServerId,
playbackCacheKeyForRef,
playbackProfileIdForRef,
playbackProfileIdForTrack,
} from '../utils/playback/playbackServer';
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb';
@@ -91,9 +93,10 @@ export function handleAudioPlaying(duration: number): void {
resetProgressEmitThrottles();
usePlayerStore.setState({ isPlaying: true, isPlaybackBuffering: false });
notifyLibraryPlaybackHint('playing');
const track = usePlayerStore.getState().currentTrack;
const { currentTrack: track, queueItems, queueIndex } = usePlayerStore.getState();
if (track) {
void playListenSessionOpen(track, getPlaybackServerId(), duration);
const ref = queueItems[queueIndex];
void playListenSessionOpen(track, playbackProfileIdForTrack(track, ref), duration);
}
}
@@ -187,7 +190,11 @@ export function handleAudioProgress(
// Scrobble at 50%: Last.fm + Navidrome (updates play_date / recently played)
if (progress >= 0.5 && !store.scrobbled) {
usePlayerStore.setState({ scrobbled: true });
scrobbleSong(track.id, Date.now(), getPlaybackServerId());
scrobbleSong(
track.id,
Date.now(),
playbackProfileIdForTrack(track, store.queueItems[store.queueIndex]),
);
const { scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState();
if (scrobblingEnabled && lastfmSessionKey) {
lastfmScrobble(track, Date.now(), lastfmSessionKey);
@@ -254,8 +261,10 @@ export function handleAudioProgress(
const shouldBytePreloadForGaplessBackup =
gaplessEnabled && remaining < gaplessBackupWindowSecs && remaining > 0;
const serverId = getPlaybackCacheServerKey();
const analysisServerId = getPlaybackIndexKey();
const serverId = nextRef ? playbackCacheKeyForRef(nextRef) : getPlaybackCacheServerKey();
const analysisServerId = nextRef
? playbackCacheKeyForRef(nextRef)
: getPlaybackIndexKey();
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
// Byte pre-download — gapless backup or crossfade; runs early so bytes are ready by chain time.
@@ -408,7 +417,8 @@ export function handleAudioTrackSwitched(_duration: number): void {
void playListenSessionOnTrackSwitched(nextTrack);
const switchServerId = getPlaybackCacheServerKey();
const switchRef = queueItems[newIndex];
const switchServerId = playbackCacheKeyForRef(switchRef);
const switchResolvedUrl = resolvePlaybackUrl(nextTrack.id, switchServerId);
const switchPlaybackSource = playbackSourceHintForResolvedUrl(nextTrack.id, switchServerId, switchResolvedUrl);
@@ -449,7 +459,9 @@ export function handleAudioTrackSwitched(_duration: number): void {
// Report Now Playing to Navidrome + Last.fm
const { nowPlayingEnabled, scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState();
if (nowPlayingEnabled) reportNowPlaying(nextTrack.id, getPlaybackServerId());
if (nowPlayingEnabled) {
reportNowPlaying(nextTrack.id, playbackProfileIdForTrack(nextTrack, switchRef));
}
if (lastfmSessionKey) {
if (scrobblingEnabled) lastfmUpdateNowPlaying(nextTrack, lastfmSessionKey);
lastfmGetTrackLoved(nextTrack.title, nextTrack.artist, lastfmSessionKey).then(loved => {
@@ -461,7 +473,7 @@ export function handleAudioTrackSwitched(_duration: number): void {
});
}
syncQueueToServer(queueItems, nextTrack, 0);
touchHotCacheOnPlayback(nextTrack.id, getPlaybackCacheServerKey());
touchHotCacheOnPlayback(nextTrack.id, switchServerId);
}
export function handleAudioError(message: string): void {
+4
View File
@@ -9,18 +9,22 @@ export function createCacheStorageActions(set: SetState): Pick<
| 'setMaxCacheMb'
| 'setDownloadFolder'
| 'setOfflineDownloadDir'
| 'setFavoritesOfflineEnabled'
| 'setHotCacheEnabled'
| 'setHotCacheMaxMb'
| 'setHotCacheDebounceSec'
| 'setHotCacheDownloadDir'
| 'setMediaDir'
> {
return {
setMaxCacheMb: (v) => set({ maxCacheMb: v }),
setDownloadFolder: (v) => set({ downloadFolder: v }),
setOfflineDownloadDir: (v) => set({ offlineDownloadDir: v }),
setFavoritesOfflineEnabled: (v) => set({ favoritesOfflineEnabled: v }),
setHotCacheEnabled: (v) => set({ hotCacheEnabled: v }),
setHotCacheMaxMb: (v) => set({ hotCacheMaxMb: v }),
setHotCacheDebounceSec: (v) => set({ hotCacheDebounceSec: v }),
setHotCacheDownloadDir: (v) => set({ hotCacheDownloadDir: v }),
setMediaDir: (v) => set({ mediaDir: v }),
};
}
+1
View File
@@ -68,6 +68,7 @@ describe('trivial pass-through setters', () => {
['setQueueNowPlayingCollapsed', 'queueNowPlayingCollapsed', true],
['setQueueDurationDisplayMode', 'queueDurationDisplayMode', 'eta'],
['setEnableHiRes', 'enableHiRes', true],
['setFavoritesOfflineEnabled', 'favoritesOfflineEnabled', true],
['setHotCacheEnabled', 'hotCacheEnabled', true],
['setMixMinRatingFilterEnabled', 'mixMinRatingFilterEnabled', true],
['setShowLuckyMixMenu', 'showLuckyMixMenu', false],
+3 -1
View File
@@ -36,12 +36,13 @@ export const useAuthStore = create<AuthState>()(
lastfmSessionKey: '',
lastfmUsername: '',
scrobblingEnabled: true,
maxCacheMb: 500,
maxCacheMb: 0,
coverRevalidateCycleDays: 30,
coverRevalidateMaxProbesPerSession: 500,
coverRevalidateMaxProbesPerMinute: 20,
downloadFolder: '',
offlineDownloadDir: '',
mediaDir: '',
excludeAudiobooks: false,
customGenreBlacklist: [],
replayGainEnabled: false,
@@ -96,6 +97,7 @@ export const useAuthStore = create<AuthState>()(
queueDisplayMode: 'queue',
enableHiRes: false,
audioOutputDevice: null,
favoritesOfflineEnabled: false,
hotCacheEnabled: false,
hotCacheMaxMb: 256,
hotCacheDebounceSec: 30,
+24
View File
@@ -150,7 +150,30 @@ export function computeAuthStoreRehydration(state: AuthState): Partial<AuthState
discordCoverSourceMigrated = { discordCoverSource: 'apple' };
}
// One-time: legacy unified `maxCacheMb` cap removed from Settings (offline + IDB covers).
const maxCacheMbMigrationKey = 'psysonic-max-cache-mb-removed-v1';
let maxCacheMbMigrated: { maxCacheMb?: number } = {};
try {
if (!localStorage.getItem(maxCacheMbMigrationKey)) {
maxCacheMbMigrated = { maxCacheMb: 0 };
localStorage.setItem(maxCacheMbMigrationKey, '1');
}
} catch { /* ignore */ }
let mediaDirMigrated: { mediaDir?: string } = {};
const stMedia = state as { mediaDir?: unknown; offlineDownloadDir?: string; hotCacheDownloadDir?: string };
if (!stMedia.mediaDir || (typeof stMedia.mediaDir === 'string' && stMedia.mediaDir.trim() === '')) {
const offline = (stMedia.offlineDownloadDir ?? '').trim();
const hot = (stMedia.hotCacheDownloadDir ?? '').trim();
if (offline && (!hot || offline === hot)) {
mediaDirMigrated = { mediaDir: offline };
} else if (hot) {
mediaDirMigrated = { mediaDir: hot };
}
}
return {
...mediaDirMigrated,
mixMinRatingSong: clampMixFilterMinStars(state.mixMinRatingSong as number),
mixMinRatingAlbum: clampMixFilterMinStars(state.mixMinRatingAlbum as number),
mixMinRatingArtist: clampMixFilterMinStars(state.mixMinRatingArtist as number),
@@ -172,5 +195,6 @@ export function computeAuthStoreRehydration(state: AuthState): Partial<AuthState
...queueDisplayModeMigrated,
...linuxWaylandTextRenderProfileMigrated,
...discordCoverSourceMigrated,
...maxCacheMbMigrated,
};
}
+7
View File
@@ -89,6 +89,8 @@ export interface AuthState {
coverRevalidateMaxProbesPerMinute: number;
downloadFolder: string;
offlineDownloadDir: string;
/** Unified local playback root `M` (replaces hot/offline dir pickers). */
mediaDir: string;
excludeAudiobooks: boolean;
customGenreBlacklist: string[];
replayGainEnabled: boolean;
@@ -193,6 +195,9 @@ export interface AuthState {
/** Selected audio output device name. null = system default. */
audioOutputDevice: string | null;
/** Auto-download starred favorites into `media/favorites/` (separate from offline library). */
favoritesOfflineEnabled: boolean;
/** Alpha: ephemeral queue prefetch cache on disk */
hotCacheEnabled: boolean;
hotCacheMaxMb: number;
@@ -342,10 +347,12 @@ export interface AuthState {
setQueueDisplayMode: (v: QueueDisplayMode) => void;
setEnableHiRes: (v: boolean) => void;
setAudioOutputDevice: (v: string | null) => void;
setFavoritesOfflineEnabled: (v: boolean) => void;
setHotCacheEnabled: (v: boolean) => void;
setHotCacheMaxMb: (v: number) => void;
setHotCacheDebounceSec: (v: number) => void;
setHotCacheDownloadDir: (v: string) => void;
setMediaDir: (v: string) => void;
setSkipStarOnManualSkipsEnabled: (v: boolean) => void;
setSkipStarManualSkipThreshold: (v: number) => void;
setMixMinRatingFilterEnabled: (v: boolean) => void;
+4 -2
View File
@@ -36,6 +36,9 @@ vi.mock('@/api/subsonicPlayQueue', () => ({
savePlayQueue: vi.fn(async () => undefined),
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
}));
vi.mock('@/utils/network/activeServerReachability', () => ({
isActiveServerReachable: () => true,
}));
const SERVER_A = {
id: 'uuid-a',
@@ -376,8 +379,7 @@ describe('B1+ — add-to-queue mutations pin queueServerId when it is null', ()
usePlayerStore.getState().enqueue([track('t1', 'Y')], true);
// Already pinned → ensureQueueServerPinned is a no-op even though the
// active server has since switched (cross-server enqueue is blocked
// elsewhere via `blockCrossServerEnqueue`, not here).
// active server has since switched (mixed-server enqueue keeps the anchor).
expect(usePlayerStore.getState().queueServerId).toBe(KEY_A);
});
});
+19
View File
@@ -0,0 +1,19 @@
import { create } from 'zustand';
interface FavoritesOfflineSyncState {
running: boolean;
lastError: string | null;
targetTrackIds: string[];
setRunning: (v: boolean) => void;
setLastError: (v: string | null) => void;
setTargetTrackIds: (ids: string[]) => void;
}
export const useFavoritesOfflineSyncStore = create<FavoritesOfflineSyncState>()((set) => ({
running: false,
lastError: null,
targetTrackIds: [],
setRunning: (v) => set({ running: v }),
setLastError: (v) => set({ lastError: v }),
setTargetTrackIds: (ids) => set({ targetTrackIds: ids }),
}));
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import type { LocalPlaybackEntry } from './localPlaybackStore';
import { countHotCacheTracks } from './hotCacheStore';
function ephemeral(
serverIndexKey: string,
trackId: string,
): LocalPlaybackEntry {
return {
serverIndexKey,
trackId,
localPath: `/media/cache/${trackId}.mp3`,
sizeBytes: 1_000,
layoutFingerprint: 'fp',
tier: 'ephemeral',
suffix: 'mp3',
cachedAt: Date.parse('2026-01-01T00:00:00.000Z'),
};
}
describe('countHotCacheTracks', () => {
it('counts all ephemeral rows regardless of server index key shape', () => {
const entries = {
'192.168.0.5:4533:t1': ephemeral('192.168.0.5:4533', 't1'),
'srv-uuid:t2': ephemeral('srv-uuid', 't2'),
'srv-uuid:t3': { ...ephemeral('srv-uuid', 't3'), tier: 'library' as const },
};
expect(countHotCacheTracks(entries)).toBe(2);
});
it('ignores non-ephemeral tiers', () => {
const entries = {
'srv:t1': ephemeral('srv', 't1'),
'srv:t2': { ...ephemeral('srv', 't2'), tier: 'favorite-auto' as const },
};
expect(countHotCacheTracks(entries)).toBe(1);
});
});
+79 -239
View File
@@ -1,25 +1,16 @@
import type { QueueItemRef } from './playerStoreTypes';
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import type { HotCacheEntry } from './hotCacheStoreTypes';
import { useLocalPlaybackStore, type LocalPlaybackEntry } from './localPlaybackStore';
import { entryBelongsToServer } from '../utils/offline/offlineLibraryHelpers';
import { invoke } from '@tauri-apps/api/core';
import { isHotCachePreviousTrackUnderGrace } from '../utils/cache/hotCacheGate';
import { emitAnalysisStorageChanged } from './analysisSync';
import { useAuthStore } from './authStore';
import { getMediaDir } from '../utils/media/mediaDir';
/** How many queue slots after the current index are eviction-protected (1 = current + next only). */
export type { HotCacheEntry } from './hotCacheStoreTypes';
/** @deprecated Use {@link LOCAL_PLAYBACK_PROTECT_AFTER_CURRENT}. */
export const HOT_CACHE_PROTECT_AFTER_CURRENT = 1;
export interface HotCacheEntry {
localPath: string;
sizeBytes: number;
cachedAt: number;
/** Last time this file was started as the current track (eviction tie-break: newer = keep longer). */
lastPlayedAt?: number;
}
interface HotCacheState {
/** Persisted map `${serverId}:${trackId}` → file meta */
entries: Record<string, HotCacheEntry>;
getLocalUrl: (trackId: string, serverId: string) => string | null;
setEntry: (
trackId: string,
@@ -27,248 +18,97 @@ interface HotCacheState {
localPath: string,
sizeBytes: number,
debugSource?: string,
layoutFingerprint?: string,
suffix?: string,
) => void;
/** Bump LRU when the user actually plays this track (if it is in the hot cache). */
touchPlayed: (trackId: string, serverId: string) => void;
removeEntry: (trackId: string, serverId: string) => void;
removeEntry: (trackId: string, serverId: string) => Promise<void>;
totalBytes: () => number;
/** Evict until total size ≤ maxBytes. Protects current + next (+ grace for last «previous» track).
* Thin-state: only track ids / positions matter here, so it takes the canonical refs. */
evictToFit: (
queue: QueueItemRef[],
queueIndex: number,
maxBytes: number,
activeServerId: string,
hotCacheCustomDir: string | null,
mediaDir: string | null,
) => Promise<void>;
clearAllDisk: (customDir: string | null) => Promise<void>;
clearAllDisk: (mediaDir: string | null) => Promise<void>;
}
function entryKey(serverId: string, trackId: string): string {
return `${serverId}:${trackId}`;
/** Ephemeral-tier view for UI selectors (Settings track count, prefetch helpers). */
export function selectHotCacheEntries(
entries: Record<string, import('./localPlaybackStore').LocalPlaybackEntry>,
): Record<string, HotCacheEntry> {
const out: Record<string, HotCacheEntry> = {};
for (const [key, e] of Object.entries(entries)) {
if (e.tier !== 'ephemeral') continue;
out[key] = {
localPath: e.localPath,
sizeBytes: e.sizeBytes,
cachedAt: e.cachedAt,
lastPlayedAt: e.lastPlayedAt,
};
}
return out;
}
function parseKey(key: string): { serverId: string; trackId: string } | null {
const i = key.indexOf(':');
if (i <= 0) return null;
return { serverId: key.slice(0, i), trackId: key.slice(i + 1) };
/** Ephemeral-tier row count for Settings (optional active-server scope). */
export function countHotCacheTracks(
entries: Record<string, LocalPlaybackEntry>,
scopeServerId?: string,
): number {
let n = 0;
for (const e of Object.values(entries)) {
if (e.tier !== 'ephemeral') continue;
if (scopeServerId && !entryBelongsToServer(e, scopeServerId)) continue;
n++;
}
return n;
}
function lruStamp(meta: HotCacheEntry | undefined): number {
if (!meta) return 0;
return meta.lastPlayedAt ?? meta.cachedAt ?? 0;
}
export const useHotCacheStore = create<HotCacheState>()(() => ({
getLocalUrl: (trackId, serverId) =>
useLocalPlaybackStore.getState().getLocalUrl(trackId, serverId, 'ephemeral'),
function evictionReasonForTier(tier: number): string {
const labels: Record<number, string> = {
0: 'inactive-server',
1: 'not-in-queue',
2: 'ahead-of-protected-window',
3: 'behind-current-in-queue',
};
return labels[tier] ?? `tier-${tier}`;
}
setEntry: (trackId, serverId, localPath, sizeBytes, _debugSource, layoutFingerprint = '', suffix = 'mp3') => {
useLocalPlaybackStore.getState().upsertEntry({
serverIndexKey: serverId,
trackId,
localPath,
sizeBytes,
layoutFingerprint,
tier: 'ephemeral',
suffix,
});
},
/** Settings → Logging → Debug, same as `emitNormalizationDebug` / lucky-mix. */
function hotCacheFrontendDebug(payload: Record<string, unknown>): void {
if (useAuthStore.getState().loggingMode !== 'debug') return;
void invoke('frontend_debug_log', {
scope: 'hot-cache',
message: JSON.stringify(payload),
}).catch(() => {});
}
touchPlayed: (trackId, serverId) => {
useLocalPlaybackStore.getState().touchPlayed(trackId, serverId);
},
export const useHotCacheStore = create<HotCacheState>()(
persist(
(set, get) => ({
entries: {},
removeEntry: async (trackId, serverId) => {
const lp = useLocalPlaybackStore.getState();
const e = lp.getEntry(trackId, serverId);
if (e?.tier === 'ephemeral' && e.localPath) {
await invoke('delete_media_file', { localPath: e.localPath, mediaDir: getMediaDir() }).catch(
() => {},
);
lp.removeEntry(trackId, serverId, 'hot-cache-shim');
}
},
getLocalUrl: (trackId, serverId) => {
const e = get().entries[entryKey(serverId, trackId)];
if (!e?.localPath) return null;
return `psysonic-local://${e.localPath}`;
},
totalBytes: () => useLocalPlaybackStore.getState().ephemeralTotalBytes(),
setEntry: (trackId, serverId, localPath, sizeBytes, debugSource) => {
const now = Date.now();
set(s => ({
entries: {
...s.entries,
[entryKey(serverId, trackId)]: {
localPath,
sizeBytes,
cachedAt: now,
lastPlayedAt: now,
},
},
}));
hotCacheFrontendDebug({
event: 'index-add',
trackId,
serverId,
sizeBytes,
source: debugSource ?? 'unknown',
});
},
evictToFit: async (queue, queueIndex, maxBytes, activeServerId, mediaDir) => {
await useLocalPlaybackStore.getState().evictEphemeralToFit(
queue,
queueIndex,
maxBytes,
activeServerId,
mediaDir,
);
},
touchPlayed: (trackId, serverId) => {
const k = entryKey(serverId, trackId);
set(s => {
const e = s.entries[k];
if (!e) return s;
return {
entries: {
...s.entries,
[k]: { ...e, lastPlayedAt: Date.now() },
},
};
});
},
removeEntry: (trackId, serverId) => {
set(s => {
const next = { ...s.entries };
delete next[entryKey(serverId, trackId)];
return { entries: next };
});
hotCacheFrontendDebug({
event: 'index-remove',
trackId,
serverId,
reason: 'explicit-removeEntry',
});
emitAnalysisStorageChanged({ trackId, reason: 'hotcache-delete' });
},
totalBytes: () =>
Object.values(get().entries).reduce((acc, e) => acc + (e.sizeBytes || 0), 0),
evictToFit: async (queue, queueIndex, maxBytes, activeServerId, hotCacheCustomDir) => {
if (maxBytes <= 0) return;
const protectLo = Math.max(0, queueIndex);
const protectHi = Math.min(queue.length - 1, queueIndex + HOT_CACHE_PROTECT_AFTER_CURRENT);
const protectedIds = new Set<string>();
for (let i = protectLo; i <= protectHi; i++) {
protectedIds.add(queue[i].trackId);
}
const indexOfInQueue = (trackId: string): number | null => {
const idx = queue.findIndex(r => r.trackId === trackId);
return idx >= 0 ? idx : null;
};
let entries = { ...get().entries };
let sum = Object.values(entries).reduce((a, e) => a + (e.sizeBytes || 0), 0);
if (sum <= maxBytes) return;
const keys = Object.keys(entries);
type Cand = { key: string; tier: number; primary: number; lru: number };
const cands: Cand[] = [];
for (const key of keys) {
const parsed = parseKey(key);
if (!parsed) continue;
const { serverId, trackId } = parsed;
if (protectedIds.has(trackId) && serverId === activeServerId) continue;
if (isHotCachePreviousTrackUnderGrace(trackId, serverId)) continue;
const meta = entries[key];
const lru = lruStamp(meta);
if (serverId !== activeServerId) {
cands.push({ key, tier: 0, primary: 0, lru });
continue;
}
const qIdx = indexOfInQueue(trackId);
if (qIdx === null) {
cands.push({ key, tier: 1, primary: 0, lru });
} else if (qIdx > protectHi) {
cands.push({ key, tier: 2, primary: -qIdx, lru });
} else if (qIdx < protectLo) {
cands.push({ key, tier: 3, primary: qIdx, lru });
}
}
cands.sort((a, b) => {
if (a.tier !== b.tier) return a.tier - b.tier;
if (a.primary !== b.primary) return a.primary - b.primary;
return a.lru - b.lru;
});
if (cands.length === 0) {
hotCacheFrontendDebug({
event: 'evict-no-candidates',
sumBytes: sum,
maxBytes,
queueIndex,
entryKeys: keys.length,
reason: 'all-protected-or-grace-or-parse-fail',
});
return;
}
hotCacheFrontendDebug({
event: 'evict-start',
sumBytes: sum,
maxBytes,
queueIndex,
protectLo,
protectHi,
candidateCount: cands.length,
});
for (const cand of cands) {
if (sum <= maxBytes) break;
const { key, tier } = cand;
const meta = entries[key];
if (!meta) continue;
const parsed = parseKey(key);
if (!parsed) continue;
await invoke('delete_hot_cache_track', {
localPath: meta.localPath,
customDir: hotCacheCustomDir || null,
}).catch((e: unknown) => {
hotCacheFrontendDebug({
event: 'evict-disk-delete-failed',
trackId: parsed.trackId,
serverId: parsed.serverId,
error: String(e),
});
});
hotCacheFrontendDebug({
event: 'evict-remove',
trackId: parsed.trackId,
serverId: parsed.serverId,
reason: `budget:${evictionReasonForTier(tier)}`,
tier,
bytes: meta.sizeBytes,
sumBytesAfter: sum - (meta.sizeBytes || 0),
maxBytes,
});
sum -= meta.sizeBytes || 0;
delete entries[key];
emitAnalysisStorageChanged({ trackId: parsed.trackId, reason: 'hotcache-delete' });
}
set({ entries });
},
clearAllDisk: async (customDir: string | null) => {
hotCacheFrontendDebug({
event: 'purge-all',
customDir: customDir && customDir.length > 0 ? '(custom)' : 'default',
});
await invoke('purge_hot_cache', { customDir: customDir || null }).catch(() => {});
set({ entries: {} });
emitAnalysisStorageChanged({ trackId: null, reason: 'hotcache-purge' });
},
}),
{
name: 'psysonic-hot-cache',
storage: createJSONStorage(() => localStorage),
partialize: s => ({ entries: s.entries }),
},
),
);
clearAllDisk: async (mediaDir) => {
await useLocalPlaybackStore.getState().purgeEphemeralDisk(mediaDir);
},
}));
+6
View File
@@ -0,0 +1,6 @@
export interface HotCacheEntry {
localPath: string;
sizeBytes: number;
cachedAt: number;
lastPlayedAt?: number;
}
+12
View File
@@ -0,0 +1,12 @@
/** Canonical local playback index key: `{serverIndexKey}:{trackId}`. */
export function localPlaybackEntryKey(serverIndexKey: string, trackId: string): string {
return `${serverIndexKey}:${trackId}`;
}
export function parseLocalPlaybackEntryKey(
key: string,
): { serverIndexKey: string; trackId: string } | null {
const i = key.indexOf(':');
if (i <= 0) return null;
return { serverIndexKey: key.slice(0, i), trackId: key.slice(i + 1) };
}
+126
View File
@@ -0,0 +1,126 @@
import type { ServerProfile } from './authStoreTypes';
import type { HotCacheEntry } from './hotCacheStoreTypes';
import { localPlaybackEntryKey } from './localPlaybackKeys';
import type { LocalPlaybackEntry, PinSource } from './localPlaybackStore';
import type { OfflineAlbumMeta, OfflineTrackMeta } from './offlineStore';
import { resolveIndexKey } from '../utils/server/serverIndexKey';
const MIGRATION_FLAG = 'psysonic-local-playback-migrated-v1';
export function legacyMigrationAlreadyDone(): boolean {
try {
return localStorage.getItem(MIGRATION_FLAG) === '1';
} catch {
return false;
}
}
export function markLegacyMigrationDone(): void {
try {
localStorage.setItem(MIGRATION_FLAG, '1');
} catch { /* ignore */ }
}
function resolveIndexKeyForServerId(serverId: string, servers: ServerProfile[]): string {
const trimmed = serverId.trim();
if (!trimmed) return trimmed;
const byId = servers.find(s => s.id === trimmed);
if (byId) return resolveIndexKey(byId.id) || trimmed;
return resolveIndexKey(trimmed) || trimmed;
}
type LegacyOfflineBlob = {
state?: {
tracks?: Record<string, OfflineTrackMeta>;
albums?: Record<string, OfflineAlbumMeta>;
};
};
type LegacyHotBlob = {
state?: {
entries?: Record<string, HotCacheEntry>;
};
};
function pinSourceForTrack(
serverKey: string,
trackId: string,
albums: Record<string, OfflineAlbumMeta>,
): PinSource | undefined {
for (const album of Object.values(albums)) {
if (album.serverId !== serverKey && resolveIndexKey(album.serverId) !== serverKey) continue;
if (!album.trackIds.includes(trackId)) continue;
return {
kind: album.type ?? 'album',
sourceId: album.id,
displayName: album.name,
};
}
return undefined;
}
/** One-time import from `psysonic-offline` + `psysonic-hot-cache` persist keys. */
export function importLegacyLocalPlayback(
servers: ServerProfile[],
): Record<string, LocalPlaybackEntry> {
const out: Record<string, LocalPlaybackEntry> = {};
try {
const offlineRaw = localStorage.getItem('psysonic-offline');
if (offlineRaw) {
const blob = JSON.parse(offlineRaw) as LegacyOfflineBlob;
const tracks = blob.state?.tracks ?? {};
const albums = blob.state?.albums ?? {};
for (const [key, meta] of Object.entries(tracks)) {
const colon = key.indexOf(':');
if (colon <= 0 || !meta?.localPath) continue;
const legacyServer = key.slice(0, colon);
const trackId = key.slice(colon + 1);
const serverIndexKey = resolveIndexKeyForServerId(legacyServer, servers);
const entryKey = localPlaybackEntryKey(serverIndexKey, trackId);
out[entryKey] = {
serverIndexKey,
trackId,
localPath: meta.localPath,
layoutFingerprint: '',
sizeBytes: 0,
tier: 'library',
cachedAt: Date.parse(meta.cachedAt) || Date.now(),
pinSource: pinSourceForTrack(serverIndexKey, trackId, albums)
?? pinSourceForTrack(legacyServer, trackId, albums),
suffix: meta.suffix || 'mp3',
};
}
}
} catch { /* ignore corrupt legacy blob */ }
try {
const hotRaw = localStorage.getItem('psysonic-hot-cache');
if (hotRaw) {
const blob = JSON.parse(hotRaw) as LegacyHotBlob;
const entries = blob.state?.entries ?? {};
for (const [key, meta] of Object.entries(entries)) {
const colon = key.indexOf(':');
if (colon <= 0 || !meta?.localPath) continue;
const legacyServer = key.slice(0, colon);
const trackId = key.slice(colon + 1);
const serverIndexKey = resolveIndexKeyForServerId(legacyServer, servers);
const entryKey = localPlaybackEntryKey(serverIndexKey, trackId);
if (out[entryKey]?.tier === 'library') continue;
out[entryKey] = {
serverIndexKey,
trackId,
localPath: meta.localPath,
layoutFingerprint: '',
sizeBytes: meta.sizeBytes ?? 0,
tier: 'ephemeral',
cachedAt: meta.cachedAt ?? Date.now(),
lastPlayedAt: meta.lastPlayedAt,
suffix: 'mp3',
};
}
}
} catch { /* ignore */ }
return out;
}
+361
View File
@@ -0,0 +1,361 @@
import type { QueueItemRef } from './playerStoreTypes';
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import { isHotCachePreviousTrackUnderGrace } from '../utils/cache/hotCacheGate';
import { emitAnalysisStorageChanged } from './analysisSync';
import { useAuthStore } from './authStore';
import { localPlaybackEntryKey, parseLocalPlaybackEntryKey } from './localPlaybackKeys';
import {
importLegacyLocalPlayback,
legacyMigrationAlreadyDone,
markLegacyMigrationDone,
} from './localPlaybackMigration';
import {
evictEphemeralOrphansToFit,
getEphemeralDiskBytes,
reconcileEphemeralCache,
} from '../utils/cache/ephemeralTierReconcile';
export type LocalPlaybackTier = 'ephemeral' | 'library' | 'favorite-auto';
export interface PinSource {
kind: 'album' | 'playlist' | 'artist' | 'track';
sourceId: string;
displayName?: string;
}
export interface LocalPlaybackEntry {
serverIndexKey: string;
trackId: string;
localPath: string;
layoutFingerprint: string;
sizeBytes: number;
tier: LocalPlaybackTier;
cachedAt: number;
lastPlayedAt?: number;
pinSource?: PinSource;
suffix: string;
}
export interface PinnedGroup {
serverIndexKey: string;
pinSource: PinSource;
trackIds: string[];
}
export const LOCAL_PLAYBACK_PROTECT_AFTER_CURRENT = 1;
interface LocalPlaybackState {
entries: Record<string, LocalPlaybackEntry>;
getEntry: (trackId: string, serverIndexKey: string) => LocalPlaybackEntry | null;
getLocalUrl: (trackId: string, serverIndexKey: string, tier?: LocalPlaybackTier) => string | null;
hasLocalBytes: (trackId: string, serverIndexKey: string) => boolean;
isPinned: (trackId: string, serverIndexKey: string) => boolean;
upsertEntry: (entry: Omit<LocalPlaybackEntry, 'cachedAt'> & { cachedAt?: number }) => void;
touchPlayed: (trackId: string, serverIndexKey: string) => void;
removeEntry: (trackId: string, serverIndexKey: string, reason?: string) => void;
removeEntriesByPinSource: (
serverIndexKey: string,
pinSource: PinSource,
mediaDir: string | null,
) => Promise<void>;
listPinnedGroups: (serverIndexKey?: string) => PinnedGroup[];
ephemeralEntries: () => Record<string, LocalPlaybackEntry>;
ephemeralTotalBytes: () => number;
evictEphemeralToFit: (
queue: QueueItemRef[],
queueIndex: number,
maxBytes: number,
activeServerIndexKey: string,
mediaDir: string | null,
) => Promise<void>;
purgeEphemeralDisk: (mediaDir: string | null) => Promise<void>;
purgeLibraryDisk: (mediaDir: string | null) => Promise<void>;
purgeFavoriteAutoDisk: (mediaDir: string | null) => Promise<void>;
}
function lruStamp(meta: LocalPlaybackEntry | undefined): number {
if (!meta) return 0;
return meta.lastPlayedAt ?? meta.cachedAt ?? 0;
}
function evictionReasonForTier(tier: number): string {
const labels: Record<number, string> = {
0: 'inactive-server',
1: 'not-in-queue',
2: 'ahead-of-protected-window',
3: 'behind-current-in-queue',
};
return labels[tier] ?? `tier-${tier}`;
}
function localPlaybackFrontendDebug(payload: Record<string, unknown>): void {
if (useAuthStore.getState().loggingMode !== 'debug') return;
void invoke('frontend_debug_log', {
scope: 'local-playback',
message: JSON.stringify(payload),
}).catch(() => {});
}
function pinGroupKey(serverIndexKey: string, pinSource: PinSource): string {
return `${serverIndexKey}:${pinSource.kind}:${pinSource.sourceId}`;
}
export const useLocalPlaybackStore = create<LocalPlaybackState>()(
persist(
(set, get) => ({
entries: {},
getEntry: (trackId, serverIndexKey) =>
get().entries[localPlaybackEntryKey(serverIndexKey, trackId)] ?? null,
getLocalUrl: (trackId, serverIndexKey, tier) => {
const e = get().entries[localPlaybackEntryKey(serverIndexKey, trackId)];
if (!e?.localPath) return null;
if (tier && e.tier !== tier) return null;
return `psysonic-local://${e.localPath}`;
},
hasLocalBytes: (trackId, serverIndexKey) =>
!!get().entries[localPlaybackEntryKey(serverIndexKey, trackId)]?.localPath,
isPinned: (trackId, serverIndexKey) =>
get().entries[localPlaybackEntryKey(serverIndexKey, trackId)]?.tier === 'library',
upsertEntry: (entry) => {
const now = Date.now();
const key = localPlaybackEntryKey(entry.serverIndexKey, entry.trackId);
set(s => ({
entries: {
...s.entries,
[key]: {
...entry,
cachedAt: entry.cachedAt ?? now,
lastPlayedAt: entry.lastPlayedAt ?? (entry.tier === 'ephemeral' ? now : entry.lastPlayedAt),
},
},
}));
},
touchPlayed: (trackId, serverIndexKey) => {
const key = localPlaybackEntryKey(serverIndexKey, trackId);
set(s => {
const e = s.entries[key];
if (!e || e.tier !== 'ephemeral') return s;
return {
entries: {
...s.entries,
[key]: { ...e, lastPlayedAt: Date.now() },
},
};
});
},
removeEntry: (trackId, serverIndexKey, reason = 'explicit-remove') => {
const key = localPlaybackEntryKey(serverIndexKey, trackId);
set(s => {
const next = { ...s.entries };
delete next[key];
return { entries: next };
});
localPlaybackFrontendDebug({ event: 'index-remove', trackId, serverIndexKey, reason });
emitAnalysisStorageChanged({ trackId, reason: 'local-playback-delete' });
},
removeEntriesByPinSource: async (serverIndexKey, pinSource, mediaDir) => {
const targets = Object.values(get().entries).filter(
e =>
e.serverIndexKey === serverIndexKey
&& e.tier === 'library'
&& e.pinSource?.kind === pinSource.kind
&& e.pinSource?.sourceId === pinSource.sourceId,
);
await Promise.all(
targets.map(async e => {
await invoke('delete_media_file', { localPath: e.localPath, mediaDir }).catch(() => {});
get().removeEntry(e.trackId, e.serverIndexKey, 'pin-group-delete');
}),
);
},
listPinnedGroups: (serverIndexKey) => {
const groups = new Map<string, PinnedGroup>();
for (const e of Object.values(get().entries)) {
if (e.tier !== 'library' || !e.pinSource) continue;
if (serverIndexKey && e.serverIndexKey !== serverIndexKey) continue;
const gk = pinGroupKey(e.serverIndexKey, e.pinSource);
const existing = groups.get(gk);
if (existing) {
if (!existing.trackIds.includes(e.trackId)) existing.trackIds.push(e.trackId);
} else {
groups.set(gk, {
serverIndexKey: e.serverIndexKey,
pinSource: e.pinSource,
trackIds: [e.trackId],
});
}
}
return [...groups.values()];
},
ephemeralEntries: () => {
const out: Record<string, LocalPlaybackEntry> = {};
for (const [key, e] of Object.entries(get().entries)) {
if (e.tier === 'ephemeral') out[key] = e;
}
return out;
},
ephemeralTotalBytes: () =>
Object.values(get().entries)
.filter(e => e.tier === 'ephemeral')
.reduce((acc, e) => acc + (e.sizeBytes || 0), 0),
evictEphemeralToFit: async (queue, queueIndex, maxBytes, activeServerIndexKey, mediaDir) => {
if (maxBytes <= 0) return;
await reconcileEphemeralCache();
let diskBytes = await getEphemeralDiskBytes(mediaDir);
if (diskBytes <= maxBytes) return;
const protectLo = Math.max(0, queueIndex);
const protectHi = Math.min(queue.length - 1, queueIndex + LOCAL_PLAYBACK_PROTECT_AFTER_CURRENT);
const protectedIds = new Set<string>();
for (let i = protectLo; i <= protectHi; i++) {
protectedIds.add(queue[i].trackId);
}
const indexOfInQueue = (trackId: string): number | null => {
const idx = queue.findIndex(r => r.trackId === trackId);
return idx >= 0 ? idx : null;
};
let entries = { ...get().entries };
let sum = Object.values(entries)
.filter(e => e.tier === 'ephemeral')
.reduce((a, e) => a + (e.sizeBytes || 0), 0);
type Cand = { key: string; tier: number; primary: number; lru: number };
const cands: Cand[] = [];
for (const [key, meta] of Object.entries(entries)) {
if (meta.tier !== 'ephemeral') continue;
const parsed = parseLocalPlaybackEntryKey(key);
if (!parsed) continue;
const { serverIndexKey, trackId } = parsed;
if (protectedIds.has(trackId) && serverIndexKey === activeServerIndexKey) continue;
if (isHotCachePreviousTrackUnderGrace(trackId, serverIndexKey)) continue;
const lru = lruStamp(meta);
if (serverIndexKey !== activeServerIndexKey) {
cands.push({ key, tier: 0, primary: 0, lru });
continue;
}
const qIdx = indexOfInQueue(trackId);
if (qIdx === null) {
cands.push({ key, tier: 1, primary: 0, lru });
} else if (qIdx > protectHi) {
cands.push({ key, tier: 2, primary: -qIdx, lru });
} else if (qIdx < protectLo) {
cands.push({ key, tier: 3, primary: qIdx, lru });
}
}
cands.sort((a, b) => {
if (a.tier !== b.tier) return a.tier - b.tier;
if (a.primary !== b.primary) return a.primary - b.primary;
return a.lru - b.lru;
});
for (const cand of cands) {
if (sum <= maxBytes) break;
const meta = entries[cand.key];
if (!meta || meta.tier !== 'ephemeral') continue;
const parsed = parseLocalPlaybackEntryKey(cand.key);
if (!parsed) continue;
await invoke('delete_media_file', {
localPath: meta.localPath,
mediaDir,
}).catch(() => {});
localPlaybackFrontendDebug({
event: 'evict-remove',
trackId: parsed.trackId,
serverIndexKey: parsed.serverIndexKey,
reason: `budget:${evictionReasonForTier(cand.tier)}`,
});
sum -= meta.sizeBytes || 0;
delete entries[cand.key];
emitAnalysisStorageChanged({ trackId: parsed.trackId, reason: 'hotcache-delete' });
}
set({ entries });
diskBytes = await getEphemeralDiskBytes(mediaDir);
if (diskBytes > maxBytes) {
const keepPaths = Object.values(get().entries)
.filter(e => e.tier === 'ephemeral')
.map(e => e.localPath);
await evictEphemeralOrphansToFit(maxBytes, mediaDir, keepPaths);
}
await invoke('prune_empty_media_tier_dirs', { tier: 'ephemeral', mediaDir }).catch(() => {});
},
purgeEphemeralDisk: async (mediaDir) => {
await invoke('purge_media_tier', { tier: 'ephemeral', mediaDir }).catch(() => {});
set(s => {
const entries = { ...s.entries };
for (const [key, e] of Object.entries(entries)) {
if (e.tier === 'ephemeral') delete entries[key];
}
return { entries };
});
emitAnalysisStorageChanged({ trackId: null, reason: 'hotcache-purge' });
},
purgeLibraryDisk: async (mediaDir) => {
await invoke('purge_media_tier', { tier: 'library', mediaDir }).catch(() => {});
set(s => {
const entries = { ...s.entries };
for (const [key, e] of Object.entries(entries)) {
if (e.tier === 'library') delete entries[key];
}
return { entries };
});
emitAnalysisStorageChanged({ trackId: null, reason: 'offline-purge' });
},
purgeFavoriteAutoDisk: async (mediaDir) => {
await invoke('purge_media_tier', { tier: 'favorite-auto', mediaDir }).catch(() => {});
set(s => {
const entries = { ...s.entries };
for (const [key, e] of Object.entries(entries)) {
if (e.tier === 'favorite-auto') delete entries[key];
}
return { entries };
});
emitAnalysisStorageChanged({ trackId: null, reason: 'favorites-offline-purge' });
},
}),
{
name: 'psysonic-local-playback',
storage: createJSONStorage(() => localStorage),
partialize: s => ({ entries: s.entries }),
onRehydrateStorage: () => (state, error) => {
if (error || !state) return;
if (legacyMigrationAlreadyDone()) return;
const servers = useAuthStore.getState().servers;
const imported = importLegacyLocalPlayback(servers);
if (Object.keys(imported).length === 0) {
markLegacyMigrationDone();
return;
}
const merged = { ...imported, ...state.entries };
useLocalPlaybackStore.setState({ entries: merged });
markLegacyMigrationDone();
},
},
),
);
+25 -1
View File
@@ -17,7 +17,7 @@ function job(over: Partial<DownloadJob>): DownloadJob {
}
beforeEach(() => {
useOfflineJobStore.setState({ jobs: [], bulkProgress: {} });
useOfflineJobStore.setState({ jobs: [], pinQueue: [], bulkProgress: {} });
cancelledDownloads.clear();
});
@@ -34,6 +34,13 @@ describe('offlineJobStore cancellation', () => {
job({ trackId: 'done', status: 'done' }),
job({ trackId: 'err', status: 'error' }),
],
pinQueue: [{
albumId: 'b',
albumName: 'B',
pinKind: 'album',
status: 'queued',
queuedAt: 1,
}],
bulkProgress: {},
});
@@ -41,11 +48,28 @@ describe('offlineJobStore cancellation', () => {
// Only settled jobs survive → the sidebar toast clears.
expect(useOfflineJobStore.getState().jobs.map(j => j.status).sort()).toEqual(['done', 'error']);
expect(useOfflineJobStore.getState().pinQueue).toEqual([]);
expect(cancelledDownloads.has('a')).toBe(true);
// Rust is told to abort the in-flight transfers for this download id.
expect(calls).toEqual([['a-1']]);
});
it('cancelDownload drops pin queue entry for the album', () => {
useOfflineJobStore.setState({
jobs: [],
pinQueue: [{
albumId: 'a',
albumName: 'A',
pinKind: 'album',
status: 'queued',
queuedAt: 1,
}],
bulkProgress: {},
});
useOfflineJobStore.getState().cancelDownload('a');
expect(useOfflineJobStore.getState().pinQueue).toEqual([]);
});
it('cancelDownload drops every job for one album and leaves others running', () => {
const calls: string[][] = [];
onInvoke('cancel_offline_downloads', (a: unknown) => {
+43
View File
@@ -13,9 +13,22 @@ export interface DownloadJob {
downloadId: string;
}
export interface OfflinePinQueueEntry {
albumId: string;
albumName: string;
pinKind: 'album' | 'playlist' | 'artist' | 'track';
status: 'queued' | 'downloading';
queuedAt: number;
}
interface OfflineJobState {
jobs: DownloadJob[];
/** Album / playlist / artist pins waiting for or undergoing download. */
pinQueue: OfflinePinQueueEntry[];
bulkProgress: Record<string, { done: number; total: number }>;
setPinQueueStatus: (albumId: string, status: OfflinePinQueueEntry['status']) => void;
removePinFromQueue: (albumId: string) => void;
bumpBulkProgressDone: (groupId: string) => void;
cancelDownload: (albumId: string) => void;
cancelAllDownloads: () => void;
}
@@ -33,8 +46,35 @@ function abortDownloadsInRust(jobs: DownloadJob[]) {
export const useOfflineJobStore = create<OfflineJobState>()((set, get) => ({
jobs: [],
pinQueue: [],
bulkProgress: {},
setPinQueueStatus: (albumId, status) => {
set(state => ({
pinQueue: state.pinQueue.map(p => (p.albumId === albumId ? { ...p, status } : p)),
}));
},
removePinFromQueue: (albumId) => {
set(state => ({
pinQueue: state.pinQueue.filter(p => p.albumId !== albumId),
}));
},
bumpBulkProgressDone: (groupId) => {
set(state => {
const cur = state.bulkProgress[groupId];
if (!cur) return state;
const done = Math.min(cur.total, cur.done + 1);
return {
bulkProgress: {
...state.bulkProgress,
[groupId]: { ...cur, done },
},
};
});
},
cancelDownload: (albumId) => {
cancelledDownloads.add(albumId);
// Abort the in-flight Rust transfers, then drop every job for this album
@@ -42,6 +82,7 @@ export const useOfflineJobStore = create<OfflineJobState>()((set, get) => ({
abortDownloadsInRust(get().jobs.filter(j => j.albumId === albumId));
set(state => ({
jobs: state.jobs.filter(j => j.albumId !== albumId),
pinQueue: state.pinQueue.filter(p => p.albumId !== albumId),
}));
},
@@ -50,11 +91,13 @@ export const useOfflineJobStore = create<OfflineJobState>()((set, get) => ({
j => j.status === 'queued' || j.status === 'downloading',
);
[...new Set(active.map(j => j.albumId))].forEach(id => cancelledDownloads.add(id));
[...get().pinQueue.map(p => p.albumId)].forEach(id => cancelledDownloads.add(id));
abortDownloadsInRust(active);
// Keep only already-settled jobs (done/error) — the active ones are gone,
// so the toast disappears instead of lingering on stuck "downloading" rows.
set(state => ({
jobs: state.jobs.filter(j => j.status !== 'queued' && j.status !== 'downloading'),
pinQueue: [],
}));
},
}));
+320 -215
View File
@@ -1,3 +1,4 @@
import { libraryUpsertSongsFromApi } from '../api/library';
import { buildStreamUrl } from '../api/subsonicStreamUrl';
import { getAlbum } from '../api/subsonicLibrary';
import { getArtist } from '../api/subsonicArtists';
@@ -8,8 +9,24 @@ import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from './authStore';
import { showToast } from '../utils/ui/toast';
import { useOfflineJobStore, cancelledDownloads } from './offlineJobStore';
import { emitAnalysisStorageChanged } from './analysisSync';
import { useLocalPlaybackStore, type PinSource } from './localPlaybackStore';
import { getMediaDir } from '../utils/media/mediaDir';
import {
findLocalPlaybackEntry,
isOfflinePinComplete,
pendingOfflinePinSongs,
} from '../utils/offline/offlineLibraryHelpers';
import { librarySqlServerId } from '../api/coverCache';
import { resolveIndexKey, serverIndexKeyForProfile } from '../utils/server/serverIndexKey';
import { isSmartPlaylistName } from '../utils/componentHelpers/playlistDetailHelpers';
import {
enqueueOfflinePin,
registerOfflinePinExecutor,
removeOfflinePinTask,
type OfflinePinTask,
} from '../utils/offline/offlinePinQueue';
/** @deprecated Metadata lives in the library index; kept for type-compat during transition. */
export interface OfflineTrackMeta {
id: string;
serverId: string;
@@ -31,6 +48,7 @@ export interface OfflineTrackMeta {
cachedAt: string;
}
/** @deprecated Grouping uses `pinSource` on local playback entries. */
export interface OfflineAlbumMeta {
id: string;
serverId: string;
@@ -39,16 +57,203 @@ export interface OfflineAlbumMeta {
coverArt?: string;
year?: number;
trackIds: string[];
type?: 'album' | 'playlist' | 'artist';
type?: 'album' | 'playlist' | 'artist' | 'track';
}
// Re-export for components that import DownloadJob from offlineStore.
export type { DownloadJob } from './offlineJobStore';
interface OfflineState {
tracks: Record<string, OfflineTrackMeta>; // key: `${serverId}:${trackId}`
albums: Record<string, OfflineAlbumMeta>; // key: `${serverId}:${albumId}`
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;
}
/** Library SQLite scope (host index key) — not the auth profile UUID. */
function librarySqlScopeForOffline(serverId: string): string {
return librarySqlServerId(serverId);
}
/** Runs one queued offline pin (all tracks for a single album / playlist). */
async function runOfflinePinDownload(task: OfflinePinTask): Promise<void> {
const {
albumId,
albumName,
albumArtist,
coverArt,
year,
songs,
serverId,
type = 'album',
} = task;
if (cancelledDownloads.has(albumId)) return;
cancelledDownloads.delete(albumId);
const CONCURRENCY = 8;
const trackIds = songs.map(s => s.id);
const jobStore = useOfflineJobStore;
const downloadId = `${albumId}-${Date.now()}`;
const serverIndexKey = serverIndexKeyForOffline(serverId);
const libraryServerId = librarySqlScopeForOffline(serverId);
const pinSource: PinSource = { kind: type, sourceId: albumId, displayName: albumName };
const mediaDir = getMediaDir();
if (mediaDir) {
const ok = await invoke<boolean>('check_dir_accessible', { path: mediaDir }).catch(() => false);
if (!ok) {
showToast('Speichermedium nicht gefunden. Bitte Verzeichnis in den Einstellungen prüfen.', 6000, 'error');
return;
}
}
useOfflineStore.setState(state => ({
albums: {
...state.albums,
[`${serverIndexKey}:${albumId}`]: {
id: albumId,
serverId: serverIndexKey,
name: albumName,
artist: albumArtist,
coverArt,
year,
trackIds,
type,
},
},
}));
await libraryUpsertSongsFromApi(libraryServerId, songs).catch(() => {});
const lp = useLocalPlaybackStore.getState();
const pendingSongs = pendingOfflinePinSongs(songs, serverId);
if (pendingSongs.length === 0) {
for (const song of songs) {
const prev = findLocalPlaybackEntry(song.id, serverId);
if (!prev) continue;
lp.upsertEntry({
...prev,
serverIndexKey,
tier: 'library',
pinSource,
});
}
jobStore.setState(state => ({
jobs: state.jobs.filter(j => j.albumId !== albumId),
}));
return;
}
jobStore.setState(state => ({
jobs: [
...state.jobs.filter(j => j.albumId !== albumId),
...pendingSongs.map((s, i) => ({
trackId: s.id,
albumId,
albumName,
trackTitle: s.title,
trackIndex: i,
totalTracks: pendingSongs.length,
status: 'queued' as const,
downloadId,
})),
],
}));
for (let i = 0; i < pendingSongs.length; i += CONCURRENCY) {
if (cancelledDownloads.has(albumId)) {
cancelledDownloads.delete(albumId);
jobStore.setState(state => ({ jobs: state.jobs.filter(j => j.albumId !== albumId) }));
invoke('clear_offline_cancel', { downloadId }).catch(() => {});
return;
}
const batch = pendingSongs.slice(i, i + CONCURRENCY);
const batchIds = new Set(batch.map(s => s.id));
jobStore.setState(state => ({
jobs: state.jobs.map(j =>
j.albumId === albumId && batchIds.has(j.trackId)
? { ...j, status: 'downloading' }
: j,
),
}));
const results = await Promise.all(
batch.map(async song => {
const suffix = song.suffix || 'mp3';
if (cancelledDownloads.has(albumId)) {
return { song, localPath: null as string | null, error: 'CANCELLED' };
}
const existing = findLocalPlaybackEntry(song.id, serverId);
if (existing?.tier === 'library' && existing.localPath) {
useLocalPlaybackStore.getState().upsertEntry({
...existing,
serverIndexKey,
pinSource,
suffix: existing.suffix || suffix,
});
return { song, localPath: existing.localPath, error: null as string | null };
}
try {
const res = await invoke<{ path: string; size: number; layoutFingerprint: string }>(
'download_track_local',
{
tier: 'library',
trackId: song.id,
serverIndexKey,
libraryServerId,
url: buildStreamUrl(song.id),
suffix,
mediaDir,
downloadId,
},
);
useLocalPlaybackStore.getState().upsertEntry({
serverIndexKey,
trackId: song.id,
localPath: res.path,
sizeBytes: res.size,
layoutFingerprint: res.layoutFingerprint,
tier: 'library',
pinSource,
suffix,
});
return { song, localPath: res.path, error: null as string | null };
} catch (err) {
const msg = typeof err === 'string' ? err : (err instanceof Error ? err.message : '');
if (msg === 'VOLUME_NOT_FOUND' && !cancelledDownloads.has(albumId)) {
cancelledDownloads.add(albumId);
showToast('Speichermedium nicht gefunden. Bitte Verzeichnis in den Einstellungen prüfen.', 6000, 'error');
}
return { song, localPath: null as string | null, error: msg };
}
}),
);
const resultMap = new Map(results.map(r => [r.song.id, r]));
jobStore.setState(state => ({
jobs: state.jobs.map(j => {
if (j.albumId !== albumId) return j;
const r = resultMap.get(j.trackId);
if (!r) return j;
if (r.error === 'CANCELLED') return j;
return { ...j, status: r.localPath ? 'done' : 'error' };
}),
}));
}
invoke('clear_offline_cancel', { downloadId }).catch(() => {});
setTimeout(() => {
jobStore.setState(state => ({
jobs: state.jobs.filter(
j => j.albumId !== albumId || (j.status !== 'done' && j.status !== 'error'),
),
}));
}, 2500);
}
interface OfflineState {
/** Legacy shim — new pins use `localPlaybackStore` only. */
albums: Record<string, OfflineAlbumMeta>;
isDownloaded: (trackId: string, serverId: string) => boolean;
isAlbumDownloaded: (albumId: string, serverId: string) => boolean;
isAlbumDownloading: (albumId: string) => boolean;
@@ -61,7 +266,7 @@ interface OfflineState {
year: number | undefined,
songs: SubsonicSong[],
serverId: string,
type?: 'album' | 'playlist' | 'artist',
type?: 'album' | 'playlist' | 'artist' | 'track',
) => Promise<void>;
downloadPlaylist: (playlistId: string, playlistName: string, coverArt: string | undefined, songs: SubsonicSong[], serverId: string) => Promise<void>;
downloadArtist: (artistId: string, artistName: string, serverId: string) => Promise<void>;
@@ -73,35 +278,51 @@ interface OfflineState {
export const useOfflineStore = create<OfflineState>()(
persist(
(set, get) => ({
tracks: {},
albums: {},
isDownloaded: (trackId, serverId) =>
!!get().tracks[`${serverId}:${trackId}`],
useLocalPlaybackStore.getState().isPinned(trackId, serverIndexKeyForOffline(serverId)),
isAlbumDownloaded: (albumId, serverId) => {
const album = get().albums[`${serverId}:${albumId}`];
if (!album || album.trackIds.length === 0) return false;
return album.trackIds.every(tid => !!get().tracks[`${serverId}:${tid}`]);
const indexKey = serverIndexKeyForOffline(serverId);
const group = useLocalPlaybackStore.getState().listPinnedGroups(indexKey)
.find(g => g.pinSource.sourceId === albumId);
if (!group || group.trackIds.length === 0) return false;
return group.trackIds.every(tid =>
useLocalPlaybackStore.getState().isPinned(tid, indexKey),
);
},
isAlbumDownloading: (albumId) =>
useOfflineJobStore.getState().jobs.some(
j => j.albumId === albumId && (j.status === 'queued' || j.status === 'downloading')
),
getLocalUrl: (trackId, serverId) => {
const meta = get().tracks[`${serverId}:${trackId}`];
if (!meta) return null;
return `psysonic-local://${meta.localPath}`;
isAlbumDownloading: (albumId) => {
const jobState = useOfflineJobStore.getState();
return jobState.pinQueue.some(p => p.albumId === albumId)
|| jobState.jobs.some(
j => j.albumId === albumId && (j.status === 'queued' || j.status === 'downloading'),
);
},
getLocalUrl: (trackId, serverId) =>
useLocalPlaybackStore.getState().getLocalUrl(trackId, serverIndexKeyForOffline(serverId), 'library'),
clearAll: async (serverId) => {
const albumKeys = Object.keys(get().albums).filter(k => k.startsWith(`${serverId}:`));
for (const key of albumKeys) {
const albumId = key.slice(`${serverId}:`.length);
await get().deleteAlbum(albumId, serverId);
const indexKey = serverIndexKeyForOffline(serverId);
const groups = useLocalPlaybackStore.getState().listPinnedGroups(indexKey);
for (const group of groups) {
await useLocalPlaybackStore.getState().removeEntriesByPinSource(
indexKey,
group.pinSource,
getMediaDir(),
);
}
set(state => {
const albums = { ...state.albums };
for (const key of Object.keys(albums)) {
if (key.startsWith(`${serverId}:`) || key.startsWith(`${indexKey}:`)) {
delete albums[key];
}
}
return { albums };
});
},
getAlbumProgress: (albumId) => {
@@ -112,171 +333,22 @@ export const useOfflineStore = create<OfflineState>()(
},
downloadAlbum: async (albumId, albumName, albumArtist, coverArt, year, songs, serverId, type = 'album') => {
// Frontend fires up to 8 invoke calls at a time so Rust always has work queued.
// The backend Semaphore (MAX_DL_CONCURRENCY = 4) is the real throttle —
// at most 4 HTTP streams run simultaneously regardless of this value.
const CONCURRENCY = 8;
const trackIds = songs.map(s => s.id);
const jobStore = useOfflineJobStore;
// Unique per run — keys the Rust-side cancellation flag so the X button
// can abort in-flight transfers, not just stop queuing new ones.
const downloadId = `${albumId}-${Date.now()}`;
// Pre-flight: verify the target directory is accessible before queuing anything.
const customDir = useAuthStore.getState().offlineDownloadDir || null;
if (customDir) {
const ok = await invoke<boolean>('check_dir_accessible', { path: customDir }).catch(() => false);
if (!ok) {
showToast('Speichermedium nicht gefunden. Bitte Verzeichnis in den Einstellungen prüfen.', 6000, 'error');
return;
}
}
// Register album in persisted store — 1 localStorage write.
set(state => ({
albums: {
...state.albums,
[`${serverId}:${albumId}`]: { id: albumId, serverId, name: albumName, artist: albumArtist, coverArt, year, trackIds, type },
},
}));
// Queue jobs in the non-persisted job store — zero localStorage writes.
jobStore.setState(state => ({
jobs: [
...state.jobs.filter(j => j.albumId !== albumId),
...songs.map((s, i) => ({
trackId: s.id,
albumId,
albumName,
trackTitle: s.title,
trackIndex: i,
totalTracks: songs.length,
status: 'queued' as const,
downloadId,
})),
],
}));
// Accumulate completed tracks locally — persisted in ONE write at the very end.
const completedTracks: Record<string, OfflineTrackMeta> = {};
for (let i = 0; i < songs.length; i += CONCURRENCY) {
// Abort if the user cancelled this download.
if (cancelledDownloads.has(albumId)) {
cancelledDownloads.delete(albumId);
// Persist whatever finished before the cancel so files already on
// disk are not orphaned, then drop the remaining jobs.
if (Object.keys(completedTracks).length > 0) {
set(state => ({ tracks: { ...state.tracks, ...completedTracks } }));
}
jobStore.setState(state => ({ jobs: state.jobs.filter(j => j.albumId !== albumId) }));
invoke('clear_offline_cancel', { downloadId }).catch(() => {});
return;
}
const batch = songs.slice(i, i + CONCURRENCY);
const batchIds = new Set(batch.map(s => s.id));
// Mark batch as downloading — job store only, no localStorage write.
jobStore.setState(state => ({
jobs: state.jobs.map(j =>
j.albumId === albumId && batchIds.has(j.trackId)
? { ...j, status: 'downloading' }
: j,
),
}));
// Run all downloads concurrently, collect results without touching any store.
const results = await Promise.all(
batch.map(async song => {
const suffix = song.suffix || 'mp3';
// Skip tracks not yet started once the user cancels mid-batch —
// the per-batch check above only catches whole batches.
if (cancelledDownloads.has(albumId)) {
return { song, suffix, localPath: null as string | null, error: 'CANCELLED' };
}
try {
const localPath = await invoke<string>('download_track_offline', {
trackId: song.id,
serverId,
url: buildStreamUrl(song.id),
suffix,
customDir,
downloadId,
});
return { song, suffix, localPath, error: null as string | null };
} catch (err) {
const msg = typeof err === 'string' ? err : (err instanceof Error ? err.message : '');
if (msg === 'VOLUME_NOT_FOUND' && !cancelledDownloads.has(albumId)) {
cancelledDownloads.add(albumId);
showToast('Speichermedium nicht gefunden. Bitte Verzeichnis in den Einstellungen prüfen.', 6000, 'error');
}
return { song, suffix, localPath: null as string | null, error: msg };
}
}),
);
// Accumulate completed tracks locally (no store write yet).
for (const { song, suffix, localPath } of results) {
if (localPath) {
completedTracks[`${serverId}:${song.id}`] = {
id: song.id,
serverId,
localPath,
title: song.title,
artist: song.artist,
album: song.album,
albumId: song.albumId,
artistId: song.artistId,
suffix,
duration: song.duration,
bitRate: song.bitRate,
coverArt: song.coverArt,
year: song.year,
genre: song.genre,
replayGainTrackDb: song.replayGain?.trackGain,
replayGainAlbumDb: song.replayGain?.albumGain,
replayGainPeak: song.replayGain?.trackPeak,
cachedAt: new Date().toISOString(),
};
}
}
// Update job statuses — job store only, no localStorage write.
const resultMap = new Map(results.map(r => [r.song.id, r]));
jobStore.setState(state => ({
jobs: state.jobs.map(j => {
if (j.albumId !== albumId) return j;
const r = resultMap.get(j.trackId);
if (!r) return j;
// A cancelled track is not a failure — leave the job for the
// cancel path to drop rather than flashing it red.
if (r.error === 'CANCELLED') return j;
return { ...j, status: r.localPath ? 'done' : 'error' };
}),
}));
}
// Persist all completed tracks in ONE localStorage write.
set(state => ({ tracks: { ...state.tracks, ...completedTracks } }));
invoke('clear_offline_cancel', { downloadId }).catch(() => {});
// Clear completed jobs after a short delay.
setTimeout(() => {
jobStore.setState(state => ({
jobs: state.jobs.filter(
j => j.albumId !== albumId || (j.status !== 'done' && j.status !== 'error'),
),
}));
}, 2500);
enqueueOfflinePin({
albumId,
albumName,
albumArtist,
coverArt,
year,
songs,
serverId,
type,
});
},
downloadPlaylist: async (playlistId, playlistName, coverArt, songs, serverId) => {
// Deduplicate songs (a track can appear multiple times in a playlist).
if (isSmartPlaylistName(playlistName)) return;
const seen = new Set<string>();
const unique = songs.filter(s => { if (seen.has(s.id)) return false; seen.add(s.id); return true; });
// Store the entire playlist as one virtual album entry so the Offline Library
// shows a single card for the playlist rather than one card per album.
await get().downloadAlbum(playlistId, playlistName, '', coverArt, undefined, unique, serverId, 'playlist');
},
@@ -287,58 +359,91 @@ export const useOfflineStore = create<OfflineState>()(
const res = await getArtist(artistId);
albums = res.albums;
} catch { return; }
jobStore.setState(state => ({
bulkProgress: { ...state.bulkProgress, [artistId]: { done: 0, total: albums.length } },
}));
for (let i = 0; i < albums.length; i++) {
const album = albums[i];
if (albums.length === 0) return;
const offline = get();
let doneCount = 0;
const toEnqueue: OfflinePinTask[] = [];
for (const album of albums) {
if (isOfflinePinComplete(album.id, serverId)) {
doneCount += 1;
continue;
}
if (offline.isAlbumDownloading(album.id)) continue;
try {
const { songs } = await getAlbum(album.id);
await get().downloadAlbum(album.id, album.name, album.artist || artistName, album.coverArt, album.year, songs, serverId, 'artist');
toEnqueue.push({
albumId: album.id,
albumName: album.name,
albumArtist: album.artist || artistName,
coverArt: album.coverArt,
year: album.year,
songs,
serverId,
type: 'artist',
artistProgressGroupId: artistId,
});
} catch { /* skip failed album */ }
jobStore.setState(state => ({
bulkProgress: { ...state.bulkProgress, [artistId]: { done: i + 1, total: albums.length } },
}));
}
if (doneCount === albums.length) return;
const existing = jobStore.getState().bulkProgress[artistId];
jobStore.setState(state => ({
bulkProgress: {
...state.bulkProgress,
[artistId]: {
done: existing && existing.done > doneCount ? existing.done : doneCount,
total: albums.length,
},
},
}));
if (toEnqueue.length === 0) return;
for (const task of toEnqueue) {
enqueueOfflinePin(task);
}
setTimeout(() => {
jobStore.setState(state => {
const progress = state.bulkProgress[artistId];
if (!progress || progress.done < progress.total) return state;
const { [artistId]: _removed, ...rest } = state.bulkProgress;
return { bulkProgress: rest };
});
}, 3000);
}, 5000);
},
deleteAlbum: async (albumId, serverId) => {
const album = get().albums[`${serverId}:${albumId}`];
if (!album) return;
await Promise.all(
album.trackIds.map(async trackId => {
const meta = get().tracks[`${serverId}:${trackId}`];
if (!meta) return;
await invoke('delete_offline_track', {
localPath: meta.localPath,
baseDir: useAuthStore.getState().offlineDownloadDir || null,
}).catch(() => {});
}),
useOfflineJobStore.getState().cancelDownload(albumId);
cancelledDownloads.delete(albumId);
removeOfflinePinTask(albumId);
const indexKey = serverIndexKeyForOffline(serverId);
const album = get().albums[`${indexKey}:${albumId}`]
?? get().albums[`${serverId}:${albumId}`];
const pinSource: PinSource = album
? { kind: album.type ?? 'album', sourceId: albumId, displayName: album.name }
: { kind: 'album', sourceId: albumId };
await useLocalPlaybackStore.getState().removeEntriesByPinSource(
indexKey,
pinSource,
getMediaDir(),
);
for (const trackId of album.trackIds) {
emitAnalysisStorageChanged({ trackId, reason: 'offline-delete' });
}
set(state => {
const tracks = { ...state.tracks };
album.trackIds.forEach(tid => delete tracks[`${serverId}:${tid}`]);
const albums = { ...state.albums };
delete albums[`${indexKey}:${albumId}`];
delete albums[`${serverId}:${albumId}`];
return { tracks, albums };
return { albums };
});
},
}),
{
name: 'psysonic-offline',
storage: createJSONStorage(() => localStorage),
partialize: state => ({ tracks: state.tracks, albums: state.albums }),
partialize: state => ({ albums: state.albums }),
},
),
);
registerOfflinePinExecutor(runOfflinePinDownload);
+8 -2
View File
@@ -53,7 +53,7 @@ describe('pendingStarSync', () => {
await vi.runAllTimersAsync();
expect(starMock).toHaveBeenCalledWith('t1', 'song');
expect(starMock).toHaveBeenCalledWith('t1', 'song', undefined);
const s = usePlayerStore.getState();
expect('t1' in s.starredOverrides).toBe(false); // cleared on success
expect(s.currentTrack?.starred).toBeTruthy(); // in-memory track patched
@@ -75,11 +75,17 @@ describe('pendingStarSync', () => {
expect(usePlayerStore.getState().starredOverrides.t1).toBe(true); // override survives (no rollback)
});
it('passes serverId through to star/unstar for cross-server favorites', async () => {
queueSongStar('t1', true, 'srv-b');
await vi.runAllTimersAsync();
expect(starMock).toHaveBeenCalledWith('t1', 'song', { serverId: 'srv-b' });
});
it('latest toggle wins when re-queued before sync', async () => {
queueSongStar('t1', true);
queueSongStar('t1', false); // user toggled back off
await vi.runAllTimersAsync();
expect(unstarMock).toHaveBeenCalledWith('t1', 'song');
expect(unstarMock).toHaveBeenCalledWith('t1', 'song', undefined);
expect('t1' in usePlayerStore.getState().starredOverrides).toBe(false);
expect(usePlayerStore.getState().currentTrack?.starred).toBeFalsy();
});
+8 -6
View File
@@ -24,7 +24,7 @@ import { patchCachedTrack } from '../utils/library/queueTrackResolver';
*/
type Task =
| { kind: 'star'; id: string; starred: boolean }
| { kind: 'star'; id: string; starred: boolean; serverId?: string }
| { kind: 'rating'; id: string; rating: number };
const pending = new Map<string, Task>(); // key `${kind}:${id}` — latest wins
@@ -33,7 +33,8 @@ const attempts = new Map<string, number>();
const MAX_BACKOFF_MS = 30_000;
let listenersArmed = false;
const keyOf = (t: Task) => `${t.kind}:${t.id}`;
const keyOf = (t: Task) =>
t.kind === 'star' ? `star:${t.serverId ?? ''}:${t.id}` : `${t.kind}:${t.id}`;
function armListeners(): void {
if (listenersArmed || typeof window === 'undefined') return;
@@ -62,8 +63,9 @@ async function run(k: string): Promise<void> {
if (!task) return;
try {
if (task.kind === 'star') {
if (task.starred) await star(task.id, 'song');
else await unstar(task.id, 'song');
const meta = task.serverId ? { serverId: task.serverId } : undefined;
if (task.starred) await star(task.id, 'song', meta);
else await unstar(task.id, 'song', meta);
onStarSuccess(task.id, task.starred);
} else {
await setRating(task.id, task.rating);
@@ -114,9 +116,9 @@ function onRatingSuccess(id: string): void {
}
/** Optimistically (un)star a song and sync it to the server with retry. */
export function queueSongStar(id: string, starred: boolean): void {
export function queueSongStar(id: string, starred: boolean, serverId?: string): void {
usePlayerStore.getState().setStarredOverride(id, starred);
const t: Task = { kind: 'star', id, starred };
const t: Task = { kind: 'star', id, starred, serverId };
const k = keyOf(t);
pending.set(k, t);
attempts.delete(k);
+81 -52
View File
@@ -5,12 +5,18 @@ import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
import { orbitBulkGuard } from '../utils/orbitBulkGuard';
import { sameQueueTrackId } from '../utils/playback/queueIdentity';
import {
bindQueueServerForPlayback,
bindQueueServerForTracks,
getPlaybackCacheServerKey,
getPlaybackIndexKey,
getPlaybackServerId,
playbackCacheKeyForTrack,
playbackProfileIdForTrack,
shouldBindQueueServerForPlay,
} from '../utils/playback/playbackServer';
import { stampTrackServerId, stampTrackServerIds } from '../utils/playback/trackServerScope';
import {
findLocalPlaybackUrl,
hasLocalPersistentPlaybackBytes,
} from '../utils/offline/offlineLibraryHelpers';
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb';
import { useAuthStore } from './authStore';
@@ -163,6 +169,9 @@ export function runPlayTrack(
void playListenSessionFinalize('skip');
const scopedTrack = stampTrackServerId(track);
const scopedQueue = queue ? stampTrackServerIds(queue) : queue;
clearAllPlaybackScheduleTimers();
set({ scheduledPauseAtMs: null, scheduledPauseStartMs: null, scheduledResumeAtMs: null, scheduledResumeStartMs: null });
@@ -181,11 +190,11 @@ export function runPlayTrack(
const state = get();
const prevTrack = state.currentTrack;
if (prevTrack?.id !== track.id) {
if (prevTrack?.id !== scopedTrack.id) {
setSeekFallbackTrackId(null);
}
const visualOnEntry = getSeekFallbackVisualTarget();
if (visualOnEntry?.trackId !== track.id) {
if (visualOnEntry?.trackId !== scopedTrack.id) {
setSeekFallbackVisualTarget(null);
}
// Thin-state: only a real queue *replacement* (explicit `queue` arg) rebuilds
@@ -193,10 +202,10 @@ export function runPlayTrack(
// canonical refs and just moves the index — so we never resolve the whole
// queue here (O(visible), not O(queue length)), which would hitch + churn
// every subscriber on each track change at scale.
const replacing = queue !== undefined;
const srcLen = replacing ? queue.length : state.queueItems.length;
if (replacing && shouldBindQueueServerForPlay(state.queueItems, queue, queue)) {
bindQueueServerForPlayback();
const replacing = scopedQueue !== undefined;
const srcLen = replacing ? scopedQueue.length : state.queueItems.length;
if (replacing && shouldBindQueueServerForPlay(state.queueItems, scopedQueue, scopedQueue)) {
bindQueueServerForTracks(scopedQueue);
}
// Prefer an explicit target index from the caller (next/previous/queue-row
// click already know the exact slot). `findIndex` returns the *first*
@@ -204,8 +213,8 @@ export function runPlayTrack(
// track twice — breaking radio playback (issue #500).
const matchesAt = (i: number): boolean =>
replacing
? sameQueueTrackId(queue[i]?.id, track.id)
: sameQueueTrackId(state.queueItems[i]?.trackId, track.id);
? sameQueueTrackId(scopedQueue[i]?.id, scopedTrack.id)
: sameQueueTrackId(state.queueItems[i]?.trackId, scopedTrack.id);
const explicitIdxValid =
typeof targetQueueIndex === 'number'
&& targetQueueIndex >= 0
@@ -214,16 +223,18 @@ export function runPlayTrack(
const idx = explicitIdxValid
? (targetQueueIndex as number)
: replacing
? queue.findIndex(t => sameQueueTrackId(t.id, track.id))
: state.queueItems.findIndex(r => sameQueueTrackId(r.trackId, track.id));
? scopedQueue.findIndex(t => sameQueueTrackId(t.id, scopedTrack.id))
: state.queueItems.findIndex(r => sameQueueTrackId(r.trackId, scopedTrack.id));
const playIdx = idx >= 0 ? idx : 0;
const playingRef = replacing ? undefined : state.queueItems[playIdx];
const prevPlayingRef = replacing ? undefined : state.queueItems[state.queueIndex];
// ±1 neighbours for replaygain normalization — resolve only these (not the
// whole queue). On replace they come from the provided Track[]; on navigation
// from the resolver cache (the bridge keeps that window warm).
const neighbourAt = (i: number): Track | null => {
if (i < 0 || i >= srcLen) return null;
if (replacing) return queue[i] ?? null;
if (i === playIdx) return track;
if (replacing) return scopedQueue[i] ?? null;
if (i === playIdx) return scopedTrack;
const ref = state.queueItems[i];
return ref ? resolveQueueTrack(ref) : null;
};
@@ -232,49 +243,60 @@ export function runPlayTrack(
// Minimal window so deriveNormalizationSnapshot reads ±1 without a full array.
const normWindow: Track[] = prevNeighbour ? [prevNeighbour] : [];
const normIdx = normWindow.length;
normWindow.push(track);
normWindow.push(scopedTrack);
if (nextNeighbour) normWindow.push(nextNeighbour);
if (manual) {
pushQueueUndoFromGetter(get);
}
const visualForInitial = getSeekFallbackVisualTarget();
const pendingVisualTarget = visualForInitial?.trackId === track.id
const pendingVisualTarget = visualForInitial?.trackId === scopedTrack.id
? visualForInitial.seconds
: null;
const initialTime = pendingVisualTarget !== null
? Math.max(0, Math.min(pendingVisualTarget, track.duration || pendingVisualTarget))
? Math.max(0, Math.min(pendingVisualTarget, scopedTrack.duration || pendingVisualTarget))
: 0;
const initialProgress =
track.duration && track.duration > 0 ? Math.max(0, Math.min(1, initialTime / track.duration)) : 0;
scopedTrack.duration && scopedTrack.duration > 0
? Math.max(0, Math.min(1, initialTime / scopedTrack.duration))
: 0;
const authState = useAuthStore.getState();
const playbackProfileId = playbackProfileIdForTrack(scopedTrack, playingRef);
const libraryLocalUrl = playbackProfileId
? findLocalPlaybackUrl(scopedTrack.id, playbackProfileId, 'library')
: null;
// Same-track replay: Rust `fetch_data` consumes `stream_completed_cache` with
// `take()` once; a second replay would full HTTP-range again unless we flush
// RAM to hot disk first (promote was only run when switching to another track).
const needSameTrackHotPromote =
Boolean(
!libraryLocalUrl
&& !(playbackProfileId && hasLocalPersistentPlaybackBytes(scopedTrack.id, playbackProfileId))
&& Boolean(
prevTrack
&& sameQueueTrackId(prevTrack.id, track.id)
&& sameQueueTrackId(prevTrack.id, scopedTrack.id)
&& authState.hotCacheEnabled
&& getPlaybackCacheServerKey(),
);
const runPlayTrackBody = () => {
const authStateNow = useAuthStore.getState();
const playbackSid = getPlaybackServerId();
const playbackCacheSid = getPlaybackCacheServerKey();
const url = resolvePlaybackUrl(track.id, playbackCacheSid);
recordEnginePlayUrl(track.id, url);
const playbackSid = playbackProfileIdForTrack(scopedTrack, playingRef);
const playbackCacheSid = playbackCacheKeyForTrack(scopedTrack, playingRef);
const url = libraryLocalUrl
?? findLocalPlaybackUrl(scopedTrack.id, playbackSid, 'library')
?? findLocalPlaybackUrl(scopedTrack.id, playbackSid, 'favorite-auto')
?? resolvePlaybackUrl(scopedTrack.id, playbackCacheSid);
recordEnginePlayUrl(scopedTrack.id, url);
const preloadedTrackId = get().enginePreloadedTrackId;
const keepPreloadHint = preloadedTrackId === track.id;
const keepPreloadHint = preloadedTrackId === scopedTrack.id;
const playbackSourceHint = playbackSourceHintForResolvedUrl(
track.id,
scopedTrack.id,
playbackCacheSid,
url,
);
if (import.meta.env.DEV) {
console.info('[psysonic][playTrack-source]', {
trackId: track.id,
trackId: scopedTrack.id,
resolvedUrl: url,
preloadedTrackId,
keepPreloadHint,
@@ -288,14 +310,21 @@ export function runPlayTrack(
// When the caller replaced the queue (explicit `queue` arg), seed the
// resolver with those tracks so the UI / hot paths resolve them without a
// network round-trip. No-arg jumps reuse already-cached refs.
if (queue && queueSid) seedQueueResolver(queueSid, queue);
if (scopedQueue) {
for (const t of scopedQueue) {
const sid = playbackCacheKeyForTrack(t);
if (sid) seedQueueResolver(sid, [t]);
}
} else if (queueSid) {
seedQueueResolver(queueSid, [scopedTrack]);
}
set({
currentTrack: track,
currentTrack: scopedTrack,
currentRadio: null,
waveformBins: null,
...deriveNormalizationSnapshot(track, normWindow, normIdx),
...deriveNormalizationSnapshot(scopedTrack, normWindow, normIdx),
// Only a replace rewrites the queue; navigation keeps the canonical refs.
...(replacing ? { queueItems: toQueueItemRefs(queueSid, queue) } : {}),
...(replacing ? { queueItems: toQueueItemRefs(queueSid, scopedQueue) } : {}),
queueIndex: idx >= 0 ? idx : 0,
progress: initialProgress,
buffered: 0,
@@ -307,15 +336,15 @@ export function runPlayTrack(
isPlaying: playbackSourceHint !== 'stream',
isPlaybackBuffering: playbackSourceHint === 'stream',
currentPlaybackSource: playbackSourceHint,
enginePreloadedTrackId: keepPreloadHint ? track.id : null,
enginePreloadedTrackId: keepPreloadHint ? scopedTrack.id : null,
});
if (
prevTrack
&& !sameQueueTrackId(prevTrack.id, track.id)
&& !sameQueueTrackId(prevTrack.id, scopedTrack.id)
&& authStateNow.hotCacheEnabled
) {
const prevPromoteSid = getPlaybackCacheServerKey();
const prevPromoteSid = playbackCacheKeyForTrack(prevTrack, prevPlayingRef);
if (prevPromoteSid) {
void promoteCompletedStreamToHotCache(
prevTrack,
@@ -324,35 +353,35 @@ export function runPlayTrack(
);
}
}
void refreshWaveformForTrack(track.id);
void refreshLoudnessForTrack(track.id);
void refreshWaveformForTrack(scopedTrack.id);
void refreshLoudnessForTrack(scopedTrack.id);
setDeferHotCachePrefetch(true);
const replayGainDb = resolveReplayGainDb(
track, prevTrack, nextNeighbour,
scopedTrack, prevTrack, nextNeighbour,
isReplayGainActive(), authStateNow.replayGainMode,
);
const replayGainPeak = isReplayGainActive() ? (track.replayGainPeak ?? null) : null;
const replayGainPeak = isReplayGainActive() ? (scopedTrack.replayGainPeak ?? null) : null;
invoke('audio_play', {
url,
volume: state.volume,
durationHint: track.duration,
durationHint: scopedTrack.duration,
replayGainDb,
replayGainPeak,
loudnessGainDb: loudnessGainDbForEngineBind(track.id),
loudnessGainDb: loudnessGainDbForEngineBind(scopedTrack.id),
preGainDb: authStateNow.replayGainPreGainDb,
fallbackDb: authStateNow.replayGainFallbackDb,
manual,
hiResEnabled: authStateNow.enableHiRes,
analysisTrackId: track.id,
analysisTrackId: scopedTrack.id,
serverId: getPlaybackIndexKey() || null,
streamFormatSuffix: track.suffix ?? null,
streamFormatSuffix: scopedTrack.suffix ?? null,
})
.then(() => {
if (getPlayGeneration() !== gen) return;
if (keepPreloadHint) {
set({ enginePreloadedTrackId: null });
}
const durSeek = track.duration && track.duration > 0 ? track.duration : null;
const durSeek = scopedTrack.duration && scopedTrack.duration > 0 ? scopedTrack.duration : null;
const seekTo = initialTime;
const canSeekAfterPlay =
seekTo > 0.05 && (durSeek == null || seekTo < durSeek - 0.05);
@@ -361,12 +390,12 @@ export function runPlayTrack(
.then(() => {
if (getPlayGeneration() !== gen) return;
setSeekTarget(seekTo);
if (getSeekFallbackVisualTarget()?.trackId === track.id) {
if (getSeekFallbackVisualTarget()?.trackId === scopedTrack.id) {
setSeekFallbackVisualTarget(null);
}
})
.catch(() => {
if (getSeekFallbackVisualTarget()?.trackId === track.id) {
if (getSeekFallbackVisualTarget()?.trackId === scopedTrack.id) {
setSeekFallbackVisualTarget(null);
}
});
@@ -385,25 +414,25 @@ export function runPlayTrack(
// Report Now Playing to Navidrome (for Live/getNowPlaying) + Last.fm
const { nowPlayingEnabled: npEnabled, scrobblingEnabled: lfmEnabled, lastfmSessionKey: lfmKey } = useAuthStore.getState();
if (npEnabled) reportNowPlaying(track.id, getPlaybackServerId());
if (npEnabled) reportNowPlaying(scopedTrack.id, playbackSid);
if (lfmKey) {
if (lfmEnabled) lastfmUpdateNowPlaying(track, lfmKey);
lastfmGetTrackLoved(track.title, track.artist, lfmKey).then(loved => {
const cacheKey = `${track.title}::${track.artist}`;
if (lfmEnabled) lastfmUpdateNowPlaying(scopedTrack, lfmKey);
lastfmGetTrackLoved(scopedTrack.title, scopedTrack.artist, lfmKey).then(loved => {
const cacheKey = `${scopedTrack.title}::${scopedTrack.artist}`;
set(s => ({
lastfmLoved: loved,
lastfmLovedCache: { ...s.lastfmLovedCache, [cacheKey]: loved },
}));
});
}
syncQueueToServer(get().queueItems, track, initialTime);
touchHotCacheOnPlayback(track.id, playbackCacheSid);
syncQueueToServer(get().queueItems, scopedTrack, initialTime);
touchHotCacheOnPlayback(scopedTrack.id, playbackCacheSid);
};
const hotPromoteSid = getPlaybackCacheServerKey();
if (needSameTrackHotPromote && hotPromoteSid) {
void promoteCompletedStreamToHotCache(
track,
scopedTrack,
hotPromoteSid,
authState.hotCacheDownloadDir || null,
)
+8 -10
View File
@@ -3,8 +3,7 @@
* (a) the module-scoped `lastOpenedWithHttpTrackId` only persists for HTTP
* URLs (offline / hot-cache plays clear it), (b) the rebind check matches
* across the `stream:` / bare id forms, and (c) the source-kind classifier
* picks 'offline' vs 'hot' only when the offline store actually has a local
* URL for the track.
* picks 'offline' vs 'hot' only when the library index has a local file.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -12,12 +11,12 @@ const { offlineStoreState } = vi.hoisted(() => ({
offlineStoreState: { localUrlByKey: new Map<string, string>() },
}));
vi.mock('./offlineStore', () => ({
useOfflineStore: {
getState: () => ({
getLocalUrl: (trackId: string, serverId: string) =>
offlineStoreState.localUrlByKey.get(`${serverId}:${trackId}`) ?? null,
}),
vi.mock('../utils/offline/offlineLibraryHelpers', () => ({
findLocalPlaybackUrl: (trackId: string, serverId: string, tier: string) => {
if (tier !== 'library') return null;
return offlineStoreState.localUrlByKey.has(`${serverId}:${trackId}`)
? `psysonic-local://${serverId}/${trackId}`
: null;
},
}));
@@ -50,7 +49,7 @@ describe('playbackSourceHintForResolvedUrl', () => {
expect(playbackSourceHintForResolvedUrl('t1', 'srv', 'https://mock/srv/t1')).toBe('stream');
});
it("classifies psysonic-local:// as 'offline' when the offline store has the file", () => {
it("classifies psysonic-local:// as 'offline' when the library index has the file", () => {
offlineStoreState.localUrlByKey.set('srv:t1', 'file:///cache/t1.mp3');
expect(playbackSourceHintForResolvedUrl('t1', 'srv', 'psysonic-local://srv/t1')).toBe('offline');
});
@@ -96,7 +95,6 @@ describe('recordEnginePlayUrl + shouldRebindPlaybackToHotCache', () => {
it('returns false when the resolved URL is still HTTP (no hot-cache entry)', () => {
recordEnginePlayUrl('t1', 'https://mock/srv/t1');
// No entry in offlineStoreState → resolvePlaybackUrl mock returns https://...
expect(shouldRebindPlaybackToHotCache('t1', 'srv')).toBe(false);
});
});
+4 -2
View File
@@ -1,6 +1,7 @@
import { findLocalPlaybackUrl } from '../utils/offline/offlineLibraryHelpers';
import { resolvePlaybackUrl, type PlaybackSourceKind } from '../utils/playback/resolvePlaybackUrl';
import { resolveServerIdForIndexKey } from '../utils/server/serverLookup';
import { sameQueueTrackId } from '../utils/playback/queueIdentity';
import { useOfflineStore } from './offlineStore';
/**
* Helpers that classify the URL `audio_play` was last invoked with, so the
@@ -22,7 +23,8 @@ export function recordEnginePlayUrl(trackId: string, url: string): void {
/** Matches `playTrack` / PlayerBar: stream vs hot-cache vs offline file from resolved `audio_play` URL. */
export function playbackSourceHintForResolvedUrl(trackId: string, serverId: string, url: string): PlaybackSourceKind {
if (!url.startsWith('psysonic-local://')) return 'stream';
return useOfflineStore.getState().getLocalUrl(trackId, serverId) ? 'offline' : 'hot';
const profileId = resolveServerIdForIndexKey(serverId) || serverId;
return findLocalPlaybackUrl(trackId, profileId, 'library') ? 'offline' : 'hot';
}
export function shouldRebindPlaybackToHotCache(trackId: string, serverId: string): boolean {
@@ -28,6 +28,9 @@ vi.mock('@/api/subsonicPlayQueue', () => ({
savePlayQueue: vi.fn(async () => undefined),
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
}));
vi.mock('@/utils/network/activeServerReachability', () => ({
isActiveServerReachable: () => true,
}));
vi.mock('@/api/subsonicStreamUrl', () => ({
buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
+2
View File
@@ -27,6 +27,8 @@ export interface Track {
bitDepth?: number;
/** Subsonic `size` in bytes when provided by the server (helps hot-cache budgeting). */
size?: number;
/** Owning server profile id when the queue spans multiple servers (e.g. offline favorites). */
serverId?: string;
autoAdded?: boolean;
radioAdded?: boolean;
/** Inserted via "Play Next". Used by the preserve-order toggle to find the
+82 -62
View File
@@ -1,90 +1,110 @@
/**
* Promote-stream-cache helper: wraps a single Rust IPC and forwards the
* result into the hot-cache store index. Tests pin the payload shape, the
* suffix fallback, the null-result skip, and the swallow-on-error
* contract.
*/
import type { Track } from './playerStoreTypes';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { invokeMock, setEntryMock, buildStreamUrlMock } = vi.hoisted(() => ({
invokeMock: vi.fn(async (_cmd: string, _args?: Record<string, unknown>) => null as { path: string; size: number } | null),
setEntryMock: vi.fn(),
buildStreamUrlMock: vi.fn((id: string) => `https://mock/stream/${id}`),
import type { Track } from './playerStoreTypes';
const invokeMock = vi.fn();
const setEntryMock = vi.fn();
vi.mock('@tauri-apps/api/core', () => ({
invoke: (...args: unknown[]) => invokeMock(...args),
}));
vi.mock('@tauri-apps/api/core', () => ({ invoke: invokeMock }));
vi.mock('../api/subsonicStreamUrl', () => ({
buildStreamUrl: buildStreamUrlMock,
buildStreamUrlForServer: (_serverId: string, id: string) => buildStreamUrlMock(id),
}));
vi.mock('./hotCacheStore', () => ({
useHotCacheStore: { getState: () => ({ setEntry: setEntryMock }) },
useHotCacheStore: {
getState: () => ({ setEntry: setEntryMock }),
},
}));
vi.mock('../utils/media/mediaDir', () => ({
getMediaDir: () => '/media',
}));
vi.mock('../api/subsonicStreamUrl', () => ({
buildStreamUrlForServer: (_sid: string, id: string) => `https://mock/stream/${id}`,
}));
vi.mock('../api/coverCache', () => ({
librarySqlServerId: (k: string) => k,
}));
const hasLocalPersistentPlaybackBytesMock = vi.fn((_trackId: string, _serverId: string) => false);
vi.mock('../utils/offline/offlineLibraryHelpers', () => ({
hasLocalPersistentPlaybackBytes: (trackId: string, serverId: string) =>
hasLocalPersistentPlaybackBytesMock(trackId, serverId),
}));
import { promoteCompletedStreamToHotCache } from './promoteStreamCache';
function track(id: string, overrides: Partial<Track> = {}): Track {
return { id, title: id, artist: 'A', album: 'X', albumId: 'X', duration: 100, ...overrides };
return {
id,
title: 'T',
artist: 'A',
album: 'Al',
albumId: 'al-1',
duration: 100,
...overrides,
};
}
beforeEach(() => {
invokeMock.mockReset();
invokeMock.mockResolvedValue(null);
setEntryMock.mockReset();
buildStreamUrlMock.mockClear();
});
describe('promoteCompletedStreamToHotCache', () => {
beforeEach(() => {
invokeMock.mockReset();
setEntryMock.mockReset();
hasLocalPersistentPlaybackBytesMock.mockReset();
hasLocalPersistentPlaybackBytesMock.mockReturnValue(false);
});
it('skips promote when library or favorites already have bytes', async () => {
hasLocalPersistentPlaybackBytesMock.mockReturnValue(true);
await promoteCompletedStreamToHotCache(track('t1'), 'srv', null);
expect(invokeMock).not.toHaveBeenCalled();
expect(setEntryMock).not.toHaveBeenCalled();
});
it('forwards a complete payload to the Rust command', async () => {
invokeMock.mockResolvedValueOnce({ path: '/cache/t1.mp3', size: 1234 });
await promoteCompletedStreamToHotCache(track('t1', { suffix: 'flac' }), 'srv', '/hot');
expect(invokeMock).toHaveBeenCalledWith('promote_stream_cache_to_hot_cache', {
invokeMock.mockResolvedValueOnce({
path: '/media/cache/t1.mp3',
size: 1234,
layoutFingerprint: 'fp1',
});
await promoteCompletedStreamToHotCache(track('t1', { suffix: 'flac' }), 'srv', null);
expect(invokeMock).toHaveBeenCalledWith('promote_stream_cache_to_local', {
trackId: 't1',
serverId: 'srv',
url: 'https://mock/stream/t1',
serverIndexKey: 'srv',
libraryServerId: 'srv',
url: expect.stringContaining('t1'),
suffix: 'flac',
customDir: '/hot',
mediaDir: '/media',
});
});
it("falls back to suffix='mp3' when the track has no suffix", async () => {
invokeMock.mockResolvedValueOnce({ path: '/cache/t1.mp3', size: 100 });
it('defaults suffix to mp3', async () => {
invokeMock.mockResolvedValueOnce({ path: '/p', size: 1, layoutFingerprint: '' });
await promoteCompletedStreamToHotCache(track('t1'), 'srv', null);
expect(invokeMock.mock.calls[0][1]?.suffix).toBe('mp3');
});
it('passes through customDir=null when the user has no hot-cache dir set', async () => {
invokeMock.mockResolvedValueOnce({ path: '/cache/t1.mp3', size: 100 });
it('stores entry when Rust returns a path', async () => {
invokeMock.mockResolvedValueOnce({
path: '/media/cache/t1.mp3',
size: 5678,
layoutFingerprint: 'fp',
});
await promoteCompletedStreamToHotCache(track('t1'), 'srv', null);
expect(invokeMock.mock.calls[0][1]?.customDir).toBeNull();
expect(setEntryMock).toHaveBeenCalledWith(
't1',
'srv',
'/media/cache/t1.mp3',
5678,
'stream-promote',
'fp',
'mp3',
);
});
it('records the entry in the hot-cache store on a successful path', async () => {
invokeMock.mockResolvedValueOnce({ path: '/cache/t1.mp3', size: 5678 });
await promoteCompletedStreamToHotCache(track('t1'), 'srv', null);
expect(setEntryMock).toHaveBeenCalledWith('t1', 'srv', '/cache/t1.mp3', 5678, 'stream-promote');
});
it('defaults size to 0 when Rust omits it', async () => {
invokeMock.mockResolvedValueOnce({ path: '/cache/t1.mp3', size: 0 });
await promoteCompletedStreamToHotCache(track('t1'), 'srv', null);
expect(setEntryMock.mock.calls[0][3]).toBe(0);
});
it('skips the hot-cache write when Rust returns null', async () => {
invokeMock.mockResolvedValueOnce(null);
await promoteCompletedStreamToHotCache(track('t1'), 'srv', null);
expect(setEntryMock).not.toHaveBeenCalled();
});
it('skips the hot-cache write when path is empty', async () => {
invokeMock.mockResolvedValueOnce({ path: '', size: 100 });
await promoteCompletedStreamToHotCache(track('t1'), 'srv', null);
expect(setEntryMock).not.toHaveBeenCalled();
});
it('swallows Rust errors silently', async () => {
invokeMock.mockRejectedValueOnce(new Error('boom'));
it('swallows invoke errors', async () => {
invokeMock.mockRejectedValueOnce(new Error('fail'));
await expect(promoteCompletedStreamToHotCache(track('t1'), 'srv', null)).resolves.toBeUndefined();
expect(setEntryMock).not.toHaveBeenCalled();
});
+26 -19
View File
@@ -2,36 +2,43 @@ import { buildStreamUrlForServer } from '../api/subsonicStreamUrl';
import type { Track } from './playerStoreTypes';
import { invoke } from '@tauri-apps/api/core';
import { useHotCacheStore } from './hotCacheStore';
import { getMediaDir } from '../utils/media/mediaDir';
import { librarySqlServerId } from '../api/coverCache';
import { hasLocalPersistentPlaybackBytes } from '../utils/offline/offlineLibraryHelpers';
/**
* Promote a track whose stream cache is full to the on-disk hot cache.
* Rust copies the cached bytes into the hot-cache directory and returns
* the resolved path + size; the JS-side `useHotCacheStore` index gets the
* entry tagged `'stream-promote'` so the LRU treats it the same as a
* prefetch hit.
*
* Best-effort: any failure is swallowed because the regular hot-cache
* prefetch path remains a fallback. `customDir` may be null when the user
* hasn't picked a hot-cache directory yet Rust then writes to the
* default location.
* Promote a track whose stream cache is full to the on-disk ephemeral tier.
* Best-effort: prefetch remains fallback.
*/
export async function promoteCompletedStreamToHotCache(
track: Track,
serverId: string,
customDir: string | null,
serverIndexKey: string,
_customDir: string | null,
): Promise<void> {
if (hasLocalPersistentPlaybackBytes(track.id, serverIndexKey)) return;
try {
const res = await invoke<{ path: string; size: number } | null>(
'promote_stream_cache_to_hot_cache',
const libraryServerId = librarySqlServerId(serverIndexKey);
const res = await invoke<{ path: string; size: number; layoutFingerprint: string } | null>(
'promote_stream_cache_to_local',
{
trackId: track.id,
serverId,
url: buildStreamUrlForServer(serverId, track.id),
serverIndexKey,
libraryServerId,
url: buildStreamUrlForServer(serverIndexKey, track.id),
suffix: track.suffix || 'mp3',
customDir,
mediaDir: getMediaDir(),
},
);
if (!res || !res.path) return;
useHotCacheStore.getState().setEntry(track.id, serverId, res.path, res.size || 0, 'stream-promote');
if (!res?.path) return;
useHotCacheStore.getState().setEntry(
track.id,
serverIndexKey,
res.path,
res.size || 0,
'stream-promote',
res.layoutFingerprint,
track.suffix || 'mp3',
);
} catch {
// best-effort promotion; normal hot-cache prefetch remains fallback
}
+7 -19
View File
@@ -19,28 +19,17 @@ import {
import { clearSeekDebounce } from './seekDebounce';
import { clearSeekFallbackRetry } from './seekFallbackState';
import { clearSeekTarget } from './seekTargetState';
import i18n from '../i18n';
import { playListenSessionFinalize } from './playListenSession';
import {
clearQueueServerForPlayback,
ensureQueueServerPinned,
playbackServerDiffersFromActive,
} from '../utils/playback/playbackServer';
import { useLuckyMixStore } from './luckyMixStore';
import { showToast } from '../utils/ui/toast';
type SetState = (
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
) => void;
type GetState = () => PlayerState;
function blockCrossServerEnqueue(): boolean {
if (useLuckyMixStore.getState().isRolling) return false;
if (!playbackServerDiffersFromActive()) return false;
showToast(i18n.t('queue.crossServerEnqueueBlocked'), 4500, 'error');
return true;
}
/**
* The canonical working ref list for a mutation (thin-state). Mutations
* splice/filter/reorder a copy of these refs and write the result back into
@@ -53,8 +42,10 @@ const itemsOf = (state: PlayerState): QueueItemRef[] => [...state.queueItems];
* without a network round-trip once `queue: Track[]` is dropped (seed-before-
* splice). No-op without a real playback server (e.g. unit tests). */
function seedIncoming(state: PlayerState, tracks: Track[]): void {
const serverId = state.queueServerId ?? '';
if (serverId) seedQueueResolver(serverId, tracks);
for (const t of tracks) {
const serverId = t.serverId ?? state.queueServerId ?? '';
if (serverId) seedQueueResolver(serverId, [t]);
}
}
/**
@@ -80,7 +71,6 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
> {
return {
enqueue: (tracks, _orbitConfirmed = false, skipQueueUndo = false) => {
if (blockCrossServerEnqueue()) return;
if (!_orbitConfirmed && tracks.length > 1) {
void orbitBulkGuard(tracks.length).then(ok => {
if (ok) get().enqueue(tracks, true, skipQueueUndo);
@@ -88,7 +78,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
return;
}
if (!skipQueueUndo) pushQueueUndoFromGetter(get);
ensureQueueServerPinned();
ensureQueueServerPinned(tracks);
set(state => {
seedIncoming(state, tracks);
const items = itemsOf(state);
@@ -164,7 +154,6 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
},
enqueueAt: (tracks, insertIndex, _orbitConfirmed = false) => {
if (blockCrossServerEnqueue()) return;
if (!_orbitConfirmed && tracks.length > 1) {
void orbitBulkGuard(tracks.length).then(ok => {
if (ok) get().enqueueAt(tracks, insertIndex, true);
@@ -172,7 +161,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
return;
}
pushQueueUndoFromGetter(get);
ensureQueueServerPinned();
ensureQueueServerPinned(tracks);
set(state => {
seedIncoming(state, tracks);
const items = itemsOf(state);
@@ -190,8 +179,7 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
playNext: (tracks) => {
if (tracks.length === 0) return;
if (blockCrossServerEnqueue()) return;
ensureQueueServerPinned();
ensureQueueServerPinned(tracks);
const state = get();
const tagged = tracks.map(t => ({ ...t, playNextAdded: true as const }));
if (!state.currentTrack) {
+13 -1
View File
@@ -7,8 +7,9 @@
*/
import type { QueueItemRef, Track } from './playerStoreTypes';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { savePlayQueueMock, playerState, progressSnapshot } = vi.hoisted(() => ({
const { savePlayQueueMock, playerState, progressSnapshot, isActiveServerReachableMock } = vi.hoisted(() => ({
savePlayQueueMock: vi.fn(async (_ids: string[], _currentId: string | undefined, _pos: number, _serverId: string) => undefined),
isActiveServerReachableMock: vi.fn(() => true),
playerState: {
queueItems: [] as QueueItemRef[],
currentTrack: null as Track | null,
@@ -18,6 +19,9 @@ const { savePlayQueueMock, playerState, progressSnapshot } = vi.hoisted(() => ({
}));
vi.mock('../api/subsonicPlayQueue', () => ({ savePlayQueue: savePlayQueueMock }));
vi.mock('../utils/network/activeServerReachability', () => ({
isActiveServerReachable: () => isActiveServerReachableMock(),
}));
vi.mock('../utils/playback/playbackServer', () => ({
getPlaybackServerId: () => 'srv-a',
}));
@@ -48,6 +52,7 @@ function ref(id: string): QueueItemRef {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-12T12:00:00Z'));
isActiveServerReachableMock.mockReturnValue(true);
savePlayQueueMock.mockClear();
savePlayQueueMock.mockResolvedValue(undefined);
playerState.queueItems = [];
@@ -64,6 +69,13 @@ afterEach(() => {
describe('syncQueueToServer (debounced)', () => {
const queue = [ref('a'), ref('b')];
it('skips sync while the active server is unreachable', () => {
isActiveServerReachableMock.mockReturnValue(false);
syncQueueToServer(queue, track('a'), 30);
vi.advanceTimersByTime(5000);
expect(savePlayQueueMock).not.toHaveBeenCalled();
});
it('does not fire before 5 s elapse', () => {
syncQueueToServer(queue, track('a'), 30);
vi.advanceTimersByTime(4999);
+8 -4
View File
@@ -1,5 +1,6 @@
import { savePlayQueue } from '../api/subsonicPlayQueue';
import type { QueueItemRef, Track } from './playerStoreTypes';
import { isActiveServerReachable } from '../utils/network/activeServerReachability';
import { getPlaybackServerId } from '../utils/playback/playbackServer';
import { getPlaybackProgressSnapshot } from './playbackProgress';
import { usePlayerStore } from './playerStore';
@@ -27,14 +28,16 @@ let syncTimeout: ReturnType<typeof setTimeout> | null = null;
let lastQueueHeartbeatAt = 0;
export function syncQueueToServer(queue: QueueItemRef[], currentTrack: Track | null, currentTime: number): void {
if (!isActiveServerReachable()) return;
if (syncTimeout) clearTimeout(syncTimeout);
syncTimeout = setTimeout(() => {
syncTimeout = null;
if (!isActiveServerReachable()) return;
const ids = queue.slice(0, QUEUE_ID_LIMIT).map(r => r.trackId);
const pos = Math.floor(currentTime * 1000);
const serverId = getPlaybackServerId();
savePlayQueue(ids, currentTrack?.id, pos, serverId).catch(err => {
console.error('Failed to sync play queue to server', err);
savePlayQueue(ids, currentTrack?.id, pos, serverId).catch(() => {
// Expected when offline or the playback server is unreachable.
});
}, SYNC_DEBOUNCE_MS);
}
@@ -44,13 +47,14 @@ export function flushQueueSyncToServer(queue: QueueItemRef[], currentTrack: Trac
clearTimeout(syncTimeout);
syncTimeout = null;
}
if (!isActiveServerReachable()) return Promise.resolve();
if (!currentTrack || queue.length === 0) return Promise.resolve();
lastQueueHeartbeatAt = Date.now();
const ids = queue.slice(0, QUEUE_ID_LIMIT).map(r => r.trackId);
const pos = Math.floor(currentTime * 1000);
const serverId = getPlaybackServerId();
return savePlayQueue(ids, currentTrack.id, pos, serverId).catch(err => {
console.error('Failed to flush play queue to server', err);
return savePlayQueue(ids, currentTrack.id, pos, serverId).catch(() => {
// Expected when offline or the playback server is unreachable.
});
}
+5 -5
View File
@@ -2,9 +2,9 @@ import type { Track } from './playerStoreTypes';
import { invoke } from '@tauri-apps/api/core';
import { setDeferHotCachePrefetch } from '../utils/cache/hotCacheGate';
import {
getPlaybackCacheServerKey,
getPlaybackIndexKey,
getPlaybackServerId,
playbackCacheKeyForTrack,
playbackProfileIdForTrack,
} from '../utils/playback/playbackServer';
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb';
@@ -43,9 +43,9 @@ export function queueUndoRestoreAudioEngine(opts: {
isReplayGainActive(), authState.replayGainMode,
);
const replayGainPeak = isReplayGainActive() ? (track.replayGainPeak ?? null) : null;
const playbackSid = getPlaybackServerId();
const playbackCacheSid = getPlaybackCacheServerKey();
const playbackIndexKey = getPlaybackIndexKey();
const playbackSid = playbackProfileIdForTrack(track);
const playbackCacheSid = playbackCacheKeyForTrack(track);
const playbackIndexKey = playbackCacheKeyForTrack(track) || getPlaybackIndexKey();
const url = resolvePlaybackUrl(track.id, playbackCacheSid);
recordEnginePlayUrl(track.id, url);
usePlayerStore.setState({