feat(offline): local-bytes browse when server is unreachable (#1017)

* feat(offline): local-bytes browse for artists and albums

Make Artists, All Albums, and artist/album detail pages work offline
from the library index limited to on-disk library and favorite-auto
tracks. Add a DEV header toggle to simulate offline browse for testing.

* feat(offline): reactive DEV offline toggle with full disconnect simulation

Subscribe nav and browse/detail hooks to useOfflineBrowseActive so UI
refreshes on toggle. DEV force-offline now blocks server probes, reports
disconnected status, and gates Subsonic like real offline for player parity.

* feat(offline): bytes-first favorites when offline browse is active

Load Favorites from local playback bytes, filter starred tracks client-side,
and restrict album-level star queries to local album ids. Drop interim perf
attempts (lean SQL, progressive load, connection singleton, prefetch UX).

* feat(offline): tracks, help, player stats; suspend library picker offline

- Offline browse for Tracks hub from local bytes; sidebar nav for tracks/help/statistics
- Statistics redirects to player-stats offline; server/Last.fm tabs skip network fetches
- Hide music-library picker offline; save filter and restore on reconnect (all libraries while disconnected)
- Unified isOfflineSidebarNavAllowed for library + system entries

* feat(offline): fork disconnect navigation by offline browse capability

When the server drops: stay on the page if nothing is browsable offline;
reload in place on offline-capable routes; otherwise redirect to All Albums
instead of the old /offline or /favorites bounce.

* feat(offline): browse cached playlists when the server is down

List and open manually pinned regular playlists from local library-tier
bytes offline, with sidebar/nav routing and read-only playlist UI.

* feat(offline): read-only artist detail and local play-all paths

Hide favorites and discography offline actions when browse is offline;
load Play All, Shuffle, and top-track continuation from local album bytes.

* feat(offline): read-only album detail and enqueue from local bytes

Hide favorites, download, and cache-offline actions on album pages when
offline browse is active. Favorites album cards enqueue via the same
resolveAlbumForServer path as play, including local playback bytes.

* chore: remove unused import in AlbumCard after enqueue refactor

* feat(offline): unify browse integration contract across the app

Add useOfflineBrowseContext, offlineMediaResolve, and offlineActionPolicy;
wire shell nav to a single capability source; migrate play/enqueue and
context-menu paths off raw getAlbum; replace readOnly with action policy
on detail surfaces. Tests updated for the media-resolve facade.

* feat(offline): close browse contract gaps and fix offline Home feed

Split offline browse modules, align favorites capability across servers,
wire action policy on context menus, migrate hooks to useOfflineBrowseContext,
and preserve stale Home feed cache when offline so the UI does not empty.

* fix(offline): block playbar stars, close audit gaps, trim dead exports

Hide star rating and favorite in PlayerBar when offline browse is active via
offlineActionPolicy playerBar surface. Wire stay-reload token into browse
hooks, migrate hooks to context.active, guard rating prefetch network calls,
and route playlist load through resolvePlaylist.

* docs: add CHANGELOG and credits for offline browse PR #1017

* fix(offline): stop DEV connection probe regression in tests

React to devForceOffline transitions only in useConnectionStatus so mount
does not double-fire check() or ignore disableBackgroundPolling. Add
pingWithCredentials to PlayerBar test mock and DEV-toggle unit tests.
This commit is contained in:
cucadmuh
2026-06-07 15:59:41 +03:00
committed by GitHub
parent f4e1086131
commit fc34a0ec59
105 changed files with 3675 additions and 999 deletions
+7
View File
@@ -46,6 +46,8 @@ import LosslessModeBanner from '../components/LosslessModeBanner';
import { isLosslessSuffix } from '../utils/library/losslessFormats';
import { isLosslessMode } from '../utils/library/losslessMode';
import { readDetailServerId } from '../utils/navigation/detailServerScope';
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
import { offlineActionPolicy } from '../utils/offline/offlineActionPolicy';
export default function AlbumDetail() {
const { t } = useTranslation();
@@ -76,6 +78,8 @@ export default function AlbumDetail() {
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
const albumEntityRatingSupport = entityRatingSupportByServer[serverId] ?? 'unknown';
const offlineCtx = useOfflineBrowseContext();
const albumActionPolicy = offlineActionPolicy('albumDetail', offlineCtx.active);
const [albumEntityRating, setAlbumEntityRating] = useState(0);
const [filterText, setFilterText] = useState('');
@@ -369,6 +373,7 @@ const handleShuffleAll = () => {
entityRatingValue={albumEntityRating}
onEntityRatingChange={handleAlbumEntityRating}
entityRatingSupport={albumEntityRatingSupport}
actionPolicy={albumActionPolicy}
/>
{losslessOnly && <LosslessModeBanner />}
@@ -381,6 +386,7 @@ const handleShuffleAll = () => {
showPlPicker={showPlPicker}
setShowPlPicker={setShowPlPicker}
t={t}
actionPolicy={albumActionPolicy}
/>
)}
@@ -402,6 +408,7 @@ const handleShuffleAll = () => {
sortKey={sortKey}
sortDir={sortDir}
onSort={handleSort}
actionPolicy={albumActionPolicy}
/>
{relatedAlbums.length > 0 && (
+7 -4
View File
@@ -1,5 +1,5 @@
import { buildDownloadUrl } from '../api/subsonicStreamUrl';
import { getAlbum } from '../api/subsonicLibrary';
import { resolveAlbum } from '../utils/offline/offlineMediaResolve';
import { songToTrack } from '../utils/playback/songToTrack';
import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from 'react';
import AlbumCard from '../components/AlbumCard';
@@ -233,8 +233,10 @@ export default function Albums() {
const handleEnqueueSelected = async () => {
if (selectedAlbums.length === 0) return;
try {
// Parallel — Navidrome handles concurrent getAlbum requests fine.
const results = await Promise.all(selectedAlbums.map(a => getAlbum(a.id).catch(() => null)));
// Parallel album resolves — Navidrome handles concurrent requests fine.
const results = await Promise.all(
selectedAlbums.map(a => resolveAlbum(serverId, a.id).catch(() => null)),
);
const tracks = results.flatMap(r => r ? r.songs.map(songToTrack) : []);
if (tracks.length > 0) {
enqueue(tracks);
@@ -277,7 +279,8 @@ export default function Albums() {
let queued = 0;
for (const album of selectedAlbums) {
try {
const detail = await getAlbum(album.id);
const detail = await resolveAlbum(serverId, album.id);
if (!detail) throw new Error('album unavailable');
downloadAlbum(album.id, album.name, album.artist, album.coverArt, album.year, detail.songs, serverId);
queued++;
} catch {
+13 -6
View File
@@ -2,7 +2,6 @@ import { uploadArtistImage } from '../api/subsonicPlaylists';
import { useCoverArt } from '../cover/useCoverArt';
import { useArtistCoverRef } from '../cover/useLibraryCoverRef';
import { setRating, star, unstar } from '../api/subsonicStarRating';
import { getAlbum } from '../api/subsonicLibrary';
import type { SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import { useEffect, useState, useRef, Fragment, useMemo } from 'react';
@@ -31,8 +30,11 @@ import {
import { useArtistDetailData } from '../hooks/useArtistDetailData';
import { useArtistSimilarArtists } from '../hooks/useArtistSimilarArtists';
import {
fetchArtistDetailTracks,
runArtistDetailPlayAll, runArtistDetailShuffle, runArtistDetailStartRadio,
} from '../utils/componentHelpers/runArtistDetailPlay';
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
import { offlineActionPolicy } from '../utils/offline/offlineActionPolicy';
import {
runArtistEntityRating, runArtistToggleStar, runArtistShare, runArtistImageUpload,
} from '../utils/componentHelpers/runArtistDetailActions';
@@ -101,6 +103,8 @@ export default function ArtistDetail() {
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
const artistEntityRatingSupport = entityRatingSupportByServer[activeServerId] ?? 'unknown';
const offlineCtx = useOfflineBrowseContext();
const artistActionPolicy = offlineActionPolicy('artistDetail', offlineCtx.active);
const [artistEntityRating, setArtistEntityRating] = useState(0);
@@ -122,8 +126,12 @@ export default function ArtistDetail() {
const toggleStar = () => runArtistToggleStar({ artist, isStarred, setIsStarred });
const handlePlayAll = () => runArtistDetailPlayAll({ albums, setPlayAllLoading, playTrack });
const handleShuffle = () => runArtistDetailShuffle({ albums, setPlayAllLoading, playTrack });
const handlePlayAll = () => runArtistDetailPlayAll({
albums, serverId: activeServerId, setPlayAllLoading, playTrack,
});
const handleShuffle = () => runArtistDetailShuffle({
albums, serverId: activeServerId, setPlayAllLoading, playTrack,
});
const handleStartRadio = () => {
if (!artist) return;
return runArtistDetailStartRadio({ artist, t, setRadioLoading, playTrack, enqueue });
@@ -139,9 +147,7 @@ export default function ArtistDetail() {
setPlayAllLoading(true);
try {
// Get all artist tracks ordered by album and track number
const results = await Promise.all(albums.map(a => getAlbum(a.id)));
const sorted = [...results].sort((a, b) => (a.album.year ?? 0) - (b.album.year ?? 0));
const allTracks = sorted.flatMap(r => [...r.songs].sort((a, b) => (a.track ?? 0) - (b.track ?? 0))).map(songToTrack);
const allTracks = await fetchArtistDetailTracks(albums, activeServerId);
// Top songs from clicked index onward
const topTracksFromIndex = topSongs.slice(startIndex).map(songToTrack);
@@ -314,6 +320,7 @@ export default function ArtistDetail() {
coverRevision={coverRevision}
headerCoverFailed={headerCoverFailed}
setHeaderCoverFailed={setHeaderCoverFailed}
actionPolicy={artistActionPolicy}
/>
{losslessOnly && <LosslessModeBanner />}
+46 -13
View File
@@ -22,10 +22,16 @@ import { useLibraryCoverPrefetch } from '../cover/useLibraryCoverPrefetch';
import { primeAlbumCoversForDisplay, warmHomeMainstageCovers } from '../cover/warmDiskPeek';
import { readBecauseYouLikeCache } from '../store/becauseYouLikeCache';
import {
isHomeFeedSnapshotEmpty,
readHomeFeedCache,
readHomeFeedCacheStale,
writeHomeFeedCache,
type HomeFeedSnapshot,
} from '../store/homeFeedCache';
import { useConnectionStatus } from '../hooks/useConnectionStatus';
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
import { useOfflineBrowseReloadToken } from '../hooks/useOfflineBrowseReloadToken';
import { useDevOfflineBrowseStore } from '../store/devOfflineBrowseStore';
/** Match Random Albums overshoot when mix filter uses album/artist axes so hero + discover row can still fill. */
const HOME_RANDOM_FETCH = 100;
@@ -52,7 +58,8 @@ const HOME_ARTWORK_VISIBLE_ROW_BUDGET_WHEN_ENABLED = 8;
function getInitialHomeFeed(): HomeFeedSnapshot | null {
const { activeServerId, musicLibraryFilterVersion } = useAuthStore.getState();
if (!activeServerId) return null;
return readHomeFeedCache(activeServerId, musicLibraryFilterVersion);
return readHomeFeedCache(activeServerId, musicLibraryFilterVersion)
?? readHomeFeedCacheStale(activeServerId);
}
export default function Home() {
@@ -63,6 +70,10 @@ export default function Home() {
const homeSections = useHomeStore(s => s.sections);
const activeServerId = useAuthStore(s => s.activeServerId);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const connStatus = useConnectionStatus().status;
const devForceOffline = useDevOfflineBrowseStore(s => s.forceOffline);
const offlineBrowseActive = useOfflineBrowseContext().active;
const offlineBrowseReloadTs = useOfflineBrowseReloadToken();
// Mix-rating deps intentionally NOT subscribed here — they change during Zustand
// rehydration and would trigger a second useEffect fire right after the first,
// showing the cached home feed briefly and then replacing it (~500 ms later)
@@ -119,7 +130,6 @@ export default function Home() {
useEffect(() => {
if (!activeServerId) return;
let cancelled = false;
const fetchFreshHomeFeed = async (): Promise<HomeFeedSnapshot | null> => {
const mixCfg = getMixMinRatingsConfigFromAuth();
const albumMix =
@@ -154,7 +164,8 @@ export default function Home() {
};
};
const cached = readHomeFeedCache(activeServerId, musicLibraryFilterVersion);
const cached = readHomeFeedCache(activeServerId, musicLibraryFilterVersion)
?? (offlineBrowseActive ? readHomeFeedCacheStale(activeServerId) : null);
if (cached) {
// When lazy initializers already pre-populated state from this same
// snapshot, re-applying it would only create new array references and
@@ -168,27 +179,37 @@ export default function Home() {
});
// Keep the current visit visually stable, but prepare fresh data so the
// next re-enter opens with a newer snapshot immediately.
void (async () => {
try {
const fresh = await fetchFreshHomeFeed();
if (!fresh || cancelled) return;
writeHomeFeedCache(fresh);
void warmHomeMainstageCovers(fresh);
} catch {
/* ignore */
}
})();
if (!offlineBrowseActive) {
void (async () => {
try {
const fresh = await fetchFreshHomeFeed();
if (!fresh || cancelled || isHomeFeedSnapshotEmpty(fresh)) return;
writeHomeFeedCache(fresh);
void warmHomeMainstageCovers(fresh);
} catch {
/* ignore */
}
})();
}
return () => {
cancelled = true;
};
}
const stale = offlineBrowseActive ? readHomeFeedCacheStale(activeServerId) : null;
if (stale) {
applyFeedSnapshot(stale);
setLoading(false);
return () => { cancelled = true; };
}
setLoading(true);
(async () => {
try {
const snap = await fetchFreshHomeFeed();
if (!snap) return;
if (cancelled) return;
if (offlineBrowseActive && isHomeFeedSnapshotEmpty(snap)) return;
writeHomeFeedCache(snap);
applyFeedSnapshot(snap);
if (!cancelled) setLoading(false);
@@ -208,8 +229,20 @@ export default function Home() {
activeServerId,
musicLibraryFilterVersion,
homeSections,
offlineBrowseActive,
offlineBrowseReloadTs,
]); // eslint-disable-line react-hooks/exhaustive-deps
/** When offline toggles without a library-filter bump, re-apply stale cache if the feed was cleared. */
useEffect(() => {
if (!activeServerId || !offlineBrowseActive) return;
const stale = readHomeFeedCacheStale(activeServerId);
if (!stale || isHomeFeedSnapshotEmpty(stale)) return;
if (recent.length > 0 || random.length > 0 || heroAlbums.length > 0) return;
applyFeedSnapshot(stale);
setLoading(false);
}, [activeServerId, connStatus, devForceOffline, offlineBrowseActive]); // eslint-disable-line react-hooks/exhaustive-deps
const loadMore = async (
type: 'starred' | 'newest' | 'random' | 'frequent' | 'recent',
currentList: SubsonicAlbum[],
+6 -3
View File
@@ -1,5 +1,5 @@
import { buildDownloadUrl } from '../api/subsonicStreamUrl';
import { getAlbum } from '../api/subsonicLibrary';
import { resolveAlbum } from '../utils/offline/offlineMediaResolve';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -232,7 +232,9 @@ export default function LosslessAlbums() {
const handleEnqueueSelected = async () => {
if (selectedAlbums.length === 0) return;
try {
const results = await Promise.all(selectedAlbums.map(a => getAlbum(a.id).catch(() => null)));
const results = await Promise.all(
selectedAlbums.map(a => resolveAlbum(serverId, a.id).catch(() => null)),
);
const tracks = results.flatMap(r => r ? r.songs.map(songToTrack) : []);
if (tracks.length > 0) {
enqueue(tracks);
@@ -248,7 +250,8 @@ export default function LosslessAlbums() {
let queued = 0;
for (const album of selectedAlbums) {
try {
const detail = await getAlbum(album.id);
const detail = await resolveAlbum(serverId, album.id);
if (!detail) throw new Error('album unavailable');
downloadAlbum(album.id, album.name, album.artist, album.coverArt, album.year, detail.songs, serverId);
queued++;
} catch {
+7 -3
View File
@@ -1,4 +1,5 @@
import { getAlbumList, getAlbum } from '../api/subsonicLibrary';
import { getAlbumList } from '../api/subsonicLibrary';
import { resolveAlbum } from '../utils/offline/offlineMediaResolve';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import React, { useEffect, useState, useCallback, useMemo } from 'react';
@@ -87,17 +88,20 @@ export default function MostPlayed() {
const { t } = useTranslation();
const navigate = useNavigate();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const activeServerId = useAuthStore(s => s.activeServerId);
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const enqueue = usePlayerStore(s => s.enqueue);
const handleEnqueueAlbum = useCallback(async (albumId: string) => {
if (!activeServerId) return;
try {
const data = await getAlbum(albumId);
const data = await resolveAlbum(activeServerId, albumId);
if (!data) return;
enqueue(data.songs.map(songToTrack));
} catch {
// Network failure — silent (toast would be too noisy for a hover action).
}
}, [enqueue]);
}, [activeServerId, enqueue]);
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
+4 -2
View File
@@ -1,6 +1,7 @@
import { buildDownloadUrl } from '../api/subsonicStreamUrl';
import { getAlbumsByGenre } from '../api/subsonicGenres';
import { getAlbumList, getAlbum } from '../api/subsonicLibrary';
import { getAlbumList } from '../api/subsonicLibrary';
import { resolveAlbum } from '../utils/offline/offlineMediaResolve';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { dedupeById } from '../utils/dedupeById';
import { useEffect, useLayoutEffect, useState, useCallback, useRef, useMemo } from 'react';
@@ -157,7 +158,8 @@ export default function NewReleases() {
let queued = 0;
for (const album of selectedAlbums) {
try {
const detail = await getAlbum(album.id);
const detail = await resolveAlbum(serverId, album.id);
if (!detail) throw new Error('album unavailable');
downloadAlbum(album.id, album.name, album.artist, album.coverArt, album.year, detail.songs, serverId);
queued++;
} catch {
+6 -1
View File
@@ -54,6 +54,8 @@ import { usePlaylistDerived } from '../hooks/usePlaylistDerived';
import { usePlaylistRouteEffects } from '../hooks/usePlaylistRouteEffects';
import { useBulkPlPickerOutsideClick } from '../hooks/useBulkPlPickerOutsideClick';
import { usePlaylistDnDReorder } from '../hooks/usePlaylistDnDReorder';
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
import { offlineActionPolicy } from '../utils/offline/offlineActionPolicy';
// ── Column configuration ──────────────────────────────────────────────────────
const PL_COLUMNS: readonly ColDef[] = [
@@ -179,13 +181,15 @@ export default function PlaylistDetail() {
// ── Load ─────────────────────────────────────────────────────
const lastModified = usePlaylistStore(s => (id ? s.lastModified[id] : undefined));
const { active: offlineBrowseActive } = useOfflineBrowseContext();
const actionPolicy = offlineActionPolicy('playlistDetail', offlineBrowseActive);
useEffect(() => {
if (!id) return;
runPlaylistLoad({
id, setLoading, setPlaylist, setSongs, setCustomCoverId, setRatings, setStarredSongs,
});
}, [id, lastModified]);
}, [id, lastModified, offlineBrowseActive]);
// ── Meta edit ─────────────────────────────────────────────────
const handleSaveMeta = async (opts: {
@@ -280,6 +284,7 @@ export default function PlaylistDetail() {
offlineStatus={resolvedOfflineStatus}
offlineProgress={offlineProgress}
activeServerId={activeServerId}
actionPolicy={actionPolicy}
setEditingMeta={setEditingMeta}
setSearchOpen={setSearchOpen}
setSearchQuery={setSearchQuery}
+19 -6
View File
@@ -1,4 +1,4 @@
import { getPlaylist } from '../api/subsonicPlaylists';
import { resolveMediaServerId, resolvePlaylist } from '../utils/offline/offlineMediaResolve';
import { getGenres } from '../api/subsonicGenres';
import { filterSongsToActiveLibrary } from '../api/subsonicLibrary';
import type { SubsonicPlaylist, SubsonicGenre } from '../api/subsonicTypes';
@@ -29,6 +29,8 @@ import PlaylistsHeader from '../components/playlists/PlaylistsHeader';
import PlaylistCard from '../components/playlists/PlaylistCard';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { VirtualCardGrid } from '../components/VirtualCardGrid';
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
import { offlineActionPolicy } from '../utils/offline/offlineActionPolicy';
function formatDuration(seconds: number): string {
return formatHumanHoursMinutes(seconds);
@@ -49,6 +51,9 @@ export default function Playlists() {
const activeServerId = useAuthStore(s => s.activeServerId);
const subsonicIdentityByServer = useAuthStore(s => s.subsonicServerIdentityByServer);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const offlineCtx = useOfflineBrowseContext();
const offlineBrowseActive = offlineCtx.active;
const playlistsActionPolicy = offlineActionPolicy('playlistsHeader', offlineCtx.active);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
@@ -94,8 +99,10 @@ export default function Playlists() {
useEffect(() => {
fetchPlaylists().finally(() => setLoading(false));
getGenres().then(setGenres).catch(() => {});
}, [fetchPlaylists]);
if (!offlineBrowseActive) {
getGenres().then(setGenres).catch(() => {});
}
}, [fetchPlaylists, offlineBrowseActive]);
useEffect(() => {
if (creating) nameInputRef.current?.focus();
@@ -138,9 +145,14 @@ export default function Playlists() {
if (playingId === pl.id) return;
setPlayingId(pl.id);
try {
const data = await getPlaylist(pl.id);
const filteredSongs = await filterSongsToActiveLibrary(data.songs);
const tracks = filteredSongs.map(songToTrack);
const serverId = resolveMediaServerId(activeServerId);
if (!serverId) return;
const data = await resolvePlaylist(serverId, pl.id);
if (!data) return;
const songs = offlineBrowseActive
? data.songs
: await filterSongsToActiveLibrary(data.songs);
const tracks = songs.map(songToTrack);
if (tracks.length > 0) {
touchPlaylist(pl.id);
playTrack(tracks[0], tracks);
@@ -233,6 +245,7 @@ export default function Playlists() {
setEditingSmartId={setEditingSmartId}
setSmartFilters={setSmartFilters}
setGenreQuery={setGenreQuery}
actionPolicy={playlistsActionPolicy}
/>
{creatingSmart && (
+4 -2
View File
@@ -1,6 +1,7 @@
import { buildDownloadUrl } from '../api/subsonicStreamUrl';
import { getAlbumsByGenre } from '../api/subsonicGenres';
import { getAlbumList, getAlbum } from '../api/subsonicLibrary';
import { getAlbumList } from '../api/subsonicLibrary';
import { resolveAlbum } from '../utils/offline/offlineMediaResolve';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { dedupeById } from '../utils/dedupeById';
import { shuffleArray } from '../utils/playback/shuffleArray';
@@ -199,7 +200,8 @@ export default function RandomAlbums() {
let queued = 0;
for (const album of selectedAlbums) {
try {
const detail = await getAlbum(album.id);
const detail = await resolveAlbum(serverId, album.id);
if (!detail) throw new Error('album unavailable');
downloadAlbum(album.id, album.name, album.artist, album.coverArt, album.year, detail.songs, serverId);
queued++;
} catch {
+25 -5
View File
@@ -2,6 +2,7 @@ import { fetchStatisticsFormatSample, fetchStatisticsLibraryAggregates, fetchSta
import { getAlbumList } from '../api/subsonicLibrary';
import type { SubsonicAlbum, SubsonicGenre } from '../api/subsonicTypes';
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Share2 } from 'lucide-react';
import { formatHumanHoursMinutes } from '../utils/format/formatHumanDuration';
import AlbumRow from '../components/AlbumRow';
@@ -12,6 +13,8 @@ import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
import { useLocation } from 'react-router-dom';
import { lastfmIsConfigured, lastfmGetTopArtists, lastfmGetTopAlbums, lastfmGetTopTracks, lastfmGetRecentTracks, LastfmPeriod, LastfmTopArtist, LastfmTopAlbum, LastfmTopTrack, LastfmRecentTrack } from '../api/lastfm';
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
import { usePlayerStatsRecordingEnabled } from '../hooks/usePlayerStatsRecordingEnabled';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function relativeTime(timestamp: number, t: (key: string, opts?: any) => string): string {
@@ -34,7 +37,10 @@ const PERIODS: { key: LastfmPeriod; label: string }[] = [
export default function Statistics() {
const { t } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
const isPlayerStats = location.pathname === '/player-stats';
const offlineBrowseActive = useOfflineBrowseContext().active;
const playerStatsEnabled = usePlayerStatsRecordingEnabled();
const { lastfmSessionKey, lastfmUsername } = useAuthStore();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const [recent, setRecent] = useState<SubsonicAlbum[]>([]);
@@ -62,6 +68,16 @@ export default function Statistics() {
const [lfmRecentLoading, setLfmRecentLoading] = useState(false);
useEffect(() => {
if (offlineBrowseActive && playerStatsEnabled && !isPlayerStats) {
navigate('/player-stats', { replace: true });
}
}, [offlineBrowseActive, playerStatsEnabled, isPlayerStats, navigate]);
useEffect(() => {
if (offlineBrowseActive || isPlayerStats) {
setLoading(false);
return;
}
fetchStatisticsOverview()
.then(d => {
setRecent(d.recent);
@@ -71,10 +87,11 @@ export default function Statistics() {
setLoading(false);
})
.catch(() => setLoading(false));
}, [musicLibraryFilterVersion]);
}, [musicLibraryFilterVersion, offlineBrowseActive, isPlayerStats]);
// Background: playtime, album/song counts, genre insights (cached per server+library like rating prefetch)
useEffect(() => {
if (offlineBrowseActive || isPlayerStats) return;
let cancelled = false;
setTotalPlaytime(null);
setTotalAlbums(null);
@@ -101,10 +118,11 @@ export default function Statistics() {
}
})();
return () => { cancelled = true; };
}, [musicLibraryFilterVersion]);
}, [musicLibraryFilterVersion, offlineBrowseActive, isPlayerStats]);
// Background: format distribution (cached random sample, same TTL as other Statistics fetches)
useEffect(() => {
if (offlineBrowseActive || isPlayerStats) return;
let cancelled = false;
setFormatData(null);
setFormatSampleSize(0);
@@ -116,17 +134,19 @@ export default function Statistics() {
})
.catch(() => {});
return () => { cancelled = true; };
}, [musicLibraryFilterVersion]);
}, [musicLibraryFilterVersion, offlineBrowseActive, isPlayerStats]);
useEffect(() => {
if (offlineBrowseActive || isPlayerStats) return;
if (!lastfmIsConfigured() || !lastfmSessionKey || !lastfmUsername) return;
setLfmRecentLoading(true);
lastfmGetRecentTracks(lastfmUsername, lastfmSessionKey, 20)
.then(tracks => { setLfmRecentTracks(tracks); setLfmRecentLoading(false); })
.catch(() => setLfmRecentLoading(false));
}, [lastfmSessionKey, lastfmUsername]);
}, [lastfmSessionKey, lastfmUsername, offlineBrowseActive, isPlayerStats]);
useEffect(() => {
if (offlineBrowseActive || isPlayerStats) return;
if (!lastfmIsConfigured() || !lastfmSessionKey || !lastfmUsername) return;
setLfmLoading(true);
Promise.all([
@@ -139,7 +159,7 @@ export default function Statistics() {
setLfmTopTracks(tracks);
setLfmLoading(false);
}).catch(() => setLfmLoading(false));
}, [lfmPeriod, lastfmSessionKey, lastfmUsername]);
}, [lfmPeriod, lastfmSessionKey, lastfmUsername, offlineBrowseActive, isPlayerStats]);
const loadMore = async (
type: 'frequent' | 'highest',