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
+98 -12
View File
@@ -1,7 +1,20 @@
import { useEffect, useState } from 'react';
import { getAlbum } from '../api/subsonicLibrary';
import { getArtist } from '../api/subsonicArtists';
import { useSearchParams } from 'react-router-dom';
import { getAlbum, getAlbumForServer } from '../api/subsonicLibrary';
import { getArtist, getArtistForServer } from '../api/subsonicArtists';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { useAuthStore } from '../store/authStore';
import { useConnectionStatus } from './useConnectionStatus';
import {
loadAlbumFromLibraryIndex,
loadArtistFromLibraryIndex,
resolveAlbumForServer,
} from '../utils/offline/favoritesOfflineBrowse';
import { readDetailServerId } from '../utils/navigation/detailServerScope';
import {
shouldAttemptSubsonicForActiveServer,
shouldAttemptSubsonicForServer,
} from '../utils/network/subsonicNetworkGuard';
type AlbumPayload = Awaited<ReturnType<typeof getAlbum>>;
@@ -20,12 +33,6 @@ interface UseAlbumDetailDataResult {
* Load an album payload by id, then resolve the artist's other albums in
* a follow-up call so the related-albums grid can render without blocking
* the initial paint.
*
* On every id change we reset `relatedAlbums` to an empty array so the
* grid doesn't briefly show the previous album's neighbours while the
* new fetch is in flight. The two starred state pieces (`isStarred`,
* `starredSongs`) are seeded from the response so optimistic toggles
* have a baseline to revert to.
*/
export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataResult {
const [album, setAlbum] = useState<AlbumPayload | null>(null);
@@ -33,26 +40,105 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
const [loading, setLoading] = useState(true);
const [isStarred, setIsStarred] = useState(false);
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled);
const activeServerId = useAuthStore(s => s.activeServerId);
const [searchParams] = useSearchParams();
const detailServerId = readDetailServerId(searchParams, activeServerId);
const { status: connStatus } = useConnectionStatus();
useEffect(() => {
if (!id) return;
setLoading(true);
setRelatedAlbums([]);
getAlbum(id).then(async data => {
const applyAlbumPayload = (data: AlbumPayload) => {
setAlbum(data);
setIsStarred(!!data.album.starred);
const initialStarred = new Set<string>();
data.songs.forEach(s => { if (s.starred) initialStarred.add(s.id); });
setStarredSongs(initialStarred);
setLoading(false);
};
const loadRelatedAlbums = async (
serverId: string | null,
artistId: string | undefined,
useLocalArtist: boolean,
) => {
if (!artistId) return;
try {
const artistData = await getArtist(data.album.artistId);
if (useLocalArtist && serverId) {
const artistLocal = await loadArtistFromLibraryIndex(serverId, artistId);
if (artistLocal) {
setRelatedAlbums(artistLocal.albums.filter(a => a.id !== id));
return;
}
}
const relatedServerId = serverId ?? detailServerId ?? activeServerId;
if (!relatedServerId || !shouldAttemptSubsonicForServer(relatedServerId)) return;
const artistData = detailServerId
? await getArtistForServer(detailServerId, artistId)
: await getArtist(artistId);
setRelatedAlbums(artistData.albums.filter(a => a.id !== id));
} catch (e) {
console.error('Failed to fetch related albums', e);
}
}).catch(() => setLoading(false));
}, [id]);
};
const libraryFirst = favoritesOfflineEnabled && !!detailServerId;
void (async () => {
if (libraryFirst && detailServerId) {
try {
const local = await resolveAlbumForServer(detailServerId, id);
if (local) {
applyAlbumPayload(local);
await loadRelatedAlbums(detailServerId, local.album.artistId, true);
return;
}
} catch { /* fall through */ }
}
const detailNetworkAllowed = detailServerId
? shouldAttemptSubsonicForServer(detailServerId)
: shouldAttemptSubsonicForActiveServer();
if (!detailNetworkAllowed) {
if (favoritesOfflineEnabled && detailServerId) {
try {
const local = await loadAlbumFromLibraryIndex(detailServerId, id);
if (local) {
applyAlbumPayload(local);
await loadRelatedAlbums(detailServerId, local.album.artistId, true);
return;
}
} catch { /* ignore */ }
}
setLoading(false);
return;
}
try {
const data = detailServerId
? await getAlbumForServer(detailServerId, id)
: await getAlbum(id);
applyAlbumPayload(data);
await loadRelatedAlbums(detailServerId, data.album.artistId, false);
} catch {
if (favoritesOfflineEnabled && detailServerId) {
try {
const local = await loadAlbumFromLibraryIndex(detailServerId, id);
if (local) {
applyAlbumPayload(local);
await loadRelatedAlbums(detailServerId, local.album.artistId, true);
return;
}
} catch { /* ignore */ }
}
setLoading(false);
}
})();
}, [id, connStatus, favoritesOfflineEnabled, detailServerId, searchParams]);
return { album, setAlbum, relatedAlbums, loading, isStarred, setIsStarred, starredSongs, setStarredSongs };
}
+52
View File
@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useOfflineJobStore } from '../store/offlineJobStore';
import { useAlbumOfflineState } from './useAlbumOfflineState';
describe('useAlbumOfflineState', () => {
beforeEach(() => {
useOfflineJobStore.setState({ jobs: [], pinQueue: [], bulkProgress: {} });
});
it('reports queued when the album waits in the pin queue', () => {
useOfflineJobStore.setState({
pinQueue: [{
albumId: 'alb-1',
albumName: 'One',
pinKind: 'album',
status: 'queued',
queuedAt: Date.now(),
}],
});
const { result } = renderHook(() => useAlbumOfflineState('alb-1', 'srv', ['t1']));
expect(result.current.resolvedOfflineStatus).toBe('queued');
expect(result.current.offlineProgress).toBeNull();
});
it('prefers downloading over queued when jobs are active', () => {
useOfflineJobStore.setState({
pinQueue: [{
albumId: 'alb-1',
albumName: 'One',
pinKind: 'album',
status: 'downloading',
queuedAt: Date.now(),
}],
jobs: [{
trackId: 't1',
albumId: 'alb-1',
albumName: 'One',
trackTitle: 'Track',
trackIndex: 0,
totalTracks: 1,
status: 'downloading',
downloadId: 'dl-1',
}],
});
const { result } = renderHook(() => useAlbumOfflineState('alb-1', 'srv', ['t1']));
expect(result.current.resolvedOfflineStatus).toBe('downloading');
expect(result.current.offlineProgress).toEqual({ done: 0, total: 1 });
});
});
+36 -18
View File
@@ -1,8 +1,11 @@
import { useOfflineStore } from '../store/offlineStore';
import { useLocalPlaybackStore } from '../store/localPlaybackStore';
import { useOfflineJobStore } from '../store/offlineJobStore';
import { isOfflinePinComplete } from '../utils/offline/offlineLibraryHelpers';
export type AlbumOfflineStatus = 'none' | 'queued' | 'downloading' | 'cached';
interface UseAlbumOfflineStateResult {
resolvedOfflineStatus: 'none' | 'downloading' | 'cached';
resolvedOfflineStatus: AlbumOfflineStatus;
offlineProgress: { done: number; total: number } | null;
}
@@ -14,36 +17,51 @@ interface UseAlbumOfflineStateResult {
* downloads (each track flip would otherwise trigger a full page render).
*
* Resolution rules:
* - If there's any queued / downloading job for this album, status is
* `downloading` and we expose a `{ done, total }` progress tuple.
* - Else we look at the persisted cache map: a fully-cached album is
* one where every trackId in the album-meta has a matching track entry.
* - Fully pinned → `cached`.
* - Active pin jobs or pin-queue `downloading` → `downloading` + progress.
* - Pin-queue `queued` (waiting behind another album) → `queued`.
* - Else `none`.
*
* `albumId` is allowed to be empty (e.g. while the page is still
* fetching) — in that case every selector short-circuits to a benign
* default.
*/
export function useAlbumOfflineState(albumId: string, serverId: string): UseAlbumOfflineStateResult {
const offlineStatus = useOfflineStore((s): 'none' | 'downloading' | 'cached' => {
if (!albumId) return 'none';
const meta = s.albums[`${serverId}:${albumId}`];
const isDownloaded = meta && meta.trackIds.length > 0 && meta.trackIds.every(tid => !!s.tracks[`${serverId}:${tid}`]);
return isDownloaded ? 'cached' : 'none';
});
export function useAlbumOfflineState(
albumId: string,
serverId: string,
songIds?: string[],
): UseAlbumOfflineStateResult {
useLocalPlaybackStore(s => s.entries);
const pinComplete = !!albumId && isOfflinePinComplete(albumId, serverId, songIds);
const isPinQueued = useOfflineJobStore(s =>
!pinComplete
&& !!albumId
&& s.pinQueue.some(p => p.albumId === albumId && p.status === 'queued'),
);
const isOfflineDownloading = useOfflineJobStore(s =>
!!albumId && s.jobs.some(j => j.albumId === albumId && (j.status === 'queued' || j.status === 'downloading')),
!pinComplete
&& !!albumId
&& (
s.pinQueue.some(p => p.albumId === albumId && p.status === 'downloading')
|| s.jobs.some(j => j.albumId === albumId && (j.status === 'queued' || j.status === 'downloading'))
),
);
const offlineProgressDone = useOfflineJobStore(s => {
if (!albumId) return 0;
if (!albumId || pinComplete) return 0;
return s.jobs.filter(j => j.albumId === albumId && (j.status === 'done' || j.status === 'error')).length;
});
const offlineProgressTotal = useOfflineJobStore(s => {
if (!albumId) return 0;
if (!albumId || pinComplete) return 0;
return s.jobs.filter(j => j.albumId === albumId).length;
});
const resolvedOfflineStatus = isOfflineDownloading ? 'downloading' : offlineStatus;
const offlineProgress = offlineProgressTotal > 0
const resolvedOfflineStatus = pinComplete
? 'cached'
: isOfflineDownloading
? 'downloading'
: isPinQueued
? 'queued'
: 'none';
const offlineProgress = isOfflineDownloading && offlineProgressTotal > 0
? { done: offlineProgressDone, total: offlineProgressTotal }
: null;
+9 -3
View File
@@ -10,6 +10,8 @@
* otherwise persist into IndexedDB.
*/
import { renderHook, act, waitFor } from '@testing-library/react';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { SubsonicArtistInfo } from '../api/subsonicTypes';
@@ -33,6 +35,10 @@ afterEach(() => {
vi.clearAllMocks();
});
function routerWrapper({ children }: { children: React.ReactNode }) {
return React.createElement(MemoryRouter, null, children);
}
function deferred<T>() {
let resolve!: (v: T) => void;
let reject!: (e?: unknown) => void;
@@ -55,7 +61,7 @@ describe('useArtistDetailData — id-gated info', () => {
const { result, rerender } = renderHook(
({ id }: { id: string }) => useArtistDetailData(id),
{ initialProps: { id: 'A' } },
{ initialProps: { id: 'A' }, wrapper: routerWrapper },
);
await act(async () => { a.resolve({ largeImageUrl: 'A.jpg' } as SubsonicArtistInfo); });
@@ -95,7 +101,7 @@ describe('useArtistDetailData — id-gated info', () => {
],
} as any);
const { result } = renderHook(() => useArtistDetailData('A'));
const { result } = renderHook(() => useArtistDetailData('A'), { wrapper: routerWrapper });
await waitFor(() => expect(result.current.featuredAlbums).toHaveLength(2));
const structured = result.current.featuredAlbums.find(a => a.id === 'comp1');
@@ -117,7 +123,7 @@ describe('useArtistDetailData — id-gated info', () => {
const { result, rerender } = renderHook(
({ id }: { id: string }) => useArtistDetailData(id),
{ initialProps: { id: 'A' } },
{ initialProps: { id: 'A' }, wrapper: routerWrapper },
);
rerender({ id: 'B' });
+52 -10
View File
@@ -1,10 +1,14 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { search } from '../api/subsonicSearch';
import { getArtist, getArtistInfo, getTopSongs } from '../api/subsonicArtists';
import { getArtist, getArtistForServer, getArtistInfo, getTopSongs } from '../api/subsonicArtists';
import type {
SubsonicAlbum, SubsonicArtist, SubsonicArtistInfo, SubsonicSong,
} from '../api/subsonicTypes';
import { useAuthStore } from '../store/authStore';
import { useConnectionStatus } from './useConnectionStatus';
import { loadArtistFromLibraryIndex } from '../utils/offline/favoritesOfflineBrowse';
import { readDetailServerId } from '../utils/navigation/detailServerScope';
import { runLocalArtistLosslessBrowse } from '../utils/library/browseTextSearch';
import { isLosslessSuffix } from '../utils/library/losslessFormats';
@@ -45,11 +49,18 @@ export function useArtistDetailData(
options: UseArtistDetailDataOptions = {},
): ArtistDetailDataResult {
const losslessOnly = options.losslessOnly ?? false;
const serverId = useAuthStore(s => s.activeServerId);
const activeServerId = useAuthStore(s => s.activeServerId);
const [searchParams] = useSearchParams();
const serverId = readDetailServerId(searchParams, activeServerId);
const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled);
const { status: connStatus } = useConnectionStatus();
const audiomuseNavidromeEnabled = useAuthStore(
s => !!(s.activeServerId && s.audiomuseNavidromeByServer[s.activeServerId]),
s => !!(serverId && s.audiomuseNavidromeByServer[serverId]),
);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const preferLocalArtist = connStatus === 'disconnected'
&& favoritesOfflineEnabled
&& !!serverId;
const [artist, setArtist] = useState<SubsonicArtist | null>(null);
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
@@ -71,11 +82,26 @@ export function useArtistDetailData(
(async () => {
try {
if (preferLocalArtist && serverId && id) {
const local = await loadArtistFromLibraryIndex(serverId, id);
if (cancelled) return;
if (local) {
setArtist(local.artist);
setIsStarred(!!local.artist.starred);
setAlbums(local.albums);
setTopSongs([]);
setLoading(false);
return;
}
}
if (losslessOnly && serverId) {
const local = await runLocalArtistLosslessBrowse(serverId, id);
if (cancelled) return;
if (local) {
const artistData = await getArtist(id).catch(() => null);
const artistData = serverId
? await getArtistForServer(serverId, id).catch(() => null)
: await getArtist(id).catch(() => null);
if (cancelled) return;
if (artistData) {
setArtist(artistData.artist);
@@ -88,7 +114,9 @@ export function useArtistDetailData(
}
}
const artistData = await getArtist(id);
const artistData = serverId
? await getArtistForServer(serverId, id)
: await getArtist(id);
if (cancelled) return;
setArtist(artistData.artist);
let nextAlbums = artistData.albums;
@@ -105,6 +133,20 @@ export function useArtistDetailData(
setTopSongs(nextSongs);
} catch (err) {
if (!cancelled) {
if (preferLocalArtist && serverId && id) {
try {
const local = await loadArtistFromLibraryIndex(serverId, id);
if (cancelled) return;
if (local) {
setArtist(local.artist);
setIsStarred(!!local.artist.starred);
setAlbums(local.albums);
setTopSongs([]);
setLoading(false);
return;
}
} catch { /* ignore */ }
}
console.error(err);
setLoading(false);
}
@@ -112,10 +154,10 @@ export function useArtistDetailData(
})();
return () => { cancelled = true; };
}, [id, losslessOnly, serverId]);
}, [id, losslessOnly, serverId, preferLocalArtist, searchParams]);
useEffect(() => {
if (!id) return;
if (!id || preferLocalArtist) return;
let cancelled = false;
setArtistInfoLoading(true);
getArtistInfo(id, { similarArtistCount: audiomuseNavidromeEnabled ? 24 : undefined })
@@ -129,10 +171,10 @@ export function useArtistDetailData(
if (!cancelled) setArtistInfoLoading(false);
});
return () => { cancelled = true; };
}, [id, audiomuseNavidromeEnabled]);
}, [id, audiomuseNavidromeEnabled, preferLocalArtist]);
useEffect(() => {
if (!id || !artist) return;
if (!id || !artist || preferLocalArtist) return;
const ownAlbumIds = new Set(albums.map(a => a.id));
setFeaturedLoading(true);
search(artist.name, { songCount: 500, artistCount: 0, albumCount: 0 })
@@ -172,7 +214,7 @@ export function useArtistDetailData(
setFeaturedLoading(false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [artist?.id, musicLibraryFilterVersion, losslessOnly, albums]);
}, [artist?.id, musicLibraryFilterVersion, losslessOnly, albums, preferLocalArtist]);
const info = infoEntry && infoEntry.id === id ? infoEntry.value : null;
+63
View File
@@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useLocalPlaybackStore } from '../store/localPlaybackStore';
import { useOfflineJobStore } from '../store/offlineJobStore';
import { useArtistOfflineState } from './useArtistOfflineState';
describe('useArtistOfflineState', () => {
beforeEach(() => {
useOfflineJobStore.setState({ jobs: [], pinQueue: [], bulkProgress: {} });
useLocalPlaybackStore.setState({ entries: {} });
});
it('reports cached when every album is pinned', () => {
useLocalPlaybackStore.setState({
entries: {
'srv:al-1': {
serverIndexKey: 'srv',
trackId: 't1',
localPath: '/x',
layoutFingerprint: 'fp',
sizeBytes: 1,
tier: 'library',
cachedAt: 1,
suffix: 'mp3',
pinSource: { kind: 'artist', sourceId: 'al-1' },
},
'srv:al-2': {
serverIndexKey: 'srv',
trackId: 't2',
localPath: '/y',
layoutFingerprint: 'fp',
sizeBytes: 1,
tier: 'library',
cachedAt: 1,
suffix: 'mp3',
pinSource: { kind: 'artist', sourceId: 'al-2' },
},
},
});
const { result } = renderHook(() =>
useArtistOfflineState('artist-1', 'srv', ['al-1', 'al-2']),
);
expect(result.current.status).toBe('cached');
});
it('reports queued when bulk progress is active but albums only wait in pin queue', () => {
useOfflineJobStore.setState({
bulkProgress: { 'artist-1': { done: 0, total: 2 } },
pinQueue: [
{ albumId: 'al-1', albumName: 'One', pinKind: 'artist', status: 'queued', queuedAt: 1 },
{ albumId: 'al-2', albumName: 'Two', pinKind: 'artist', status: 'queued', queuedAt: 2 },
],
jobs: [],
});
const { result } = renderHook(() =>
useArtistOfflineState('artist-1', 'srv', ['al-1', 'al-2']),
);
expect(result.current.status).toBe('queued');
expect(result.current.progress).toEqual({ done: 0, total: 2 });
});
});
+56
View File
@@ -0,0 +1,56 @@
import { useLocalPlaybackStore } from '../store/localPlaybackStore';
import { useOfflineJobStore } from '../store/offlineJobStore';
import { isOfflinePinComplete } from '../utils/offline/offlineLibraryHelpers';
export type ArtistOfflineStatus = 'none' | 'queued' | 'downloading' | 'cached';
interface UseArtistOfflineStateResult {
status: ArtistOfflineStatus;
progress: { done: number; total: number } | null;
}
/**
* Offline discography status for an artist page. Uses persisted library pins
* (not ephemeral bulkProgress) so "Discography cached" survives navigation.
*/
export function useArtistOfflineState(
artistId: string,
serverId: string,
albumIds: string[],
): UseArtistOfflineStateResult {
useLocalPlaybackStore(s => s.entries);
const allPinned = albumIds.length > 0
&& albumIds.every(id => isOfflinePinComplete(id, serverId));
const bulkDone = useOfflineJobStore(s => (artistId ? s.bulkProgress[artistId]?.done : undefined));
const bulkTotal = useOfflineJobStore(s => (artistId ? s.bulkProgress[artistId]?.total : undefined));
const hasQueuedAlbums = useOfflineJobStore(s =>
albumIds.length > 0
&& albumIds.some(id => s.pinQueue.some(p => p.albumId === id && p.status === 'queued')),
);
const hasDownloadingAlbums = useOfflineJobStore(s =>
albumIds.length > 0
&& albumIds.some(id =>
s.pinQueue.some(p => p.albumId === id && p.status === 'downloading')
|| s.jobs.some(j => j.albumId === id && (j.status === 'queued' || j.status === 'downloading')),
),
);
const bulkActive = bulkTotal !== undefined && bulkDone !== undefined && bulkDone < bulkTotal;
const waitingInQueue = bulkActive && hasQueuedAlbums && !hasDownloadingAlbums;
const status: ArtistOfflineStatus = allPinned
? 'cached'
: hasDownloadingAlbums || (bulkActive && !waitingInQueue)
? 'downloading'
: waitingInQueue
? 'queued'
: 'none';
const progress = bulkActive && bulkDone !== undefined && bulkTotal !== undefined
? { done: bulkDone, total: bulkTotal }
: null;
return { status, progress };
}
+9 -1
View File
@@ -8,6 +8,7 @@ import {
isLanUrl,
type ServerEndpointKind,
} from '../utils/server/serverEndpoint';
import { setActiveServerReachable } from '../utils/network/activeServerReachability';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
// Backward-compatible re-export for call sites that still import from the hook.
@@ -29,11 +30,13 @@ export function useConnectionStatus() {
const check = useCallback(async () => {
const server = useAuthStore.getState().getActiveServer();
if (!server) {
setActiveServerReachable(false);
setStatus('disconnected');
return;
}
if (!navigator.onLine) {
setActiveServerReachable(false);
setStatus('disconnected');
return;
}
@@ -58,6 +61,7 @@ export function useConnectionStatus() {
} else {
setActiveEndpointKind(null);
}
setActiveServerReachable(probe.ok);
setStatus(probe.ok ? 'connected' : 'disconnected');
}, []);
@@ -77,6 +81,7 @@ export function useConnectionStatus() {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
setActiveServerReachable(true);
setStatus('connected');
return;
}
@@ -90,7 +95,10 @@ export function useConnectionStatus() {
if (sid) invalidateReachableEndpointCache(sid);
check();
};
const handleOffline = () => setStatus('disconnected');
const handleOffline = () => {
setActiveServerReachable(false);
setStatus('disconnected');
};
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
+66 -17
View File
@@ -7,6 +7,12 @@ import type {
import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
import type { TopFavoriteArtist } from '../components/favorites/TopFavoriteArtists';
import { useConnectionStatus } from './useConnectionStatus';
import { isActiveServerReachable } from '../utils/network/activeServerReachability';
import {
loadStarredFromAllLibraryIndexes,
loadStarredFromAllServersOnline,
} from '../utils/offline/favoritesOfflineBrowse';
export interface FavoritesDataResult {
albums: SubsonicAlbum[];
@@ -20,6 +26,12 @@ export interface FavoritesDataResult {
unfavoriteStation: (id: string) => void;
}
function topArtistKey(song: SubsonicSong): string {
const artistKey = song.artistId || song.artist;
if (!artistKey) return '';
return song.serverId ? `${song.serverId}:${artistKey}` : artistKey;
}
export function useFavoritesData(): FavoritesDataResult {
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [artists, setArtists] = useState<SubsonicArtist[]>([]);
@@ -28,39 +40,74 @@ export function useFavoritesData(): FavoritesDataResult {
const [loading, setLoading] = useState(true);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled);
const servers = useAuthStore(s => s.servers);
const { status: connStatus } = useConnectionStatus();
const starredOverrides = usePlayerStore(s => s.starredOverrides);
useEffect(() => {
const loadAll = async () => {
const [starredResult] = await Promise.allSettled([
getStarred(),
]);
if (starredResult.status === 'fulfilled') {
setAlbums(starredResult.value.albums);
setArtists(starredResult.value.artists);
setSongs(starredResult.value.songs);
}
let cancelled = false;
// Radio favorites: read IDs from localStorage, fetch all stations, filter
const applyStarred = (starred: {
albums: SubsonicAlbum[];
artists: SubsonicArtist[];
songs: SubsonicSong[];
}) => {
if (cancelled) return;
setAlbums(starred.albums);
setArtists(starred.artists);
setSongs(starred.songs);
};
const loadRadioFavorites = async () => {
if (!isActiveServerReachable()) return;
try {
const favIds = new Set<string>(JSON.parse(localStorage.getItem('psysonic_radio_favorites') ?? '[]'));
if (favIds.size > 0) {
const all = await getInternetRadioStations();
if (favIds.size === 0) return;
const all = await getInternetRadioStations();
if (!cancelled) {
setRadioStations(all.filter(s => favIds.has(s.id)));
}
} catch { /* ignore */ }
setLoading(false);
};
loadAll();
}, [musicLibraryFilterVersion]);
const loadAll = async () => {
setLoading(true);
if (favoritesOfflineEnabled) {
try {
applyStarred(await loadStarredFromAllLibraryIndexes());
} catch { /* ignore */ }
if (!cancelled) setLoading(false);
if (connStatus === 'connected' && isActiveServerReachable()) {
try {
applyStarred(await loadStarredFromAllServersOnline());
} catch { /* keep library snapshot */ }
}
} else {
if (connStatus === 'connected' && isActiveServerReachable()) {
const [starredResult] = await Promise.allSettled([getStarred()]);
if (starredResult.status === 'fulfilled') {
applyStarred(starredResult.value);
}
}
if (!cancelled) setLoading(false);
}
void loadRadioFavorites();
};
void loadAll();
return () => { cancelled = true; };
}, [musicLibraryFilterVersion, connStatus, favoritesOfflineEnabled, servers]);
// ── Top Favorite Artists aggregated from favorited songs ─────────────
const topFavoriteArtists = useMemo<TopFavoriteArtist[]>(() => {
const counts = new Map<string, TopFavoriteArtist>();
for (const s of songs) {
if (starredOverrides[s.id] === false) continue;
const key = s.artistId || s.artist;
const key = topArtistKey(s);
if (!key) continue;
const existing = counts.get(key);
if (existing) {
@@ -71,6 +118,8 @@ export function useFavoritesData(): FavoritesDataResult {
name: s.artist || key,
count: 1,
coverArtId: s.artistId || '',
serverId: s.serverId,
artistId: s.artistId || s.artist,
});
}
}
+87
View File
@@ -0,0 +1,87 @@
import { useMemo } from 'react';
import { useAuthStore } from '../store/authStore';
import { useFavoritesOfflineSyncStore } from '../store/favoritesOfflineSyncStore';
import { useLocalPlaybackStore } from '../store/localPlaybackStore';
import { useOfflineJobStore } from '../store/offlineJobStore';
import { FAVORITES_OFFLINE_JOB_ID } from '../utils/offline/favoritesOfflineConstants';
import { entryBelongsToServer } from '../utils/offline/offlineLibraryHelpers';
export type FavoritesOfflineUiStatus =
| 'disabled'
| 'syncing'
| 'complete'
| 'partial'
| 'error'
| 'idle';
export type FavoritesOfflineSemaphore = 'red' | 'yellow' | 'green';
export interface FavoritesOfflineStatusResult {
enabled: boolean;
status: FavoritesOfflineUiStatus;
semaphore: FavoritesOfflineSemaphore | null;
savedCount: number;
targetCount: number;
jobDone: number;
jobTotal: number;
}
export function useFavoritesOfflineStatus(): FavoritesOfflineStatusResult {
const enabled = useAuthStore(s => s.favoritesOfflineEnabled);
const serverId = useAuthStore(s => s.activeServerId);
const entries = useLocalPlaybackStore(s => s.entries);
const running = useFavoritesOfflineSyncStore(s => s.running);
const lastError = useFavoritesOfflineSyncStore(s => s.lastError);
const targetTrackIds = useFavoritesOfflineSyncStore(s => s.targetTrackIds);
const jobs = useOfflineJobStore(s => s.jobs);
return useMemo(() => {
if (!enabled) {
return {
enabled: false,
status: 'disabled' as const,
semaphore: null,
savedCount: 0,
targetCount: 0,
jobDone: 0,
jobTotal: 0,
};
}
const favJobs = jobs.filter(j => j.albumId === FAVORITES_OFFLINE_JOB_ID);
const jobDone = favJobs.filter(j => j.status === 'done').length;
const jobTotal = favJobs.length;
const hasActiveJobs = favJobs.some(j => j.status === 'downloading' || j.status === 'queued');
const hasJobErrors = favJobs.some(j => j.status === 'error');
const savedCount = serverId
? Object.values(entries).filter(
e => e.tier === 'favorite-auto' && entryBelongsToServer(e, serverId),
).length
: 0;
const targetCount = targetTrackIds.length;
let status: FavoritesOfflineUiStatus = 'idle';
if (running || hasActiveJobs) {
status = 'syncing';
} else if (lastError || hasJobErrors) {
status = 'error';
} else if (targetCount > 0 && savedCount >= targetCount) {
status = 'complete';
} else if (savedCount > 0 && targetCount > 0 && savedCount < targetCount) {
status = 'partial';
} else if (savedCount > 0) {
status = 'complete';
}
let semaphore: FavoritesOfflineSemaphore = 'green';
if (lastError || hasJobErrors) {
semaphore = 'red';
} else if (running || hasActiveJobs || (targetCount > 0 && savedCount < targetCount)) {
semaphore = 'yellow';
}
return { enabled, status, semaphore, savedCount, targetCount, jobDone, jobTotal };
}, [enabled, serverId, entries, running, lastError, targetTrackIds, jobs]);
}
+8 -4
View File
@@ -78,11 +78,15 @@ export function useFavoritesSongFiltering(deps: FavoritesSongFilteringDeps): Fav
// Remove unfavorited
if (starredOverrides[s.id] === false) return false;
// Artist filter
// Artist filter (composite key when favorites span servers)
if (selectedArtist) {
const artistMatch = s.artistId === selectedArtist ||
s.artist === selectedArtist ||
s.albumArtist === selectedArtist;
const compositeKey = s.serverId
? `${s.serverId}:${s.artistId || s.artist}`
: (s.artistId || s.artist);
const artistMatch = selectedArtist === compositeKey
|| s.artistId === selectedArtist
|| s.artist === selectedArtist
|| s.albumArtist === selectedArtist;
if (!artistMatch) return false;
}
+3
View File
@@ -17,6 +17,9 @@ vi.mock('../api/subsonicArtists');
vi.mock('../api/subsonicLibrary');
vi.mock('../api/bandsintown');
vi.mock('../api/lastfm');
vi.mock('../utils/network/subsonicNetworkGuard', () => ({
shouldAttemptSubsonicForServer: () => true,
}));
import { getArtistForServer, getArtistInfoForServer, getTopSongsForServer } from '../api/subsonicArtists';
import { getAlbumForServer, getSongForServer } from '../api/subsonicLibrary';
+18 -10
View File
@@ -8,6 +8,8 @@ import {
type LastfmArtistStats, type LastfmTrackInfo,
} from '../api/lastfm';
import { makeCache } from '../utils/cache/nowPlayingCache';
import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard';
import { useConnectionStatus } from './useConnectionStatus';
// Module-level TTL caches (shared across mounts)
const songMetaCache = makeCache<SubsonicSong | null>();
@@ -79,6 +81,7 @@ export async function prewarmNowPlayingFetchers(
} = deps;
if (!fetchEnabled || !subsonicServerId) return;
if (!shouldAttemptSubsonicForServer(subsonicServerId, songId)) return;
const jobs: Array<Promise<unknown>> = [];
@@ -196,9 +199,14 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF
const [lfmArtistEntry, setLfmArtistEntry] = useState<KeySlot<LastfmArtistStats | null>>(() =>
seedKeySlot(lfmArtistKey, k => lfmArtistCache.get(k)));
const { status: connStatus } = useConnectionStatus();
const subsonicFetchAllowed = fetchEnabled
&& !!subsonicServerId
&& shouldAttemptSubsonicForServer(subsonicServerId, songId);
// Fetch batch per entity change (not per song switch — same-artist songs share artist/top/tour fetches)
useEffect(() => {
if (!fetchEnabled || !subsonicServerId || !songId) { setSongMetaEntry(null); return; }
if (!subsonicFetchAllowed || !songId) { setSongMetaEntry(null); return; }
const cacheKey = subsonicCacheKey(subsonicServerId, songId);
const cached = songMetaCache.get(cacheKey);
if (cached !== undefined) { setSongMetaEntry({ id: songId, value: cached }); return; }
@@ -208,10 +216,10 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF
.then(v => { if (!cancelled) { songMetaCache.set(cacheKey, v ?? null); setSongMetaEntry({ id: songId, value: v ?? null }); } })
.catch(() => { if (!cancelled) { songMetaCache.set(cacheKey, null); setSongMetaEntry({ id: songId, value: null }); } });
return () => { cancelled = true; };
}, [fetchEnabled, subsonicServerId, songId]);
}, [subsonicFetchAllowed, subsonicServerId, songId, connStatus]);
useEffect(() => {
if (!fetchEnabled || !subsonicServerId || !artistId) { setArtistInfoEntry(null); return; }
if (!subsonicFetchAllowed || !artistId) { setArtistInfoEntry(null); return; }
const cacheKey = subsonicCacheKey(subsonicServerId, artistId);
const cached = artistInfoCache.get(cacheKey);
if (cached !== undefined) { setArtistInfoEntry({ id: artistId, value: cached }); return; }
@@ -221,10 +229,10 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF
.then(v => { if (!cancelled) { artistInfoCache.set(cacheKey, v ?? null); setArtistInfoEntry({ id: artistId, value: v ?? null }); } })
.catch(() => { if (!cancelled) { artistInfoCache.set(cacheKey, null); setArtistInfoEntry({ id: artistId, value: null }); } });
return () => { cancelled = true; };
}, [fetchEnabled, subsonicServerId, artistId, audiomuseNavidromeEnabled]);
}, [subsonicFetchAllowed, subsonicServerId, artistId, audiomuseNavidromeEnabled, connStatus]);
useEffect(() => {
if (!fetchEnabled || !subsonicServerId || !albumId) { setAlbumDataEntry(null); return; }
if (!subsonicFetchAllowed || !albumId) { setAlbumDataEntry(null); return; }
const cacheKey = subsonicCacheKey(subsonicServerId, albumId);
const cached = albumCache.get(cacheKey);
if (cached !== undefined) { setAlbumDataEntry({ id: albumId, value: cached }); return; }
@@ -234,10 +242,10 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF
.then(v => { if (!cancelled) { albumCache.set(cacheKey, v); setAlbumDataEntry({ id: albumId, value: v }); } })
.catch(() => { if (!cancelled) { albumCache.set(cacheKey, null); setAlbumDataEntry({ id: albumId, value: null }); } });
return () => { cancelled = true; };
}, [fetchEnabled, subsonicServerId, albumId]);
}, [subsonicFetchAllowed, subsonicServerId, albumId, connStatus]);
useEffect(() => {
if (!fetchEnabled || !topSongsKey) { setTopSongsEntry(null); return; }
if (!subsonicFetchAllowed || !topSongsKey) { setTopSongsEntry(null); return; }
const cached = topSongsCache.get(topSongsKey);
if (cached !== undefined) { setTopSongsEntry({ key: topSongsKey, value: cached }); return; }
setTopSongsEntry(null);
@@ -246,7 +254,7 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF
.then(v => { if (!cancelled) { topSongsCache.set(topSongsKey, v); setTopSongsEntry({ key: topSongsKey, value: v }); } })
.catch(() => { if (!cancelled) { topSongsCache.set(topSongsKey, []); setTopSongsEntry({ key: topSongsKey, value: [] }); } });
return () => { cancelled = true; };
}, [fetchEnabled, topSongsKey, subsonicServerId, artistName]);
}, [subsonicFetchAllowed, topSongsKey, subsonicServerId, artistName, connStatus]);
useEffect(() => {
if (!tourKey) { setTourEventsEntry(null); setTourLoading(false); return; }
@@ -263,7 +271,7 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF
// Discography via getArtist
useEffect(() => {
if (!fetchEnabled || !subsonicServerId || !artistId) { setDiscographyEntry(null); return; }
if (!subsonicFetchAllowed || !artistId) { setDiscographyEntry(null); return; }
const cacheKey = subsonicCacheKey(subsonicServerId, artistId);
const cached = discographyCache.get(cacheKey);
if (cached !== undefined) { setDiscographyEntry({ id: artistId, value: cached }); return; }
@@ -273,7 +281,7 @@ export function useNowPlayingFetchers(deps: NowPlayingFetchersDeps): NowPlayingF
.then(v => { if (!cancelled) { discographyCache.set(cacheKey, v.albums); setDiscographyEntry({ id: artistId, value: v.albums }); } })
.catch(() => { if (!cancelled) { discographyCache.set(cacheKey, []); setDiscographyEntry({ id: artistId, value: [] }); } });
return () => { cancelled = true; };
}, [fetchEnabled, subsonicServerId, artistId]);
}, [subsonicFetchAllowed, subsonicServerId, artistId, connStatus]);
// Last.fm track info (per-track)
useEffect(() => {
+2 -1
View File
@@ -13,6 +13,7 @@ import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
import { usePlaybackServerId } from './usePlaybackServerId';
import { primaryTrackArtistRef } from '../utils/playback/trackArtistRefs';
import { shouldAttemptSubsonicForServer } from '../utils/network/subsonicNetworkGuard';
const NOW_PLAYING_COVER_CSS_PX = 800;
@@ -64,7 +65,7 @@ export function useNowPlayingPrewarm(): void {
lastfmUsername,
currentTrack,
subsonicServerId: playbackServerId,
fetchEnabled: true,
fetchEnabled: shouldAttemptSubsonicForServer(playbackServerId, currentTrack.id),
});
if (currentTrack.albumId && currentTrack.id) {
+3 -2
View File
@@ -1,13 +1,14 @@
import { useCallback, useEffect, useState } from 'react';
import { queueSongStar } from '../store/pendingStarSync';
import type { SubsonicSong } from '../api/subsonicTypes';
import type { Track } from '../store/playerStoreTypes';
import {
lastfmLoveTrack, lastfmUnloveTrack,
type LastfmTrackInfo,
} from '../api/lastfm';
export interface NowPlayingStarLoveDeps {
currentTrack: { id: string; title: string; artist: string } | null;
currentTrack: Pick<Track, 'id' | 'title' | 'artist' | 'serverId'> | null;
songMeta: SubsonicSong | null;
lfmTrack: LastfmTrackInfo | null;
lfmLoveEnabled: boolean;
@@ -31,7 +32,7 @@ export function useNowPlayingStarLove(deps: NowPlayingStarLoveDeps): NowPlayingS
if (!currentTrack) return;
const next = !starred;
setStarred(next); // local view; helper owns the override + retried server sync (no rollback)
queueSongStar(currentTrack.id, next);
queueSongStar(currentTrack.id, next, currentTrack.serverId);
}, [currentTrack, starred]);
// Last.fm love (seeded from track.getInfo, toggle via love/unlove)
+24 -8
View File
@@ -4,10 +4,11 @@ import type { NavigateFunction } from 'react-router-dom';
type ConnStatus = 'connected' | 'disconnected' | 'connecting' | 'unknown';
/**
* Auto-route the user between the offline library and main pages based on
* Auto-route the user between offline-capable pages and main pages based on
* connection status:
* - Disconnect with cached content → push `/offline`.
* - Reconnect while sitting on `/offline` → push back to `/`.
* - Disconnect with manual offline pins → push `/offline`.
* - Disconnect with favorites offline browse enabled → push `/favorites`.
* - Reconnect while sitting on `/offline` or `/favorites` → push back to `/`.
*
* Only fires on transitions (not on every render). Reconnect-bounce is
* gated on `prev === 'disconnected'` so a user who navigates to `/offline`
@@ -15,7 +16,8 @@ type ConnStatus = 'connected' | 'disconnected' | 'connecting' | 'unknown';
*/
export function useOfflineAutoNav(
connStatus: ConnStatus | string,
hasOfflineContent: boolean,
hasManualOfflineContent: boolean,
favoritesOfflineBrowse: boolean,
pathname: string,
navigate: NavigateFunction,
): void {
@@ -24,11 +26,25 @@ export function useOfflineAutoNav(
const prev = prevConnStatus.current;
prevConnStatus.current = connStatus;
if (connStatus === 'disconnected' && hasOfflineContent && prev !== 'disconnected') {
navigate('/offline', { replace: true });
if (connStatus === 'disconnected' && prev !== 'disconnected') {
if (hasManualOfflineContent) {
navigate('/offline', { replace: true });
} else if (favoritesOfflineBrowse) {
navigate('/favorites', { replace: true });
}
}
if (connStatus === 'connected' && prev === 'disconnected' && pathname === '/offline') {
if (
connStatus === 'connected'
&& prev === 'disconnected'
&& (pathname === '/offline' || pathname === '/favorites')
) {
navigate('/', { replace: true });
}
}, [connStatus, hasOfflineContent, pathname, navigate]);
}, [
connStatus,
hasManualOfflineContent,
favoritesOfflineBrowse,
pathname,
navigate,
]);
}
+17
View File
@@ -31,6 +31,23 @@ describe('usePlaybackServerId', () => {
expect(result.current).toBe('a');
});
it('updates when the playing slot moves in a mixed-server queue', () => {
useAuthStore.setState({ activeServerId: 'a' });
usePlayerStore.setState({
queueItems: [
{ serverId: 'a.test', trackId: 't1' },
{ serverId: 'b.test', trackId: 't2' },
],
queueServerId: 'a.test',
queueIndex: 0,
});
const { result, rerender } = renderHook(() => usePlaybackServerId());
expect(result.current).toBe('a');
usePlayerStore.setState({ queueIndex: 1 });
rerender();
expect(result.current).toBe('b');
});
it('does not call switchActiveServer when browsed server changes', async () => {
const { switchActiveServer } = await import('../utils/server/switchActiveServer');
vi.mocked(switchActiveServer).mockClear();
+5 -2
View File
@@ -9,10 +9,13 @@ import { getPlaybackServerId } from '../utils/playback/playbackServer';
*/
export function usePlaybackServerId(): string {
const queueServerId = usePlayerStore(s => s.queueServerId);
const queueLength = usePlayerStore(s => s.queueItems.length);
const queueIndex = usePlayerStore(s => s.queueIndex);
const playingServerId = usePlayerStore(
s => s.queueItems[s.queueIndex]?.serverId ?? '',
);
const activeServerId = useAuthStore(s => s.activeServerId);
return useMemo(
() => getPlaybackServerId(),
[queueServerId, queueLength, activeServerId],
[queueServerId, queueIndex, playingServerId, activeServerId],
);
}
+1 -1
View File
@@ -34,7 +34,7 @@ export function usePlaylistStarRating(deps: PlaylistStarRatingDeps): PlaylistSta
return next;
});
// F4: optimistic override + retried server sync via the central helper (no rollback).
queueSongStar(song.id, !isStarred);
queueSongStar(song.id, !isStarred, song.serverId);
};
return { handleRate, handleToggleStar };
+2 -1
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getAlbumList } from '../api/subsonicLibrary';
import { isActiveServerReachable } from '../utils/network/activeServerReachability';
import {
NEW_RELEASES_RESET_DELAY_MS,
NEW_RELEASES_SEEN_MAX_IDS,
@@ -62,7 +63,7 @@ export function useSidebarNewReleasesUnread({ serverId, filterId, isLoggedIn, pa
const seq = ++newReleasesRefreshSeqRef.current;
const isCurrent = () => seq === newReleasesRefreshSeqRef.current;
if (!isLoggedIn || !serverId) {
if (!isLoggedIn || !serverId || !isActiveServerReachable()) {
if (isCurrent()) setNewReleasesUnreadCount(0);
return;
}