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(