From fc34a0ec598d8995869f3fc6b83bc77367fddb46 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Sun, 7 Jun 2026 15:59:41 +0300 Subject: [PATCH] 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. --- CHANGELOG.md | 11 + src/api/subsonicRatings.test.ts | 3 + src/api/subsonicRatings.ts | 3 + src/app/AppShell.tsx | 33 +- src/components/AlbumCard.tsx | 13 +- src/components/AlbumHeader.tsx | 239 +++++++------- src/components/AlbumTrackList.tsx | 15 +- src/components/BecauseYouLikeRail.tsx | 8 +- src/components/ContextMenu.test.tsx | 17 + src/components/ContextMenu.tsx | 28 ++ src/components/DevNetworkModeToggle.tsx | 23 ++ src/components/Hero.tsx | 21 +- src/components/MobileMoreOverlay.tsx | 28 +- src/components/PlayerBar.test.tsx | 7 + src/components/PlayerBar.tsx | 1 - src/components/QueuePanel.tsx | 8 +- src/components/Sidebar.tsx | 48 ++- .../albumDetail/AlbumDetailToolbar.tsx | 38 ++- src/components/albumTrackList/TrackRow.tsx | 5 + .../artistDetail/ArtistDetailHero.tsx | 28 +- .../AlbumArtistToPlaylistSubmenu.tsx | 27 +- .../contextMenu/AlbumContextItems.tsx | 201 +++++++----- .../contextMenu/ArtistContextItems.tsx | 173 +++++----- .../MultiAlbumToPlaylistSubmenu.tsx | 15 +- .../MultiArtistToPlaylistSubmenu.tsx | 23 +- .../contextMenu/PlaylistContextItems.tsx | 59 ++-- .../contextMenu/SongContextItems.tsx | 168 +++++----- .../contextMenu/contextMenuItemTypes.ts | 2 + src/components/playerBar/PlayerTrackInfo.tsx | 8 +- src/components/playlist/PlaylistHero.tsx | 32 +- src/components/playlists/PlaylistsHeader.tsx | 6 +- .../statistics/StatisticsTabBar.tsx | 37 ++- src/config/settingsCredits.ts | 1 + src/hooks/useAlbumBrowseData.ts | 58 +++- src/hooks/useAlbumDetailData.ts | 78 +++-- src/hooks/useArtistDetailData.ts | 29 +- src/hooks/useArtistsBrowseCatalog.ts | 55 +++- src/hooks/useBrowseAlbumTextSearch.ts | 14 +- src/hooks/useBrowseArtistTextSearch.ts | 14 +- src/hooks/useConnectionStatus.test.ts | 38 +++ src/hooks/useConnectionStatus.ts | 51 ++- src/hooks/useFavoritesData.ts | 11 +- src/hooks/useOfflineAutoNav.ts | 82 +++-- src/hooks/useOfflineBrowseContext.ts | 36 ++ src/hooks/useOfflineBrowseReloadToken.ts | 8 + src/hooks/useOfflineLibraryFilterSuspend.ts | 27 ++ src/hooks/usePlaylistsLibraryScopeCounts.ts | 17 +- src/hooks/useQueuePanelDrag.ts | 15 +- src/hooks/useSongBrowseList.test.ts | 4 + src/hooks/useSongBrowseList.ts | 24 +- src/pages/AlbumDetail.tsx | 7 + src/pages/Albums.tsx | 11 +- src/pages/ArtistDetail.tsx | 19 +- src/pages/Home.tsx | 59 +++- src/pages/LosslessAlbums.tsx | 9 +- src/pages/MostPlayed.tsx | 10 +- src/pages/NewReleases.tsx | 6 +- src/pages/PlaylistDetail.tsx | 7 +- src/pages/Playlists.tsx | 25 +- src/pages/RandomAlbums.tsx | 6 +- src/pages/Statistics.tsx | 30 +- src/store/devOfflineBrowseStore.ts | 19 ++ src/store/homeFeedCache.ts | 21 ++ src/store/playlistStore.ts | 9 + src/styles/layout/dev-build-chrome.css | 21 ++ .../runArtistDetailPlay.test.ts | 46 +++ .../componentHelpers/runArtistDetailPlay.ts | 38 ++- src/utils/library/albumBrowseCatalogChunk.ts | 43 +++ src/utils/mix/luckyMixHelpers.ts | 5 +- .../network/activeServerReachability.test.ts | 9 + src/utils/network/activeServerReachability.ts | 3 + src/utils/network/subsonicNetworkGuard.ts | 2 + .../offline/favoritesOfflineBrowse.test.ts | 116 ++++++- src/utils/offline/favoritesOfflineBrowse.ts | 225 ------------- src/utils/offline/favoritesOfflineSync.ts | 3 +- src/utils/offline/offlineActionPolicy.test.ts | 33 ++ src/utils/offline/offlineActionPolicy.ts | 59 ++++ .../offline/offlineAlbumBrowseCatalog.ts | 49 +++ .../offline/offlineBrowseContext.test.ts | 101 ++++++ src/utils/offline/offlineBrowseContext.ts | 91 ++++++ src/utils/offline/offlineBrowseMode.test.ts | 21 ++ src/utils/offline/offlineBrowseMode.ts | 28 ++ .../offline/offlineBrowseRouting.test.ts | 48 +++ src/utils/offline/offlineBrowseRouting.ts | 77 +++++ .../offlineLibraryFilterSuspend.test.ts | 31 ++ .../offline/offlineLibraryFilterSuspend.ts | 36 ++ src/utils/offline/offlineLibraryIndexLoad.ts | 92 ++++++ src/utils/offline/offlineLocalBrowse.test.ts | 95 ++++++ src/utils/offline/offlineLocalBrowse.ts | 307 ++++++++++++++++++ src/utils/offline/offlineMediaResolve.test.ts | 161 +++++++++ src/utils/offline/offlineMediaResolve.ts | 121 +++++++ src/utils/offline/offlineNavPolicy.ts | 41 +++ .../offline/offlinePlaylistBrowse.test.ts | 152 +++++++++ src/utils/offline/offlinePlaylistBrowse.ts | 109 +++++++ src/utils/offline/offlineStarredLoad.ts | 168 ++++++++++ .../resumeIncompleteOfflinePins.test.ts | 2 +- .../offline/resumeIncompleteOfflinePins.ts | 2 +- src/utils/playback/fetchTracksForSource.ts | 34 +- src/utils/playback/playAlbum.test.ts | 59 ++-- src/utils/playback/playAlbum.ts | 9 +- src/utils/playback/playArtistShuffled.ts | 18 +- src/utils/playback/playByOpaqueId.ts | 15 +- src/utils/playlist/runPlaylistLoad.ts | 43 ++- src/utils/share/applySharePaste.ts | 21 +- src/utils/share/enqueueShareSearchPayload.ts | 12 +- 105 files changed, 3675 insertions(+), 999 deletions(-) create mode 100644 src/components/DevNetworkModeToggle.tsx create mode 100644 src/hooks/useOfflineBrowseContext.ts create mode 100644 src/hooks/useOfflineBrowseReloadToken.ts create mode 100644 src/hooks/useOfflineLibraryFilterSuspend.ts create mode 100644 src/store/devOfflineBrowseStore.ts create mode 100644 src/utils/componentHelpers/runArtistDetailPlay.test.ts create mode 100644 src/utils/library/albumBrowseCatalogChunk.ts create mode 100644 src/utils/offline/offlineActionPolicy.test.ts create mode 100644 src/utils/offline/offlineActionPolicy.ts create mode 100644 src/utils/offline/offlineAlbumBrowseCatalog.ts create mode 100644 src/utils/offline/offlineBrowseContext.test.ts create mode 100644 src/utils/offline/offlineBrowseContext.ts create mode 100644 src/utils/offline/offlineBrowseMode.test.ts create mode 100644 src/utils/offline/offlineBrowseMode.ts create mode 100644 src/utils/offline/offlineBrowseRouting.test.ts create mode 100644 src/utils/offline/offlineBrowseRouting.ts create mode 100644 src/utils/offline/offlineLibraryFilterSuspend.test.ts create mode 100644 src/utils/offline/offlineLibraryFilterSuspend.ts create mode 100644 src/utils/offline/offlineLibraryIndexLoad.ts create mode 100644 src/utils/offline/offlineLocalBrowse.test.ts create mode 100644 src/utils/offline/offlineLocalBrowse.ts create mode 100644 src/utils/offline/offlineMediaResolve.test.ts create mode 100644 src/utils/offline/offlineMediaResolve.ts create mode 100644 src/utils/offline/offlineNavPolicy.ts create mode 100644 src/utils/offline/offlinePlaylistBrowse.test.ts create mode 100644 src/utils/offline/offlinePlaylistBrowse.ts create mode 100644 src/utils/offline/offlineStarredLoad.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ab64d5a9..02409f96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Offline — local-bytes browse when the server is down + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#1017](https://github.com/Psychotoxical/psysonic/pull/1017)** + +* When the active server is unreachable, browse and detail pages read from **local playback bytes** and the **library index** instead of Subsonic — albums, artists, tracks, cached playlists, and cross-server favorites. +* Single integration contract: `offlineBrowseContext`, `offlineActionPolicy`, and `resolveAlbum` / `resolveArtist` / `resolvePlaylist` resolvers; context menus and detail toolbars block server mutations offline. +* Disconnect navigation forks by offline capability (stay on page, stay-reload, or redirect); Home reuses the last cached feed snapshot; DEV offline toggle simulates full disconnect for testing. +* PlayerBar hides star rating and favorite controls while offline browse is active. + + + ## Changed ### Dependencies — npm and Rust refresh diff --git a/src/api/subsonicRatings.test.ts b/src/api/subsonicRatings.test.ts index ece9b2ee..a27863c8 100644 --- a/src/api/subsonicRatings.test.ts +++ b/src/api/subsonicRatings.test.ts @@ -2,6 +2,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('./subsonicArtists', () => ({ getArtist: vi.fn() })); vi.mock('./subsonicLibrary', () => ({ getAlbum: vi.fn() })); +vi.mock('../utils/network/subsonicNetworkGuard', () => ({ + shouldAttemptSubsonicForActiveServer: vi.fn(() => true), +})); import { getArtist } from './subsonicArtists'; import { invalidateEntityUserRatingCaches, prefetchArtistUserRatings } from './subsonicRatings'; diff --git a/src/api/subsonicRatings.ts b/src/api/subsonicRatings.ts index 042d4abe..74f6f036 100644 --- a/src/api/subsonicRatings.ts +++ b/src/api/subsonicRatings.ts @@ -1,5 +1,6 @@ import { getArtist } from './subsonicArtists'; import { getAlbum } from './subsonicLibrary'; +import { shouldAttemptSubsonicForActiveServer } from '../utils/network/subsonicNetworkGuard'; const MIX_RATING_PREFETCH_CONCURRENCY = 8; const RATING_CACHE_TTL = 7 * 60 * 1000; // 7 minutes @@ -55,6 +56,7 @@ export async function prefetchArtistUserRatings( else uncached.push(id); } if (!uncached.length) return out; + if (!shouldAttemptSubsonicForActiveServer()) return out; let next = 0; async function worker() { for (;;) { @@ -93,6 +95,7 @@ export async function prefetchAlbumUserRatings( else uncached.push(id); } if (!uncached.length) return out; + if (!shouldAttemptSubsonicForActiveServer()) return out; let next = 0; async function worker() { for (;;) { diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index f2c71d15..daccb294 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -11,6 +11,7 @@ import PlayerBar from '../components/PlayerBar'; import BottomNav from '../components/BottomNav'; import { useIsMobile } from '../hooks/useIsMobile'; import LiveSearch from '../components/LiveSearch'; +import DevNetworkModeToggle from '../components/DevNetworkModeToggle'; import NowPlayingDropdown from '../components/NowPlayingDropdown'; import QueuePanel from '../components/QueuePanel'; import AppRoutes from './AppRoutes'; @@ -39,11 +40,8 @@ import { useOrbitHost } from '../hooks/useOrbitHost'; import { useOrbitGuest } from '../hooks/useOrbitGuest'; import { useOrbitBodyAttrs } from '../hooks/useOrbitBodyAttrs'; import { usePlatformShellSetup } from '../hooks/usePlatformShellSetup'; -import { - hasOfflineBrowsingContent, -} from '../utils/offline/favoritesOfflineBrowse'; -import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers'; -import { useLibraryIndexStore } from '../store/libraryIndexStore'; +import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext'; +import { offlineBrowseNavFlags } from '../utils/offline/offlineBrowseContext'; import { useWindowFullscreenState } from '../hooks/useWindowFullscreenState'; import { useNowPlayingTrayTitle } from '../hooks/useNowPlayingTrayTitle'; import { useTrayMenuI18n } from '../hooks/useTrayMenuI18n'; @@ -56,11 +54,11 @@ import { useCoverNavigationPriority } from '../hooks/useCoverNavigationPriority' import { useLiveSearchRouteScope } from '../hooks/useLiveSearchRouteScope'; import { useNowPlayingPrewarm } from '../hooks/useNowPlayingPrewarm'; import { useOfflineAutoNav } from '../hooks/useOfflineAutoNav'; +import { useOfflineLibraryFilterSuspend } from '../hooks/useOfflineLibraryFilterSuspend'; import { AppShellQueueResizerSeam } from '../components/AppShellQueueResizerSeam'; import { IS_LINUX } from '../utils/platform'; import { useConnectionStatus } from '../hooks/useConnectionStatus'; import { useAuthStore } from '../store/authStore'; -import { useOfflineStore } from '../store/offlineStore'; import { usePlayerStore } from '../store/playerStore'; import '../store/previewPlayerVolumeSync'; import '../store/queueResolverBridge'; @@ -110,13 +108,10 @@ export function AppShell() { useLiveSearchRouteScope(); useNowPlayingPrewarm(); const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar); - const offlineAlbums = useOfflineStore(s => s.albums); - const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled); - const activeServerId = useAuthStore(s => s.activeServerId); - const libraryIndexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(activeServerId)); - const favoritesOfflineBrowse = favoritesOfflineEnabled && libraryIndexEnabled; - const hasManualOfflineContent = hasAnyOfflineAlbums(offlineAlbums); - const hasOfflineContent = hasOfflineBrowsingContent(offlineAlbums); + const offlineCtx = useOfflineBrowseContext(); + const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities); + const hasOfflineContent = offlineCtx.hasBrowsingContent; + const hasOfflineBrowse = offlineCtx.hasBrowseCapability; const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar); const perfFlags = usePerfProbeFlags(); @@ -144,13 +139,8 @@ export function AppShell() { document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID)?.scrollTo({ top: 0 }); }, [location.pathname, location.state]); - useOfflineAutoNav( - connStatus, - hasManualOfflineContent, - favoritesOfflineBrowse, - location.pathname, - navigate, - ); + useOfflineAutoNav(connStatus, offlineNav, location, navigate); + useOfflineLibraryFilterSuspend(); useEffect(() => { initializeFromServerQueue(); @@ -264,6 +254,7 @@ export function AppShell() {
+ {import.meta.env.DEV && }
@@ -282,7 +273,7 @@ export function AppShell() {
{connStatus === 'disconnected' && ( - + )}
{ e.stopPropagation(); try { - const data = await getAlbum(album.id); - enqueue(data.songs.map(songToTrack)); + const tracks = await fetchAlbumTracks( + album.id, + offlineServerId || undefined, + ); + if (tracks.length > 0) enqueue(tracks); } catch { - // Network failure — silent (toast would be too noisy for a hover action) + // Unavailable offline or network failure — silent on hover action } }} aria-label={t('contextMenu.enqueueAlbum')} diff --git a/src/components/AlbumHeader.tsx b/src/components/AlbumHeader.tsx index fc9f50a4..33316593 100644 --- a/src/components/AlbumHeader.tsx +++ b/src/components/AlbumHeader.tsx @@ -19,6 +19,7 @@ import { formatMb } from '../utils/format/formatBytes'; import { sanitizeHtml } from '../utils/sanitizeHtml'; import { OpenArtistRefInline } from './OpenArtistRefInline'; import { tooltipAttrs } from './tooltipAttrs'; +import { offlineActionPolicy, type OfflineActionPolicy } from '../utils/offline/offlineActionPolicy'; /** True when the album artist label means "no single artist" — `getArtistInfo` * has nothing meaningful to return for these, so the Artist Bio entry is hidden. @@ -91,6 +92,8 @@ interface AlbumHeaderProps { onEntityRatingChange: (rating: number) => void; /** `unknown` = probe pending or not run; from `entityRatingSupportByServer`. */ entityRatingSupport: EntityRatingSupportLevel | 'unknown'; + /** Offline browse action gates (favorites, download, cache, bio, ratings). */ + actionPolicy?: OfflineActionPolicy; } export default function AlbumHeader({ @@ -117,7 +120,9 @@ export default function AlbumHeader({ entityRatingValue, onEntityRatingChange, entityRatingSupport, + actionPolicy, }: AlbumHeaderProps) { + const policy = actionPolicy ?? offlineActionPolicy('albumDetail', false); const { t } = useTranslation(); const navigate = useNavigate(); const goBack = useAlbumDetailBack(); @@ -222,7 +227,7 @@ export default function AlbumHeader({
@@ -250,14 +255,16 @@ export default function AlbumHeader({ {/* Row 2 — Secondary actions */}
- + {policy.canFavorite && ( + + )} - {showBioButton && ( + {showBioButton && policy.canShowBio && ( + {policy.canDownload && ( + downloadProgress !== null ? ( +
+ + {downloadProgress}% +
+ ) : ( + + ) )} - {offlineStatus === 'downloading' ? ( -
- -
- ) : offlineStatus === 'queued' ? ( - - ) : offlineStatus === 'cached' ? ( - - ) : ( - + {policy.canPinOffline && ( + offlineStatus === 'downloading' ? ( +
+ +
+ ) : offlineStatus === 'queued' ? ( + + ) : offlineStatus === 'cached' ? ( + + ) : ( + + ) )}
@@ -357,13 +368,15 @@ export default function AlbumHeader({ > - + {policy.canFavorite && ( + + )} + ) : ( + + ) )} - {offlineStatus === 'downloading' && offlineProgress ? ( -
- - {t('albumDetail.offlineDownloading', { n: offlineProgress.done, total: offlineProgress.total })} -
- ) : offlineStatus === 'queued' ? ( - - ) : offlineStatus === 'cached' ? ( - - ) : ( - + {policy.canPinOffline && ( + offlineStatus === 'downloading' && offlineProgress ? ( +
+ + {t('albumDetail.offlineDownloading', { n: offlineProgress.done, total: offlineProgress.total })} +
+ ) : offlineStatus === 'queued' ? ( + + ) : offlineStatus === 'cached' ? ( + + ) : ( + + ) )} )} diff --git a/src/components/AlbumTrackList.tsx b/src/components/AlbumTrackList.tsx index a116205e..5cf127fd 100644 --- a/src/components/AlbumTrackList.tsx +++ b/src/components/AlbumTrackList.tsx @@ -1,6 +1,6 @@ import type { SubsonicSong } from '../api/subsonicTypes'; import type { Track } from '../store/playerStoreTypes'; -import React, { useState, useEffect } from 'react'; +import React, { useMemo, useState, useEffect } from 'react'; import { useTracklistColumns } from '../utils/useTracklistColumns'; import { usePlayerStore } from '../store/playerStore'; import { useTranslation } from 'react-i18next'; @@ -15,6 +15,7 @@ import { TrackRow } from './albumTrackList/TrackRow'; import { AlbumTrackListMobile } from './albumTrackList/AlbumTrackListMobile'; import { TracklistColumnPicker } from './albumTrackList/TracklistColumnPicker'; import { TracklistHeaderRow } from './albumTrackList/TracklistHeaderRow'; +import { offlineActionPolicy, type OfflineActionPolicy } from '../utils/offline/offlineActionPolicy'; export type { SortKey } from '../utils/componentHelpers/albumTrackListHelpers'; @@ -38,6 +39,7 @@ interface AlbumTrackListProps { sortKey?: SortKey; sortDir?: 'asc' | 'desc'; onSort?: (key: SortKey) => void; + actionPolicy?: OfflineActionPolicy; } // ── AlbumTrackList ──────────────────────────────────────────────────────────── @@ -60,7 +62,9 @@ export default function AlbumTrackList({ sortKey, sortDir, onSort, + actionPolicy, }: AlbumTrackListProps) { + const policy = actionPolicy ?? offlineActionPolicy('trackRow', false); const { t } = useTranslation(); const isMobile = useIsMobile(); const [contextMenuSongId, setContextMenuSongId] = useState(null); @@ -98,6 +102,10 @@ export default function AlbumTrackList({ ); const currentTrackId = currentTrack?.id ?? null; + const displayCols = useMemo( + () => (policy.canFavorite ? visibleCols : visibleCols.filter(c => c.key !== 'favorite')), + [policy.canFavorite, visibleCols], + ); if (isMobile) { return ( @@ -139,7 +147,7 @@ export default function AlbumTrackList({ > ); })} diff --git a/src/components/BecauseYouLikeRail.tsx b/src/components/BecauseYouLikeRail.tsx index 6dd34c8d..23c20225 100644 --- a/src/components/BecauseYouLikeRail.tsx +++ b/src/components/BecauseYouLikeRail.tsx @@ -1,5 +1,6 @@ import { getArtist, getArtistInfo } from '../api/subsonicArtists'; -import { filterAlbumsToActiveLibrary, getAlbum } from '../api/subsonicLibrary'; +import { filterAlbumsToActiveLibrary } from '../api/subsonicLibrary'; +import { resolveAlbum, resolveMediaServerId } from '../utils/offline/offlineMediaResolve'; import type { SubsonicAlbum } from '../api/subsonicTypes'; import { songToTrack } from '../utils/playback/songToTrack'; import { shuffleArray } from '../utils/playback/shuffleArray'; @@ -596,7 +597,10 @@ const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork, e const handleEnqueue = async (e: React.MouseEvent) => { e.stopPropagation(); try { - const data = await getAlbum(album.id); + const serverId = resolveMediaServerId(album.serverId); + if (!serverId) return; + const data = await resolveAlbum(serverId, album.id); + if (!data) return; enqueue(data.songs.map(songToTrack)); } catch { /* silent — toast would be too noisy for a hover action */ diff --git a/src/components/ContextMenu.test.tsx b/src/components/ContextMenu.test.tsx index a09db856..de08bdf5 100644 --- a/src/components/ContextMenu.test.tsx +++ b/src/components/ContextMenu.test.tsx @@ -43,6 +43,23 @@ vi.mock('@/utils/orbitBulkGuard', () => ({ orbitBulkGuard: vi.fn(async () => true), })); +vi.mock('@/hooks/useOfflineBrowseContext', () => ({ + useOfflineBrowseContext: () => ({ + active: false, + serverId: 'srv-1', + capabilities: { + localLibrary: false, + favorites: false, + playlists: false, + manualPins: false, + playerStats: false, + }, + hasBrowseCapability: false, + hasBrowsingContent: false, + connStatus: 'connected' as const, + }), +})); + import ContextMenu from './ContextMenu'; import { renderWithProviders } from '@/test/helpers/renderWithProviders'; import { usePlayerStore } from '@/store/playerStore'; diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 2d2b06ab..02a2efa1 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -17,8 +17,29 @@ import { useContextMenuKeyboardNav } from '../hooks/useContextMenuKeyboardNav'; import { useContextMenuRating } from '../hooks/useContextMenuRating'; import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate'; import { useNavigate } from 'react-router-dom'; +import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext'; +import { + offlineActionPolicy, + type OfflineSurface, +} from '../utils/offline/offlineActionPolicy'; import ContextMenuItems from './contextMenu/ContextMenuItems'; +function contextMenuSurfaceForType(type: string | null): OfflineSurface { + switch (type) { + case 'album': + case 'multi-album': + return 'contextMenuAlbum'; + case 'artist': + case 'multi-artist': + return 'contextMenuArtist'; + case 'playlist': + case 'multi-playlist': + return 'contextMenuPlaylist'; + default: + return 'contextMenuSong'; + } +} + export { AddToPlaylistSubmenu }; @@ -180,6 +201,12 @@ export default function ContextMenu() { const downloadAlbum = downloadAlbumAction; + const { active: offlineBrowseActive } = useOfflineBrowseContext(); + const offlinePolicy = offlineActionPolicy( + contextMenuSurfaceForType(type), + offlineBrowseActive, + ); + if (!contextMenu.isOpen || !contextMenu.item) return null; return ( @@ -233,6 +260,7 @@ export default function ContextMenu() { isStarred={isStarred} pinToPlaybackServer={pinToPlaybackServer} navigateLibrary={navigateLibrary} + offlinePolicy={offlinePolicy} /> diff --git a/src/components/DevNetworkModeToggle.tsx b/src/components/DevNetworkModeToggle.tsx new file mode 100644 index 00000000..55a1fa28 --- /dev/null +++ b/src/components/DevNetworkModeToggle.tsx @@ -0,0 +1,23 @@ +import { Cloud, CloudOff } from 'lucide-react'; +import { useDevOfflineBrowseStore } from '../store/devOfflineBrowseStore'; + +/** DEV-only: simulate full offline (disconnect UI, block Subsonic, local playback only). */ +export default function DevNetworkModeToggle() { + const forceOffline = useDevOfflineBrowseStore(s => s.forceOffline); + const toggle = useDevOfflineBrowseStore(s => s.toggleForceOffline); + + if (!import.meta.env.DEV) return null; + + return ( + + ); +} diff --git a/src/components/Hero.tsx b/src/components/Hero.tsx index 07eed645..669df452 100644 --- a/src/components/Hero.tsx +++ b/src/components/Hero.tsx @@ -1,4 +1,5 @@ -import { getRandomAlbums, getAlbum } from '../api/subsonicLibrary'; +import { getRandomAlbums } from '../api/subsonicLibrary'; +import { resolveAlbum, resolveMediaServerId } from '../utils/offline/offlineMediaResolve'; import type { SubsonicAlbum } from '../api/subsonicTypes'; import { songToTrack } from '../utils/playback/songToTrack'; import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react'; @@ -259,7 +260,13 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) { const [albumFormats, setAlbumFormats] = useState>({}); useEffect(() => { if (!album || albumFormats[album.id] !== undefined) return; - getAlbum(album.id).then(data => { + const serverId = resolveMediaServerId(album.serverId); + if (!serverId) return; + resolveAlbum(serverId, album.id).then(data => { + if (!data) { + setAlbumFormats(prev => ({ ...prev, [album.id]: '' })); + return; + } const fmts = [...new Set(data.songs.map(s => s.suffix).filter((f): f is string => !!f))]; setAlbumFormats(prev => ({ ...prev, [album.id]: fmts.map(f => f.toUpperCase()).join(' / ') })); }).catch(() => { @@ -339,7 +346,10 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) { onClick={async e => { e.stopPropagation(); try { - const albumData = await getAlbum(album.id); + const serverId = resolveMediaServerId(album.serverId); + if (!serverId) return; + const albumData = await resolveAlbum(serverId, album.id); + if (!albumData) return; usePlayerStore.getState().enqueue(albumData.songs.map(songToTrack)); } catch (_) {} }} @@ -369,7 +379,10 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) { onClick={async (e) => { e.stopPropagation(); try { - const albumData = await getAlbum(album.id); + const serverId = resolveMediaServerId(album.serverId); + if (!serverId) return; + const albumData = await resolveAlbum(serverId, album.id); + if (!albumData) return; const tracks = albumData.songs.map(songToTrack); usePlayerStore.getState().enqueue(tracks); } catch (_) {} diff --git a/src/components/MobileMoreOverlay.tsx b/src/components/MobileMoreOverlay.tsx index 2ee6646d..a21f9060 100644 --- a/src/components/MobileMoreOverlay.tsx +++ b/src/components/MobileMoreOverlay.tsx @@ -4,13 +4,11 @@ import { useTranslation } from 'react-i18next'; import { Settings, HardDriveDownload } from 'lucide-react'; import { useSidebarStore } from '../store/sidebarStore'; import { useAuthStore } from '../store/authStore'; -import { useOfflineStore } from '../store/offlineStore'; import { ALL_NAV_ITEMS } from '../config/navItems'; import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable'; -import { isOfflineSidebarLibraryNavAllowed } from '../utils/offline/favoritesOfflineBrowse'; -import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers'; -import { useConnectionStatus } from '../hooks/useConnectionStatus'; -import { useLibraryIndexStore } from '../store/libraryIndexStore'; +import { isOfflineSidebarNavAllowed } from '../utils/offline/offlineNavPolicy'; +import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext'; +import { offlineBrowseNavFlags } from '../utils/offline/offlineBrowseContext'; const BOTTOM_NAV_ROUTES = new Set(['/', '/albums', '/now-playing']); @@ -18,14 +16,10 @@ export default function MobileMoreOverlay({ onClose }: { onClose: () => void }) const { t } = useTranslation(); const sidebarItems = useSidebarStore(s => s.items); const randomNavMode = useAuthStore(s => s.randomNavMode); - const serverId = useAuthStore(s => s.activeServerId ?? ''); - const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled); - const libraryIndexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId)); - const favoritesOfflineBrowse = favoritesOfflineEnabled && libraryIndexEnabled; - const { status: connStatus } = useConnectionStatus(); - const isServerOffline = connStatus === 'disconnected'; - const offlineAlbums = useOfflineStore(s => s.albums); - const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums); + const offlineCtx = useOfflineBrowseContext(); + const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities); + const isServerOffline = offlineCtx.active; + const hasOfflineContent = offlineCtx.capabilities.manualPins; const luckyMixBase = useLuckyMixAvailable(); const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate'; @@ -38,7 +32,13 @@ export default function MobileMoreOverlay({ onClose }: { onClose: () => void }) if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums')) return false; if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false; if (cfg.id === 'luckyMix' && !luckyMixAvailable) return false; - if (isServerOffline && !isOfflineSidebarLibraryNavAllowed(cfg.id, favoritesOfflineBrowse)) { + if (isServerOffline && !isOfflineSidebarNavAllowed( + cfg.id, + offlineNav.favoritesOfflineBrowse, + offlineNav.localLibraryBrowse, + offlineNav.playerStatsBrowse, + offlineNav.playlistsOfflineBrowse, + )) { return false; } return true; diff --git a/src/components/PlayerBar.test.tsx b/src/components/PlayerBar.test.tsx index 70f9e75d..f372eb05 100644 --- a/src/components/PlayerBar.test.tsx +++ b/src/components/PlayerBar.test.tsx @@ -10,6 +10,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('@/api/subsonic', () => ({ savePlayQueue: vi.fn(async () => undefined), getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })), + pingWithCredentials: vi.fn(async () => ({ + ok: true, + type: 'navidrome', + serverVersion: '0.55.0', + openSubsonic: true, + })), + scheduleInstantMixProbeForServer: vi.fn(), buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`), buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`), buildDownloadUrl: vi.fn((id: string) => `https://mock/download/${id}`), diff --git a/src/components/PlayerBar.tsx b/src/components/PlayerBar.tsx index ce8d40be..34f7036d 100644 --- a/src/components/PlayerBar.tsx +++ b/src/components/PlayerBar.tsx @@ -15,7 +15,6 @@ import { useAuthStore } from '../store/authStore'; import { useThemeStore } from '../store/themeStore'; import WaveformSeek from './WaveformSeek'; import Equalizer from './Equalizer'; -import StarRating from './StarRating'; import { useTranslation } from 'react-i18next'; import { usePlaybackLibraryNavigate } from '../hooks/usePlaybackLibraryNavigate'; import { useLyricsStore } from '../store/lyricsStore'; diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx index c08614d5..9d60b6af 100644 --- a/src/components/QueuePanel.tsx +++ b/src/components/QueuePanel.tsx @@ -1,5 +1,6 @@ import { Play } from 'lucide-react'; -import { getPlaylist, updatePlaylist } from '../api/subsonicPlaylists'; +import { updatePlaylist } from '../api/subsonicPlaylists'; +import { resolvePlaylist, resolveMediaServerId } from '../utils/offline/offlineMediaResolve'; import { songToTrack } from '../utils/playback/songToTrack'; import type { Track } from '../store/playerStoreTypes'; import { useState, useRef, useMemo } from 'react'; @@ -398,7 +399,10 @@ function QueuePanelHostOrSolo() { onClose={() => setLoadModalOpen(false)} onLoad={async (id, name, mode) => { try { - const data = await getPlaylist(id); + const serverId = resolveMediaServerId(); + if (!serverId) return; + const data = await resolvePlaylist(serverId, id); + if (!data) return; const tracks: Track[] = data.songs.map(songToTrack); if (tracks.length > 0) { if (mode === 'append') { diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 9706061d..76405c02 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,7 +1,6 @@ import { useState, useRef, useEffect, useMemo } from 'react'; import { createPortal } from 'react-dom'; import { usePlayerStore } from '../store/playerStore'; -import { useOfflineStore } from '../store/offlineStore'; import { useOfflineJobStore } from '../store/offlineJobStore'; import { clearOfflinePinTasks } from '../utils/offline/offlinePinQueue'; import { useDeviceSyncJobStore } from '../store/deviceSyncJobStore'; @@ -24,10 +23,9 @@ import { useSidebarNewReleasesUnread } from '../hooks/useSidebarNewReleasesUnrea import { useSidebarNavDnd } from '../hooks/useSidebarNavDnd'; import { useSidebarLibraryDropdown } from '../hooks/useSidebarLibraryDropdown'; import { useSidebarScrollVisible } from '../hooks/useSidebarScrollVisible'; -import { isOfflineSidebarLibraryNavAllowed } from '../utils/offline/favoritesOfflineBrowse'; -import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers'; -import { useConnectionStatus } from '../hooks/useConnectionStatus'; -import { useLibraryIndexStore } from '../store/libraryIndexStore'; +import { isOfflineSidebarNavAllowed } from '../utils/offline/offlineNavPolicy'; +import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext'; +import { offlineBrowseNavFlags } from '../utils/offline/offlineBrowseContext'; import { useSidebarPerfProbe } from '../hooks/useSidebarPerfProbe'; import SidebarPerfProbeModal from './sidebar/SidebarPerfProbeModal'; import SidebarNavBody from './sidebar/SidebarNavBody'; @@ -61,7 +59,8 @@ export default function Sidebar({ const syncJobFail = useDeviceSyncJobStore(s => s.failed); const syncJobTotal = useDeviceSyncJobStore(s => s.total); const isSyncing = syncJobStatus === 'running'; - const offlineAlbums = useOfflineStore(s => s.albums); + const offlineCtx = useOfflineBrowseContext(); + const offlineNav = offlineBrowseNavFlags(offlineCtx.capabilities); const serverId = useAuthStore(s => s.activeServerId ?? ''); const isLoggedIn = useAuthStore(s => s.isLoggedIn); const musicFolders = useAuthStore(s => s.musicFolders); @@ -73,12 +72,8 @@ export default function Sidebar({ const setNormalizationEngine = useAuthStore(s => s.setNormalizationEngine); const loggingMode = useAuthStore(s => s.loggingMode); const setLoggingMode = useAuthStore(s => s.setLoggingMode); - const { status: connStatus } = useConnectionStatus(); - const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled); - const libraryIndexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId)); - const favoritesOfflineBrowse = favoritesOfflineEnabled && libraryIndexEnabled; - const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums); - const isServerOffline = connStatus === 'disconnected'; + const hasOfflineContent = offlineCtx.capabilities.manualPins; + const isServerOffline = offlineCtx.active; const sidebarItems = useSidebarStore(s => s.items); const setSidebarItems = useSidebarStore(s => s.setItems); const randomNavMode = useAuthStore(s => s.randomNavMode); @@ -99,7 +94,7 @@ export default function Sidebar({ }, [playlistsRaw]); const [sidebarViewportEl, setSidebarViewportEl] = useState(null); const isSidebarScrolling = useSidebarScrollVisible(sidebarViewportEl); - const showLibraryPicker = !isCollapsed && isLoggedIn && musicFolders.length > 1; + const showLibraryPicker = !isCollapsed && isLoggedIn && musicFolders.length > 1 && !isServerOffline; const filterId = serverId ? (musicLibraryFilterByServer[serverId] ?? 'all') : 'all'; const selectedFolderName = @@ -118,20 +113,34 @@ export default function Sidebar({ libraryItemsForReorder.filter(c => { if (!c.visible) return false; if (c.id === 'luckyMix' && !luckyMixAvailable) return false; - if (isServerOffline && !isOfflineSidebarLibraryNavAllowed(c.id, favoritesOfflineBrowse)) { + if (isServerOffline && !isOfflineSidebarNavAllowed( + c.id, + offlineNav.favoritesOfflineBrowse, + offlineNav.localLibraryBrowse, + offlineNav.playerStatsBrowse, + offlineNav.playlistsOfflineBrowse, + )) { return false; } return true; }), - [libraryItemsForReorder, luckyMixAvailable, isServerOffline, favoritesOfflineBrowse], + [libraryItemsForReorder, luckyMixAvailable, isServerOffline, offlineNav], ); const visibleSystemConfigs = useMemo( () => systemItemsForReorder.filter(c => { if (!c.visible) return false; - if (isServerOffline) return false; + if (isServerOffline && !isOfflineSidebarNavAllowed( + c.id, + offlineNav.favoritesOfflineBrowse, + offlineNav.localLibraryBrowse, + offlineNav.playerStatsBrowse, + offlineNav.playlistsOfflineBrowse, + )) { + return false; + } return true; }), - [systemItemsForReorder, isServerOffline], + [systemItemsForReorder, isServerOffline, offlineNav], ); const sidebarItemsRef = useRef(sidebarItems); @@ -164,10 +173,15 @@ export default function Sidebar({ const pickLibrary = (id: 'all' | string) => { + if (isServerOffline) return; setMusicLibraryFilter(id); setLibraryDropdownOpen(false); }; + useEffect(() => { + if (isServerOffline) setLibraryDropdownOpen(false); + }, [isServerOffline, setLibraryDropdownOpen]); + // Fetch playlists when expanded useEffect(() => { if (!playlistsExpanded || !isLoggedIn) return; diff --git a/src/components/albumDetail/AlbumDetailToolbar.tsx b/src/components/albumDetail/AlbumDetailToolbar.tsx index f0d39c59..a6e1efab 100644 --- a/src/components/albumDetail/AlbumDetailToolbar.tsx +++ b/src/components/albumDetail/AlbumDetailToolbar.tsx @@ -3,6 +3,7 @@ import { ListPlus, Search, X } from 'lucide-react'; import type { TFunction } from 'i18next'; import { useSelectionStore } from '../../store/selectionStore'; import { AddToPlaylistSubmenu } from '../ContextMenu'; +import { offlineActionPolicy, type OfflineActionPolicy } from '../../utils/offline/offlineActionPolicy'; interface Props { filterText: string; @@ -12,6 +13,7 @@ interface Props { showPlPicker: boolean; setShowPlPicker: React.Dispatch>; t: TFunction; + actionPolicy?: OfflineActionPolicy; } /** @@ -31,7 +33,9 @@ export function AlbumDetailToolbar({ showPlPicker, setShowPlPicker, t, + actionPolicy, }: Props) { + const policy = actionPolicy ?? offlineActionPolicy('albumDetail', false); return (
@@ -59,22 +63,24 @@ export function AlbumDetailToolbar({ {t('common.bulkSelected', { count: selectedCount })} -
- - {showPlPicker && ( - { setShowPlPicker(false); useSelectionStore.getState().clearAll(); }} - dropDown - /> - )} -
+ {policy.canAddToPlaylist && ( +
+ + {showPlPicker && ( + { setShowPlPicker(false); useSelectionStore.getState().clearAll(); }} + dropDown + /> + )} +
+ )}
@@ -168,15 +172,17 @@ export default function ArtistDetailHero({
)} - + {policy.canFavorite && ( + + )}
@@ -222,7 +228,7 @@ export default function ArtistDetailHero({ )} - {albums.length > 0 && ( + {policy.canCacheDiscography && albums.length > 0 && (
handleAction(async () => { - const albumData = await getAlbum(album.id); + const serverId = resolveMediaServerId(album.serverId); + if (!serverId) return; + const albumData = await resolveAlbum(serverId, album.id); + if (!albumData) return; const tracks = albumData.songs.map(songToTrack); if (tracks.length === 0) return; playNext(tracks); @@ -48,7 +51,10 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) { {t('contextMenu.playNext')}
handleAction(async () => { - const albumData = await getAlbum(album.id); + const serverId = resolveMediaServerId(album.serverId); + if (!serverId) return; + const albumData = await resolveAlbum(serverId, album.id); + if (!albumData) return; enqueue(albumData.songs.map(songToTrack)); })}> {t('contextMenu.enqueueAlbum')} @@ -57,58 +63,66 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
handleAction(() => goLibrary(`/artist/${album.artistId}`))}> {t('contextMenu.goToArtist')}
-
handleAction(() => { - const starred = isStarred(album.id, album.starred); - setStarredOverride(album.id, !starred); - const meta = { - serverId: album.serverId, - name: album.name, - artist: album.artist, - artistId: album.artistId, - coverArtId: album.coverArt, - year: album.year, - }; - return starred ? unstar(album.id, 'album', meta) : star(album.id, 'album', meta); - })}> - - {isStarred(album.id, album.starred) ? t('contextMenu.unfavoriteAlbum') : t('contextMenu.favoriteAlbum')} -
-
e.stopPropagation()} - > - - { setKeyboardRating({ kind: 'album', id: album.id, value: r }); applyAlbumRating(album, r); }} - /> -
+ {offlinePolicy.canFavorite && ( +
handleAction(() => { + const starred = isStarred(album.id, album.starred); + setStarredOverride(album.id, !starred); + const meta = { + serverId: album.serverId, + name: album.name, + artist: album.artist, + artistId: album.artistId, + coverArtId: album.coverArt, + year: album.year, + }; + return starred ? unstar(album.id, 'album', meta) : star(album.id, 'album', meta); + })}> + + {isStarred(album.id, album.starred) ? t('contextMenu.unfavoriteAlbum') : t('contextMenu.favoriteAlbum')} +
+ )} + {offlinePolicy.canRate && ( +
e.stopPropagation()} + > + + { setKeyboardRating({ kind: 'album', id: album.id, value: r }); applyAlbumRating(album, r); }} + /> +
+ )}
handleAction(() => copyShareLink('album', album.id))}> {t('contextMenu.shareLink')}
-
handleAction(() => downloadAlbum(album.name, album.id))}> - {t('contextMenu.download')} -
-
{ cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`album:${album.id}`]); setPlaylistSubmenuOpen(true); }} - onMouseLeave={onPlaylistSubmenuTriggerMouseLeave} - > - {t('contextMenu.addToPlaylist')} - - {playlistSubmenuOpen && playlistSongIds[0] === `album:${album.id}` && ( - { setPlaylistSubmenuOpen(false); closeContextMenu(); }} /> - )} -
+ {offlinePolicy.canDownload && ( +
handleAction(() => downloadAlbum(album.name, album.id))}> + {t('contextMenu.download')} +
+ )} + {offlinePolicy.canAddToPlaylist && ( +
{ cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`album:${album.id}`]); setPlaylistSubmenuOpen(true); }} + onMouseLeave={onPlaylistSubmenuTriggerMouseLeave} + > + {t('contextMenu.addToPlaylist')} + + {playlistSubmenuOpen && playlistSongIds[0] === `album:${album.id}` && ( + { setPlaylistSubmenuOpen(false); closeContextMenu(); }} /> + )} +
+ )} ); })()} @@ -131,47 +145,56 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
handleAction(async () => { - // Parallel — Navidrome handles concurrent getAlbum requests fine. - const results = await Promise.all(albums.map(a => getAlbum(a.id))); - const allTracks = results.flatMap(r => r.songs.map(songToTrack)); + const results = await Promise.all(albums.map(async a => { + const serverId = resolveMediaServerId(a.serverId); + if (!serverId) return null; + return resolveAlbum(serverId, a.id); + })); + const allTracks = results + .filter((r): r is NonNullable => r != null) + .flatMap(r => r.songs.map(songToTrack)); enqueue(allTracks); })}> {t('contextMenu.enqueueAlbums', { count: albums.length })}
-
{ cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-album:${albumIds.join(',')}`]); setPlaylistSubmenuOpen(true); }} - onMouseLeave={onPlaylistSubmenuTriggerMouseLeave} - > - {t('contextMenu.addToPlaylist')} - - {playlistSubmenuOpen && playlistSongIds[0] === `multi-album:${albumIds.join(',')}` && ( - { setPlaylistSubmenuOpen(false); closeContextMenu(); }} /> - )} -
-
e.stopPropagation()} - > - - { - setKeyboardRating({ kind: 'album', id: multiAlbumRatingId, value: r }); - for (const a of albums) applyAlbumRating(a, r); - }} - /> -
+ {offlinePolicy.canAddToPlaylist && ( +
{ cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-album:${albumIds.join(',')}`]); setPlaylistSubmenuOpen(true); }} + onMouseLeave={onPlaylistSubmenuTriggerMouseLeave} + > + {t('contextMenu.addToPlaylist')} + + {playlistSubmenuOpen && playlistSongIds[0] === `multi-album:${albumIds.join(',')}` && ( + { setPlaylistSubmenuOpen(false); closeContextMenu(); }} /> + )} +
+ )} + {offlinePolicy.canRate && ( +
e.stopPropagation()} + > + + { + setKeyboardRating({ kind: 'album', id: multiAlbumRatingId, value: r }); + for (const a of albums) applyAlbumRating(a, r); + }} + /> +
+ )} ); })()} diff --git a/src/components/contextMenu/ArtistContextItems.tsx b/src/components/contextMenu/ArtistContextItems.tsx index cf783467..c12b86b8 100644 --- a/src/components/contextMenu/ArtistContextItems.tsx +++ b/src/components/contextMenu/ArtistContextItems.tsx @@ -20,6 +20,7 @@ export default function ArtistContextItems(props: ContextMenuItemsProps) { orbitRole, entityRatingSupport, audiomuseNavidromeEnabled, applySongRating, applyAlbumRating, applyArtistRating, handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred, + offlinePolicy, } = props; const { t } = useTranslation(); const auth = useAuthStore(); @@ -35,54 +36,64 @@ export default function ArtistContextItems(props: ContextMenuItemsProps) {
handleAction(() => startRadio(artist.id, artist.name))}> {t('contextMenu.startRadio')}
-
{ cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`artist:${artist.id}`]); setPlaylistSubmenuOpen(true); }} - onMouseLeave={onPlaylistSubmenuTriggerMouseLeave} - > - {t('contextMenu.addToPlaylist')} - - {playlistSubmenuOpen && playlistSongIds[0] === `artist:${artist.id}` && ( - { setPlaylistSubmenuOpen(false); closeContextMenu(); }} /> - )} -
+ {offlinePolicy.canAddToPlaylist && ( +
{ cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`artist:${artist.id}`]); setPlaylistSubmenuOpen(true); }} + onMouseLeave={onPlaylistSubmenuTriggerMouseLeave} + > + {t('contextMenu.addToPlaylist')} + + {playlistSubmenuOpen && playlistSongIds[0] === `artist:${artist.id}` && ( + { setPlaylistSubmenuOpen(false); closeContextMenu(); }} /> + )} +
+ )}
handleAction(() => copyShareLink(shareKindOverride ?? 'artist', artist.id))}> {t('contextMenu.shareLink')}
-
-
handleAction(() => { - const starred = isStarred(artist.id, artist.starred); - setStarredOverride(artist.id, !starred); - const meta = { - serverId: artist.serverId, - name: artist.name, - albumCount: artist.albumCount, - }; - return starred - ? unstar(artist.id, 'artist', meta) - : star(artist.id, 'artist', meta); - })}> - - {isStarred(artist.id, artist.starred) ? t('contextMenu.unfavoriteArtist') : t('contextMenu.favoriteArtist')} -
-
e.stopPropagation()} - > - - { setKeyboardRating({ kind: 'artist', id: artist.id, value: r }); applyArtistRating(artist, r); }} - /> -
+ {(offlinePolicy.canFavorite || offlinePolicy.canRate) && ( + <> +
+ {offlinePolicy.canFavorite && ( +
handleAction(() => { + const starred = isStarred(artist.id, artist.starred); + setStarredOverride(artist.id, !starred); + const meta = { + serverId: artist.serverId, + name: artist.name, + albumCount: artist.albumCount, + }; + return starred + ? unstar(artist.id, 'artist', meta) + : star(artist.id, 'artist', meta); + })}> + + {isStarred(artist.id, artist.starred) ? t('contextMenu.unfavoriteArtist') : t('contextMenu.favoriteArtist')} +
+ )} + {offlinePolicy.canRate && ( +
e.stopPropagation()} + > + + { setKeyboardRating({ kind: 'artist', id: artist.id, value: r }); applyArtistRating(artist, r); }} + /> +
+ )} + + )} ); })()} @@ -104,40 +115,44 @@ export default function ArtistContextItems(props: ContextMenuItemsProps) { {t('contextMenu.selectedArtists', { count: artists.length })}
-
{ cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-artist:${artistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }} - onMouseLeave={onPlaylistSubmenuTriggerMouseLeave} - > - {t('contextMenu.addToPlaylist')} - - {playlistSubmenuOpen && playlistSongIds[0] === `multi-artist:${artistIds.join(',')}` && ( - { setPlaylistSubmenuOpen(false); closeContextMenu(); }} /> - )} -
-
e.stopPropagation()} - > - - { - setKeyboardRating({ kind: 'artist', id: multiArtistRatingId, value: r }); - for (const a of artists) applyArtistRating(a, r); - }} - /> -
+ {offlinePolicy.canAddToPlaylist && ( +
{ cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-artist:${artistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }} + onMouseLeave={onPlaylistSubmenuTriggerMouseLeave} + > + {t('contextMenu.addToPlaylist')} + + {playlistSubmenuOpen && playlistSongIds[0] === `multi-artist:${artistIds.join(',')}` && ( + { setPlaylistSubmenuOpen(false); closeContextMenu(); }} /> + )} +
+ )} + {offlinePolicy.canRate && ( +
e.stopPropagation()} + > + + { + setKeyboardRating({ kind: 'artist', id: multiArtistRatingId, value: r }); + for (const a of artists) applyArtistRating(a, r); + }} + /> +
+ )} ); })()} diff --git a/src/components/contextMenu/MultiAlbumToPlaylistSubmenu.tsx b/src/components/contextMenu/MultiAlbumToPlaylistSubmenu.tsx index 4b812e94..6c7330e7 100644 --- a/src/components/contextMenu/MultiAlbumToPlaylistSubmenu.tsx +++ b/src/components/contextMenu/MultiAlbumToPlaylistSubmenu.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ListMusic, Plus } from 'lucide-react'; -import { getAlbum } from '../../api/subsonicLibrary'; +import { resolveAlbum, resolveMediaServerId, resolvePlaylist } from '../../utils/offline/offlineMediaResolve'; import type { SubsonicPlaylist } from '../../api/subsonicTypes'; import { usePlaylistStore } from '../../store/playlistStore'; import { showToast } from '../../utils/ui/toast'; @@ -26,7 +26,10 @@ export function MultiAlbumToPlaylistSubmenu({ albumIds, onDone, triggerId: _trig setTotalAlbums(albumIds.length); const loadingTimeout = setTimeout(() => setShowLoading(true), 300); (async () => { - const albumSongs = await Promise.all(albumIds.map(id => getAlbum(id).then(r => r.songs).catch(() => []))); + const serverId = resolveMediaServerId(); + const albumSongs = serverId + ? await Promise.all(albumIds.map(id => resolveAlbum(serverId, id).then(r => r?.songs ?? []).catch(() => []))) + : []; const allSongs = albumSongs.flat(); setResolvedIds(allSongs.map(s => s.id)); })().catch(() => setResolvedIds([])); @@ -34,11 +37,15 @@ export function MultiAlbumToPlaylistSubmenu({ albumIds, onDone, triggerId: _trig }, [albumIds]); const handleAddWithToast = async (pl: SubsonicPlaylist, songIds: string[]) => { - const { getPlaylist, updatePlaylist } = await import('../../api/subsonicPlaylists'); + const { updatePlaylist } = await import('../../api/subsonicPlaylists'); const touchPlaylist = usePlaylistStore.getState().touchPlaylist; try { - const { songs: existingSongs } = await getPlaylist(pl.id); + const serverId = resolveMediaServerId(); + if (!serverId) return; + const resolved = await resolvePlaylist(serverId, pl.id); + if (!resolved) return; + const { songs: existingSongs } = resolved; const existingIds = new Set(existingSongs.map((s) => s.id)); const newIds: string[] = []; diff --git a/src/components/contextMenu/MultiArtistToPlaylistSubmenu.tsx b/src/components/contextMenu/MultiArtistToPlaylistSubmenu.tsx index 931e1690..14778606 100644 --- a/src/components/contextMenu/MultiArtistToPlaylistSubmenu.tsx +++ b/src/components/contextMenu/MultiArtistToPlaylistSubmenu.tsx @@ -1,8 +1,7 @@ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ListMusic, Plus } from 'lucide-react'; -import { getAlbum } from '../../api/subsonicLibrary'; -import { getArtist } from '../../api/subsonicArtists'; +import { resolveAlbum, resolveArtist, resolveMediaServerId, resolvePlaylist } from '../../utils/offline/offlineMediaResolve'; import { getPlaylists } from '../../api/subsonicPlaylists'; import type { SubsonicPlaylist } from '../../api/subsonicTypes'; import { usePlaylistStore } from '../../store/playlistStore'; @@ -29,10 +28,18 @@ export function MultiArtistToPlaylistSubmenu({ artistIds, onDone, triggerId: _tr const loadingTimeout = setTimeout(() => setShowLoading(true), 300); (async () => { const allSongs: string[] = []; + const serverId = resolveMediaServerId(); + if (!serverId) { + setResolvedIds([]); + return; + } for (const artistId of artistIds) { try { - const { albums } = await getArtist(artistId); - const albumSongs = await Promise.all(albums.map(a => getAlbum(a.id).then(r => r.songs).catch(() => []))); + const artistData = await resolveArtist(serverId, artistId); + if (!artistData) continue; + const albumSongs = await Promise.all( + artistData.albums.map(a => resolveAlbum(serverId, a.id).then(r => r?.songs ?? []).catch(() => [])), + ); allSongs.push(...albumSongs.flat().map(s => s.id)); } catch { // Skip failed artists @@ -44,11 +51,15 @@ export function MultiArtistToPlaylistSubmenu({ artistIds, onDone, triggerId: _tr }, [artistIds]); const handleAddWithToast = async (pl: SubsonicPlaylist, songIds: string[]) => { - const { getPlaylist, updatePlaylist } = await import('../../api/subsonicPlaylists'); + const { updatePlaylist } = await import('../../api/subsonicPlaylists'); const touchPlaylist = usePlaylistStore.getState().touchPlaylist; try { - const { songs: existingSongs } = await getPlaylist(pl.id); + const serverId = resolveMediaServerId(); + if (!serverId) return; + const resolved = await resolvePlaylist(serverId, pl.id); + if (!resolved) return; + const { songs: existingSongs } = resolved; const existingIds = new Set(existingSongs.map((s) => s.id)); const newIds: string[] = []; diff --git a/src/components/contextMenu/PlaylistContextItems.tsx b/src/components/contextMenu/PlaylistContextItems.tsx index 727d2cc0..25683996 100644 --- a/src/components/contextMenu/PlaylistContextItems.tsx +++ b/src/components/contextMenu/PlaylistContextItems.tsx @@ -18,6 +18,7 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) { orbitRole, entityRatingSupport, audiomuseNavidromeEnabled, applySongRating, applyAlbumRating, applyArtistRating, handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred, + offlinePolicy, } = props; const { t } = useTranslation(); const auth = useAuthStore(); @@ -33,18 +34,22 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) { {t('contextMenu.playNow')}
-
{ cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`playlist:${playlist.id}`]); setPlaylistSubmenuOpen(true); }} - onMouseLeave={onPlaylistSubmenuTriggerMouseLeave} - > - {t('contextMenu.addToPlaylist')} - - {playlistSubmenuOpen && playlistSongIds[0] === `playlist:${playlist.id}` && ( - { setPlaylistSubmenuOpen(false); closeContextMenu(); }} /> - )} -
+ {offlinePolicy.canAddToPlaylist && ( +
{ cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`playlist:${playlist.id}`]); setPlaylistSubmenuOpen(true); }} + onMouseLeave={onPlaylistSubmenuTriggerMouseLeave} + > + {t('contextMenu.addToPlaylist')} + + {playlistSubmenuOpen && playlistSongIds[0] === `playlist:${playlist.id}` && ( + { setPlaylistSubmenuOpen(false); closeContextMenu(); }} /> + )} +
+ )} + {offlinePolicy.canEditPlaylist && ( + <>
handleAction(async () => { const { showToast } = await import('../../utils/ui/toast'); @@ -64,6 +69,8 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) { })}> {t('playlists.deletePlaylist')}
+ + )} ); })()} @@ -77,18 +84,21 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) { {t('contextMenu.selectedPlaylists', { count: selectedPlaylists.length })}
-
{ cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-playlist:${playlistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }} - onMouseLeave={onPlaylistSubmenuTriggerMouseLeave} - > - {t('contextMenu.addToPlaylist')} - - {playlistSubmenuOpen && playlistSongIds[0] === `multi-playlist:${playlistIds.join(',')}` && ( - { setPlaylistSubmenuOpen(false); closeContextMenu(); }} /> - )} -
+ {offlinePolicy.canAddToPlaylist && ( +
{ cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-playlist:${playlistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }} + onMouseLeave={onPlaylistSubmenuTriggerMouseLeave} + > + {t('contextMenu.addToPlaylist')} + + {playlistSubmenuOpen && playlistSongIds[0] === `multi-playlist:${playlistIds.join(',')}` && ( + { setPlaylistSubmenuOpen(false); closeContextMenu(); }} /> + )} +
+ )} + {offlinePolicy.canEditPlaylist && (
handleAction(async () => { const { showToast } = await import('../../utils/ui/toast'); const { deletePlaylist } = await import('../../api/subsonicPlaylists'); @@ -113,6 +123,7 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) { })}> {t('playlists.deleteSelected')}
+ )} ); })()} diff --git a/src/components/contextMenu/SongContextItems.tsx b/src/components/contextMenu/SongContextItems.tsx index 2aca22eb..efb5784b 100644 --- a/src/components/contextMenu/SongContextItems.tsx +++ b/src/components/contextMenu/SongContextItems.tsx @@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'; import { Play, ListPlus, Radio, Heart, ChevronRight, ChevronsRight, User, Disc3, ListMusic, Info, Sparkles, Star, Trash2, HeartCrack, Share2, Orbit as OrbitIcon } from 'lucide-react'; import { useNavigateToAlbum } from '../../hooks/useNavigateToAlbum'; import { useNavigateToArtist } from '../../hooks/useNavigateToArtist'; -import { getAlbum } from '../../api/subsonicLibrary'; +import { resolveAlbum, resolveMediaServerId, resolvePlaylist } from '../../utils/offline/offlineMediaResolve'; import { queueSongStar } from '../../store/pendingStarSync'; import { lastfmLoveTrack, lastfmUnloveTrack } from '../../api/lastfm'; import type { Track } from '../../store/playerStoreTypes'; @@ -27,6 +27,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) { orbitRole, entityRatingSupport, audiomuseNavidromeEnabled, applySongRating, applyAlbumRating, applyArtistRating, handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred, + offlinePolicy, } = props; const { t } = useTranslation(); const auth = useAuthStore(); @@ -80,21 +81,26 @@ export default function SongContextItems(props: ContextMenuItemsProps) { {t('orbit.ctxAddToSessionHost')}
)} -
{ cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }} - onMouseLeave={onPlaylistSubmenuTriggerMouseLeave} - > - {t('contextMenu.addToPlaylist')} - - {playlistSubmenuOpen && playlistSongIds[0] === song.id && ( - { setPlaylistSubmenuOpen(false); closeContextMenu(); }} /> - )} -
+ {offlinePolicy.canAddToPlaylist && ( +
{ cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }} + onMouseLeave={onPlaylistSubmenuTriggerMouseLeave} + > + {t('contextMenu.addToPlaylist')} + + {playlistSubmenuOpen && playlistSongIds[0] === song.id && ( + { setPlaylistSubmenuOpen(false); closeContextMenu(); }} /> + )} +
+ )} {type === 'album-song' && (
handleAction(async () => { - const albumData = await getAlbum(song.albumId); + const serverId = resolveMediaServerId(song.serverId); + if (!serverId || !song.albumId) return; + const albumData = await resolveAlbum(serverId, song.albumId); + if (!albumData) return; const tracks = albumData.songs.map(songToTrack); enqueue(tracks); })}> @@ -120,12 +126,14 @@ export default function SongContextItems(props: ContextMenuItemsProps) { {t('contextMenu.instantMix')}
)} -
handleAction(() => { - queueSongStar(song.id, !isStarred(song.id, song.starred), song.serverId); - })}> - - {isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')} -
+ {offlinePolicy.canFavorite && ( +
handleAction(() => { + queueSongStar(song.id, !isStarred(song.id, song.starred), song.serverId); + })}> + + {isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')} +
+ )} {auth.lastfmSessionKey && (() => { const loveKey = `${song.title}::${song.artist}`; const loved = lastfmLovedCache[loveKey] ?? false; @@ -141,22 +149,24 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
); })()} -
e.stopPropagation()} - > - - { setKeyboardRating({ kind: 'song', id: song.id, value: r }); applySongRating(song.id, r); }} - ariaLabel={t('albumDetail.ratingLabel')} - /> -
+ {offlinePolicy.canRate && ( +
e.stopPropagation()} + > + + { setKeyboardRating({ kind: 'song', id: song.id, value: r }); applySongRating(song.id, r); }} + ariaLabel={t('albumDetail.ratingLabel')} + /> +
+ )}
handleAction(() => copyShareLink('track', song.id))}> {t('contextMenu.shareLink')} @@ -164,13 +174,17 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
handleAction(() => openSongInfo(song.id))}> {t('contextMenu.songInfo')}
- {playlistId && playlistSongIndex !== undefined && ( + {offlinePolicy.canEditPlaylist && playlistId && playlistSongIndex !== undefined && (
handleAction(async () => { - const { getPlaylist, updatePlaylist } = await import('../../api/subsonicPlaylists'); + const { updatePlaylist } = await import('../../api/subsonicPlaylists'); const { showToast } = await import('../../utils/ui/toast'); const touchPlaylist = usePlaylistStore.getState().touchPlaylist; try { - const { songs } = await getPlaylist(playlistId); + const serverId = resolveMediaServerId(); + if (!serverId) return; + const resolved = await resolvePlaylist(serverId, playlistId); + if (!resolved) return; + const { songs } = resolved; const prevCount = songs.length; const updatedIds = songs.filter((_, i) => i !== playlistSongIndex).map(s => s.id); await updatePlaylist(playlistId, updatedIds, prevCount); @@ -232,18 +246,20 @@ export default function SongContextItems(props: ContextMenuItemsProps) { {t('orbit.ctxAddToSessionHost')}
)} -
{ cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }} - onMouseLeave={onPlaylistSubmenuTriggerMouseLeave} - > - {t('contextMenu.addToPlaylist')} - - {playlistSubmenuOpen && playlistSongIds[0] === song.id && ( - { setPlaylistSubmenuOpen(false); closeContextMenu(); }} /> - )} -
+ {offlinePolicy.canAddToPlaylist && ( +
{ cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }} + onMouseLeave={onPlaylistSubmenuTriggerMouseLeave} + > + {t('contextMenu.addToPlaylist')} + + {playlistSubmenuOpen && playlistSongIds[0] === song.id && ( + { setPlaylistSubmenuOpen(false); closeContextMenu(); }} /> + )} +
+ )}
{song.albumId && (
handleAction(() => navigateToAlbum(song.albumId!))}> @@ -278,22 +294,24 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
); })()} -
e.stopPropagation()} - > - - { setKeyboardRating({ kind: 'song', id: song.id, value: r }); applySongRating(song.id, r); }} - ariaLabel={t('albumDetail.ratingLabel')} - /> -
+ {offlinePolicy.canRate && ( +
e.stopPropagation()} + > + + { setKeyboardRating({ kind: 'song', id: song.id, value: r }); applySongRating(song.id, r); }} + ariaLabel={t('albumDetail.ratingLabel')} + /> +
+ )}
handleAction(() => copyShareLink('track', song.id))}> {t('contextMenu.shareLink')} @@ -301,12 +319,16 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
handleAction(() => openSongInfo(song.id))}> {t('contextMenu.songInfo')}
-
-
handleAction(() => { - queueSongStar(song.id, false, song.serverId); - })}> - {t('contextMenu.unfavorite')} -
+ {offlinePolicy.canFavorite && ( + <> +
+
handleAction(() => { + queueSongStar(song.id, false, song.serverId); + })}> + {t('contextMenu.unfavorite')} +
+ + )} ); })()} diff --git a/src/components/contextMenu/contextMenuItemTypes.ts b/src/components/contextMenu/contextMenuItemTypes.ts index 3b2a7cc2..9cc63459 100644 --- a/src/components/contextMenu/contextMenuItemTypes.ts +++ b/src/components/contextMenu/contextMenuItemTypes.ts @@ -2,6 +2,7 @@ import type React from 'react'; import type { SubsonicAlbum, SubsonicArtist } from '../../api/subsonicTypes'; import type { QueueItemRef, Track } from '../../store/playerStoreTypes'; import type { EntityShareKind } from '../../utils/share/shareLink'; +import type { OfflineActionPolicy } from '../../utils/offline/offlineActionPolicy'; export type RatingKind = 'song' | 'album' | 'artist'; @@ -56,4 +57,5 @@ export interface ContextMenuItemsProps { /** When true, album/artist links switch to the queue server before routing. */ pinToPlaybackServer: boolean; navigateLibrary: (path: string) => void | Promise; + offlinePolicy: OfflineActionPolicy; } diff --git a/src/components/playerBar/PlayerTrackInfo.tsx b/src/components/playerBar/PlayerTrackInfo.tsx index f86f2c61..fcc265e6 100644 --- a/src/components/playerBar/PlayerTrackInfo.tsx +++ b/src/components/playerBar/PlayerTrackInfo.tsx @@ -19,6 +19,8 @@ import { usePlayerBarLayoutStore, type PlayerBarLayoutItemId, } from '../../store/playerBarLayoutStore'; +import { useOfflineBrowseContext } from '../../hooks/useOfflineBrowseContext'; +import { offlineActionPolicy } from '../../utils/offline/offlineActionPolicy'; interface Props { currentTrack: Track | null; @@ -67,6 +69,8 @@ export function PlayerTrackInfo({ const layoutItems = usePlayerBarLayoutStore(s => s.items); const isLayoutVisible = (id: PlayerBarLayoutItemId) => layoutItems.find(i => i.id === id)?.visible !== false; + const offlineBrowseActive = useOfflineBrowseContext().active; + const playerPolicy = offlineActionPolicy('playerBar', offlineBrowseActive); return (
@@ -168,7 +172,7 @@ export function PlayerTrackInfo({ onClick={() => !isRadio && !showPreviewMeta && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)} /> )} - {currentTrack && !isRadio && !showPreviewMeta && isLayoutVisible('starRating') && ( + {currentTrack && !isRadio && !showPreviewMeta && isLayoutVisible('starRating') && playerPolicy.canRate && ( queueSongRating(currentTrack.id, r)} @@ -182,7 +186,7 @@ export function PlayerTrackInfo({ )}
- {currentTrack && !isRadio && isLayoutVisible('favorite') && ( + {currentTrack && !isRadio && isLayoutVisible('favorite') && playerPolicy.canFavorite && ( + {actionPolicy.canEditPlaylist && ( + + )}
{playlist.comment && (
{playlist.comment}
@@ -172,7 +176,7 @@ export default function PlaylistHero({
- {isLayoutVisible('addSongs') && ( + {actionPolicy.canEditPlaylist && isLayoutVisible('addSongs') && ( )} - {isLayoutVisible('importCsv') && ( + {actionPolicy.canEditPlaylist && isLayoutVisible('importCsv') && ( ) )} - {isLayoutVisible('offlineCache') && songs.length > 0 && id + {actionPolicy.canPinOffline && isLayoutVisible('offlineCache') && songs.length > 0 && id && (!isSmartPlaylistName(playlist.name) || offlineStatus !== 'none') && ( - + {showServerTab && ( + + )} + {playerStatsEnabled && ( + + )}
); } diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 6dc90410..3d1f9c14 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -153,6 +153,7 @@ const CONTRIBUTOR_ENTRIES = [ 'Cover backfill: follow the smart local/public endpoint switch so off-LAN clients stop fetching covers from the unreachable local address (PR #952)', 'Audio: Symphonia 0.6 migration with libopus adapter 0.3; ranged-stream start latency fix (probe seek-gate) and a probe timeout so a stalled stream no longer hangs playback start (PR #999)', 'Offline experience — unified media layout (cache/library/favorites), localPlaybackStore, library-index Offline Library, favorites auto-sync, cached album/playlist/artist pin reconcile, mixed-server offline queue, and single mediaDir setting (PR #1008)', + 'Offline browse — local-bytes catalog when server is down, integration contract (context/policy/resolvers), disconnect nav fork, Home stale-cache feed, read-only context menus, PlayerBar rating/favorite guard (PR #1017)', ], }, { diff --git a/src/hooks/useAlbumBrowseData.ts b/src/hooks/useAlbumBrowseData.ts index a72bc225..1550246c 100644 --- a/src/hooks/useAlbumBrowseData.ts +++ b/src/hooks/useAlbumBrowseData.ts @@ -31,6 +31,13 @@ import { ALBUM_YEAR_FILTER_DEBOUNCE_MS, resolveAlbumYearBounds, } from '../utils/library/albumYearFilter'; +import { loadOfflineAlbumBrowseInitial } from '../utils/offline/offlineAlbumBrowseCatalog'; +import { useOfflineBrowseReloadToken } from './useOfflineBrowseReloadToken'; +import { + fetchAlbumBrowseCatalogChunk, + mergeAlbumCatalogChunk, +} from '../utils/library/albumBrowseCatalogChunk'; +import { useOfflineBrowseContext } from './useOfflineBrowseContext'; import { useClientSliceInfiniteScroll } from './useClientSliceInfiniteScroll'; import { useDebouncedValue } from './useDebouncedValue'; import { useInpageScrollSentinel } from './useInpageScrollSentinel'; @@ -91,6 +98,8 @@ export function useAlbumBrowseData({ scrollRootEl, restoreDisplayCount, }: UseAlbumBrowseDataArgs) { + const offlineBrowseActive = useOfflineBrowseContext().active; + const offlineBrowseReloadTs = useOfflineBrowseReloadToken(); const [albums, setAlbums] = useState([]); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); @@ -232,18 +241,19 @@ export function useAlbumBrowseData({ catalogLoadingRef.current = true; setCatalogLoadingMore(true); try { - const chunk = await fetchLocalAlbumCatalogChunk(serverId, query, offset, CATALOG_CHUNK_SIZE); + const chunk = await fetchAlbumBrowseCatalogChunk( + serverId, + query, + offset, + CATALOG_CHUNK_SIZE, + starredOverrides, + ); if (generation !== loadGenerationRef.current || chunk == null) return; - if (append) { - setAlbums(prev => { - const merged = dedupeById([...prev, ...chunk.albums]); - catalogOffsetRef.current = merged.length; - return merged; - }); - } else { - setAlbums(chunk.albums); - catalogOffsetRef.current = chunk.albums.length; - } + setAlbums(prev => { + const { albums: next, offset: nextOffset } = mergeAlbumCatalogChunk(prev, chunk, append); + catalogOffsetRef.current = nextOffset; + return next; + }); setCatalogHasMore(chunk.hasMore); } finally { catalogLoadingRef.current = false; @@ -251,7 +261,7 @@ export function useAlbumBrowseData({ setCatalogLoadingMore(false); } } - }, [serverId]); + }, [offlineBrowseActive, serverId, starredOverrides]); const loadBrowse = useCallback(async ( query: AlbumBrowseQuery, @@ -321,6 +331,28 @@ export function useAlbumBrowseData({ setLoading(true); void (async () => { + if (offlineBrowseActive) { + const generation = ++loadGenerationRef.current; + if (cancelled || generation !== loadGenerationRef.current) return; + setBrowseMode('slice'); + try { + const first = await loadOfflineAlbumBrowseInitial( + serverId, + browseQuery, + CATALOG_CHUNK_SIZE, + starredOverrides, + ); + if (cancelled || generation !== loadGenerationRef.current) return; + setAlbums(first.albums); + catalogOffsetRef.current = first.albums.length; + setCatalogHasMore(first.hasMore); + } catch { + setAlbums([]); + setCatalogHasMore(false); + } + setLoading(false); + return; + } if (indexEnabled && serverId) { const generation = ++loadGenerationRef.current; coverTrafficBeginGridPagination(); @@ -353,7 +385,7 @@ export function useAlbumBrowseData({ return () => { cancelled = true; }; - }, [browseQuery, indexEnabled, serverId, loadBrowse, musicLibraryFilterVersion]); + }, [browseQuery, indexEnabled, offlineBrowseActive, offlineBrowseReloadTs, serverId, loadBrowse, musicLibraryFilterVersion]); useEffect(() => { if (!genreCatalogActive) { diff --git a/src/hooks/useAlbumDetailData.ts b/src/hooks/useAlbumDetailData.ts index 54fb7848..3aed0dcd 100644 --- a/src/hooks/useAlbumDetailData.ts +++ b/src/hooks/useAlbumDetailData.ts @@ -1,22 +1,28 @@ import { useEffect, useState } from 'react'; 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'; +} from '../utils/offline/offlineLibraryIndexLoad'; +import { + resolveAlbum, + resolveArtist, + type ResolvedAlbum, +} from '../utils/offline/offlineMediaResolve'; +import { useOfflineBrowseContext } from './useOfflineBrowseContext'; +import { + loadArtistFromLocalPlayback, + offlineLocalBrowseEnabled, +} from '../utils/offline/offlineLocalBrowse'; import { readDetailServerId } from '../utils/navigation/detailServerScope'; import { shouldAttemptSubsonicForActiveServer, shouldAttemptSubsonicForServer, } from '../utils/network/subsonicNetworkGuard'; -type AlbumPayload = Awaited>; +type AlbumPayload = ResolvedAlbum; interface UseAlbumDetailDataResult { album: AlbumPayload | null; @@ -44,7 +50,7 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe const activeServerId = useAuthStore(s => s.activeServerId); const [searchParams] = useSearchParams(); const detailServerId = readDetailServerId(searchParams, activeServerId); - const { status: connStatus } = useConnectionStatus(); + const offlineBrowseActive = useOfflineBrowseContext().active && !!detailServerId; useEffect(() => { if (!id) return; @@ -64,22 +70,25 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe serverId: string | null, artistId: string | undefined, useLocalArtist: boolean, + localBytesOnly: boolean, ) => { if (!artistId) return; try { if (useLocalArtist && serverId) { - const artistLocal = await loadArtistFromLibraryIndex(serverId, artistId); + const artistLocal = localBytesOnly + ? await loadArtistFromLocalPlayback(serverId, artistId) + : 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)); + if (!relatedServerId) return; + const artistData = await resolveArtist(relatedServerId, artistId); + if (artistData) { + setRelatedAlbums(artistData.albums.filter(a => a.id !== id)); + } } catch (e) { console.error('Failed to fetch related albums', e); } @@ -88,12 +97,28 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe const libraryFirst = favoritesOfflineEnabled && !!detailServerId; void (async () => { + if (offlineBrowseActive && detailServerId) { + const local = await resolveAlbum(detailServerId, id); + if (local) { + applyAlbumPayload(local); + await loadRelatedAlbums( + detailServerId, + local.album.artistId, + true, + offlineLocalBrowseEnabled(detailServerId), + ); + return; + } + setLoading(false); + return; + } + if (libraryFirst && detailServerId) { try { - const local = await resolveAlbumForServer(detailServerId, id); + const local = await resolveAlbum(detailServerId, id); if (local) { applyAlbumPayload(local); - await loadRelatedAlbums(detailServerId, local.album.artistId, true); + await loadRelatedAlbums(detailServerId, local.album.artistId, true, false); return; } } catch { /* fall through */ } @@ -106,10 +131,10 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe if (!detailNetworkAllowed) { if (favoritesOfflineEnabled && detailServerId) { try { - const local = await loadAlbumFromLibraryIndex(detailServerId, id); + const local = await resolveAlbum(detailServerId, id); if (local) { applyAlbumPayload(local); - await loadRelatedAlbums(detailServerId, local.album.artistId, true); + await loadRelatedAlbums(detailServerId, local.album.artistId, true, false); return; } } catch { /* ignore */ } @@ -119,18 +144,25 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe } try { - const data = detailServerId - ? await getAlbumForServer(detailServerId, id) - : await getAlbum(id); + const sid = detailServerId ?? activeServerId; + if (!sid) { + setLoading(false); + return; + } + const data = await resolveAlbum(sid, id); + if (!data) { + setLoading(false); + return; + } applyAlbumPayload(data); - await loadRelatedAlbums(detailServerId, data.album.artistId, false); + await loadRelatedAlbums(detailServerId, data.album.artistId, false, false); } catch { if (favoritesOfflineEnabled && detailServerId) { try { const local = await loadAlbumFromLibraryIndex(detailServerId, id); if (local) { applyAlbumPayload(local); - await loadRelatedAlbums(detailServerId, local.album.artistId, true); + await loadRelatedAlbums(detailServerId, local.album.artistId, true, false); return; } } catch { /* ignore */ } @@ -138,7 +170,7 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe setLoading(false); } })(); - }, [id, connStatus, favoritesOfflineEnabled, detailServerId, searchParams]); + }, [activeServerId, detailServerId, favoritesOfflineEnabled, id, offlineBrowseActive, searchParams]); return { album, setAlbum, relatedAlbums, loading, isStarred, setIsStarred, starredSongs, setStarredSongs }; } diff --git a/src/hooks/useArtistDetailData.ts b/src/hooks/useArtistDetailData.ts index 07da2b52..883b0e2f 100644 --- a/src/hooks/useArtistDetailData.ts +++ b/src/hooks/useArtistDetailData.ts @@ -7,7 +7,9 @@ import type { } from '../api/subsonicTypes'; import { useAuthStore } from '../store/authStore'; import { useConnectionStatus } from './useConnectionStatus'; -import { loadArtistFromLibraryIndex } from '../utils/offline/favoritesOfflineBrowse'; +import { loadArtistFromLibraryIndex } from '../utils/offline/offlineLibraryIndexLoad'; +import { useOfflineBrowseContext } from './useOfflineBrowseContext'; +import { loadArtistFromLocalPlayback, offlineLocalBrowseEnabled } from '../utils/offline/offlineLocalBrowse'; import { readDetailServerId } from '../utils/navigation/detailServerScope'; import { runLocalArtistLosslessBrowse } from '../utils/library/browseTextSearch'; import { isLosslessSuffix } from '../utils/library/losslessFormats'; @@ -58,9 +60,10 @@ export function useArtistDetailData( s => !!(serverId && s.audiomuseNavidromeByServer[serverId]), ); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); - const preferLocalArtist = connStatus === 'disconnected' - && favoritesOfflineEnabled - && !!serverId; + const offlineBrowseActive = useOfflineBrowseContext().active && !!serverId; + const preferLocalBytesOnly = offlineBrowseActive && offlineLocalBrowseEnabled(serverId); + const preferLocalArtist = preferLocalBytesOnly + || (connStatus === 'disconnected' && favoritesOfflineEnabled && !!serverId); const [artist, setArtist] = useState(null); const [albums, setAlbums] = useState([]); @@ -82,8 +85,14 @@ export function useArtistDetailData( (async () => { try { + if (offlineBrowseActive && !preferLocalBytesOnly) { + setLoading(false); + return; + } if (preferLocalArtist && serverId && id) { - const local = await loadArtistFromLibraryIndex(serverId, id); + const local = preferLocalBytesOnly + ? await loadArtistFromLocalPlayback(serverId, id) + : await loadArtistFromLibraryIndex(serverId, id); if (cancelled) return; if (local) { setArtist(local.artist); @@ -93,6 +102,10 @@ export function useArtistDetailData( setLoading(false); return; } + if (preferLocalBytesOnly) { + setLoading(false); + return; + } } if (losslessOnly && serverId) { @@ -135,7 +148,9 @@ export function useArtistDetailData( if (!cancelled) { if (preferLocalArtist && serverId && id) { try { - const local = await loadArtistFromLibraryIndex(serverId, id); + const local = preferLocalBytesOnly + ? await loadArtistFromLocalPlayback(serverId, id) + : await loadArtistFromLibraryIndex(serverId, id); if (cancelled) return; if (local) { setArtist(local.artist); @@ -154,7 +169,7 @@ export function useArtistDetailData( })(); return () => { cancelled = true; }; - }, [id, losslessOnly, serverId, preferLocalArtist, searchParams]); + }, [id, losslessOnly, serverId, offlineBrowseActive, preferLocalArtist, preferLocalBytesOnly, searchParams]); useEffect(() => { if (!id || preferLocalArtist) return; diff --git a/src/hooks/useArtistsBrowseCatalog.ts b/src/hooks/useArtistsBrowseCatalog.ts index 320fb742..36fd82c0 100644 --- a/src/hooks/useArtistsBrowseCatalog.ts +++ b/src/hooks/useArtistsBrowseCatalog.ts @@ -6,6 +6,13 @@ import { fetchLocalArtistCatalogChunk, fetchNetworkStarredArtists, } from '../utils/library/browseTextSearch'; +import { useOfflineBrowseContext } from './useOfflineBrowseContext'; +import { useOfflineBrowseReloadToken } from './useOfflineBrowseReloadToken'; +import { + fetchOfflineLocalArtistCatalogChunk, + fetchOfflineLocalStarredArtists, + offlineLocalBrowseEnabled, +} from '../utils/offline/offlineLocalBrowse'; /** Local-index artist catalog buffer grows by this many rows per background SQL chunk. */ export const ARTIST_CATALOG_CHUNK_SIZE = 200; @@ -25,6 +32,8 @@ export function useArtistsBrowseCatalog({ starredOnly, musicLibraryFilterVersion, }: UseArtistsBrowseCatalogArgs) { + const offlineBrowseActive = useOfflineBrowseContext().active; + const offlineBrowseReloadTs = useOfflineBrowseReloadToken(); const [catalogArtists, setCatalogArtists] = useState([]); const [loading, setLoading] = useState(true); const [catalogHasMore, setCatalogHasMore] = useState(false); @@ -41,6 +50,27 @@ export function useArtistsBrowseCatalog({ catalogLoadingRef.current = true; setCatalogLoadingMore(true); try { + if (offlineBrowseActive) { + if (!offlineLocalBrowseEnabled(serverId)) return; + const chunk = await fetchOfflineLocalArtistCatalogChunk( + serverId, + catalogOffsetRef.current, + ARTIST_CATALOG_CHUNK_SIZE, + ); + if (generation !== loadGenerationRef.current || chunk == null) return; + if (append) { + setCatalogArtists(prev => { + const merged = dedupeById([...prev, ...chunk.artists]); + catalogOffsetRef.current = merged.length; + return merged; + }); + } else { + setCatalogArtists(chunk.artists); + catalogOffsetRef.current = chunk.artists.length; + } + setCatalogHasMore(chunk.hasMore); + return; + } const chunk = await fetchLocalArtistCatalogChunk( serverId, catalogOffsetRef.current, @@ -65,7 +95,7 @@ export function useArtistsBrowseCatalog({ setCatalogLoadingMore(false); } } - }, [serverId]); + }, [offlineBrowseActive, serverId]); useEffect(() => { let cancelled = false; @@ -80,6 +110,27 @@ export function useArtistsBrowseCatalog({ void (async () => { try { + if (offlineBrowseActive) { + if (!cancelled && generation === loadGenerationRef.current) { + if (serverId && starredOnly && offlineLocalBrowseEnabled(serverId)) { + setCatalogArtists((await fetchOfflineLocalStarredArtists(serverId)) ?? []); + } else if (serverId && !starredOnly && offlineLocalBrowseEnabled(serverId)) { + const first = await fetchOfflineLocalArtistCatalogChunk( + serverId, + 0, + ARTIST_CATALOG_CHUNK_SIZE, + ); + setCatalogArtists(first?.artists ?? []); + catalogOffsetRef.current = first?.artists.length ?? 0; + setCatalogHasMore(first?.hasMore ?? false); + } else { + setCatalogArtists([]); + setCatalogHasMore(false); + } + setBrowseMode('slice'); + } + return; + } if (starredOnly) { if (!cancelled && generation === loadGenerationRef.current) { setCatalogArtists(await fetchNetworkStarredArtists()); @@ -116,7 +167,7 @@ export function useArtistsBrowseCatalog({ return () => { cancelled = true; }; - }, [musicLibraryFilterVersion, indexEnabled, serverId, starredOnly]); + }, [musicLibraryFilterVersion, indexEnabled, offlineBrowseActive, offlineBrowseReloadTs, serverId, starredOnly]); return { catalogArtists, diff --git a/src/hooks/useBrowseAlbumTextSearch.ts b/src/hooks/useBrowseAlbumTextSearch.ts index a9a99df0..3a2f574f 100644 --- a/src/hooks/useBrowseAlbumTextSearch.ts +++ b/src/hooks/useBrowseAlbumTextSearch.ts @@ -8,6 +8,8 @@ import { runLocalBrowseAlbums, runNetworkBrowseAlbums, } from '../utils/library/browseTextSearch'; +import { useOfflineBrowseContext } from './useOfflineBrowseContext'; +import { offlineLocalBrowseEnabled, searchOfflineLocalAlbums } from '../utils/offline/offlineLocalBrowse'; /** * Debounced album title search with local-vs-network race when the @@ -19,6 +21,7 @@ export function useBrowseAlbumTextSearch( serverId: string | null | undefined, losslessOnly = false, ) { + const offlineBrowseActive = useOfflineBrowseContext().active; const [debouncedFilter, setDebouncedFilter] = useState(''); const [textSearchAlbums, setTextSearchAlbums] = useState(null); const [textSearchLoading, setTextSearchLoading] = useState(false); @@ -43,6 +46,15 @@ export function useBrowseAlbumTextSearch( setTextSearchLoading(true); void (async () => { + if (offlineBrowseActive) { + const albums = offlineLocalBrowseEnabled(serverId) + ? await searchOfflineLocalAlbums(serverId, q, losslessOnly) + : []; + if (isStale()) return; + setTextSearchAlbums(albums); + setTextSearchLoading(false); + return; + } if (!indexEnabled) { const albums = await runNetworkBrowseAlbums(q); if (isStale()) return; @@ -66,7 +78,7 @@ export function useBrowseAlbumTextSearch( setTextSearchAlbums(outcome?.result ?? null); setTextSearchLoading(false); })(); - }, [debouncedFilter, indexEnabled, serverId, losslessOnly]); + }, [debouncedFilter, indexEnabled, offlineBrowseActive, serverId, losslessOnly]); const effectiveFilter = textSearchAlbums != null ? '' : filter; return { textSearchAlbums, textSearchLoading, effectiveFilter }; diff --git a/src/hooks/useBrowseArtistTextSearch.ts b/src/hooks/useBrowseArtistTextSearch.ts index 362ee1ef..5feeba52 100644 --- a/src/hooks/useBrowseArtistTextSearch.ts +++ b/src/hooks/useBrowseArtistTextSearch.ts @@ -9,6 +9,8 @@ import { runNetworkBrowseArtists, type LibrarySearchSurface, } from '../utils/library/browseTextSearch'; +import { useOfflineBrowseContext } from './useOfflineBrowseContext'; +import { offlineLocalBrowseEnabled, searchOfflineLocalArtists } from '../utils/offline/offlineLocalBrowse'; /** * Debounced artist/composer name search with local-vs-network race when the @@ -22,6 +24,7 @@ export function useBrowseArtistTextSearch( serverId: string | null | undefined, surface: LibrarySearchSurface = 'artists_browse', ) { + const offlineBrowseActive = useOfflineBrowseContext().active; const [debouncedFilter, setDebouncedFilter] = useState(''); const [textSearchArtists, setTextSearchArtists] = useState(null); const [textSearchLoading, setTextSearchLoading] = useState(false); @@ -46,6 +49,15 @@ export function useBrowseArtistTextSearch( setTextSearchLoading(true); void (async () => { + if (offlineBrowseActive) { + const artists = offlineLocalBrowseEnabled(serverId) + ? await searchOfflineLocalArtists(serverId, q) + : []; + if (isStale()) return; + setTextSearchArtists(artists); + setTextSearchLoading(false); + return; + } const outcome = await raceBrowseWithLocalFallback( isStale, () => runLocalBrowseArtists(serverId, q), @@ -61,7 +73,7 @@ export function useBrowseArtistTextSearch( setTextSearchArtists(outcome?.result ?? null); setTextSearchLoading(false); })(); - }, [debouncedFilter, indexEnabled, serverId, surface]); + }, [debouncedFilter, indexEnabled, offlineBrowseActive, serverId, surface]); const effectiveFilter = textSearchArtists != null ? '' : filter; return { textSearchArtists, textSearchLoading, effectiveFilter }; diff --git a/src/hooks/useConnectionStatus.test.ts b/src/hooks/useConnectionStatus.test.ts index abebee3c..42820af6 100644 --- a/src/hooks/useConnectionStatus.test.ts +++ b/src/hooks/useConnectionStatus.test.ts @@ -17,11 +17,13 @@ vi.mock('@/utils/perf/perfFlags', () => ({ })); import { pingWithCredentials } from '@/api/subsonic'; +import { useDevOfflineBrowseStore } from '@/store/devOfflineBrowseStore'; import { useConnectionStatus } from './useConnectionStatus'; beforeEach(() => { resetAuthStore(); invalidateReachableEndpointCache(); + useDevOfflineBrowseStore.getState().setForceOffline(false); vi.mocked(pingWithCredentials).mockReset(); }); @@ -133,3 +135,39 @@ describe('useConnectionStatus online event', () => { expect(vi.mocked(pingWithCredentials).mock.calls.length).toBeGreaterThanOrEqual(2); }); }); + +describe('useConnectionStatus DEV offline toggle', () => { + it('does not probe again on mount beyond the polling effect', async () => { + seedDualAddressServer(); + vi.mocked(pingWithCredentials).mockResolvedValue({ + ok: true, + type: 'navidrome', + serverVersion: '0.55.0', + openSubsonic: true, + }); + + renderHook(() => useConnectionStatus()); + await waitFor(() => expect(vi.mocked(pingWithCredentials).mock.calls.length).toBeGreaterThanOrEqual(1)); + const callsAfterMount = vi.mocked(pingWithCredentials).mock.calls.length; + await new Promise(r => setTimeout(r, 20)); + expect(vi.mocked(pingWithCredentials).mock.calls.length).toBe(callsAfterMount); + }); + + it('disconnects on force-offline toggle without an extra probe', async () => { + seedDualAddressServer(); + vi.mocked(pingWithCredentials).mockResolvedValue({ + ok: true, + type: 'navidrome', + serverVersion: '0.55.0', + openSubsonic: true, + }); + + const { result } = renderHook(() => useConnectionStatus()); + await waitFor(() => expect(result.current.status).toBe('connected')); + const callsBeforeToggle = vi.mocked(pingWithCredentials).mock.calls.length; + + act(() => useDevOfflineBrowseStore.getState().setForceOffline(true)); + await waitFor(() => expect(result.current.status).toBe('disconnected')); + expect(vi.mocked(pingWithCredentials).mock.calls.length).toBe(callsBeforeToggle); + }); +}); diff --git a/src/hooks/useConnectionStatus.ts b/src/hooks/useConnectionStatus.ts index 6aabf9ab..afbf6deb 100644 --- a/src/hooks/useConnectionStatus.ts +++ b/src/hooks/useConnectionStatus.ts @@ -10,6 +10,10 @@ import { } from '../utils/server/serverEndpoint'; import { setActiveServerReachable } from '../utils/network/activeServerReachability'; import { usePerfProbeFlags } from '../utils/perf/perfFlags'; +import { + isDevOfflineBrowseForced, + useDevOfflineBrowseStore, +} from '../store/devOfflineBrowseStore'; // Backward-compatible re-export for call sites that still import from the hook. export { isLanUrl }; @@ -18,6 +22,7 @@ export type ConnectionStatus = 'connected' | 'disconnected' | 'checking'; export function useConnectionStatus() { const perfFlags = usePerfProbeFlags(); + const devForceOffline = useDevOfflineBrowseStore(s => s.forceOffline); const [status, setStatus] = useState('checking'); const [isRetrying, setIsRetrying] = useState(false); // Tracks the kind of endpoint the last successful probe answered on so the @@ -26,8 +31,15 @@ export function useConnectionStatus() { // public alternate must read as 'public', not 'local'. const [activeEndpointKind, setActiveEndpointKind] = useState(null); const intervalRef = useRef | null>(null); + const prevDevForceOfflineRef = useRef(null); const check = useCallback(async () => { + if (isDevOfflineBrowseForced()) { + setActiveServerReachable(false); + setStatus('disconnected'); + return; + } + const server = useAuthStore.getState().getActiveServer(); if (!server) { setActiveServerReachable(false); @@ -75,14 +87,47 @@ export function useConnectionStatus() { setIsRetrying(false); }, [check]); + // DEV offline toggle: react to transitions only — the polling effect already + // probes on mount; an unconditional check() here doubled probes and ignored + // disableBackgroundPolling (PlayerBar tests, perf-flagged runs). + useEffect(() => { + if (!import.meta.env.DEV) return; + + if (prevDevForceOfflineRef.current === null) { + prevDevForceOfflineRef.current = devForceOffline; + if (devForceOffline) { + setActiveServerReachable(false); + setStatus('disconnected'); + } + return; + } + + if (prevDevForceOfflineRef.current === devForceOffline) return; + prevDevForceOfflineRef.current = devForceOffline; + + if (devForceOffline) { + setActiveServerReachable(false); + setStatus('disconnected'); + return; + } + if (!perfFlags.disableBackgroundPolling) { + void check(); + } + }, [devForceOffline, check, perfFlags.disableBackgroundPolling]); + useEffect(() => { if (perfFlags.disableBackgroundPolling) { if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; } - setActiveServerReachable(true); - setStatus('connected'); + if (isDevOfflineBrowseForced()) { + setActiveServerReachable(false); + setStatus('disconnected'); + } else { + setActiveServerReachable(true); + setStatus('connected'); + } return; } check(); @@ -108,7 +153,7 @@ export function useConnectionStatus() { window.removeEventListener('online', handleOnline); window.removeEventListener('offline', handleOffline); }; - }, [check, perfFlags.disableBackgroundPolling]); + }, [check, devForceOffline, perfFlags.disableBackgroundPolling]); const server = useAuthStore(s => s.getActiveServer()); const servers = useAuthStore(s => s.servers); diff --git a/src/hooks/useFavoritesData.ts b/src/hooks/useFavoritesData.ts index f3ffef63..11bd9222 100644 --- a/src/hooks/useFavoritesData.ts +++ b/src/hooks/useFavoritesData.ts @@ -9,10 +9,12 @@ import { usePlayerStore } from '../store/playerStore'; import type { TopFavoriteArtist } from '../components/favorites/TopFavoriteArtists'; import { useConnectionStatus } from './useConnectionStatus'; import { isActiveServerReachable } from '../utils/network/activeServerReachability'; +import { useOfflineBrowseContext } from './useOfflineBrowseContext'; +import { useOfflineBrowseReloadToken } from './useOfflineBrowseReloadToken'; import { loadStarredFromAllLibraryIndexes, loadStarredFromAllServersOnline, -} from '../utils/offline/favoritesOfflineBrowse'; +} from '../utils/offline/offlineStarredLoad'; export interface FavoritesDataResult { albums: SubsonicAlbum[]; @@ -43,6 +45,8 @@ export function useFavoritesData(): FavoritesDataResult { const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled); const servers = useAuthStore(s => s.servers); const { status: connStatus } = useConnectionStatus(); + const offlineBrowseActive = useOfflineBrowseContext().active; + const offlineBrowseReloadTs = useOfflineBrowseReloadToken(); const starredOverrides = usePlayerStore(s => s.starredOverrides); useEffect(() => { @@ -76,7 +80,7 @@ export function useFavoritesData(): FavoritesDataResult { if (favoritesOfflineEnabled) { try { - applyStarred(await loadStarredFromAllLibraryIndexes()); + applyStarred(await loadStarredFromAllLibraryIndexes(offlineBrowseActive)); } catch { /* ignore */ } if (!cancelled) setLoading(false); @@ -100,9 +104,8 @@ export function useFavoritesData(): FavoritesDataResult { void loadAll(); return () => { cancelled = true; }; - }, [musicLibraryFilterVersion, connStatus, favoritesOfflineEnabled, servers]); + }, [musicLibraryFilterVersion, connStatus, favoritesOfflineEnabled, offlineBrowseActive, offlineBrowseReloadTs, servers]); - // ── Top Favorite Artists aggregated from favorited songs ───────────── const topFavoriteArtists = useMemo(() => { const counts = new Map(); for (const s of songs) { diff --git a/src/hooks/useOfflineAutoNav.ts b/src/hooks/useOfflineAutoNav.ts index f6243ef4..c952748a 100644 --- a/src/hooks/useOfflineAutoNav.ts +++ b/src/hooks/useOfflineAutoNav.ts @@ -1,24 +1,29 @@ import { useEffect, useRef } from 'react'; -import type { NavigateFunction } from 'react-router-dom'; +import type { Location, NavigateFunction } from 'react-router-dom'; +import { resolveOfflineDisconnectNavAction } from '../utils/offline/offlineBrowseRouting'; type ConnStatus = 'connected' | 'disconnected' | 'connecting' | 'unknown'; +type OfflineAutoNavContext = { + favoritesOfflineBrowse: boolean; + localLibraryBrowse: boolean; + playerStatsBrowse: boolean; + playlistsOfflineBrowse: boolean; + hasManualOfflineContent: boolean; +}; + /** - * Auto-route the user between offline-capable pages and main pages based on - * connection status: - * - 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 `/`. + * On disconnect: + * - No offline browse content → stay on the current page (banner only). + * - Offline-capable route → stay and bump location state so data hooks reload. + * - Otherwise → redirect to All Albums. * - * Only fires on transitions (not on every render). Reconnect-bounce is - * gated on `prev === 'disconnected'` so a user who navigates to `/offline` - * manually while online stays there. + * Only runs on connection transitions, not every render. */ export function useOfflineAutoNav( connStatus: ConnStatus | string, - hasManualOfflineContent: boolean, - favoritesOfflineBrowse: boolean, - pathname: string, + ctx: OfflineAutoNavContext, + location: Pick, navigate: NavigateFunction, ): void { const prevConnStatus = useRef(connStatus); @@ -26,25 +31,46 @@ export function useOfflineAutoNav( const prev = prevConnStatus.current; prevConnStatus.current = connStatus; - 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' || pathname === '/favorites') - ) { - navigate('/', { replace: true }); + if (connStatus !== 'disconnected' || prev === 'disconnected') return; + + const action = resolveOfflineDisconnectNavAction( + location.pathname, + ctx.favoritesOfflineBrowse, + ctx.localLibraryBrowse, + ctx.playerStatsBrowse, + ctx.playlistsOfflineBrowse, + ctx.hasManualOfflineContent, + ); + + if (action.kind === 'stay') return; + + if (action.kind === 'stay-reload') { + navigate( + { pathname: location.pathname, search: location.search }, + { + replace: true, + state: { + ...(typeof location.state === 'object' && location.state != null + ? location.state as Record + : {}), + offlineBrowseReloadTs: Date.now(), + }, + }, + ); + return; } + + navigate(action.to, { replace: true }); }, [ connStatus, - hasManualOfflineContent, - favoritesOfflineBrowse, - pathname, + ctx.favoritesOfflineBrowse, + ctx.localLibraryBrowse, + ctx.playerStatsBrowse, + ctx.playlistsOfflineBrowse, + ctx.hasManualOfflineContent, + location.pathname, + location.search, + location.state, navigate, ]); } diff --git a/src/hooks/useOfflineBrowseContext.ts b/src/hooks/useOfflineBrowseContext.ts new file mode 100644 index 00000000..ad0adb17 --- /dev/null +++ b/src/hooks/useOfflineBrowseContext.ts @@ -0,0 +1,36 @@ +import { useAuthStore } from '../store/authStore'; +import { useOfflineStore } from '../store/offlineStore'; +import { useConnectionStatus } from './useConnectionStatus'; +import { usePlayerStatsRecordingEnabled } from './usePlayerStatsRecordingEnabled'; +import { hasOfflineBrowsingContent } from '../utils/offline/favoritesOfflineBrowse'; +import { useOfflineBrowseActive } from '../utils/offline/offlineBrowseMode'; +import { + buildOfflineBrowseContext, + computeOfflineBrowseCapabilities, + type OfflineBrowseContext, +} from '../utils/offline/offlineBrowseContext'; + +/** Single subscription for shell and pages: offline browse mode + capabilities. */ +export function useOfflineBrowseContext(): OfflineBrowseContext { + const active = useOfflineBrowseActive(); + const serverId = useAuthStore(s => s.activeServerId); + const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled); + const offlineAlbums = useOfflineStore(s => s.albums); + const playerStats = usePlayerStatsRecordingEnabled(); + const { status: connStatus } = useConnectionStatus(); + + const capabilities = computeOfflineBrowseCapabilities({ + activeServerId: serverId, + favoritesOfflineEnabled, + offlineAlbums, + playerStats, + }); + + return buildOfflineBrowseContext({ + active, + serverId, + capabilities, + connStatus, + hasBrowsingContent: hasOfflineBrowsingContent(offlineAlbums), + }); +} diff --git a/src/hooks/useOfflineBrowseReloadToken.ts b/src/hooks/useOfflineBrowseReloadToken.ts new file mode 100644 index 00000000..5e7957de --- /dev/null +++ b/src/hooks/useOfflineBrowseReloadToken.ts @@ -0,0 +1,8 @@ +import { useLocation } from 'react-router-dom'; + +/** Bumps when disconnect fork chooses stay-reload ({@link useOfflineAutoNav}). */ +export function useOfflineBrowseReloadToken(): number | undefined { + const location = useLocation(); + const state = location.state as { offlineBrowseReloadTs?: number } | null; + return state?.offlineBrowseReloadTs; +} diff --git a/src/hooks/useOfflineLibraryFilterSuspend.ts b/src/hooks/useOfflineLibraryFilterSuspend.ts new file mode 100644 index 00000000..9c102355 --- /dev/null +++ b/src/hooks/useOfflineLibraryFilterSuspend.ts @@ -0,0 +1,27 @@ +import { useEffect, useRef } from 'react'; +import { useOfflineBrowseContext } from './useOfflineBrowseContext'; +import { + restoreMusicLibraryFiltersAfterOffline, + suspendMusicLibraryFiltersForOffline, +} from '../utils/offline/offlineLibraryFilterSuspend'; + +/** Disable scoped library browse offline; restore the picker value when back online. */ +export function useOfflineLibraryFilterSuspend(): void { + const offlineBrowseActive = useOfflineBrowseContext().active; + const prevOfflineRef = useRef(null); + + useEffect(() => { + const prev = prevOfflineRef.current; + prevOfflineRef.current = offlineBrowseActive; + + if (prev === null) { + if (offlineBrowseActive) suspendMusicLibraryFiltersForOffline(); + return; + } + if (offlineBrowseActive && !prev) { + suspendMusicLibraryFiltersForOffline(); + } else if (!offlineBrowseActive && prev) { + restoreMusicLibraryFiltersAfterOffline(); + } + }, [offlineBrowseActive]); +} diff --git a/src/hooks/usePlaylistsLibraryScopeCounts.ts b/src/hooks/usePlaylistsLibraryScopeCounts.ts index f6bd0ff7..99c03d03 100644 --- a/src/hooks/usePlaylistsLibraryScopeCounts.ts +++ b/src/hooks/usePlaylistsLibraryScopeCounts.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { filterSongsToActiveLibrary } from '../api/subsonicLibrary'; import { getPlaylist } from '../api/subsonicPlaylists'; import type { SubsonicPlaylist } from '../api/subsonicTypes'; +import { useOfflineBrowseContext } from './useOfflineBrowseContext'; export interface PlaylistsLibraryScopeCountsResult { filteredSongCountByPlaylist: Record; @@ -20,6 +21,7 @@ export function usePlaylistsLibraryScopeCounts( ): PlaylistsLibraryScopeCountsResult { const [filteredSongCountByPlaylist, setFilteredSongCountByPlaylist] = useState>({}); const [filteredDurationByPlaylist, setFilteredDurationByPlaylist] = useState>({}); + const offlineBrowseActive = useOfflineBrowseContext().active; useEffect(() => { let cancelled = false; @@ -31,6 +33,19 @@ export function usePlaylistsLibraryScopeCounts( } return; } + if (offlineBrowseActive) { + const next: Record = {}; + const nextDuration: Record = {}; + for (const pl of playlists) { + next[pl.id] = pl.songCount; + nextDuration[pl.id] = pl.duration; + } + if (!cancelled) { + setFilteredSongCountByPlaylist(next); + setFilteredDurationByPlaylist(nextDuration); + } + return; + } const ids = playlists.map((pl) => pl.id); const next: Record = {}; const nextDuration: Record = {}; @@ -60,7 +75,7 @@ export function usePlaylistsLibraryScopeCounts( }; run(); return () => { cancelled = true; }; - }, [playlists, musicLibraryFilterVersion]); + }, [playlists, musicLibraryFilterVersion, offlineBrowseActive]); return { filteredSongCountByPlaylist, filteredDurationByPlaylist }; } diff --git a/src/hooks/useQueuePanelDrag.ts b/src/hooks/useQueuePanelDrag.ts index d8e9950b..b708b2e9 100644 --- a/src/hooks/useQueuePanelDrag.ts +++ b/src/hooks/useQueuePanelDrag.ts @@ -1,5 +1,6 @@ import React, { useEffect, useRef, useState } from 'react'; -import { getAlbum } from '../api/subsonicLibrary'; +import { resolveAlbum, resolveMediaServerId } from '../utils/offline/offlineMediaResolve'; +import { songToTrack } from '../utils/playback/songToTrack'; import { useDragDrop, registerQueueDragHitTest } from '../contexts/DragDropContext'; import { usePlayerStore } from '../store/playerStore'; import type { Track } from '../store/playerStoreTypes'; @@ -77,13 +78,11 @@ export function useQueuePanelDrag({ } else if (parsedData.type === 'songs') { enqueueAt(parsedData.tracks as Track[], insertIdx); } else if (parsedData.type === 'album') { - const albumData = await getAlbum(parsedData.id); - const tracks: Track[] = albumData.songs.map((s: any) => ({ - id: s.id, title: s.title, artist: s.artist, album: s.album, - albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track, - year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre, - })); - enqueueAt(tracks, insertIdx); + const serverId = resolveMediaServerId(parsedData.serverId); + if (!serverId) return; + const albumData = await resolveAlbum(serverId, parsedData.id); + if (!albumData) return; + enqueueAt(albumData.songs.map(songToTrack), insertIdx); } }; diff --git a/src/hooks/useSongBrowseList.test.ts b/src/hooks/useSongBrowseList.test.ts index 0a649abe..51d30d0b 100644 --- a/src/hooks/useSongBrowseList.test.ts +++ b/src/hooks/useSongBrowseList.test.ts @@ -18,6 +18,10 @@ vi.mock('../utils/library/advancedSearchLocal', () => ({ runLocalSongBrowse: vi.fn(async () => []), })); +vi.mock('./useOfflineBrowseReloadToken', () => ({ + useOfflineBrowseReloadToken: () => undefined, +})); + vi.mock('../utils/library/browseTextSearch', () => ({ BROWSE_TEXT_DEBOUNCE_NETWORK_MS: 10, BROWSE_TEXT_DEBOUNCE_RACE_MS: 10, diff --git a/src/hooks/useSongBrowseList.ts b/src/hooks/useSongBrowseList.ts index d635467c..d1179675 100644 --- a/src/hooks/useSongBrowseList.ts +++ b/src/hooks/useSongBrowseList.ts @@ -14,6 +14,13 @@ import { } from '../utils/library/browseTextSearch'; import { useAuthStore } from '../store/authStore'; import { useLibraryIndexStore } from '../store/libraryIndexStore'; +import { useOfflineBrowseContext } from './useOfflineBrowseContext'; +import { useOfflineBrowseReloadToken } from './useOfflineBrowseReloadToken'; +import { + fetchOfflineLocalBrowsableSongPage, + offlineLocalBrowseEnabled, + searchOfflineLocalBrowsableSongs, +} from '../utils/offline/offlineLocalBrowse'; const PAGE_SIZE = 50; @@ -50,7 +57,10 @@ type UseSongBrowseListArgs = { /** Tracks hub song browse — all-library paging or filtered text search. */ export function useSongBrowseList({ enabled, searchQuery, initialRestore }: UseSongBrowseListArgs) { const serverId = useAuthStore(s => s.activeServerId); + const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId)); + const offlineBrowseActive = useOfflineBrowseContext().active; + const offlineBrowseReloadTs = useOfflineBrowseReloadToken(); const [debouncedQuery, setDebouncedQuery] = useState( () => initialRestore?.query.trim() ?? searchQuery.trim(), @@ -73,7 +83,6 @@ export function useSongBrowseList({ enabled, searchQuery, initialRestore }: UseS const restoreQueryHoldRef = useRef( initialRestore?.query.trim() ? initialRestore.query.trim() : null, ); - useEffect(() => { if (!enabled) return; const incoming = searchQuery.trim(); @@ -88,6 +97,15 @@ export function useSongBrowseList({ enabled, searchQuery, initialRestore }: UseS const fetchSongPage = useCallback( async (q: string, pageOffset: number, isStale: () => boolean): Promise => { + if (offlineBrowseActive && serverId && offlineLocalBrowseEnabled(serverId)) { + localSearchModeRef.current = true; + if (q === '') { + const page = await fetchOfflineLocalBrowsableSongPage(serverId, pageOffset, PAGE_SIZE); + return page?.songs ?? []; + } + return (await searchOfflineLocalBrowsableSongs(serverId, q, pageOffset, PAGE_SIZE)) ?? []; + } + if (q === '') { return fetchBrowseAllPage(serverId, pageOffset); } @@ -123,7 +141,7 @@ export function useSongBrowseList({ enabled, searchQuery, initialRestore }: UseS return (await runNetworkBrowseSongPage(q, pageOffset, PAGE_SIZE)) ?? []; }, - [indexEnabled, serverId], + [indexEnabled, musicLibraryFilterVersion, offlineBrowseActive, serverId], ); useEffect(() => { @@ -171,7 +189,7 @@ export function useSongBrowseList({ enabled, searchQuery, initialRestore }: UseS return () => { cancelled = true; }; - }, [debouncedQuery, searchQuery, fetchSongPage, enabled]); + }, [debouncedQuery, searchQuery, fetchSongPage, enabled, musicLibraryFilterVersion, offlineBrowseReloadTs]); const loadMore = useCallback(async () => { if (!enabled || loading || !hasMore) return; diff --git a/src/pages/AlbumDetail.tsx b/src/pages/AlbumDetail.tsx index 073d8b3d..90f6aca7 100644 --- a/src/pages/AlbumDetail.tsx +++ b/src/pages/AlbumDetail.tsx @@ -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 && } @@ -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 && ( diff --git a/src/pages/Albums.tsx b/src/pages/Albums.tsx index 4ccdb5c1..a5cdf4c9 100644 --- a/src/pages/Albums.tsx +++ b/src/pages/Albums.tsx @@ -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 { diff --git a/src/pages/ArtistDetail.tsx b/src/pages/ArtistDetail.tsx index 063b46a7..82598c84 100644 --- a/src/pages/ArtistDetail.tsx +++ b/src/pages/ArtistDetail.tsx @@ -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 && } diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 6aacf716..7e27e2ec 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -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 => { 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[], diff --git a/src/pages/LosslessAlbums.tsx b/src/pages/LosslessAlbums.tsx index 4c0e558d..1a7ac18e 100644 --- a/src/pages/LosslessAlbums.tsx +++ b/src/pages/LosslessAlbums.tsx @@ -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 { diff --git a/src/pages/MostPlayed.tsx b/src/pages/MostPlayed.tsx index eb05bbf5..4100af12 100644 --- a/src/pages/MostPlayed.tsx +++ b/src/pages/MostPlayed.tsx @@ -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([]); const [loading, setLoading] = useState(true); diff --git a/src/pages/NewReleases.tsx b/src/pages/NewReleases.tsx index 12648d4b..fa360840 100644 --- a/src/pages/NewReleases.tsx +++ b/src/pages/NewReleases.tsx @@ -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 { diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index b1a8f94a..15bf5c5b 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -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} diff --git a/src/pages/Playlists.tsx b/src/pages/Playlists.tsx index 37b27116..094f3903 100644 --- a/src/pages/Playlists.tsx +++ b/src/pages/Playlists.tsx @@ -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 && ( diff --git a/src/pages/RandomAlbums.tsx b/src/pages/RandomAlbums.tsx index 3d8b31f9..a3dcb9f2 100644 --- a/src/pages/RandomAlbums.tsx +++ b/src/pages/RandomAlbums.tsx @@ -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 { diff --git a/src/pages/Statistics.tsx b/src/pages/Statistics.tsx index 4e3ac266..68b1a5fd 100644 --- a/src/pages/Statistics.tsx +++ b/src/pages/Statistics.tsx @@ -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([]); @@ -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', diff --git a/src/store/devOfflineBrowseStore.ts b/src/store/devOfflineBrowseStore.ts new file mode 100644 index 00000000..ef6a1850 --- /dev/null +++ b/src/store/devOfflineBrowseStore.ts @@ -0,0 +1,19 @@ +import { create } from 'zustand'; + +/** DEV-only: simulate full offline (no server probes, no Subsonic, local playback only). */ +interface DevOfflineBrowseState { + forceOffline: boolean; + setForceOffline: (v: boolean) => void; + toggleForceOffline: () => void; +} + +export const useDevOfflineBrowseStore = create()((set, get) => ({ + forceOffline: false, + setForceOffline: (v) => set({ forceOffline: v }), + toggleForceOffline: () => set({ forceOffline: !get().forceOffline }), +})); + +/** True when DEV mode forces disconnected server + offline player behavior. */ +export function isDevOfflineBrowseForced(): boolean { + return import.meta.env.DEV && useDevOfflineBrowseStore.getState().forceOffline; +} diff --git a/src/store/homeFeedCache.ts b/src/store/homeFeedCache.ts index 4b37607f..73d8d708 100644 --- a/src/store/homeFeedCache.ts +++ b/src/store/homeFeedCache.ts @@ -28,6 +28,27 @@ export function readHomeFeedCache( return snapshot; } +/** Last good snapshot for this server when filter version changed (e.g. offline filter suspend). */ +export function readHomeFeedCacheStale( + serverId: string | null | undefined, +): HomeFeedSnapshot | null { + if (!serverId || !snapshot) return null; + if (snapshot.serverId !== serverId) return null; + if (Date.now() - snapshot.savedAt > TTL_MS) return null; + return snapshot; +} + +export function isHomeFeedSnapshotEmpty(snap: HomeFeedSnapshot): boolean { + return snap.heroAlbums.length === 0 + && snap.recent.length === 0 + && snap.random.length === 0 + && snap.starred.length === 0 + && snap.mostPlayed.length === 0 + && snap.recentlyPlayed.length === 0 + && snap.discoverSongs.length === 0 + && snap.randomArtists.length === 0; +} + export function writeHomeFeedCache(data: Omit): void { snapshot = { ...data, savedAt: Date.now() }; } diff --git a/src/store/playlistStore.ts b/src/store/playlistStore.ts index 2d82ef92..2bbca30d 100644 --- a/src/store/playlistStore.ts +++ b/src/store/playlistStore.ts @@ -3,6 +3,9 @@ import type { SubsonicPlaylist } from '../api/subsonicTypes'; import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { createPlaylist as apiCreatePlaylist } from '../api/subsonicPlaylists'; +import { useAuthStore } from './authStore'; +import { isOfflineBrowseActive } from '../utils/offline/offlineBrowseMode'; +import { fetchOfflineBrowsablePlaylists } from '../utils/offline/offlinePlaylistBrowse'; interface PlaylistStore { recentIds: string[]; playlists: SubsonicPlaylist[]; @@ -32,6 +35,12 @@ export const usePlaylistStore = create()( fetchPlaylists: async () => { set({ playlistsLoading: true }); try { + const serverId = useAuthStore.getState().activeServerId; + if (isOfflineBrowseActive() && serverId) { + const playlists = await fetchOfflineBrowsablePlaylists(serverId); + set({ playlists, playlistsLoading: false }); + return; + } const playlists = await getPlaylists(); set({ playlists, playlistsLoading: false }); } catch { diff --git a/src/styles/layout/dev-build-chrome.css b/src/styles/layout/dev-build-chrome.css index 09d7f9a1..b274824e 100644 --- a/src/styles/layout/dev-build-chrome.css +++ b/src/styles/layout/dev-build-chrome.css @@ -61,3 +61,24 @@ html[data-dev-build] .app-shell[data-mobile] .dev-build-badge { html[data-dev-build] .app-shell[data-mobile][data-titlebar] .dev-build-badge { top: var(--titlebar-height); } + +html[data-dev-build] .dev-network-mode-toggle { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.25rem 0.55rem; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text-muted); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.03em; + text-transform: uppercase; + cursor: pointer; +} + +html[data-dev-build] .dev-network-mode-toggle--offline { + color: var(--warning, #e6a700); + border-color: color-mix(in srgb, var(--warning, #e6a700) 45%, var(--border)); +} diff --git a/src/utils/componentHelpers/runArtistDetailPlay.test.ts b/src/utils/componentHelpers/runArtistDetailPlay.test.ts new file mode 100644 index 00000000..2f73fb07 --- /dev/null +++ b/src/utils/componentHelpers/runArtistDetailPlay.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SubsonicAlbum } from '../../api/subsonicTypes'; +import * as offlineMediaResolve from '../offline/offlineMediaResolve'; +import { fetchArtistDetailTracks } from './runArtistDetailPlay'; + +vi.mock('../offline/offlineMediaResolve', () => ({ + resolveAlbum: vi.fn(), + resolveMediaServerId: vi.fn((id?: string | null) => id ?? 'srv-1'), +})); + +const resolveAlbumMock = vi.mocked(offlineMediaResolve.resolveAlbum); + +const albums: SubsonicAlbum[] = [ + { id: 'al-2', name: 'B', artist: 'A', artistId: 'ar-1', songCount: 1, duration: 100, year: 2001 }, + { id: 'al-1', name: 'A', artist: 'A', artistId: 'ar-1', songCount: 1, duration: 100, year: 2000 }, +]; + +describe('fetchArtistDetailTracks', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('loads albums via resolveAlbum when serverId is set', async () => { + resolveAlbumMock + .mockResolvedValueOnce({ + album: albums[1], + songs: [{ id: 't1', title: 'One', artist: 'A', album: 'A', albumId: 'al-1', duration: 100, track: 2 }], + }) + .mockResolvedValueOnce({ + album: albums[0], + songs: [{ id: 't2', title: 'Two', artist: 'A', album: 'B', albumId: 'al-2', duration: 100, track: 1 }], + }); + + const tracks = await fetchArtistDetailTracks(albums, 'srv-1'); + expect(tracks.map(t => t.id)).toEqual(['t1', 't2']); + expect(resolveAlbumMock).toHaveBeenCalledTimes(2); + }); + + it('returns empty when no server scope', async () => { + vi.mocked(offlineMediaResolve.resolveMediaServerId).mockReturnValueOnce(null); + + const tracks = await fetchArtistDetailTracks(albums, null); + expect(tracks).toEqual([]); + expect(resolveAlbumMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/utils/componentHelpers/runArtistDetailPlay.ts b/src/utils/componentHelpers/runArtistDetailPlay.ts index 4ef0927e..e79e4f2c 100644 --- a/src/utils/componentHelpers/runArtistDetailPlay.ts +++ b/src/utils/componentHelpers/runArtistDetailPlay.ts @@ -1,33 +1,53 @@ import type { TFunction } from 'i18next'; -import { getAlbum } from '../../api/subsonicLibrary'; import { getSimilarSongs2, getTopSongs } from '../../api/subsonicArtists'; import type { SubsonicAlbum, SubsonicArtist } from '../../api/subsonicTypes'; import type { Track } from '../../store/playerStoreTypes'; import { songToTrack } from '../playback/songToTrack'; import { runBulkPlayAll, runBulkShuffle } from '../playback/runBulkPlay'; +import { resolveAlbum, resolveMediaServerId } from '../offline/offlineMediaResolve'; -async function fetchAllTracks(albums: SubsonicAlbum[]): Promise { - 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)); - return sorted.flatMap(r => [...r.songs].sort((a, b) => (a.track ?? 0) - (b.track ?? 0))).map(songToTrack); +/** Ordered artist discography tracks for play-all / shuffle (network or local bytes). */ +export async function fetchArtistDetailTracks( + albums: SubsonicAlbum[], + serverId?: string | null, +): Promise { + const sid = resolveMediaServerId(serverId ?? albums[0]?.serverId); + if (!sid) return []; + + const loaded = await Promise.all(albums.map(a => resolveAlbum(sid, a.id))); + const sorted = loaded + .filter((r): r is NonNullable => r != null) + .sort((a, b) => (a.album.year ?? 0) - (b.album.year ?? 0)); + return sorted.flatMap(r => + [...r.songs].sort((a, b) => (a.track ?? 0) - (b.track ?? 0)).map(songToTrack), + ); } export interface RunArtistDetailPlayDeps { albums: SubsonicAlbum[]; + serverId?: string | null; setPlayAllLoading: (v: boolean) => void; playTrack: (track: Track, queue: Track[]) => void; } export async function runArtistDetailPlayAll(deps: RunArtistDetailPlayDeps): Promise { - const { albums, setPlayAllLoading, playTrack } = deps; + const { albums, serverId, setPlayAllLoading, playTrack } = deps; if (albums.length === 0) return; - await runBulkPlayAll({ fetchTracks: () => fetchAllTracks(albums), setLoading: setPlayAllLoading, playTrack }); + await runBulkPlayAll({ + fetchTracks: () => fetchArtistDetailTracks(albums, serverId), + setLoading: setPlayAllLoading, + playTrack, + }); } export async function runArtistDetailShuffle(deps: RunArtistDetailPlayDeps): Promise { - const { albums, setPlayAllLoading, playTrack } = deps; + const { albums, serverId, setPlayAllLoading, playTrack } = deps; if (albums.length === 0) return; - await runBulkShuffle({ fetchTracks: () => fetchAllTracks(albums), setLoading: setPlayAllLoading, playTrack }); + await runBulkShuffle({ + fetchTracks: () => fetchArtistDetailTracks(albums, serverId), + setLoading: setPlayAllLoading, + playTrack, + }); } export interface RunArtistDetailStartRadioDeps { diff --git a/src/utils/library/albumBrowseCatalogChunk.ts b/src/utils/library/albumBrowseCatalogChunk.ts new file mode 100644 index 00000000..9f915a54 --- /dev/null +++ b/src/utils/library/albumBrowseCatalogChunk.ts @@ -0,0 +1,43 @@ +import type { SubsonicAlbum } from '../../api/subsonicTypes'; +import { dedupeById } from '../dedupeById'; +import { isOfflineBrowseActive } from '../offline/offlineBrowseMode'; +import { loadOfflineAlbumCatalogChunk } from '../offline/offlineAlbumBrowseCatalog'; +import type { AlbumBrowseQuery } from './albumBrowseTypes'; +import { fetchLocalAlbumCatalogChunk } from './albumBrowseLoad'; + +export type AlbumCatalogChunk = { + albums: SubsonicAlbum[]; + hasMore: boolean; +}; + +export function mergeAlbumCatalogChunk( + prev: SubsonicAlbum[], + chunk: AlbumCatalogChunk, + append: boolean, +): { albums: SubsonicAlbum[]; offset: number } { + if (!append) { + return { albums: chunk.albums, offset: chunk.albums.length }; + } + const merged = dedupeById([...prev, ...chunk.albums]); + return { albums: merged, offset: merged.length }; +} + +/** Local-index or offline-bytes catalog chunk for the albums grid. */ +export async function fetchAlbumBrowseCatalogChunk( + serverId: string, + query: AlbumBrowseQuery, + offset: number, + chunkSize: number, + starredOverrides: Record, +): Promise { + if (isOfflineBrowseActive()) { + return loadOfflineAlbumCatalogChunk( + serverId, + query, + offset, + chunkSize, + starredOverrides, + ); + } + return fetchLocalAlbumCatalogChunk(serverId, query, offset, chunkSize); +} diff --git a/src/utils/mix/luckyMixHelpers.ts b/src/utils/mix/luckyMixHelpers.ts index 8c6c192e..da8cb020 100644 --- a/src/utils/mix/luckyMixHelpers.ts +++ b/src/utils/mix/luckyMixHelpers.ts @@ -1,5 +1,6 @@ import { getTopSongs } from '../../api/subsonicArtists'; -import { filterSongsToActiveLibrary, getAlbum, getAlbumList, getRandomSongs } from '../../api/subsonicLibrary'; +import { filterSongsToActiveLibrary, getAlbumList, getRandomSongs } from '../../api/subsonicLibrary'; +import { resolveAlbumForActiveServer } from '../offline/offlineMediaResolve'; import type { SubsonicAlbum, SubsonicSong } from '../../api/subsonicTypes'; import { filterSongsForLuckyMixRatings, @@ -98,7 +99,7 @@ export async function pickSongsForAlbum( need: number, mixRatings: MixMinRatingsConfig, ): Promise { - const full = await getAlbum(albumId).catch(() => null); + const full = await resolveAlbumForActiveServer(albumId).catch(() => null); if (!full?.songs?.length) return []; const scopedSongs = await filterSongsToActiveLibrary(full.songs); const unique = uniqueBySongId(scopedSongs); diff --git a/src/utils/network/activeServerReachability.test.ts b/src/utils/network/activeServerReachability.test.ts index 9340e7e8..066977e3 100644 --- a/src/utils/network/activeServerReachability.test.ts +++ b/src/utils/network/activeServerReachability.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useDevOfflineBrowseStore } from '../../store/devOfflineBrowseStore'; import { getActiveServerReachable, isActiveServerReachable, @@ -8,6 +9,7 @@ import { describe('activeServerReachability', () => { beforeEach(() => { + useDevOfflineBrowseStore.setState({ forceOffline: false }); setActiveServerReachable(null); }); @@ -24,6 +26,13 @@ describe('activeServerReachability', () => { expect(getActiveServerReachable()).toBe(true); }); + it('isActiveServerReachable is false when DEV force-offline is enabled', () => { + if (!import.meta.env.DEV) return; + setActiveServerReachable(true); + useDevOfflineBrowseStore.setState({ forceOffline: true }); + expect(isActiveServerReachable()).toBe(false); + }); + it('onActiveServerBecameReachable fires only on false/null → true', () => { const listener = vi.fn(); onActiveServerBecameReachable(listener); diff --git a/src/utils/network/activeServerReachability.ts b/src/utils/network/activeServerReachability.ts index 07f0b1a6..f2cf3f57 100644 --- a/src/utils/network/activeServerReachability.ts +++ b/src/utils/network/activeServerReachability.ts @@ -1,3 +1,5 @@ +import { isDevOfflineBrowseForced } from '../../store/devOfflineBrowseStore'; + /** * Active-server reachability snapshot maintained by `useConnectionStatus`. * Non-hook code (queue sync, favorites refresh) uses this to avoid noisy @@ -27,6 +29,7 @@ export function getActiveServerReachable(): boolean | null { /** True only when the browser is online and the last active-server probe succeeded. */ export function isActiveServerReachable(): boolean { + if (isDevOfflineBrowseForced()) return false; if (typeof navigator !== 'undefined' && !navigator.onLine) return false; return activeServerReachable === true; } diff --git a/src/utils/network/subsonicNetworkGuard.ts b/src/utils/network/subsonicNetworkGuard.ts index 0b2b8bcb..97f748fc 100644 --- a/src/utils/network/subsonicNetworkGuard.ts +++ b/src/utils/network/subsonicNetworkGuard.ts @@ -1,5 +1,6 @@ import { useAuthStore } from '../../store/authStore'; import { resolvePlaybackUrl } from '../playback/resolvePlaybackUrl'; +import { isDevOfflineBrowseForced } from '../../store/devOfflineBrowseStore'; import { isActiveServerReachable } from './activeServerReachability'; /** @@ -9,6 +10,7 @@ import { isActiveServerReachable } from './activeServerReachability'; */ export function shouldAttemptSubsonicForServer(serverId: string, trackId?: string): boolean { if (!serverId) return false; + if (isDevOfflineBrowseForced()) return false; if (typeof navigator !== 'undefined' && !navigator.onLine) return false; if (trackId) { const url = resolvePlaybackUrl(trackId, serverId); diff --git a/src/utils/offline/favoritesOfflineBrowse.test.ts b/src/utils/offline/favoritesOfflineBrowse.test.ts index c2040b8f..8d1ea4e5 100644 --- a/src/utils/offline/favoritesOfflineBrowse.test.ts +++ b/src/utils/offline/favoritesOfflineBrowse.test.ts @@ -1,14 +1,21 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useAuthStore } from '../../store/authStore'; +import { useLibraryIndexStore } from '../../store/libraryIndexStore'; import { useLocalPlaybackStore } from '../../store/localPlaybackStore'; import { favoritesOfflineBrowseEnabled, hasOfflineBrowsingContent, +} from './favoritesOfflineBrowse'; +import { isOfflineSidebarLibraryNavAllowed, + isOfflineSidebarNavAllowed, + isOfflineSidebarSystemNavAllowed, +} from './offlineNavPolicy'; +import { loadStarredFromLibraryIndex, mergeStarredFromServers, - resolveAlbumForServer, -} from './favoritesOfflineBrowse'; +} from './offlineStarredLoad'; +import { resolveAlbumForServer } from './offlineMediaResolve'; const isActiveServerReachableMock = vi.fn(() => true); const shouldAttemptSubsonicForServerMock = vi.fn((_serverId: string, _trackId?: string) => true); @@ -23,6 +30,7 @@ vi.mock('../network/subsonicNetworkGuard', () => ({ const getAlbumForServerMock = vi.fn(); const libraryAdvancedSearchMock = vi.fn(); const libraryGetTracksByAlbumMock = vi.fn(); +const libraryGetTracksBatchChunkedMock = vi.fn(); vi.mock('../../api/subsonicLibrary', () => ({ getAlbumForServer: (...args: unknown[]) => getAlbumForServerMock(...args), @@ -31,6 +39,7 @@ vi.mock('../../api/subsonicLibrary', () => ({ vi.mock('../../api/library', () => ({ libraryAdvancedSearch: (...args: unknown[]) => libraryAdvancedSearchMock(...args), libraryGetTracksByAlbum: (...args: unknown[]) => libraryGetTracksByAlbumMock(...args), + libraryGetTracksBatchChunked: (...args: unknown[]) => libraryGetTracksBatchChunkedMock(...args), })); describe('favoritesOfflineBrowse', () => { @@ -40,6 +49,8 @@ describe('favoritesOfflineBrowse', () => { getAlbumForServerMock.mockReset(); libraryGetTracksByAlbumMock.mockReset(); libraryAdvancedSearchMock.mockReset(); + libraryGetTracksBatchChunkedMock.mockReset(); + useLibraryIndexStore.setState({ masterEnabled: true }); useAuthStore.setState({ favoritesOfflineEnabled: false, activeServerId: 'srv-1', @@ -87,28 +98,121 @@ describe('favoritesOfflineBrowse', () => { expect(merged.songs.map(s => s.serverId)).toEqual(['srv-1', 'srv-2']); }); - it('isOfflineSidebarLibraryNavAllowed keeps only favorites when offline', () => { + it('isOfflineSidebarLibraryNavAllowed gates offline sidebar entries', () => { expect(isOfflineSidebarLibraryNavAllowed('favorites', true)).toBe(true); expect(isOfflineSidebarLibraryNavAllowed('favorites', false)).toBe(false); - expect(isOfflineSidebarLibraryNavAllowed('albums', true)).toBe(false); + expect(isOfflineSidebarLibraryNavAllowed('artists', false, true)).toBe(true); + expect(isOfflineSidebarLibraryNavAllowed('allAlbums', false, true)).toBe(true); + expect(isOfflineSidebarLibraryNavAllowed('tracks', false, true)).toBe(true); + expect(isOfflineSidebarLibraryNavAllowed('tracks', false, false)).toBe(false); + expect(isOfflineSidebarLibraryNavAllowed('allAlbums', false, false)).toBe(false); + expect(isOfflineSidebarLibraryNavAllowed('offline', false, false)).toBe(true); + expect(isOfflineSidebarLibraryNavAllowed('playlists', false, false, true)).toBe(true); + expect(isOfflineSidebarLibraryNavAllowed('playlists', false, false, false)).toBe(false); }); - it('loadStarredFromLibraryIndex omits artist entity (no artist.starred_at in index)', async () => { + it('isOfflineSidebarSystemNavAllowed keeps help and player stats offline', () => { + expect(isOfflineSidebarSystemNavAllowed('help', false)).toBe(true); + expect(isOfflineSidebarSystemNavAllowed('statistics', true)).toBe(true); + expect(isOfflineSidebarSystemNavAllowed('statistics', false)).toBe(false); + expect(isOfflineSidebarNavAllowed('help', false, false, false)).toBe(true); + expect(isOfflineSidebarNavAllowed('statistics', false, false, true)).toBe(true); + expect(isOfflineSidebarNavAllowed('tracks', false, true, false)).toBe(true); + expect(isOfflineSidebarNavAllowed('playlists', false, false, false, true)).toBe(true); + }); + + it('loadStarredFromLibraryIndex uses starred advanced search when not offline-bytes', async () => { libraryAdvancedSearchMock.mockResolvedValue({ albums: [{ id: 'alb-1', name: 'A', artist: 'X', artistId: 'art-1', serverId: 'srv-1' }], - artists: [{ id: 'art-99', name: 'Not A Favorite', serverId: 'srv-1' }], tracks: [{ id: 't-1', title: 'S', artist: 'X', album: 'A', albumId: 'alb-1', durationSec: 1, serverId: 'srv-1' }], + artists: [], }); const starred = await loadStarredFromLibraryIndex('srv-1'); expect(libraryAdvancedSearchMock).toHaveBeenCalledWith(expect.objectContaining({ + serverId: 'srv-1', entityTypes: ['album', 'track'], starredOnly: true, })); + expect(libraryGetTracksBatchChunkedMock).not.toHaveBeenCalled(); expect(starred.artists).toEqual([]); expect(starred.songs).toHaveLength(1); }); + it('loadStarredFromLibraryIndex prefers local bytes then starred filter when offline', async () => { + useLocalPlaybackStore.setState({ + entries: { + 'a.test:t1': { + serverIndexKey: 'a.test', + trackId: 't1', + localPath: '/media/library/a.test/a/al/t1.mp3', + layoutFingerprint: 'fp', + sizeBytes: 1, + tier: 'favorite-auto', + cachedAt: 1, + suffix: 'mp3', + }, + 'a.test:t2': { + serverIndexKey: 'a.test', + trackId: 't2', + localPath: '/media/library/a.test/a/al/t2.mp3', + layoutFingerprint: 'fp', + sizeBytes: 1, + tier: 'favorite-auto', + cachedAt: 1, + suffix: 'mp3', + }, + }, + }); + libraryGetTracksBatchChunkedMock.mockResolvedValue([ + { + id: 't1', + title: 'Starred', + artist: 'X', + album: 'A', + albumId: 'alb-1', + durationSec: 1, + starredAt: 1, + serverId: 'srv-1', + }, + { + id: 't2', + title: 'Not starred', + artist: 'X', + album: 'A', + albumId: 'alb-1', + durationSec: 1, + serverId: 'srv-1', + }, + ]); + libraryAdvancedSearchMock.mockResolvedValue({ + albums: [{ + id: 'alb-2', + name: 'Album star only', + artist: 'Y', + artistId: 'art-2', + starredAt: 1, + serverId: 'srv-1', + }], + artists: [], + tracks: [], + }); + + const starred = await loadStarredFromLibraryIndex('srv-1', true); + + expect(libraryGetTracksBatchChunkedMock).toHaveBeenCalled(); + expect(libraryAdvancedSearchMock).toHaveBeenCalled(); + expect(libraryAdvancedSearchMock).toHaveBeenCalledWith(expect.objectContaining({ + serverId: 'srv-1', + entityTypes: ['album'], + starredOnly: true, + restrictAlbumIds: ['alb-1'], + })); + expect(starred.songs).toHaveLength(1); + expect(starred.songs[0]?.id).toBe('t1'); + expect(starred.albums.map(a => a.id).sort()).toEqual(['alb-1', 'alb-2']); + }); + it('resolveAlbumForServer uses library index when network fails', async () => { useAuthStore.setState({ favoritesOfflineEnabled: true }); shouldAttemptSubsonicForServerMock.mockReturnValue(true); diff --git a/src/utils/offline/favoritesOfflineBrowse.ts b/src/utils/offline/favoritesOfflineBrowse.ts index c2f776d6..a1e98ae9 100644 --- a/src/utils/offline/favoritesOfflineBrowse.ts +++ b/src/utils/offline/favoritesOfflineBrowse.ts @@ -1,23 +1,6 @@ -import { getStarredForServer } from '../../api/subsonicStarRating'; -import { isActiveServerReachable } from '../network/activeServerReachability'; -import { shouldAttemptSubsonicForServer } from '../network/subsonicNetworkGuard'; -import { getAlbumForServer } from '../../api/subsonicLibrary'; -import { libraryAdvancedSearch, libraryGetTracksByAlbum } from '../../api/library'; -import type { - StarredResults, - SubsonicAlbum, - SubsonicArtist, - SubsonicSong, -} from '../../api/subsonicTypes'; import { useAuthStore } from '../../store/authStore'; import { useLibraryIndexStore } from '../../store/libraryIndexStore'; import type { OfflineAlbumMeta } from '../../store/offlineStore'; -import { - albumToAlbum, - artistToArtist, - trackToSong, -} from '../library/advancedSearchLocal'; -import { dedupeById } from '../dedupeById'; import { countFavoriteAutoTracks, hasAnyOfflineAlbums } from './offlineLibraryHelpers'; /** Saved servers with a local library index (cross-server favorites scope). */ @@ -34,14 +17,6 @@ export function favoritesOfflineBrowseEnabled(): boolean { return favoritesServerIds().length > 0; } -export function isOfflineSidebarLibraryNavAllowed( - navId: string, - favoritesOfflineBrowse: boolean, -): boolean { - if (navId === 'favorites') return favoritesOfflineBrowse; - return false; -} - /** Any offline browsing surface: manual pins and/or saved favorite-auto bytes. */ export function hasOfflineBrowsingContent( offlineAlbums: Record, @@ -50,203 +25,3 @@ export function hasOfflineBrowsingContent( if (favoritesOfflineBrowseEnabled() && countFavoriteAutoTracks() > 0) return true; return false; } - -function tagStarredWithServer(starred: StarredResults, serverId: string): StarredResults { - const withServer = (items: T[]): (T & { serverId: string })[] => - items.map(item => ({ ...item, serverId })); - - return { - artists: withServer(starred.artists), - albums: withServer(starred.albums), - songs: withServer(starred.songs), - }; -} - -/** Merge starred lists from multiple servers; dedupe by `serverId:id`. */ -export function mergeStarredFromServers( - entries: { serverId: string; starred: StarredResults }[], -): StarredResults { - const artists: SubsonicArtist[] = []; - const albums: SubsonicAlbum[] = []; - const songs: SubsonicSong[] = []; - for (const { serverId, starred } of entries) { - const tagged = tagStarredWithServer(starred, serverId); - artists.push(...tagged.artists); - albums.push(...tagged.albums); - songs.push(...tagged.songs); - } - return { - artists: dedupeById(artists), - albums: dedupeById(albums), - songs: dedupeById(songs), - }; -} - -export async function loadStarredFromLibraryIndex(serverId: string): Promise { - // Artist-level favorites are network-only today (`artist` has no `starred_at`; - // `starredOnly` on artists would return the whole artist table). Songs/albums - // use track/album stars in the index. - const response = await libraryAdvancedSearch({ - serverId, - entityTypes: ['album', 'track'], - starredOnly: true, - limit: 10_000, - }); - return { - artists: [], - albums: response.albums.map(albumToAlbum), - songs: response.tracks.map(trackToSong), - }; -} - -export async function loadStarredFromAllLibraryIndexes(): Promise { - const serverIds = favoritesServerIds(); - const entries = await Promise.all( - serverIds.map(async serverId => { - try { - const starred = await loadStarredFromLibraryIndex(serverId); - return { serverId, starred }; - } catch { - return { serverId, starred: { artists: [], albums: [], songs: [] } satisfies StarredResults }; - } - }), - ); - return mergeStarredFromServers(entries); -} - -/** Online starred merge with per-server local index fallback. */ -export async function loadStarredFromAllServersOnline(): Promise { - if (!isActiveServerReachable()) { - return loadStarredFromAllLibraryIndexes(); - } - const serverIds = favoritesServerIds(); - const entries = await Promise.all( - serverIds.map(async serverId => { - try { - const starred = await getStarredForServer(serverId); - return { serverId, starred }; - } catch { - try { - const starred = await loadStarredFromLibraryIndex(serverId); - return { serverId, starred }; - } catch { - return { serverId, starred: { artists: [], albums: [], songs: [] } satisfies StarredResults }; - } - } - }), - ); - return mergeStarredFromServers(entries); -} - -/** - * Album detail / play / offline pin: use the network album when reachable so the - * track list is complete. The library index may only contain a subset (e.g. - * starred tracks or a partial sync) — never prefer that over `getAlbum` online. - * When the server is unreachable, fall back to the index when favorites-offline - * browsing is enabled. - */ -export async function resolveAlbumForServer( - serverId: string, - albumId: string, -): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null> { - const favoritesOffline = useAuthStore.getState().favoritesOfflineEnabled; - const networkAllowed = shouldAttemptSubsonicForServer(serverId); - - if (networkAllowed) { - try { - const data = await getAlbumForServer(serverId, albumId); - return { album: data.album, songs: data.songs }; - } catch { - /* fall through to library index */ - } - } else if (!favoritesOffline) { - return null; - } - - try { - return await loadAlbumFromLibraryIndex(serverId, albumId); - } catch { - return null; - } -} - -export async function loadAlbumFromLibraryIndex( - serverId: string, - albumId: string, -): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null> { - const tracks = await libraryGetTracksByAlbum(serverId, albumId); - if (tracks.length === 0) return null; - - const songs = tracks.map(trackToSong); - const albumSearch = await libraryAdvancedSearch({ - serverId, - entityTypes: ['album'], - restrictAlbumIds: [albumId], - limit: 1, - }); - const albumDto = albumSearch.albums[0]; - if (albumDto) { - const album = albumToAlbum(albumDto); - return { - album: { - ...album, - serverId, - songCount: songs.length, - duration: songs.reduce((sum, s) => sum + (s.duration ?? 0), 0), - }, - songs: songs.map(s => ({ ...s, serverId })), - }; - } - - const first = tracks[0]; - return { - album: { - id: albumId, - name: first.album ?? albumId, - artist: first.artist ?? '', - artistId: first.artistId ?? '', - songCount: songs.length, - duration: songs.reduce((sum, s) => sum + (s.duration ?? 0), 0), - coverArt: first.coverArtId ?? albumId, - year: first.year ?? undefined, - genre: first.genre ?? undefined, - starred: first.starredAt != null ? new Date(first.starredAt).toISOString() : undefined, - serverId, - }, - songs: songs.map(s => ({ ...s, serverId })), - }; -} - -export async function loadArtistFromLibraryIndex( - serverId: string, - artistId: string, -): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] } | null> { - const response = await libraryAdvancedSearch({ - serverId, - entityTypes: ['album', 'artist'], - limit: 10_000, - }); - const albums = response.albums - .filter(a => a.artistId === artistId) - .map(albumToAlbum) - .map(a => ({ ...a, serverId })); - const artistDto = response.artists.find(a => a.id === artistId); - if (!artistDto && albums.length === 0) return null; - - const artist = artistDto - ? { ...artistToArtist(artistDto), serverId } - : { - id: artistId, - name: albums[0]?.artist ?? artistId, - albumCount: albums.length, - serverId, - }; - - return { - artist: { - ...artist, - albumCount: albums.length, - }, - albums, - }; -} diff --git a/src/utils/offline/favoritesOfflineSync.ts b/src/utils/offline/favoritesOfflineSync.ts index 2551b6d0..f6af0fd4 100644 --- a/src/utils/offline/favoritesOfflineSync.ts +++ b/src/utils/offline/favoritesOfflineSync.ts @@ -15,7 +15,8 @@ import { getMediaDir } from '../media/mediaDir'; import { resolveIndexKey, serverIndexKeyForProfile } from '../server/serverIndexKey'; import { FAVORITES_OFFLINE_JOB_ID } from './favoritesOfflineConstants'; import { isActiveServerReachable } from '../network/activeServerReachability'; -import { favoritesServerIds, loadAlbumFromLibraryIndex } from './favoritesOfflineBrowse'; +import { favoritesServerIds } from './favoritesOfflineBrowse'; +import { loadAlbumFromLibraryIndex } from './offlineLibraryIndexLoad'; import { entryBelongsToServer, hasLocalLibraryBytes, diff --git a/src/utils/offline/offlineActionPolicy.test.ts b/src/utils/offline/offlineActionPolicy.test.ts new file mode 100644 index 00000000..e2e24988 --- /dev/null +++ b/src/utils/offline/offlineActionPolicy.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { offlineActionPolicy } from './offlineActionPolicy'; + +describe('offlineActionPolicy', () => { + it('allows all mutations when offline browse is inactive', () => { + const p = offlineActionPolicy('albumDetail', false); + expect(p.canFavorite).toBe(true); + expect(p.canDownload).toBe(true); + expect(p.canPinOffline).toBe(true); + expect(p.canAddToPlaylist).toBe(true); + }); + + it('blocks server mutations when offline browse is active', () => { + const p = offlineActionPolicy('albumDetail', true); + expect(p.canFavorite).toBe(false); + expect(p.canRate).toBe(false); + expect(p.canDownload).toBe(false); + expect(p.canPinOffline).toBe(false); + expect(p.canAddToPlaylist).toBe(false); + expect(p.canShowBio).toBe(false); + }); + + it('applies same read-only policy to context menu surfaces', () => { + expect(offlineActionPolicy('contextMenuAlbum', true).canFavorite).toBe(false); + expect(offlineActionPolicy('contextMenuSong', true).canAddToPlaylist).toBe(false); + }); + + it('blocks rating and favorite in player bar when offline browse is active', () => { + const p = offlineActionPolicy('playerBar', true); + expect(p.canRate).toBe(false); + expect(p.canFavorite).toBe(false); + }); +}); diff --git a/src/utils/offline/offlineActionPolicy.ts b/src/utils/offline/offlineActionPolicy.ts new file mode 100644 index 00000000..1abdf016 --- /dev/null +++ b/src/utils/offline/offlineActionPolicy.ts @@ -0,0 +1,59 @@ +export type OfflineSurface = + | 'albumDetail' + | 'artistDetail' + | 'albumCard' + | 'trackRow' + | 'playlistDetail' + | 'playlistsHeader' + | 'contextMenuAlbum' + | 'contextMenuSong' + | 'contextMenuArtist' + | 'contextMenuPlaylist' + | 'hero' + | 'statistics' + | 'playerBar'; + +export type OfflineActionPolicy = { + canFavorite: boolean; + canRate: boolean; + canDownload: boolean; + canPinOffline: boolean; + canCacheDiscography: boolean; + canAddToPlaylist: boolean; + canEditPlaylist: boolean; + canShowBio: boolean; + canScrobble: boolean; +}; + +const ALLOW_ALL: OfflineActionPolicy = { + canFavorite: true, + canRate: true, + canDownload: true, + canPinOffline: true, + canCacheDiscography: true, + canAddToPlaylist: true, + canEditPlaylist: true, + canShowBio: true, + canScrobble: true, +}; + +const READ_ONLY_MUTATIONS: OfflineActionPolicy = { + canFavorite: false, + canRate: false, + canDownload: false, + canPinOffline: false, + canCacheDiscography: false, + canAddToPlaylist: false, + canEditPlaylist: false, + canShowBio: false, + canScrobble: false, +}; + +/** + * What server-mutating actions are allowed on a UI surface while offline browse is active. + * `surface` is reserved for per-surface divergence; today all surfaces share read-only policy. + */ +export function offlineActionPolicy(_surface: OfflineSurface, active: boolean): OfflineActionPolicy { + if (!active) return ALLOW_ALL; + return READ_ONLY_MUTATIONS; +} diff --git a/src/utils/offline/offlineAlbumBrowseCatalog.ts b/src/utils/offline/offlineAlbumBrowseCatalog.ts new file mode 100644 index 00000000..d5eb4a8e --- /dev/null +++ b/src/utils/offline/offlineAlbumBrowseCatalog.ts @@ -0,0 +1,49 @@ +import type { SubsonicAlbum } from '../../api/subsonicTypes'; +import type { AlbumBrowseQuery } from '../library/albumBrowseTypes'; +import { isOfflineBrowseActive } from './offlineBrowseMode'; +import { + fetchOfflineLocalAlbumCatalogChunk, + offlineLocalBrowseEnabled, +} from './offlineLocalBrowse'; + +type OfflineAlbumCatalogChunk = { + albums: SubsonicAlbum[]; + hasMore: boolean; +}; + +/** Offline album grid catalog chunk; null when offline browse or local bytes are unavailable. */ +export async function loadOfflineAlbumCatalogChunk( + serverId: string, + browseQuery: AlbumBrowseQuery, + offset: number, + chunkSize: number, + starredOverrides: Record, +): Promise { + if (!isOfflineBrowseActive() || !offlineLocalBrowseEnabled(serverId)) return null; + const chunk = await fetchOfflineLocalAlbumCatalogChunk( + serverId, + browseQuery, + offset, + chunkSize, + starredOverrides, + ); + if (chunk == null) return null; + return { albums: chunk.albums, hasMore: chunk.hasMore }; +} + +/** Initial offline album browse load for the albums grid. */ +export async function loadOfflineAlbumBrowseInitial( + serverId: string, + browseQuery: AlbumBrowseQuery, + chunkSize: number, + starredOverrides: Record, +): Promise { + const first = await loadOfflineAlbumCatalogChunk( + serverId, + browseQuery, + 0, + chunkSize, + starredOverrides, + ); + return first ?? { albums: [], hasMore: false }; +} diff --git a/src/utils/offline/offlineBrowseContext.test.ts b/src/utils/offline/offlineBrowseContext.test.ts new file mode 100644 index 00000000..284d21e0 --- /dev/null +++ b/src/utils/offline/offlineBrowseContext.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAuthStore } from '../../store/authStore'; +import { useLibraryIndexStore } from '../../store/libraryIndexStore'; +import { useLocalPlaybackStore } from '../../store/localPlaybackStore'; +import { + buildOfflineBrowseContext, + computeOfflineBrowseCapabilities, + offlineBrowseNavFlags, +} from './offlineBrowseContext'; + +vi.mock('./offlineLocalBrowse', () => ({ + offlineLocalBrowseEnabled: vi.fn(() => false), + countLocalBrowsableTracks: vi.fn(() => 0), +})); + +vi.mock('./offlinePlaylistBrowse', () => ({ + playlistsOfflineBrowseEnabled: vi.fn(() => false), +})); + +import { offlineLocalBrowseEnabled } from './offlineLocalBrowse'; +import { playlistsOfflineBrowseEnabled } from './offlinePlaylistBrowse'; + +describe('offlineBrowseContext', () => { + beforeEach(() => { + useAuthStore.setState({ + favoritesOfflineEnabled: false, + activeServerId: 'srv-1', + } as Partial>); + useLibraryIndexStore.setState({ masterEnabled: true }); + useLocalPlaybackStore.setState({ entries: {} }); + vi.mocked(offlineLocalBrowseEnabled).mockReturnValue(false); + vi.mocked(playlistsOfflineBrowseEnabled).mockReturnValue(false); + }); + + it('computeOfflineBrowseCapabilities returns all false when nothing enabled', () => { + const caps = computeOfflineBrowseCapabilities({ + activeServerId: 'srv-1', + favoritesOfflineEnabled: false, + offlineAlbums: {}, + playerStats: false, + }); + expect(caps).toEqual({ + localLibrary: false, + favorites: false, + playlists: false, + manualPins: false, + playerStats: false, + }); + }); + + it('favorites capability uses cross-server index when setting is on', () => { + useAuthStore.setState({ + favoritesOfflineEnabled: true, + servers: [{ id: 'srv-2', name: 'B', url: 'https://b.test', username: 'u', password: 'p' }], + activeServerId: null, + }); + const caps = computeOfflineBrowseCapabilities({ + activeServerId: null, + favoritesOfflineEnabled: true, + offlineAlbums: {}, + playerStats: false, + }); + expect(caps.favorites).toBe(true); + }); + + it('buildOfflineBrowseContext sets hasBrowseCapability from capabilities', () => { + vi.mocked(offlineLocalBrowseEnabled).mockReturnValue(true); + const caps = computeOfflineBrowseCapabilities({ + activeServerId: 'srv-1', + favoritesOfflineEnabled: false, + offlineAlbums: {}, + playerStats: true, + }); + const ctx = buildOfflineBrowseContext({ + active: true, + serverId: 'srv-1', + capabilities: caps, + connStatus: 'disconnected', + hasBrowsingContent: true, + }); + expect(ctx.hasBrowseCapability).toBe(true); + expect(ctx.capabilities.playerStats).toBe(true); + }); + + it('offlineBrowseNavFlags maps capability fields for sidebar', () => { + const flags = offlineBrowseNavFlags({ + localLibrary: true, + favorites: false, + playlists: true, + manualPins: true, + playerStats: false, + }); + expect(flags).toEqual({ + favoritesOfflineBrowse: false, + localLibraryBrowse: true, + playlistsOfflineBrowse: true, + playerStatsBrowse: false, + hasManualOfflineContent: true, + }); + }); +}); diff --git a/src/utils/offline/offlineBrowseContext.ts b/src/utils/offline/offlineBrowseContext.ts new file mode 100644 index 00000000..d5af55a2 --- /dev/null +++ b/src/utils/offline/offlineBrowseContext.ts @@ -0,0 +1,91 @@ +import type { ConnectionStatus } from '../../hooks/useConnectionStatus'; +import type { OfflineAlbumMeta } from '../../store/offlineStore'; +import { favoritesOfflineBrowseEnabled } from './favoritesOfflineBrowse'; +import { hasOfflineBrowseCapability } from './offlineBrowseRouting'; +import { offlineLocalBrowseEnabled } from './offlineLocalBrowse'; +import { playlistsOfflineBrowseEnabled } from './offlinePlaylistBrowse'; +import { hasAnyOfflineAlbums } from './offlineLibraryHelpers'; + +export type OfflineBrowseCapabilities = { + localLibrary: boolean; + favorites: boolean; + playlists: boolean; + manualPins: boolean; + playerStats: boolean; +}; + +export type OfflineBrowseContext = { + active: boolean; + serverId: string | null; + capabilities: OfflineBrowseCapabilities; + /** Disconnect fork / banner: local library, favorites, or manual pins. */ + hasBrowseCapability: boolean; + /** Any offline bytes to show (includes favorite-auto without browse). */ + hasBrowsingContent: boolean; + connStatus: ConnectionStatus; +}; + +type ComputeOfflineBrowseCapabilitiesInput = { + activeServerId: string | null; + favoritesOfflineEnabled: boolean; + offlineAlbums: Record; + playerStats: boolean; +}; + +/** Pure capability snapshot for tests and non-React callers. */ +export function computeOfflineBrowseCapabilities( + input: ComputeOfflineBrowseCapabilitiesInput, +): OfflineBrowseCapabilities { + const { activeServerId, favoritesOfflineEnabled, offlineAlbums, playerStats } = input; + + return { + localLibrary: offlineLocalBrowseEnabled(activeServerId), + favorites: favoritesBrowseCapabilityAnyServer(favoritesOfflineEnabled), + playlists: playlistsOfflineBrowseEnabled(activeServerId), + manualPins: hasAnyOfflineAlbums(offlineAlbums), + playerStats, + }; +} + +export function buildOfflineBrowseContext(input: { + active: boolean; + serverId: string | null; + capabilities: OfflineBrowseCapabilities; + connStatus: ConnectionStatus; + hasBrowsingContent: boolean; +}): OfflineBrowseContext { + const { capabilities, hasBrowsingContent, ...rest } = input; + return { + ...rest, + capabilities, + hasBrowseCapability: hasOfflineBrowseCapability( + capabilities.localLibrary, + capabilities.favorites, + capabilities.manualPins, + ), + hasBrowsingContent, + }; +} + +/** Sidebar / disconnect helpers — maps capability snapshot to nav gate flags. */ +export function offlineBrowseNavFlags(capabilities: OfflineBrowseCapabilities): { + favoritesOfflineBrowse: boolean; + localLibraryBrowse: boolean; + playlistsOfflineBrowse: boolean; + playerStatsBrowse: boolean; + hasManualOfflineContent: boolean; +} { + return { + favoritesOfflineBrowse: capabilities.favorites, + localLibraryBrowse: capabilities.localLibrary, + playlistsOfflineBrowse: capabilities.playlists, + playerStatsBrowse: capabilities.playerStats, + hasManualOfflineContent: capabilities.manualPins, + }; +} + +/** Cross-server favorites scope (setting + any indexed server). */ +function favoritesBrowseCapabilityAnyServer(favoritesOfflineEnabled: boolean): boolean { + if (!favoritesOfflineEnabled) return false; + return favoritesOfflineBrowseEnabled(); +} diff --git a/src/utils/offline/offlineBrowseMode.test.ts b/src/utils/offline/offlineBrowseMode.test.ts new file mode 100644 index 00000000..5be3c33b --- /dev/null +++ b/src/utils/offline/offlineBrowseMode.test.ts @@ -0,0 +1,21 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, expect, it, beforeEach } from 'vitest'; +import { useDevOfflineBrowseStore } from '../../store/devOfflineBrowseStore'; +import { useOfflineBrowseActive } from './offlineBrowseMode'; + +describe('useOfflineBrowseActive', () => { + beforeEach(() => { + useDevOfflineBrowseStore.setState({ forceOffline: false }); + }); + + it('enables offline browse when DEV force-offline is set', () => { + if (!import.meta.env.DEV) return; + + act(() => { + useDevOfflineBrowseStore.getState().setForceOffline(true); + }); + + const { result } = renderHook(() => useOfflineBrowseActive()); + expect(result.current).toBe(true); + }); +}); diff --git a/src/utils/offline/offlineBrowseMode.ts b/src/utils/offline/offlineBrowseMode.ts new file mode 100644 index 00000000..d6123c27 --- /dev/null +++ b/src/utils/offline/offlineBrowseMode.ts @@ -0,0 +1,28 @@ +import { + isDevOfflineBrowseForced, + useDevOfflineBrowseStore, +} from '../../store/devOfflineBrowseStore'; +import { useConnectionStatus } from '../../hooks/useConnectionStatus'; +import { isActiveServerReachable } from '../network/activeServerReachability'; + +/** True when browse/detail pages should use local-bytes-only data sources. */ +export function isOfflineBrowseActive(): boolean { + if (isDevOfflineBrowseForced()) return true; + if (typeof navigator !== 'undefined' && !navigator.onLine) return true; + return !isActiveServerReachable(); +} + +/** + * Reactive offline-browse flag for React trees. Re-renders when the DEV toggle, + * browser online state, or active-server connection status changes. + */ +export function useOfflineBrowseActive(): boolean { + const devForceOffline = useDevOfflineBrowseStore(s => s.forceOffline); + const { status: connStatus } = useConnectionStatus(); + + if (import.meta.env.DEV && devForceOffline) return true; + if (typeof navigator !== 'undefined' && !navigator.onLine) return true; + if (connStatus === 'disconnected') return true; + if (connStatus === 'connected') return false; + return !isActiveServerReachable(); +} diff --git a/src/utils/offline/offlineBrowseRouting.test.ts b/src/utils/offline/offlineBrowseRouting.test.ts new file mode 100644 index 00000000..324a0454 --- /dev/null +++ b/src/utils/offline/offlineBrowseRouting.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { + hasOfflineBrowseCapability, + isPathOfflineBrowsable, + resolveOfflineDisconnectNavAction, +} from './offlineBrowseRouting'; + +describe('offlineBrowseRouting', () => { + it('hasOfflineBrowseCapability is true when any offline surface exists', () => { + expect(hasOfflineBrowseCapability(false, false, false)).toBe(false); + expect(hasOfflineBrowseCapability(true, false, false)).toBe(true); + expect(hasOfflineBrowseCapability(false, true, false)).toBe(true); + expect(hasOfflineBrowseCapability(false, false, true)).toBe(true); + }); + + it('isPathOfflineBrowsable covers library, detail, and local-only routes', () => { + const ctx = [true, true, true, true] as const; + expect(isPathOfflineBrowsable('/albums', ...ctx)).toBe(true); + expect(isPathOfflineBrowsable('/album/abc', ...ctx)).toBe(true); + expect(isPathOfflineBrowsable('/artist/abc', ...ctx)).toBe(true); + expect(isPathOfflineBrowsable('/playlists', ...ctx)).toBe(true); + expect(isPathOfflineBrowsable('/playlists/pl-1', ...ctx)).toBe(true); + expect(isPathOfflineBrowsable('/now-playing', ...ctx)).toBe(true); + expect(isPathOfflineBrowsable('/', ...ctx)).toBe(false); + expect(isPathOfflineBrowsable('/playlists', true, true, true, false)).toBe(false); + }); + + it('resolveOfflineDisconnectNavAction stays when nothing offline', () => { + expect(resolveOfflineDisconnectNavAction('/playlists', false, false, false, false, false)) + .toEqual({ kind: 'stay' }); + expect(resolveOfflineDisconnectNavAction('/albums', false, false, false, false, false)) + .toEqual({ kind: 'stay' }); + }); + + it('resolveOfflineDisconnectNavAction reloads allowed pages', () => { + expect(resolveOfflineDisconnectNavAction('/artists', false, true, false, false, false)) + .toEqual({ kind: 'stay-reload' }); + expect(resolveOfflineDisconnectNavAction('/playlists', false, false, false, true, true)) + .toEqual({ kind: 'stay-reload' }); + }); + + it('resolveOfflineDisconnectNavAction redirects disallowed pages to all albums', () => { + expect(resolveOfflineDisconnectNavAction('/playlists', false, true, false, false, false)) + .toEqual({ kind: 'redirect', to: '/albums' }); + expect(resolveOfflineDisconnectNavAction('/', false, true, false, false, false)) + .toEqual({ kind: 'redirect', to: '/albums' }); + }); +}); diff --git a/src/utils/offline/offlineBrowseRouting.ts b/src/utils/offline/offlineBrowseRouting.ts new file mode 100644 index 00000000..28d11224 --- /dev/null +++ b/src/utils/offline/offlineBrowseRouting.ts @@ -0,0 +1,77 @@ +import { isOfflineSidebarNavAllowed } from './offlineNavPolicy'; + +/** Any offline browse surface the disconnect fork may use. */ +export function hasOfflineBrowseCapability( + localLibraryBrowse: boolean, + favoritesOfflineBrowse: boolean, + hasManualOfflineContent: boolean, +): boolean { + return localLibraryBrowse || favoritesOfflineBrowse || hasManualOfflineContent; +} + +/** Map a route to a sidebar nav id for offline-allow checks (detail pages included). */ +function offlineNavIdForPathname(pathname: string): string | null { + if (pathname === '/albums') return 'allAlbums'; + if (pathname === '/artists' || pathname.startsWith('/artist/')) return 'artists'; + if (pathname === '/playlists' || pathname.startsWith('/playlists/')) return 'playlists'; + if (pathname === '/tracks') return 'tracks'; + if (pathname === '/favorites') return 'favorites'; + if (pathname === '/offline') return 'offline'; + if (pathname === '/help') return 'help'; + if (pathname === '/statistics' || pathname === '/player-stats') return 'statistics'; + if (pathname.startsWith('/album/')) return 'allAlbums'; + return null; +} + +const OFFLINE_ALWAYS_STAY_PATHS = new Set([ + '/now-playing', + '/settings', +]); + +export function isPathOfflineBrowsable( + pathname: string, + favoritesOfflineBrowse: boolean, + localLibraryBrowse: boolean, + playerStatsBrowse: boolean, + playlistsOfflineBrowse: boolean, +): boolean { + if (OFFLINE_ALWAYS_STAY_PATHS.has(pathname)) return true; + const navId = offlineNavIdForPathname(pathname); + if (!navId) return false; + return isOfflineSidebarNavAllowed( + navId, + favoritesOfflineBrowse, + localLibraryBrowse, + playerStatsBrowse, + playlistsOfflineBrowse, + ); +} + +type OfflineDisconnectNavAction = + | { kind: 'stay' } + | { kind: 'stay-reload' } + | { kind: 'redirect'; to: '/albums' }; + +/** Decide what to do when the active server just became unreachable. */ +export function resolveOfflineDisconnectNavAction( + pathname: string, + favoritesOfflineBrowse: boolean, + localLibraryBrowse: boolean, + playerStatsBrowse: boolean, + playlistsOfflineBrowse: boolean, + hasManualOfflineContent: boolean, +): OfflineDisconnectNavAction { + if (!hasOfflineBrowseCapability(localLibraryBrowse, favoritesOfflineBrowse, hasManualOfflineContent)) { + return { kind: 'stay' }; + } + if (isPathOfflineBrowsable( + pathname, + favoritesOfflineBrowse, + localLibraryBrowse, + playerStatsBrowse, + playlistsOfflineBrowse, + )) { + return { kind: 'stay-reload' }; + } + return { kind: 'redirect', to: '/albums' }; +} diff --git a/src/utils/offline/offlineLibraryFilterSuspend.test.ts b/src/utils/offline/offlineLibraryFilterSuspend.test.ts new file mode 100644 index 00000000..2572d106 --- /dev/null +++ b/src/utils/offline/offlineLibraryFilterSuspend.test.ts @@ -0,0 +1,31 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { useAuthStore } from '../../store/authStore'; +import { + resetOfflineLibraryFilterSuspendState, + restoreMusicLibraryFiltersAfterOffline, + suspendMusicLibraryFiltersForOffline, +} from './offlineLibraryFilterSuspend'; + +describe('offlineLibraryFilterSuspend', () => { + beforeEach(() => { + resetOfflineLibraryFilterSuspendState(); + useAuthStore.setState({ + activeServerId: 'srv-a', + musicLibraryFilterByServer: { 'srv-a': 'lib-1' }, + musicLibraryFilterVersion: 0, + }); + }); + + it('suspend saves scoped filter and resets active server to all', () => { + suspendMusicLibraryFiltersForOffline(); + expect(useAuthStore.getState().musicLibraryFilterByServer['srv-a']).toBe('all'); + expect(useAuthStore.getState().musicLibraryFilterVersion).toBe(1); + }); + + it('restore brings back the saved filter after reconnect', () => { + suspendMusicLibraryFiltersForOffline(); + restoreMusicLibraryFiltersAfterOffline(); + expect(useAuthStore.getState().musicLibraryFilterByServer['srv-a']).toBe('lib-1'); + expect(useAuthStore.getState().musicLibraryFilterVersion).toBe(2); + }); +}); diff --git a/src/utils/offline/offlineLibraryFilterSuspend.ts b/src/utils/offline/offlineLibraryFilterSuspend.ts new file mode 100644 index 00000000..35702fc0 --- /dev/null +++ b/src/utils/offline/offlineLibraryFilterSuspend.ts @@ -0,0 +1,36 @@ +import { useAuthStore } from '../../store/authStore'; + +let savedFilterByServer: Record | null = null; + +/** Remember sidebar library filters and browse all libraries while offline. */ +export function suspendMusicLibraryFiltersForOffline(): void { + if (savedFilterByServer != null) return; + const auth = useAuthStore.getState(); + savedFilterByServer = { ...auth.musicLibraryFilterByServer }; + const serverId = auth.activeServerId; + if (!serverId) return; + const current = auth.musicLibraryFilterByServer[serverId] ?? 'all'; + if (current !== 'all') { + auth.setMusicLibraryFilter('all'); + } +} + +/** Restore the pre-offline library filter for the active server. */ +export function restoreMusicLibraryFiltersAfterOffline(): void { + if (!savedFilterByServer) return; + const snapshot = savedFilterByServer; + savedFilterByServer = null; + const auth = useAuthStore.getState(); + const serverId = auth.activeServerId; + if (!serverId) return; + const saved = snapshot[serverId] ?? 'all'; + const current = auth.musicLibraryFilterByServer[serverId] ?? 'all'; + if (saved !== current) { + auth.setMusicLibraryFilter(saved); + } +} + +/** Test helper — drop suspended snapshot without restoring. */ +export function resetOfflineLibraryFilterSuspendState(): void { + savedFilterByServer = null; +} diff --git a/src/utils/offline/offlineLibraryIndexLoad.ts b/src/utils/offline/offlineLibraryIndexLoad.ts new file mode 100644 index 00000000..7555faf8 --- /dev/null +++ b/src/utils/offline/offlineLibraryIndexLoad.ts @@ -0,0 +1,92 @@ +import { libraryAdvancedSearch, libraryGetTracksByAlbum } from '../../api/library'; +import type { + SubsonicAlbum, + SubsonicArtist, + SubsonicSong, +} from '../../api/subsonicTypes'; +import { + albumToAlbum, + artistToArtist, + trackToSong, +} from '../library/advancedSearchLocal'; + +export async function loadAlbumFromLibraryIndex( + serverId: string, + albumId: string, +): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null> { + const tracks = await libraryGetTracksByAlbum(serverId, albumId); + if (tracks.length === 0) return null; + + const songs = tracks.map(trackToSong); + const albumSearch = await libraryAdvancedSearch({ + serverId, + entityTypes: ['album'], + restrictAlbumIds: [albumId], + limit: 1, + }); + const albumDto = albumSearch.albums[0]; + if (albumDto) { + const album = albumToAlbum(albumDto); + return { + album: { + ...album, + serverId, + songCount: songs.length, + duration: songs.reduce((sum, s) => sum + (s.duration ?? 0), 0), + }, + songs: songs.map(s => ({ ...s, serverId })), + }; + } + + const first = tracks[0]; + return { + album: { + id: albumId, + name: first.album ?? albumId, + artist: first.artist ?? '', + artistId: first.artistId ?? '', + songCount: songs.length, + duration: songs.reduce((sum, s) => sum + (s.duration ?? 0), 0), + coverArt: first.coverArtId ?? albumId, + year: first.year ?? undefined, + genre: first.genre ?? undefined, + starred: first.starredAt != null ? new Date(first.starredAt).toISOString() : undefined, + serverId, + }, + songs: songs.map(s => ({ ...s, serverId })), + }; +} + +export async function loadArtistFromLibraryIndex( + serverId: string, + artistId: string, +): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] } | null> { + const response = await libraryAdvancedSearch({ + serverId, + entityTypes: ['album', 'artist'], + limit: 10_000, + }); + const albums = response.albums + .filter(a => a.artistId === artistId) + .map(albumToAlbum) + .map(a => ({ ...a, serverId })); + const artistDto = response.artists.find(a => a.id === artistId); + if (!artistDto && albums.length === 0) return null; + + const artist = artistDto + ? { ...artistToArtist(artistDto), serverId } + : { + id: artistId, + name: albums[0]?.artist ?? artistId, + albumCount: albums.length, + serverId, + }; + + return { + artist: { + ...artist, + albumCount: albums.length, + }, + albums, + }; +} diff --git a/src/utils/offline/offlineLocalBrowse.test.ts b/src/utils/offline/offlineLocalBrowse.test.ts new file mode 100644 index 00000000..a08c1f1b --- /dev/null +++ b/src/utils/offline/offlineLocalBrowse.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { LibraryTrackDto } from '../../api/library'; +import { useAuthStore } from '../../store/authStore'; +import { useLibraryIndexStore } from '../../store/libraryIndexStore'; +import { useLocalPlaybackStore } from '../../store/localPlaybackStore'; +import { + countLocalBrowsableTracks, + fetchOfflineLocalBrowsableSongPage, + offlineLocalBrowseEnabled, +} from './offlineLocalBrowse'; + +const { libraryGetTracksBatchChunkedMock } = vi.hoisted(() => ({ + libraryGetTracksBatchChunkedMock: vi.fn(async (): Promise => []), +})); + +vi.mock('../../api/library', () => ({ + libraryGetTracksBatchChunked: libraryGetTracksBatchChunkedMock, + libraryGetTracksByAlbum: vi.fn(async () => []), + libraryAdvancedSearch: vi.fn(async () => ({ albums: [], artists: [], tracks: [] })), +})); + +describe('offlineLocalBrowse', () => { + beforeEach(() => { + useAuthStore.setState({ + activeServerId: 'srv-a', + servers: [{ id: 'srv-a', name: 'A', url: 'https://a.test', username: 'u', password: 'p' }], + }); + useLibraryIndexStore.setState({ masterEnabled: true }); + useLocalPlaybackStore.setState({ entries: {} }); + libraryGetTracksBatchChunkedMock.mockReset(); + libraryGetTracksBatchChunkedMock.mockResolvedValue([]); + }); + + it('offlineLocalBrowseEnabled requires index and local bytes', () => { + expect(offlineLocalBrowseEnabled('srv-a')).toBe(false); + useLocalPlaybackStore.setState({ + entries: { + 'a.test:t1': { + serverIndexKey: 'a.test', + trackId: 't1', + localPath: '/media/library/a.test/a/al/t1.mp3', + layoutFingerprint: 'fp', + sizeBytes: 1, + tier: 'library', + cachedAt: 1, + suffix: 'mp3', + }, + }, + }); + expect(countLocalBrowsableTracks('srv-a')).toBe(1); + expect(offlineLocalBrowseEnabled('srv-a')).toBe(true); + }); + + it('fetchOfflineLocalBrowsableSongPage pages local bytes alphabetically', async () => { + useLocalPlaybackStore.setState({ + entries: { + 'a.test:t1': { + serverIndexKey: 'a.test', + trackId: 't1', + localPath: '/media/library/a.test/a/al/t1.mp3', + layoutFingerprint: 'fp', + sizeBytes: 1, + tier: 'library', + cachedAt: 1, + suffix: 'mp3', + }, + 'a.test:t2': { + serverIndexKey: 'a.test', + trackId: 't2', + localPath: '/media/library/a.test/a/al/t2.mp3', + layoutFingerprint: 'fp', + sizeBytes: 1, + tier: 'library', + cachedAt: 1, + suffix: 'mp3', + }, + }, + }); + libraryGetTracksBatchChunkedMock.mockResolvedValue([ + { + id: 't2', title: 'Beta', artist: 'A', album: 'Al', albumId: 'al-1', + durationSec: 1, serverId: 'srv-a', syncedAt: 1, rawJson: {}, + }, + { + id: 't1', title: 'Alpha', artist: 'A', album: 'Al', albumId: 'al-1', + durationSec: 1, serverId: 'srv-a', syncedAt: 1, rawJson: {}, + }, + ]); + + const page = await fetchOfflineLocalBrowsableSongPage('srv-a', 0, 1); + expect(page?.songs.map(s => s.id)).toEqual(['t1']); + expect(page?.hasMore).toBe(true); + }); + +}); diff --git a/src/utils/offline/offlineLocalBrowse.ts b/src/utils/offline/offlineLocalBrowse.ts new file mode 100644 index 00000000..460d8f6f --- /dev/null +++ b/src/utils/offline/offlineLocalBrowse.ts @@ -0,0 +1,307 @@ +import type { LibraryTrackDto } from '../../api/library'; +import { libraryAdvancedSearch, libraryGetTracksBatchChunked, libraryGetTracksByAlbum } from '../../api/library'; +import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../../api/subsonicTypes'; +import { useLibraryIndexStore } from '../../store/libraryIndexStore'; +import type { LocalPlaybackEntry } from '../../store/localPlaybackStore'; +import { useLocalPlaybackStore } from '../../store/localPlaybackStore'; +import { + albumToAlbum, + artistToArtist, + resolveTrackCoverArtId, + trackToSong, +} from '../library/advancedSearchLocal'; +import { + filterAlbumsByCompilation, + filterAlbumsByGenres, + filterAlbumsByStarred, + filterAlbumsByYearBounds, +} from '../library/albumBrowseFilters'; +import type { AlbumBrowseQuery } from '../library/albumBrowseTypes'; +import { sortSubsonicAlbums } from '../library/albumBrowseSort'; +import { isLosslessSuffix } from '../library/losslessFormats'; +import { entryBelongsToServer } from './offlineLibraryHelpers'; + +function sortBrowsableSongs(songs: SubsonicSong[]): SubsonicSong[] { + return [...songs].sort((a, b) => a.title.localeCompare(b.title)); +} + +function listBrowsableEntries(serverId: string): LocalPlaybackEntry[] { + return Object.values(useLocalPlaybackStore.getState().entries).filter( + e => (e.tier === 'library' || e.tier === 'favorite-auto') + && !!e.localPath + && entryBelongsToServer(e, serverId), + ); +} + +export function countLocalBrowsableTracks(serverId: string): number { + return listBrowsableEntries(serverId).length; +} + +/** Local library index + at least one on-disk library/favorites track for this server. */ +export function offlineLocalBrowseEnabled(serverId: string | null | undefined): boolean { + if (!serverId) return false; + if (!useLibraryIndexStore.getState().isIndexEnabled(serverId)) return false; + return countLocalBrowsableTracks(serverId) > 0; +} + +/** Track DTOs for every library/favorite-auto entry with on-disk bytes for this server. */ +export async function fetchBrowsableLocalTrackDtos(serverId: string): Promise { + const entries = listBrowsableEntries(serverId); + if (entries.length === 0) return []; + const refs = entries.map(e => ({ serverId, trackId: e.trackId })); + return libraryGetTracksBatchChunked(refs); +} + +export function buildAlbumFromTracks( + albumId: string, + tracks: LibraryTrackDto[], + serverId: string, +): SubsonicAlbum { + const songs = tracks.map(trackToSong).map(s => ({ ...s, serverId })); + const first = tracks[0]; + const starred = tracks.some(t => t.starredAt != null); + return { + id: albumId, + name: first.album ?? albumId, + artist: first.artist ?? first.albumArtist ?? '', + artistId: first.artistId ?? '', + coverArt: resolveTrackCoverArtId(first) ?? albumId, + year: first.year ?? undefined, + genre: first.genre ?? undefined, + songCount: songs.length, + duration: songs.reduce((sum, s) => sum + (s.duration ?? 0), 0), + starred: starred ? new Date().toISOString() : undefined, + serverId, + }; +} + +function aggregateAlbumsFromTracks( + tracks: LibraryTrackDto[], + serverId: string, +): SubsonicAlbum[] { + const byAlbum = new Map(); + for (const track of tracks) { + const albumId = track.albumId; + if (!albumId) continue; + const list = byAlbum.get(albumId) ?? []; + list.push(track); + byAlbum.set(albumId, list); + } + return [...byAlbum.entries()].map(([albumId, albumTracks]) => + buildAlbumFromTracks(albumId, albumTracks, serverId), + ); +} + +function aggregateArtistsFromTracks( + tracks: LibraryTrackDto[], + serverId: string, +): SubsonicArtist[] { + const albumIdsByArtist = new Map>(); + const names = new Map(); + for (const track of tracks) { + const artistId = track.artistId; + if (!artistId) continue; + names.set(artistId, track.artist ?? track.albumArtist ?? artistId); + const set = albumIdsByArtist.get(artistId) ?? new Set(); + if (track.albumId) set.add(track.albumId); + albumIdsByArtist.set(artistId, set); + } + return [...names.entries()] + .map(([id, name]) => ({ + id, + name, + albumCount: albumIdsByArtist.get(id)?.size ?? 0, + serverId, + })) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +function applyAlbumBrowseQuery( + albums: SubsonicAlbum[], + query: AlbumBrowseQuery, + starredOverrides: Record, +): SubsonicAlbum[] { + let out = albums; + if (query.genres.length > 0) { + out = filterAlbumsByGenres(out, query.genres); + } + if (query.year) { + out = filterAlbumsByYearBounds(out, query.year); + } + if (query.starredOnly) { + out = filterAlbumsByStarred(out, starredOverrides); + } + if (query.compFilter !== 'all') { + out = filterAlbumsByCompilation(out, query.compFilter); + } + return sortSubsonicAlbums(out, query.sort); +} + +export async function fetchOfflineLocalBrowsableSongPage( + serverId: string, + offset: number, + chunkSize: number, +): Promise<{ songs: SubsonicSong[]; hasMore: boolean } | null> { + if (!offlineLocalBrowseEnabled(serverId)) return null; + const tracks = await fetchBrowsableLocalTrackDtos(serverId); + const songs = sortBrowsableSongs( + tracks.map(trackToSong).map(s => ({ ...s, serverId })), + ); + const slice = songs.slice(offset, offset + chunkSize); + return { songs: slice, hasMore: offset + chunkSize < songs.length }; +} + +export async function searchOfflineLocalBrowsableSongs( + serverId: string, + query: string, + offset: number, + chunkSize: number, +): Promise { + if (!offlineLocalBrowseEnabled(serverId)) return null; + const q = query.trim().toLowerCase(); + if (!q) return null; + const tracks = await fetchBrowsableLocalTrackDtos(serverId); + const matched = tracks + .filter(t => + (t.title?.toLowerCase().includes(q)) + || (t.artist?.toLowerCase().includes(q)) + || (t.album?.toLowerCase().includes(q)), + ) + .map(trackToSong) + .map(s => ({ ...s, serverId })); + return sortBrowsableSongs(matched).slice(offset, offset + chunkSize); +} + +export async function fetchOfflineLocalStarredArtists(serverId: string): Promise { + if (!offlineLocalBrowseEnabled(serverId)) return null; + const tracks = (await fetchBrowsableLocalTrackDtos(serverId)).filter(t => t.starredAt != null); + return aggregateArtistsFromTracks(tracks, serverId); +} + +export async function fetchOfflineLocalArtistCatalogChunk( + serverId: string, + offset: number, + chunkSize: number, +): Promise<{ artists: SubsonicArtist[]; hasMore: boolean } | null> { + if (!offlineLocalBrowseEnabled(serverId)) return null; + const tracks = await fetchBrowsableLocalTrackDtos(serverId); + const artists = aggregateArtistsFromTracks(tracks, serverId); + const slice = artists.slice(offset, offset + chunkSize); + return { + artists: slice, + hasMore: offset + chunkSize < artists.length, + }; +} + +export async function searchOfflineLocalArtists( + serverId: string, + query: string, +): Promise { + if (!offlineLocalBrowseEnabled(serverId)) return null; + const q = query.trim().toLowerCase(); + if (!q) return []; + const tracks = await fetchBrowsableLocalTrackDtos(serverId); + return aggregateArtistsFromTracks(tracks, serverId) + .filter(a => a.name.toLowerCase().includes(q)); +} + +export async function fetchOfflineLocalAlbumCatalogChunk( + serverId: string, + query: AlbumBrowseQuery, + offset: number, + chunkSize: number, + starredOverrides: Record = {}, +): Promise<{ albums: SubsonicAlbum[]; hasMore: boolean } | null> { + if (!offlineLocalBrowseEnabled(serverId)) return null; + let tracks = await fetchBrowsableLocalTrackDtos(serverId); + if (query.losslessOnly) { + tracks = tracks.filter(t => isLosslessSuffix(t.suffix ?? undefined)); + } + let albums = aggregateAlbumsFromTracks(tracks, serverId); + albums = applyAlbumBrowseQuery(albums, query, starredOverrides); + const slice = albums.slice(offset, offset + chunkSize); + return { + albums: slice, + hasMore: offset + chunkSize < albums.length, + }; +} + +export async function searchOfflineLocalAlbums( + serverId: string, + query: string, + losslessOnly = false, +): Promise { + if (!offlineLocalBrowseEnabled(serverId)) return null; + const q = query.trim().toLowerCase(); + if (!q) return []; + let tracks = await fetchBrowsableLocalTrackDtos(serverId); + if (losslessOnly) { + tracks = tracks.filter(t => isLosslessSuffix(t.suffix ?? undefined)); + } + return aggregateAlbumsFromTracks(tracks, serverId) + .filter(a => a.name.toLowerCase().includes(q) || a.artist.toLowerCase().includes(q)); +} + +export async function loadAlbumFromLocalPlayback( + serverId: string, + albumId: string, +): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null> { + if (!offlineLocalBrowseEnabled(serverId)) return null; + const localIds = new Set(listBrowsableEntries(serverId).map(e => e.trackId)); + const tracks = await libraryGetTracksByAlbum(serverId, albumId); + const localTracks = tracks.filter(t => localIds.has(t.id)); + if (localTracks.length === 0) return null; + + const songs = localTracks.map(trackToSong).map(s => ({ ...s, serverId })); + const albumSearch = await libraryAdvancedSearch({ + serverId, + entityTypes: ['album'], + restrictAlbumIds: [albumId], + limit: 1, + }).catch(() => null); + const albumDto = albumSearch?.albums[0]; + const album = albumDto + ? { ...albumToAlbum(albumDto), serverId, songCount: songs.length } + : buildAlbumFromTracks(albumId, localTracks, serverId); + + return { + album: { + ...album, + duration: songs.reduce((sum, s) => sum + (s.duration ?? 0), 0), + }, + songs, + }; +} + +export async function loadArtistFromLocalPlayback( + serverId: string, + artistId: string, +): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] } | null> { + if (!offlineLocalBrowseEnabled(serverId)) return null; + const localIds = new Set(listBrowsableEntries(serverId).map(e => e.trackId)); + const tracks = (await fetchBrowsableLocalTrackDtos(serverId)).filter( + t => t.artistId === artistId && localIds.has(t.id), + ); + if (tracks.length === 0) return null; + + const albums = aggregateAlbumsFromTracks(tracks, serverId) + .sort((a, b) => a.name.localeCompare(b.name)); + const artistDto = tracks[0]; + const artistSearch = await libraryAdvancedSearch({ + serverId, + entityTypes: ['artist'], + limit: 10_000, + }).catch(() => null); + const match = artistSearch?.artists.find(a => a.id === artistId); + + const artist = match + ? { ...artistToArtist(match), serverId, albumCount: albums.length } + : { + id: artistId, + name: artistDto.artist ?? artistDto.albumArtist ?? artistId, + albumCount: albums.length, + serverId, + }; + + return { artist, albums }; +} diff --git a/src/utils/offline/offlineMediaResolve.test.ts b/src/utils/offline/offlineMediaResolve.test.ts new file mode 100644 index 00000000..900a6256 --- /dev/null +++ b/src/utils/offline/offlineMediaResolve.test.ts @@ -0,0 +1,161 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAuthStore } from '../../store/authStore'; +import { + resolveAlbum, + resolveAlbumForActiveServer, + resolveArtist, + resolvePlaylist, +} from './offlineMediaResolve'; + +const isOfflineBrowseActiveMock = vi.fn(() => false); +const offlineLocalBrowseEnabledMock = vi.fn((_serverId: string) => false); +const playlistsOfflineBrowseEnabledMock = vi.fn((_serverId: string) => false); +const loadAlbumFromLocalPlaybackMock = vi.fn(); +const loadArtistFromLocalPlaybackMock = vi.fn(); +const loadAlbumFromLibraryIndexMock = vi.fn(); +const loadArtistFromLibraryIndexMock = vi.fn(); +const loadOfflineBrowsablePlaylistMock = vi.fn(); +const shouldAttemptSubsonicForServerMock = vi.fn((_serverId: string, _trackId?: string) => true); +const getAlbumForServerMock = vi.fn((_serverId: string, _albumId: string) => ({})); +const getArtistForServerMock = vi.fn((_serverId: string, _artistId: string) => ({})); +const getPlaylistForServerMock = vi.fn((_serverId: string, _playlistId: string) => ({})); + +vi.mock('./offlineBrowseMode', () => ({ + isOfflineBrowseActive: () => isOfflineBrowseActiveMock(), +})); + +vi.mock('./offlineLocalBrowse', () => ({ + offlineLocalBrowseEnabled: (id: string) => offlineLocalBrowseEnabledMock(id), + loadAlbumFromLocalPlayback: (serverId: string, albumId: string) => + loadAlbumFromLocalPlaybackMock(serverId, albumId), + loadArtistFromLocalPlayback: (serverId: string, artistId: string) => + loadArtistFromLocalPlaybackMock(serverId, artistId), +})); + +vi.mock('./offlineLibraryIndexLoad', () => ({ + loadAlbumFromLibraryIndex: (...args: unknown[]) => loadAlbumFromLibraryIndexMock(...args), + loadArtistFromLibraryIndex: (...args: unknown[]) => loadArtistFromLibraryIndexMock(...args), +})); + +vi.mock('./offlinePlaylistBrowse', () => ({ + playlistsOfflineBrowseEnabled: (id: string) => playlistsOfflineBrowseEnabledMock(id), + loadOfflineBrowsablePlaylist: (playlistId: string, serverId: string) => + loadOfflineBrowsablePlaylistMock(playlistId, serverId), +})); + +vi.mock('../network/subsonicNetworkGuard', () => ({ + shouldAttemptSubsonicForServer: (serverId: string, trackId?: string) => + shouldAttemptSubsonicForServerMock(serverId, trackId), +})); + +vi.mock('../../api/subsonicLibrary', () => ({ + getAlbumForServer: (serverId: string, albumId: string) => getAlbumForServerMock(serverId, albumId), +})); + +vi.mock('../../api/subsonicArtists', () => ({ + getArtistForServer: (serverId: string, artistId: string) => getArtistForServerMock(serverId, artistId), +})); + +vi.mock('../../api/subsonicPlaylists', () => ({ + getPlaylistForServer: (serverId: string, playlistId: string) => + getPlaylistForServerMock(serverId, playlistId), +})); + +describe('offlineMediaResolve', () => { + beforeEach(() => { + isOfflineBrowseActiveMock.mockReturnValue(false); + offlineLocalBrowseEnabledMock.mockReturnValue(false); + playlistsOfflineBrowseEnabledMock.mockReturnValue(false); + shouldAttemptSubsonicForServerMock.mockReturnValue(true); + loadAlbumFromLocalPlaybackMock.mockReset(); + loadArtistFromLocalPlaybackMock.mockReset(); + loadAlbumFromLibraryIndexMock.mockReset(); + loadArtistFromLibraryIndexMock.mockReset(); + loadOfflineBrowsablePlaylistMock.mockReset(); + getAlbumForServerMock.mockReset(); + getArtistForServerMock.mockReset(); + getPlaylistForServerMock.mockReset(); + useAuthStore.setState({ favoritesOfflineEnabled: true, activeServerId: 'srv-1' } as Partial< + ReturnType + >); + }); + + it('resolveAlbum prefers local bytes when offline browse and local library enabled', async () => { + isOfflineBrowseActiveMock.mockReturnValue(true); + offlineLocalBrowseEnabledMock.mockReturnValue(true); + loadAlbumFromLocalPlaybackMock.mockResolvedValue({ + album: { id: 'alb-1', name: 'Local' }, + songs: [{ id: 't1', title: 'One' }], + }); + const result = await resolveAlbum('srv-1', 'alb-1'); + expect(loadAlbumFromLocalPlaybackMock).toHaveBeenCalledWith('srv-1', 'alb-1'); + expect(result?.songs).toHaveLength(1); + expect(getAlbumForServerMock).not.toHaveBeenCalled(); + }); + + it('resolveAlbum uses network when allowed', async () => { + getAlbumForServerMock.mockResolvedValue({ + album: { id: 'alb-1', name: 'Net' }, + songs: [{ id: 't1' }, { id: 't2' }], + }); + const result = await resolveAlbum('srv-1', 'alb-1'); + expect(getAlbumForServerMock).toHaveBeenCalledWith('srv-1', 'alb-1'); + expect(result?.songs).toHaveLength(2); + }); + + it('resolveAlbum falls back to library index when network blocked', async () => { + shouldAttemptSubsonicForServerMock.mockReturnValue(false); + loadAlbumFromLibraryIndexMock.mockResolvedValue({ + album: { id: 'alb-1', name: 'Idx' }, + songs: [{ id: 't1' }], + }); + const result = await resolveAlbum('srv-1', 'alb-1'); + expect(loadAlbumFromLibraryIndexMock).toHaveBeenCalledWith('srv-1', 'alb-1'); + expect(result?.album.name).toBe('Idx'); + }); + + it('resolveAlbumForActiveServer uses active server id', async () => { + getAlbumForServerMock.mockResolvedValue({ + album: { id: 'alb-2' }, + songs: [], + }); + await resolveAlbumForActiveServer('alb-2'); + expect(getAlbumForServerMock).toHaveBeenCalledWith('srv-1', 'alb-2'); + }); + + it('resolveArtist prefers local bytes when offline browse and local library enabled', async () => { + isOfflineBrowseActiveMock.mockReturnValue(true); + offlineLocalBrowseEnabledMock.mockReturnValue(true); + loadArtistFromLocalPlaybackMock.mockResolvedValue({ + artist: { id: 'art-1', name: 'Local Artist' }, + albums: [{ id: 'alb-1' }], + }); + const result = await resolveArtist('srv-1', 'art-1'); + expect(loadArtistFromLocalPlaybackMock).toHaveBeenCalledWith('srv-1', 'art-1'); + expect(result?.albums).toHaveLength(1); + expect(getArtistForServerMock).not.toHaveBeenCalled(); + }); + + it('resolvePlaylist uses offline browse cache when enabled', async () => { + isOfflineBrowseActiveMock.mockReturnValue(true); + playlistsOfflineBrowseEnabledMock.mockReturnValue(true); + loadOfflineBrowsablePlaylistMock.mockResolvedValue({ + playlist: { id: 'pl-1', name: 'Offline' }, + songs: [{ id: 't1' }], + }); + const result = await resolvePlaylist('srv-1', 'pl-1'); + expect(loadOfflineBrowsablePlaylistMock).toHaveBeenCalledWith('pl-1', 'srv-1'); + expect(result?.songs).toHaveLength(1); + expect(getPlaylistForServerMock).not.toHaveBeenCalled(); + }); + + it('resolvePlaylist uses network when allowed', async () => { + getPlaylistForServerMock.mockResolvedValue({ + playlist: { id: 'pl-2', name: 'Net' }, + songs: [{ id: 't1' }, { id: 't2' }], + }); + const result = await resolvePlaylist('srv-1', 'pl-2'); + expect(getPlaylistForServerMock).toHaveBeenCalledWith('srv-1', 'pl-2'); + expect(result?.songs).toHaveLength(2); + }); +}); diff --git a/src/utils/offline/offlineMediaResolve.ts b/src/utils/offline/offlineMediaResolve.ts new file mode 100644 index 00000000..382126ed --- /dev/null +++ b/src/utils/offline/offlineMediaResolve.ts @@ -0,0 +1,121 @@ +import { getAlbumForServer } from '../../api/subsonicLibrary'; +import { getArtistForServer } from '../../api/subsonicArtists'; +import { getPlaylistForServer } from '../../api/subsonicPlaylists'; +import type { + SubsonicAlbum, + SubsonicArtist, + SubsonicPlaylist, + SubsonicSong, +} from '../../api/subsonicTypes'; +import { useAuthStore } from '../../store/authStore'; +import { shouldAttemptSubsonicForServer } from '../network/subsonicNetworkGuard'; +import { isOfflineBrowseActive } from './offlineBrowseMode'; +import { + loadAlbumFromLibraryIndex, + loadArtistFromLibraryIndex, +} from './offlineLibraryIndexLoad'; +import { + loadAlbumFromLocalPlayback, + loadArtistFromLocalPlayback, + offlineLocalBrowseEnabled, +} from './offlineLocalBrowse'; +import { + loadOfflineBrowsablePlaylist, + playlistsOfflineBrowseEnabled, +} from './offlinePlaylistBrowse'; + +export type ResolvedAlbum = { album: SubsonicAlbum; songs: SubsonicSong[] }; + +/** + * Album detail / play / enqueue: network album when reachable (complete track list); + * local bytes or library index when offline browse is active. + */ +export async function resolveAlbum( + serverId: string, + albumId: string, +): Promise { + if (isOfflineBrowseActive() && offlineLocalBrowseEnabled(serverId)) { + return loadAlbumFromLocalPlayback(serverId, albumId); + } + const favoritesOffline = useAuthStore.getState().favoritesOfflineEnabled; + const networkAllowed = shouldAttemptSubsonicForServer(serverId); + + if (networkAllowed) { + try { + const data = await getAlbumForServer(serverId, albumId); + return { album: data.album, songs: data.songs }; + } catch { + /* fall through to library index */ + } + } else if (!favoritesOffline) { + return null; + } + + try { + return await loadAlbumFromLibraryIndex(serverId, albumId); + } catch { + return null; + } +} + +/** @deprecated Use {@link resolveAlbum}. */ +export const resolveAlbumForServer = resolveAlbum; + +export async function resolveArtist( + serverId: string, + artistId: string, +): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] } | null> { + if (isOfflineBrowseActive() && offlineLocalBrowseEnabled(serverId)) { + return loadArtistFromLocalPlayback(serverId, artistId); + } + const favoritesOffline = useAuthStore.getState().favoritesOfflineEnabled; + const networkAllowed = shouldAttemptSubsonicForServer(serverId); + + if (networkAllowed) { + try { + return await getArtistForServer(serverId, artistId); + } catch { + /* fall through */ + } + } else if (!favoritesOffline) { + return null; + } + + try { + return await loadArtistFromLibraryIndex(serverId, artistId); + } catch { + return null; + } +} + +export async function resolvePlaylist( + serverId: string, + playlistId: string, +): Promise<{ playlist: SubsonicPlaylist; songs: SubsonicSong[] } | null> { + if (isOfflineBrowseActive() && playlistsOfflineBrowseEnabled(serverId)) { + const offline = await loadOfflineBrowsablePlaylist(playlistId, serverId); + if (offline) return offline; + } + + if (!shouldAttemptSubsonicForServer(serverId)) return null; + + try { + return await getPlaylistForServer(serverId, playlistId); + } catch { + return null; + } +} + +export function resolveMediaServerId(explicit?: string | null): string | null { + return explicit ?? useAuthStore.getState().activeServerId; +} + +/** Resolve album for active server when `serverId` omitted. */ +export async function resolveAlbumForActiveServer( + albumId: string, + serverId?: string, +): Promise { + const sid = serverId ?? useAuthStore.getState().activeServerId; + if (!sid) return null; + return resolveAlbum(sid, albumId); +} diff --git a/src/utils/offline/offlineNavPolicy.ts b/src/utils/offline/offlineNavPolicy.ts new file mode 100644 index 00000000..0ee8b5e7 --- /dev/null +++ b/src/utils/offline/offlineNavPolicy.ts @@ -0,0 +1,41 @@ +/** Sidebar / mobile-more navigation gates while offline browse is active. */ + +export function isOfflineSidebarLibraryNavAllowed( + navId: string, + favoritesOfflineBrowse: boolean, + localLibraryBrowse = false, + playlistsOfflineBrowse = false, +): boolean { + if (navId === 'favorites') return favoritesOfflineBrowse; + if (navId === 'artists' || navId === 'allAlbums' || navId === 'tracks') return localLibraryBrowse; + if (navId === 'playlists') return playlistsOfflineBrowse; + if (navId === 'offline') return true; + return false; +} + +/** System nav entries that stay available without a Subsonic connection. */ +export function isOfflineSidebarSystemNavAllowed( + navId: string, + playerStatsBrowse: boolean, +): boolean { + if (navId === 'help') return true; + if (navId === 'statistics') return playerStatsBrowse; + return false; +} + +/** Sidebar / mobile-more gate while offline browse is active. */ +export function isOfflineSidebarNavAllowed( + navId: string, + favoritesOfflineBrowse: boolean, + localLibraryBrowse: boolean, + playerStatsBrowse: boolean, + playlistsOfflineBrowse = false, +): boolean { + if (isOfflineSidebarSystemNavAllowed(navId, playerStatsBrowse)) return true; + return isOfflineSidebarLibraryNavAllowed( + navId, + favoritesOfflineBrowse, + localLibraryBrowse, + playlistsOfflineBrowse, + ); +} diff --git a/src/utils/offline/offlinePlaylistBrowse.test.ts b/src/utils/offline/offlinePlaylistBrowse.test.ts new file mode 100644 index 00000000..155da394 --- /dev/null +++ b/src/utils/offline/offlinePlaylistBrowse.test.ts @@ -0,0 +1,152 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as libraryApi from '../../api/library'; +import { useAuthStore } from '../../store/authStore'; +import { useLibraryIndexStore } from '../../store/libraryIndexStore'; +import { useLocalPlaybackStore } from '../../store/localPlaybackStore'; +import { useOfflineStore } from '../../store/offlineStore'; +import { + fetchOfflineBrowsablePlaylists, + loadOfflineBrowsablePlaylist, + playlistsOfflineBrowseEnabled, +} from './offlinePlaylistBrowse'; + +vi.mock('../../api/library', () => ({ + libraryGetTracksBatchChunked: vi.fn(), +})); + +const libraryGetTracksBatchChunkedMock = vi.mocked(libraryApi.libraryGetTracksBatchChunked); + +function seedPlaylistPin(serverId = 'srv-1', indexKey = 'srv-1') { + useAuthStore.setState({ + activeServerId: serverId, + servers: [{ + id: serverId, + name: 'Test', + url: 'https://music.test', + username: 'u', + password: 'p', + }], + }); + useLibraryIndexStore.setState({ masterEnabled: true }); + useOfflineStore.setState({ + albums: { + [`${indexKey}:pl-1`]: { + id: 'pl-1', + serverId: indexKey, + name: 'Road mix', + artist: '', + trackIds: ['t1', 't2'], + type: 'playlist', + }, + }, + }); + useLocalPlaybackStore.setState({ + entries: { + [`${indexKey}:t1`]: { + serverIndexKey: indexKey, + trackId: 't1', + localPath: '/media/library/t1.mp3', + layoutFingerprint: 'fp', + sizeBytes: 1000, + tier: 'library', + cachedAt: 1, + suffix: 'mp3', + pinSource: { kind: 'playlist', sourceId: 'pl-1', displayName: 'Road mix' }, + }, + [`${indexKey}:t2`]: { + serverIndexKey: indexKey, + trackId: 't2', + localPath: '/media/library/t2.mp3', + layoutFingerprint: 'fp', + sizeBytes: 1000, + tier: 'library', + cachedAt: 1, + suffix: 'mp3', + pinSource: { kind: 'playlist', sourceId: 'pl-1', displayName: 'Road mix' }, + }, + }, + }); +} + +describe('offlinePlaylistBrowse', () => { + beforeEach(() => { + vi.clearAllMocks(); + useAuthStore.setState({ activeServerId: null, servers: [] }); + useLibraryIndexStore.setState({ masterEnabled: false }); + useOfflineStore.setState({ albums: {} }); + useLocalPlaybackStore.setState({ entries: {} }); + }); + + it('playlistsOfflineBrowseEnabled is true when cached playlist bytes exist', () => { + seedPlaylistPin(); + expect(playlistsOfflineBrowseEnabled('srv-1')).toBe(true); + expect(playlistsOfflineBrowseEnabled(null)).toBe(false); + }); + + it('fetchOfflineBrowsablePlaylists returns cached regular playlists only', async () => { + seedPlaylistPin(); + libraryGetTracksBatchChunkedMock.mockResolvedValueOnce([ + { + serverId: 'srv-1', + id: 't1', + title: 'A', + artist: 'Ar', + album: 'Al', + albumId: 'al-1', + durationSec: 100, + syncedAt: 1, + rawJson: {}, + }, + { + serverId: 'srv-1', + id: 't2', + title: 'B', + artist: 'Br', + album: 'Bl', + albumId: 'al-2', + durationSec: 200, + syncedAt: 1, + rawJson: {}, + }, + ]); + + const playlists = await fetchOfflineBrowsablePlaylists('srv-1'); + expect(playlists).toHaveLength(1); + expect(playlists[0]?.id).toBe('pl-1'); + expect(playlists[0]?.name).toBe('Road mix'); + expect(playlists[0]?.songCount).toBe(2); + expect(playlists[0]?.duration).toBe(300); + }); + + it('loadOfflineBrowsablePlaylist preserves playlist track order', async () => { + seedPlaylistPin(); + libraryGetTracksBatchChunkedMock.mockResolvedValueOnce([ + { + serverId: 'srv-1', + id: 't1', + title: 'A', + artist: 'Ar', + album: 'Al', + albumId: 'al-1', + durationSec: 100, + syncedAt: 1, + rawJson: {}, + }, + { + serverId: 'srv-1', + id: 't2', + title: 'B', + artist: 'Br', + album: 'Bl', + albumId: 'al-2', + durationSec: 200, + syncedAt: 1, + rawJson: {}, + }, + ]); + + const loaded = await loadOfflineBrowsablePlaylist('pl-1', 'srv-1'); + expect(loaded?.playlist.name).toBe('Road mix'); + expect(loaded?.songs.map(s => s.id)).toEqual(['t1', 't2']); + }); +}); diff --git a/src/utils/offline/offlinePlaylistBrowse.ts b/src/utils/offline/offlinePlaylistBrowse.ts new file mode 100644 index 00000000..6e8a52c4 --- /dev/null +++ b/src/utils/offline/offlinePlaylistBrowse.ts @@ -0,0 +1,109 @@ +import { libraryGetTracksBatchChunked } from '../../api/library'; +import type { SubsonicPlaylist, SubsonicSong } from '../../api/subsonicTypes'; +import { useLibraryIndexStore } from '../../store/libraryIndexStore'; +import type { PinnedGroup } from '../../store/localPlaybackStore'; +import { useLocalPlaybackStore } from '../../store/localPlaybackStore'; +import { trackToSong } from '../library/advancedSearchLocal'; +import { isManualOfflinePlaylist } from './pinnedOfflineSync'; +import { + hasLocalLibraryBytes, + indexKeyBelongsToServer, + resolveOfflineAlbumMeta, +} from './offlineLibraryHelpers'; + +function listPlaylistPinnedGroupsForServer(serverId: string): PinnedGroup[] { + return useLocalPlaybackStore.getState() + .listPinnedGroups() + .filter(g => g.pinSource.kind === 'playlist' && indexKeyBelongsToServer(g.serverIndexKey, serverId)); +} + +function orderedPlayableTrackIds( + playlistId: string, + serverId: string, + group: PinnedGroup, +): string[] { + const meta = resolveOfflineAlbumMeta(playlistId, serverId); + const ordered = meta?.trackIds?.length ? meta.trackIds : group.trackIds; + return ordered.filter(tid => hasLocalLibraryBytes(tid, serverId)); +} + +/** Cached regular playlists with on-disk bytes for the active server. */ +export function playlistsOfflineBrowseEnabled(serverId: string | null | undefined): boolean { + if (!serverId) return false; + if (!useLibraryIndexStore.getState().isIndexEnabled(serverId)) return false; + return listPlaylistPinnedGroupsForServer(serverId).some(g => { + if (!isManualOfflinePlaylist(g.pinSource.sourceId, serverId, g.pinSource.displayName)) { + return false; + } + return orderedPlayableTrackIds(g.pinSource.sourceId, serverId, g).length > 0; + }); +} + +export async function fetchOfflineBrowsablePlaylists(serverId: string): Promise { + const groups = listPlaylistPinnedGroupsForServer(serverId) + .filter(g => isManualOfflinePlaylist(g.pinSource.sourceId, serverId, g.pinSource.displayName)); + + const playlists: SubsonicPlaylist[] = []; + for (const group of groups) { + const playlistId = group.pinSource.sourceId; + const trackIds = orderedPlayableTrackIds(playlistId, serverId, group); + if (trackIds.length === 0) continue; + + const meta = resolveOfflineAlbumMeta(playlistId, serverId); + const refs = trackIds.map(trackId => ({ serverId, trackId })); + const dtos = await libraryGetTracksBatchChunked(refs); + const byId = new Map(dtos.map(d => [d.id, d])); + let duration = 0; + for (const trackId of trackIds) { + duration += byId.get(trackId)?.durationSec ?? 0; + } + + playlists.push({ + id: playlistId, + name: group.pinSource.displayName ?? meta?.name ?? playlistId, + songCount: trackIds.length, + duration, + created: '', + changed: '', + coverArt: meta?.coverArt, + }); + } + + return playlists.sort((a, b) => a.name.localeCompare(b.name)); +} + +export async function loadOfflineBrowsablePlaylist( + playlistId: string, + serverId: string, +): Promise<{ playlist: SubsonicPlaylist; songs: SubsonicSong[] } | null> { + const group = listPlaylistPinnedGroupsForServer(serverId) + .find(g => g.pinSource.sourceId === playlistId); + if (!group) return null; + if (!isManualOfflinePlaylist(playlistId, serverId, group.pinSource.displayName)) return null; + + const trackIds = orderedPlayableTrackIds(playlistId, serverId, group); + if (trackIds.length === 0) return null; + + const meta = resolveOfflineAlbumMeta(playlistId, serverId); + const refs = trackIds.map(trackId => ({ serverId, trackId })); + const dtos = await libraryGetTracksBatchChunked(refs); + const byId = new Map(dtos.map(d => [d.id, d])); + const songs = trackIds + .map(id => byId.get(id)) + .filter((dto): dto is NonNullable => !!dto) + .map(dto => ({ ...trackToSong(dto), serverId })); + + const duration = songs.reduce((sum, song) => sum + (song.duration ?? 0), 0); + return { + playlist: { + id: playlistId, + name: group.pinSource.displayName ?? meta?.name ?? playlistId, + songCount: songs.length, + duration, + created: '', + changed: '', + coverArt: meta?.coverArt, + }, + songs, + }; +} diff --git a/src/utils/offline/offlineStarredLoad.ts b/src/utils/offline/offlineStarredLoad.ts new file mode 100644 index 00000000..71ad51ec --- /dev/null +++ b/src/utils/offline/offlineStarredLoad.ts @@ -0,0 +1,168 @@ +import { getStarredForServer } from '../../api/subsonicStarRating'; +import { libraryAdvancedSearch } from '../../api/library'; +import type { + StarredResults, + SubsonicAlbum, + SubsonicArtist, + SubsonicSong, +} from '../../api/subsonicTypes'; +import { isActiveServerReachable } from '../network/activeServerReachability'; +import { + albumToAlbum, + trackToSong, +} from '../library/advancedSearchLocal'; +import { dedupeById } from '../dedupeById'; +import { isOfflineBrowseActive } from './offlineBrowseMode'; +import { favoritesServerIds } from './favoritesOfflineBrowse'; +import { + buildAlbumFromTracks, + fetchBrowsableLocalTrackDtos, + offlineLocalBrowseEnabled, +} from './offlineLocalBrowse'; + +function tagStarredWithServer(starred: StarredResults, serverId: string): StarredResults { + const withServer = (items: T[]): (T & { serverId: string })[] => + items.map(item => ({ ...item, serverId })); + + return { + artists: withServer(starred.artists), + albums: withServer(starred.albums), + songs: withServer(starred.songs), + }; +} + +/** Merge starred lists from multiple servers; dedupe by `serverId:id`. */ +export function mergeStarredFromServers( + entries: { serverId: string; starred: StarredResults }[], +): StarredResults { + const artists: SubsonicArtist[] = []; + const albums: SubsonicAlbum[] = []; + const songs: SubsonicSong[] = []; + for (const { serverId, starred } of entries) { + const tagged = tagStarredWithServer(starred, serverId); + artists.push(...tagged.artists); + albums.push(...tagged.albums); + songs.push(...tagged.songs); + } + return { + artists: dedupeById(artists), + albums: dedupeById(albums), + songs: dedupeById(songs), + }; +} + +/** + * Offline favorites: start from on-disk bytes, then keep starred tracks/albums only. + * Avoids scanning the full starred catalog in SQL when only a local subset is playable. + */ +async function loadStarredFromBrowsableLocalBytes(serverId: string): Promise { + const allLocal = await fetchBrowsableLocalTrackDtos(serverId); + if (allLocal.length === 0) { + return { artists: [], albums: [], songs: [] }; + } + + const starredTracks = allLocal.filter(t => t.starredAt != null); + const songs = starredTracks + .map(trackToSong) + .map(s => ({ ...s, serverId })); + + const albumsById = new Map(); + const byStarredAlbum = new Map(); + for (const track of starredTracks) { + if (!track.albumId) continue; + const list = byStarredAlbum.get(track.albumId) ?? []; + list.push(track); + byStarredAlbum.set(track.albumId, list); + } + for (const [albumId, albumTracks] of byStarredAlbum) { + albumsById.set(albumId, buildAlbumFromTracks(albumId, albumTracks, serverId)); + } + + const localAlbumIds = [...new Set( + allLocal.map(t => t.albumId).filter((id): id is string => !!id), + )]; + if (localAlbumIds.length > 0) { + const albumSearch = await libraryAdvancedSearch({ + serverId, + entityTypes: ['album'], + starredOnly: true, + restrictAlbumIds: localAlbumIds, + limit: localAlbumIds.length, + skipTotals: true, + }); + for (const dto of albumSearch.albums) { + albumsById.set(dto.id, { ...albumToAlbum(dto), serverId }); + } + } + + return { + artists: [], + albums: [...albumsById.values()], + songs, + }; +} + +export async function loadStarredFromLibraryIndex( + serverId: string, + preferLocalBytes = false, +): Promise { + if (preferLocalBytes && offlineLocalBrowseEnabled(serverId)) { + return loadStarredFromBrowsableLocalBytes(serverId); + } + + // Artist-level favorites are network-only today (`artist` has no `starred_at`; + // `starredOnly` on artists would return the whole artist table). Songs/albums + // use track/album stars in the index. + const response = await libraryAdvancedSearch({ + serverId, + entityTypes: ['album', 'track'], + starredOnly: true, + limit: 10_000, + }); + return { + artists: [], + albums: response.albums.map(albumToAlbum), + songs: response.tracks.map(trackToSong), + }; +} + +export async function loadStarredFromAllLibraryIndexes( + preferLocalBytes = isOfflineBrowseActive(), +): Promise { + const serverIds = favoritesServerIds(); + const entries = await Promise.all( + serverIds.map(async serverId => { + try { + const starred = await loadStarredFromLibraryIndex(serverId, preferLocalBytes); + return { serverId, starred }; + } catch { + return { serverId, starred: { artists: [], albums: [], songs: [] } satisfies StarredResults }; + } + }), + ); + return mergeStarredFromServers(entries); +} + +/** Online starred merge with per-server local index fallback. */ +export async function loadStarredFromAllServersOnline(): Promise { + if (!isActiveServerReachable()) { + return loadStarredFromAllLibraryIndexes(); + } + const serverIds = favoritesServerIds(); + const entries = await Promise.all( + serverIds.map(async serverId => { + try { + const starred = await getStarredForServer(serverId); + return { serverId, starred }; + } catch { + try { + const starred = await loadStarredFromLibraryIndex(serverId); + return { serverId, starred }; + } catch { + return { serverId, starred: { artists: [], albums: [], songs: [] } satisfies StarredResults }; + } + } + }), + ); + return mergeStarredFromServers(entries); +} diff --git a/src/utils/offline/resumeIncompleteOfflinePins.test.ts b/src/utils/offline/resumeIncompleteOfflinePins.test.ts index f729053d..febf700f 100644 --- a/src/utils/offline/resumeIncompleteOfflinePins.test.ts +++ b/src/utils/offline/resumeIncompleteOfflinePins.test.ts @@ -18,7 +18,7 @@ vi.mock('./offlineLibraryHelpers', () => ({ isOfflinePinCompleteMock(albumId, serverId), })); -vi.mock('./favoritesOfflineBrowse', () => ({ +vi.mock('./offlineMediaResolve', () => ({ resolveAlbumForServer: (serverId: string, albumId: string) => resolveAlbumForServerMock(serverId, albumId), })); diff --git a/src/utils/offline/resumeIncompleteOfflinePins.ts b/src/utils/offline/resumeIncompleteOfflinePins.ts index b9c5da04..bf27713d 100644 --- a/src/utils/offline/resumeIncompleteOfflinePins.ts +++ b/src/utils/offline/resumeIncompleteOfflinePins.ts @@ -9,7 +9,7 @@ import { isActiveServerReachable, onActiveServerBecameReachable } from '../netwo import { shouldAttemptSubsonicForServer } from '../network/subsonicNetworkGuard'; import { resolveServerIdForIndexKey } from '../server/serverLookup'; import { isOfflinePinComplete } from './offlineLibraryHelpers'; -import { resolveAlbumForServer } from './favoritesOfflineBrowse'; +import { resolveAlbumForServer } from './offlineMediaResolve'; const DEBOUNCE_MS = 800; diff --git a/src/utils/playback/fetchTracksForSource.ts b/src/utils/playback/fetchTracksForSource.ts index c2657058..5c92a114 100644 --- a/src/utils/playback/fetchTracksForSource.ts +++ b/src/utils/playback/fetchTracksForSource.ts @@ -1,18 +1,32 @@ -import { getArtist } from '../../api/subsonicArtists'; -import { getAlbum } from '../../api/subsonicLibrary'; -import { getPlaylist } from '../../api/subsonicPlaylists'; import type { SubsonicSong } from '../../api/subsonicTypes'; import type { DeviceSyncSource } from '../../store/deviceSyncStore'; +import { + resolveAlbum, + resolveArtist, + resolveMediaServerId, + resolvePlaylist, +} from '../offline/offlineMediaResolve'; export async function fetchTracksForSource(source: DeviceSyncSource): Promise { - if (source.type === 'playlist') { const { songs } = await getPlaylist(source.id); return songs; } - if (source.type === 'album') { const { songs } = await getAlbum(source.id); return songs; } - const { albums } = await getArtist(source.id); - // Parallel album fetches — Navidrome handles getAlbum requests in flight - // without serialising. Sequential awaits here multiplied a 50-album artist - // sync into 50 round-trips (~7 s blocking) before any device write started. + const serverId = resolveMediaServerId(); + if (!serverId) return []; + + if (source.type === 'playlist') { + const result = await resolvePlaylist(serverId, source.id); + return result?.songs ?? []; + } + if (source.type === 'album') { + const result = await resolveAlbum(serverId, source.id); + return result?.songs ?? []; + } + + const artistData = await resolveArtist(serverId, source.id); + if (!artistData) return []; + const results = await Promise.all( - albums.map(a => getAlbum(a.id).then(r => r.songs).catch(() => [] as SubsonicSong[])), + artistData.albums.map(a => + resolveAlbum(serverId, a.id).then(r => r?.songs ?? []).catch(() => [] as SubsonicSong[]), + ), ); return results.flat(); } diff --git a/src/utils/playback/playAlbum.test.ts b/src/utils/playback/playAlbum.test.ts index 27176336..45844169 100644 --- a/src/utils/playback/playAlbum.test.ts +++ b/src/utils/playback/playAlbum.test.ts @@ -4,20 +4,37 @@ import { onInvoke } from '@/test/mocks/tauri'; import { resetOrbitStore, resetPlayerStore } from '@/test/helpers/storeReset'; import type { Track } from '../../store/playerStoreTypes'; -vi.mock('../../api/subsonicLibrary', () => ({ - getAlbum: vi.fn(), +vi.mock('../offline/offlineMediaResolve', () => ({ + resolveAlbumForActiveServer: vi.fn(), })); vi.mock('./fadeOut', () => ({ fadeOut: vi.fn(async () => undefined), })); -import { getAlbum } from '../../api/subsonicLibrary'; +import { resolveAlbumForActiveServer } from '../offline/offlineMediaResolve'; import { useOrbitStore } from '../../store/orbitStore'; import { usePlayerStore } from '../../store/playerStore'; import { playAlbum, playAlbumShuffled } from './playAlbum'; import * as shuffleModule from './shuffleArray'; +const albumPayload = { + album: { + id: 'al-1', + name: 'Test Album', + artist: 'Test Artist', + artistId: 'artist-1', + songCount: 3, + duration: 540, + genre: 'Rock', + }, + songs: [ + makeSubsonicSong({ id: 't1', title: 'One' }), + makeSubsonicSong({ id: 't2', title: 'Two' }), + makeSubsonicSong({ id: 't3', title: 'Three' }), + ], +}; + function stubPlaybackActions() { onInvoke('audio_play', () => undefined); onInvoke('audio_pause', () => undefined); @@ -40,22 +57,7 @@ describe('playAlbum', () => { resetPlayerStore(); resetOrbitStore(); stubPlaybackActions(); - vi.mocked(getAlbum).mockResolvedValue({ - album: { - id: 'al-1', - name: 'Test Album', - artist: 'Test Artist', - artistId: 'artist-1', - songCount: 3, - duration: 540, - genre: 'Rock', - }, - songs: [ - makeSubsonicSong({ id: 't1', title: 'One' }), - makeSubsonicSong({ id: 't2', title: 'Two' }), - makeSubsonicSong({ id: 't3', title: 'Three' }), - ], - }); + vi.mocked(resolveAlbumForActiveServer).mockResolvedValue(albumPayload); }); afterEach(() => { @@ -90,22 +92,7 @@ describe('playAlbumShuffled', () => { resetPlayerStore(); resetOrbitStore(); stubPlaybackActions(); - vi.mocked(getAlbum).mockResolvedValue({ - album: { - id: 'al-1', - name: 'Test Album', - artist: 'Test Artist', - artistId: 'artist-1', - songCount: 3, - duration: 540, - genre: 'Rock', - }, - songs: [ - makeSubsonicSong({ id: 't1', title: 'One' }), - makeSubsonicSong({ id: 't2', title: 'Two' }), - makeSubsonicSong({ id: 't3', title: 'Three' }), - ], - }); + vi.mocked(resolveAlbumForActiveServer).mockResolvedValue(albumPayload); }); afterEach(() => { @@ -143,7 +130,7 @@ describe('playAlbumShuffled', () => { }); it('does not start playback for an empty album', async () => { - vi.mocked(getAlbum).mockResolvedValue({ + vi.mocked(resolveAlbumForActiveServer).mockResolvedValue({ album: { id: 'al-empty', name: 'Empty', diff --git a/src/utils/playback/playAlbum.ts b/src/utils/playback/playAlbum.ts index 81faadeb..5dd184f1 100644 --- a/src/utils/playback/playAlbum.ts +++ b/src/utils/playback/playAlbum.ts @@ -1,16 +1,13 @@ -import { getAlbum } from '../../api/subsonicLibrary'; import { usePlayerStore } from '../../store/playerStore'; -import { resolveAlbumForServer } from '../offline/favoritesOfflineBrowse'; +import { resolveAlbumForActiveServer } from '../offline/offlineMediaResolve'; import { songToTrack } from './songToTrack'; import { useOrbitStore } from '../../store/orbitStore'; import { fadeOut } from './fadeOut'; import type { Track } from '../../store/playerStoreTypes'; import { shuffleArray } from './shuffleArray'; -async function fetchAlbumTracks(albumId: string, serverId?: string): Promise { - const albumData = serverId - ? await resolveAlbumForServer(serverId, albumId) - : await getAlbum(albumId).then(d => ({ album: d.album, songs: d.songs })); +export async function fetchAlbumTracks(albumId: string, serverId?: string): Promise { + const albumData = await resolveAlbumForActiveServer(albumId, serverId); if (!albumData) throw new Error(`Album ${albumId} not available`); const albumGenre = albumData.album.genre; const ownerServerId = serverId ?? albumData.album.serverId; diff --git a/src/utils/playback/playArtistShuffled.ts b/src/utils/playback/playArtistShuffled.ts index 28d5f8aa..8f22ec57 100644 --- a/src/utils/playback/playArtistShuffled.ts +++ b/src/utils/playback/playArtistShuffled.ts @@ -1,19 +1,23 @@ -import { getArtist } from '../../api/subsonicArtists'; -import { getAlbum } from '../../api/subsonicLibrary'; +import { resolveAlbum, resolveArtist, resolveMediaServerId } from '../offline/offlineMediaResolve'; import { songToTrack } from './songToTrack'; import { shuffleArray } from './shuffleArray'; import { usePlayerStore } from '../../store/playerStore'; /** * All tracks from the artist’s albums, shuffled — same idea as Artist page “shuffle play”. */ -export async function playArtistShuffled(artistId: string): Promise { - const { albums } = await getArtist(artistId); - if (albums.length === 0) { +export async function playArtistShuffled(artistId: string, serverId?: string): Promise { + const sid = resolveMediaServerId(serverId); + if (!sid) throw new Error('play_artist_no_tracks'); + + const artistData = await resolveArtist(sid, artistId); + if (!artistData || artistData.albums.length === 0) { throw new Error('play_artist_no_tracks'); } - 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 results = await Promise.all(artistData.albums.map(a => resolveAlbum(sid, a.id))); + const sorted = results + .filter((r): r is NonNullable => r != null) + .sort((a, b) => (a.album.year ?? 0) - (b.album.year ?? 0)); const tracks = sorted.flatMap(r => [...r.songs].sort((a, b) => (a.track ?? 0) - (b.track ?? 0)).map(songToTrack), ); diff --git a/src/utils/playback/playByOpaqueId.ts b/src/utils/playback/playByOpaqueId.ts index 23b60549..b5864ee5 100644 --- a/src/utils/playback/playByOpaqueId.ts +++ b/src/utils/playback/playByOpaqueId.ts @@ -1,4 +1,5 @@ -import { getAlbum, getSong } from '../../api/subsonicLibrary'; +import { getSong } from '../../api/subsonicLibrary'; +import { resolveAlbumForActiveServer } from '../offline/offlineMediaResolve'; import { songToTrack } from './songToTrack'; import { playAlbum } from './playAlbum'; import { playArtistShuffled } from './playArtistShuffled'; @@ -17,14 +18,10 @@ export async function playByOpaqueId(id: string): Promise { return; } - try { - const { songs } = await getAlbum(trimmed); - if (songs.length > 0) { - await playAlbum(trimmed); - return; - } - } catch { - /* not an album */ + const albumData = await resolveAlbumForActiveServer(trimmed); + if (albumData && albumData.songs.length > 0) { + await playAlbum(trimmed); + return; } try { diff --git a/src/utils/playlist/runPlaylistLoad.ts b/src/utils/playlist/runPlaylistLoad.ts index 538ac2c8..ca9b5e22 100644 --- a/src/utils/playlist/runPlaylistLoad.ts +++ b/src/utils/playlist/runPlaylistLoad.ts @@ -2,7 +2,10 @@ import type React from 'react'; import { getPlaylist } from '../../api/subsonicPlaylists'; import { filterSongsToActiveLibrary } from '../../api/subsonicLibrary'; import type { SubsonicPlaylist, SubsonicSong } from '../../api/subsonicTypes'; +import { useAuthStore } from '../../store/authStore'; import { usePlaylistStore } from '../../store/playlistStore'; +import { isOfflineBrowseActive } from '../offline/offlineBrowseMode'; +import { resolvePlaylist } from '../offline/offlineMediaResolve'; export interface RunPlaylistLoadDeps { id: string; @@ -14,23 +17,41 @@ export interface RunPlaylistLoadDeps { setStarredSongs: React.Dispatch>>; } +function applyLoadedPlaylist( + deps: RunPlaylistLoadDeps, + playlist: SubsonicPlaylist, + songs: SubsonicSong[], +): void { + const { setPlaylist, setSongs, setCustomCoverId, setRatings, setStarredSongs } = deps; + setPlaylist(playlist); + setSongs(songs); + if (playlist.coverArt) setCustomCoverId(playlist.coverArt); + const init: Record = {}; + const starred = new Set(); + songs.forEach(s => { + if (s.userRating) init[s.id] = s.userRating; + if (s.starred) starred.add(s.id); + }); + setRatings(init); + setStarredSongs(starred); +} + export async function runPlaylistLoad(deps: RunPlaylistLoadDeps): Promise { const { id, setLoading, setPlaylist, setSongs, setCustomCoverId, setRatings, setStarredSongs } = deps; setLoading(true); try { + const serverId = useAuthStore.getState().activeServerId ?? ''; + if (isOfflineBrowseActive() && serverId) { + const loaded = await resolvePlaylist(serverId, id); + if (loaded) { + applyLoadedPlaylist(deps, loaded.playlist, loaded.songs); + return; + } + } + const { playlist, songs } = await getPlaylist(id); const filteredSongs = await filterSongsToActiveLibrary(songs); - setPlaylist(playlist); - setSongs(filteredSongs); - if (playlist.coverArt) setCustomCoverId(playlist.coverArt); - const init: Record = {}; - const starred = new Set(); - filteredSongs.forEach(s => { - if (s.userRating) init[s.id] = s.userRating; - if (s.starred) starred.add(s.id); - }); - setRatings(init); - setStarredSongs(starred); + applyLoadedPlaylist(deps, playlist, filteredSongs); } catch { const stub = usePlaylistStore.getState().playlists.find(p => p.id === id); if (stub) { diff --git a/src/utils/share/applySharePaste.ts b/src/utils/share/applySharePaste.ts index 27372e18..d413270c 100644 --- a/src/utils/share/applySharePaste.ts +++ b/src/utils/share/applySharePaste.ts @@ -1,5 +1,5 @@ -import { getArtist } from '../../api/subsonicArtists'; -import { getAlbum, getSong } from '../../api/subsonicLibrary'; +import { getSong } from '../../api/subsonicLibrary'; +import { resolveAlbum, resolveArtist } from '../offline/offlineMediaResolve'; import type { SubsonicSong } from '../../api/subsonicTypes'; import { songToTrack } from '../playback/songToTrack'; import type { Location, NavigateFunction } from 'react-router-dom'; @@ -130,9 +130,8 @@ export async function applySharePastePayload( } if (payload.k === 'album') { - try { - await getAlbum(payload.id); - } catch { + const albumResult = await resolveAlbum(serverId, payload.id); + if (!albumResult) { showToast(t('sharePaste.albumUnavailable'), 5000, 'error'); return; } @@ -146,9 +145,8 @@ export async function applySharePastePayload( } if (payload.k === 'artist') { - try { - await getArtist(payload.id); - } catch { + const artistResult = await resolveArtist(serverId, payload.id); + if (!artistResult) { showToast(t('sharePaste.artistUnavailable'), 5000, 'error'); return; } @@ -159,11 +157,10 @@ export async function applySharePastePayload( if (payload.k === 'composer') { // Same id space as artists (Subsonic / Navidrome use one id pool for - // every participant role), so getArtist still validates the entity — + // every participant role), so resolveArtist still validates the entity — // the difference is which view we navigate to. - try { - await getArtist(payload.id); - } catch { + const composerResult = await resolveArtist(serverId, payload.id); + if (!composerResult) { showToast(t('sharePaste.composerUnavailable'), 5000, 'error'); return; } diff --git a/src/utils/share/enqueueShareSearchPayload.ts b/src/utils/share/enqueueShareSearchPayload.ts index 2cce0104..61361c52 100644 --- a/src/utils/share/enqueueShareSearchPayload.ts +++ b/src/utils/share/enqueueShareSearchPayload.ts @@ -4,8 +4,8 @@ import { getArtistWithCredentials, getSongWithCredentials, } from '../../api/subsonicEntityWithCredentials'; -import { getAlbum, getSong } from '../../api/subsonicLibrary'; -import { getArtist } from '../../api/subsonicArtists'; +import { getSong } from '../../api/subsonicLibrary'; +import { resolveAlbum, resolveArtist } from '../offline/offlineMediaResolve'; import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../../api/subsonicTypes'; import { useAuthStore } from '../../store/authStore'; import type { ServerProfile } from '../../store/authStoreTypes'; @@ -118,7 +118,9 @@ async function getAlbumAfterActivation( serverId: string, ): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> { activateShareServer(serverId); - return getAlbum(id); + const result = await resolveAlbum(serverId, id); + if (!result) throw new Error('album unavailable'); + return result; } async function getArtistAfterActivation( @@ -126,7 +128,9 @@ async function getArtistAfterActivation( serverId: string, ): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] }> { activateShareServer(serverId); - return getArtist(id); + const result = await resolveArtist(serverId, id); + if (!result) throw new Error('artist unavailable'); + return result; } export async function resolveShareSearchPayload(