From 49170898eadffae8c38bb649384a3a93e9dc646e Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Fri, 17 Jul 2026 01:45:04 +0300 Subject: [PATCH] feat(releases): merge selected server catalog feeds --- src/features/album/pages/NewReleases.tsx | 41 +++++++--- src/features/sidebar/components/Sidebar.tsx | 19 ++++- .../hooks/useSidebarNewReleasesUnread.ts | 81 ++++++------------- .../sidebar/utils/sidebarHelpers.test.ts | 39 +++++---- src/features/sidebar/utils/sidebarHelpers.ts | 10 +-- src/lib/api/subsonicStreamUrl.ts | 13 +++ src/lib/library/libraryBrowseScope.ts | 9 ++- src/lib/library/newReleasesLocal.test.ts | 44 ++++++++++ src/lib/library/newReleasesLocal.ts | 18 +++++ 9 files changed, 176 insertions(+), 98 deletions(-) create mode 100644 src/lib/library/newReleasesLocal.test.ts create mode 100644 src/lib/library/newReleasesLocal.ts diff --git a/src/features/album/pages/NewReleases.tsx b/src/features/album/pages/NewReleases.tsx index c856355c..79629c51 100644 --- a/src/features/album/pages/NewReleases.tsx +++ b/src/features/album/pages/NewReleases.tsx @@ -1,6 +1,5 @@ -import { buildDownloadUrl } from '@/lib/api/subsonicStreamUrl'; +import { buildDownloadUrlForServer } from '@/lib/api/subsonicStreamUrl'; import { getAlbumsByGenre } from '@/lib/api/subsonicGenres'; -import { getAlbumList } from '@/lib/api/subsonicLibrary'; import { resolveAlbum } from '@/features/offline'; import type { SubsonicAlbum } from '@/lib/api/subsonicTypes'; import { dedupeById } from '@/lib/util/dedupeById'; @@ -39,6 +38,8 @@ import { albumArtistDisplayName } from '@/features/album/utils/deriveAlbumHeader import { useLibraryIndexStore } from '@/store/libraryIndexStore'; import { filterAlbumsByGenres } from '@/lib/library/albumBrowseFilters'; import { useScopedBrowseSearchQuery } from '@/store/liveSearchScopeStore'; +import { deriveLibraryBrowseScope } from '@/lib/library/libraryBrowseScope'; +import { loadLocalNewReleases } from '@/lib/library/newReleasesLocal'; const PAGE_SIZE = 30; @@ -59,6 +60,21 @@ export default function NewReleases() { const auth = useAuthStore(); const serverId = useAuthStore(s => s.activeServerId ?? ''); const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId)); + const { anchorServerId, pairs: releaseScopes, fingerprint: releaseScopeFingerprint } = useMemo(() => ( + deriveLibraryBrowseScope({ + servers: auth.servers, + activeServerId: auth.activeServerId, + libraryBrowseServerIds: auth.libraryBrowseServerIds, + musicFoldersByServer: auth.musicFoldersByServer, + libraryBrowseSelectionByServer: auth.libraryBrowseSelectionByServer, + }) + ), [ + auth.activeServerId, + auth.libraryBrowseSelectionByServer, + auth.libraryBrowseServerIds, + auth.musicFoldersByServer, + auth.servers, + ]); const downloadAlbum = useOfflineStore(s => s.downloadAlbum); const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder); const navigate = useNavigate(); @@ -145,7 +161,7 @@ export default function NewReleases() { const downloadId = crypto.randomUUID(); const filename = `${sanitizeFilename(album.name)}.zip`; const destPath = await join(folder, filename); - const url = buildDownloadUrl(album.id); + const url = buildDownloadUrlForServer(album.serverId ?? serverId, album.id); start(downloadId, filename); try { await downloadZip({ id: downloadId, url, destPath }); @@ -163,9 +179,10 @@ export default function NewReleases() { let queued = 0; for (const album of selectedAlbums) { try { - const detail = await resolveAlbum(serverId, album.id); + const ownerServerId = album.serverId ?? serverId; + const detail = await resolveAlbum(ownerServerId, album.id); if (!detail) throw new Error('album unavailable'); - downloadAlbum(album.id, album.name, albumArtistDisplayName(album), album.coverArt, album.year, detail.songs, serverId); + downloadAlbum(album.id, album.name, albumArtistDisplayName(album), album.coverArt, album.year, detail.songs, ownerServerId); queued++; } catch { showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error'); @@ -177,12 +194,12 @@ export default function NewReleases() { const load = useCallback(async (offset: number, append = false) => { await runLoad(async () => { - const data = await getAlbumList('newest', PAGE_SIZE, offset); - if (append) setAlbums(prev => [...prev, ...data]); - else setAlbums(data); - setHasMore(data.length === PAGE_SIZE); + const data = await loadLocalNewReleases(anchorServerId ?? '', releaseScopes, PAGE_SIZE, offset); + if (append) setAlbums(prev => [...prev, ...data.albums]); + else setAlbums(data.albums); + setHasMore(data.hasMore); }); - }, [runLoad]); + }, [anchorServerId, releaseScopes, runLoad]); const loadFiltered = useCallback(async (genres: string[]) => { setLoading(true); @@ -205,7 +222,7 @@ export default function NewReleases() { resetPage(); void load(0); } - }, [genreFiltered, selectedGenres, load, loadFiltered, resetPage, scopedSearchQuery]); + }, [genreFiltered, selectedGenres, load, loadFiltered, resetPage, scopedSearchQuery, releaseScopeFingerprint]); const loadMore = useCallback(() => { if (!gridHasMore || genreFiltered || textSearchActive || isBlocked()) return; @@ -234,7 +251,7 @@ export default function NewReleases() { scrollSnapshotRef, getScrollRoot, isScrollRestorePending, - resetKey: [newReleasesSearchQuery, selectedGenres.join('\u0001'), serverId].join('|'), + resetKey: [newReleasesSearchQuery, selectedGenres.join('\u0001'), releaseScopeFingerprint].join('|'), }); useLayoutEffect(() => { diff --git a/src/features/sidebar/components/Sidebar.tsx b/src/features/sidebar/components/Sidebar.tsx index ead15a86..43bd8e7e 100644 --- a/src/features/sidebar/components/Sidebar.tsx +++ b/src/features/sidebar/components/Sidebar.tsx @@ -31,6 +31,7 @@ import SidebarPerfProbeModal from '@/features/sidebar/components/SidebarPerfProb import SidebarNavBody from '@/features/sidebar/components/SidebarNavBody'; import { resolveServerIdForIndexKey } from '@/lib/server/serverLookup'; import { libraryScopeCacheKeyForServer } from '@/lib/api/subsonicClient'; +import { deriveLibraryBrowseScope } from '@/lib/library/libraryBrowseScope'; import { serverListDisplayLabel } from '@/lib/server/serverDisplayName'; const EMPTY_LIBRARY_IDS: string[] = []; @@ -201,9 +202,23 @@ export default function Sidebar({ sidebarItemsRef, setSidebarItems, }); - const newReleasesUnreadCount = useSidebarNewReleasesUnread({ + const newReleasesScope = useMemo(() => deriveLibraryBrowseScope({ + servers, + activeServerId: serverId || null, + libraryBrowseServerIds, + musicFoldersByServer, + libraryBrowseSelectionByServer, + }), [ + libraryBrowseSelectionByServer, + libraryBrowseServerIds, + musicFoldersByServer, serverId, - libraryScopeKey, + servers, + ]); + const newReleasesUnreadCount = useSidebarNewReleasesUnread({ + anchorServerId: newReleasesScope.anchorServerId, + scopes: newReleasesScope.pairs, + scopeFingerprint: newReleasesScope.fingerprint, isLoggedIn, pathname: location.pathname, }); diff --git a/src/features/sidebar/hooks/useSidebarNewReleasesUnread.ts b/src/features/sidebar/hooks/useSidebarNewReleasesUnread.ts index dde70ece..602923d4 100644 --- a/src/features/sidebar/hooks/useSidebarNewReleasesUnread.ts +++ b/src/features/sidebar/hooks/useSidebarNewReleasesUnread.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { getAlbumList } from '@/lib/api/subsonicLibrary'; -import { isActiveServerReachable } from '@/lib/network/activeServerReachability'; +import type { LibraryScopePair } from '@/lib/api/library/scopeReads'; +import { loadLocalNewReleases } from '@/lib/library/newReleasesLocal'; import { NEW_RELEASES_RESET_DELAY_MS, NEW_RELEASES_SEEN_MAX_IDS, @@ -11,15 +11,17 @@ import { } from '@/features/sidebar/utils/sidebarHelpers'; interface Args { - serverId: string; - libraryScopeKey: string; + anchorServerId: string | null; + scopes: LibraryScopePair[]; + scopeFingerprint: string; isLoggedIn: boolean; pathname: string; } export function useSidebarNewReleasesUnread({ - serverId, - libraryScopeKey, + anchorServerId, + scopes, + scopeFingerprint, isLoggedIn, pathname, }: Args): number { @@ -29,17 +31,13 @@ export function useSidebarNewReleasesUnread({ const newReleasesResetTimerRef = useRef(null); const scopedSeenStorageKey = useMemo( - () => buildNewReleasesSeenStorageKey(serverId, libraryScopeKey), - [serverId, libraryScopeKey], - ); - const newReleasesSeenAllScopeStorageKey = useMemo( - () => buildNewReleasesSeenStorageKey(serverId, 'all'), - [serverId], + () => buildNewReleasesSeenStorageKey(scopeFingerprint), + [scopeFingerprint], ); - const readSeenNewReleaseIdsByKey = useCallback((key: string): string[] => { + const readSeenNewReleaseIds = useCallback((): string[] => { try { - const raw = localStorage.getItem(key); + const raw = localStorage.getItem(scopedSeenStorageKey); if (!raw) return []; const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return []; @@ -47,46 +45,30 @@ export function useSidebarNewReleasesUnread({ } catch { return []; } - }, []); + }, [scopedSeenStorageKey]); - const readSeenNewReleaseIds = useCallback( - () => readSeenNewReleaseIdsByKey(scopedSeenStorageKey), - [scopedSeenStorageKey, readSeenNewReleaseIdsByKey], - ); - - const writeSeenNewReleaseIdsByKey = useCallback((key: string, ids: string[]) => { + const writeSeenNewReleaseIds = useCallback((ids: string[]) => { const normalized = Array.from(new Set(ids.filter(Boolean))).slice(0, NEW_RELEASES_SEEN_MAX_IDS); - localStorage.setItem(key, JSON.stringify(normalized)); - }, []); - - const writeSeenNewReleaseIds = useCallback( - (ids: string[]) => writeSeenNewReleaseIdsByKey(scopedSeenStorageKey, ids), - [scopedSeenStorageKey, writeSeenNewReleaseIdsByKey], - ); + localStorage.setItem(scopedSeenStorageKey, JSON.stringify(normalized)); + }, [scopedSeenStorageKey]); const refreshNewReleasesUnread = useCallback(async (markAsSeen = false) => { const seq = ++newReleasesRefreshSeqRef.current; const isCurrent = () => seq === newReleasesRefreshSeqRef.current; - if (!isLoggedIn || !serverId || !isActiveServerReachable()) { + if (!isLoggedIn || !anchorServerId || scopes.length === 0) { if (isCurrent()) setNewReleasesUnreadCount(0); return; } try { - const newest = await getAlbumList('newest', NEW_RELEASES_UNREAD_SAMPLE_SIZE, 0); - const newestIds = newest.map(a => a.id).filter(Boolean); - let seenIds = readSeenNewReleaseIds(); - - // For a concrete library scope, bootstrap from the server-wide "all libraries" - // baseline when available, so switching scope doesn't hide existing unread. - if (seenIds.length === 0 && libraryScopeKey !== 'all') { - const allScopeSeen = readSeenNewReleaseIdsByKey(newReleasesSeenAllScopeStorageKey); - if (allScopeSeen.length > 0) { - seenIds = allScopeSeen; - writeSeenNewReleaseIdsByKey(scopedSeenStorageKey, allScopeSeen); - } - } + const newest = await loadLocalNewReleases( + anchorServerId, + scopes, + NEW_RELEASES_UNREAD_SAMPLE_SIZE, + ); + const newestIds = newest.albums.map(a => a.id).filter(Boolean); + const seenIds = readSeenNewReleaseIds(); if (seenIds.length === 0) { // First bootstrap for this server/scope: baseline is "already seen". @@ -99,13 +81,6 @@ export function useSidebarNewReleasesUnread({ // Prepend the live newest sample so a full `seenIds` list + slice(500) // cannot silently discard freshly "read" albums (fixes badge coming back). writeSeenNewReleaseIds(mergeSeenNewReleaseIdsCap(seenIds, newestIds, NEW_RELEASES_SEEN_MAX_IDS)); - // Keep server-wide baseline in sync so scope fallback never resurrects - // already-viewed items after opening the New Releases page. - const allScopeSeen = readSeenNewReleaseIdsByKey(newReleasesSeenAllScopeStorageKey); - writeSeenNewReleaseIdsByKey( - newReleasesSeenAllScopeStorageKey, - mergeSeenNewReleaseIdsCap(allScopeSeen, newestIds, NEW_RELEASES_SEEN_MAX_IDS), - ); if (isCurrent()) setNewReleasesUnreadCount(0); return; } @@ -118,15 +93,11 @@ export function useSidebarNewReleasesUnread({ // Keep previous value on transient network/API errors. } }, [ - libraryScopeKey, + anchorServerId, isLoggedIn, - newReleasesSeenAllScopeStorageKey, - scopedSeenStorageKey, readSeenNewReleaseIds, - readSeenNewReleaseIdsByKey, - serverId, + scopes, writeSeenNewReleaseIds, - writeSeenNewReleaseIdsByKey, ]); useEffect(() => { diff --git a/src/features/sidebar/utils/sidebarHelpers.test.ts b/src/features/sidebar/utils/sidebarHelpers.test.ts index 69782c16..a9a86e89 100644 --- a/src/features/sidebar/utils/sidebarHelpers.test.ts +++ b/src/features/sidebar/utils/sidebarHelpers.test.ts @@ -1,30 +1,27 @@ import { describe, expect, it } from 'vitest'; -import { libraryScopeCacheKeyForServer } from '@/lib/api/subsonicClient'; -import { useAuthStore } from '@/store/authStore'; -import { newReleasesSeenStorageKey } from '@/features/sidebar/utils/sidebarHelpers'; +import { + mergeSeenNewReleaseIdsCap, + newReleasesSeenStorageKey, +} from '@/features/sidebar/utils/sidebarHelpers'; describe('newReleasesSeenStorageKey', () => { - it('uses the all-libraries segment when scope is empty', () => { - useAuthStore.setState({ - activeServerId: 'srv-1', - musicLibrarySelectionByServer: { 'srv-1': [] }, - musicLibraryFilterByServer: { 'srv-1': 'all' }, - }); - expect(libraryScopeCacheKeyForServer('srv-1')).toBe('all'); - expect(newReleasesSeenStorageKey('srv-1', 'all')).toBe( - 'psy_new_releases_unread_seen_v1:srv-1:all', + it('uses an empty-scope segment when no selected libraries exist', () => { + expect(newReleasesSeenStorageKey('')).toBe( + 'psy_new_releases_unread_seen_v2:empty', ); }); - it('uses a comma-joined scope key for multi-library selection', () => { - useAuthStore.setState({ - activeServerId: 'srv-1', - musicLibrarySelectionByServer: { 'srv-1': ['lib-b', 'lib-a'] }, - musicLibraryFilterByServer: { 'srv-1': 'lib-b' }, - }); - expect(libraryScopeCacheKeyForServer('srv-1')).toBe('lib-b,lib-a'); - expect(newReleasesSeenStorageKey('srv-1', libraryScopeCacheKeyForServer('srv-1'))).toBe( - 'psy_new_releases_unread_seen_v1:srv-1:lib-b,lib-a', + it('keeps selected server and library pairs in the scope key', () => { + expect(newReleasesSeenStorageKey('srv-a:a2|srv-b:b1')).toBe( + 'psy_new_releases_unread_seen_v2:srv-a:a2|srv-b:b1', ); }); }); + +describe('mergeSeenNewReleaseIdsCap', () => { + it('counts a repeated globally unique album id once', () => { + expect(mergeSeenNewReleaseIdsCap(['older'], ['new', 'new', 'older'], 10)).toEqual([ + 'new', 'older', + ]); + }); +}); diff --git a/src/features/sidebar/utils/sidebarHelpers.ts b/src/features/sidebar/utils/sidebarHelpers.ts index 594c59ff..3fb64f14 100644 --- a/src/features/sidebar/utils/sidebarHelpers.ts +++ b/src/features/sidebar/utils/sidebarHelpers.ts @@ -1,18 +1,18 @@ export const SIDEBAR_NAV_LONG_PRESS_MS = 1000; export const SIDEBAR_NAV_LONG_PRESS_MOVE_CANCEL_PX = 10; export const SMART_PREFIX = 'psy-smart-'; -export const NEW_RELEASES_UNREAD_STORAGE_PREFIX = 'psy_new_releases_unread_seen_v1'; +export const NEW_RELEASES_UNREAD_STORAGE_PREFIX = 'psy_new_releases_unread_seen_v2'; export const NEW_RELEASES_UNREAD_SAMPLE_SIZE = 80; export const NEW_RELEASES_UNREAD_POLL_MS = 2 * 60 * 1000; export const NEW_RELEASES_RESET_DELAY_MS = 5_000; -/** Max album ids persisted per server/scope; cap must not drop the latest "newest" batch when marking read. */ +/** Max album ids persisted per selected-library scope; cap keeps the newest batch. */ export const NEW_RELEASES_SEEN_MAX_IDS = 500; -export function newReleasesSeenStorageKey(serverId: string, libraryScopeKey: string): string { - return `${NEW_RELEASES_UNREAD_STORAGE_PREFIX}:${serverId || 'no-server'}:${libraryScopeKey || 'all'}`; +export function newReleasesSeenStorageKey(scopeFingerprint: string): string { + return `${NEW_RELEASES_UNREAD_STORAGE_PREFIX}:${scopeFingerprint || 'empty'}`; } -/** Merge previous seen IDs with the current `getAlbumList(newest)` sample: newest batch is kept in full first, then older seen until `maxIds` (localStorage budget). */ +/** Merge previous seen IDs with the current local chronological sample. */ export function mergeSeenNewReleaseIdsCap(prevSeen: string[], newestBatch: string[], maxIds: number): string[] { const out: string[] = []; const seen = new Set(); diff --git a/src/lib/api/subsonicStreamUrl.ts b/src/lib/api/subsonicStreamUrl.ts index 32ec3dcc..9bcded9f 100644 --- a/src/lib/api/subsonicStreamUrl.ts +++ b/src/lib/api/subsonicStreamUrl.ts @@ -120,3 +120,16 @@ export function buildDownloadUrl(id: string): string { }); return `${baseUrl}/rest/download.view?${p.toString()}`; } + +export function buildDownloadUrlForServer(serverId: string, id: string): string { + const server = findServerByIdOrIndexKey(serverId); + if (!server) return buildDownloadUrl(id); + const salt = secureRandomSalt(); + const token = md5(server.password + salt); + const p = new URLSearchParams({ + id, + u: server.username, + t: token, s: salt, v: '1.16.1', c: SUBSONIC_CLIENT, f: 'json', + }); + return `${restBaseFromUrl(connectBaseUrlForServer(server))}/download.view?${p.toString()}`; +} diff --git a/src/lib/library/libraryBrowseScope.ts b/src/lib/library/libraryBrowseScope.ts index 09a669b6..ad7b73e7 100644 --- a/src/lib/library/libraryBrowseScope.ts +++ b/src/lib/library/libraryBrowseScope.ts @@ -3,7 +3,7 @@ export interface LibraryBrowseScopePair { libraryId: string; } -interface LibraryBrowseScopeSource { +export interface LibraryBrowseScopeSource { servers: Array<{ id: string }>; activeServerId: string | null; libraryBrowseServerIds: string[]; @@ -32,8 +32,7 @@ export interface LibraryBrowseScope { } /** Ordered concrete source pairs used only by Library pages and search. */ -export function getLibraryBrowseScope(): LibraryBrowseScope { - const state = readLibraryBrowseScopeSource(); +export function deriveLibraryBrowseScope(state: LibraryBrowseScopeSource): LibraryBrowseScope { const selectedServers = new Set(state.libraryBrowseServerIds); const orderedServers = state.servers.filter(server => selectedServers.has(server.id)); const pairs: LibraryBrowseScopePair[] = []; @@ -57,3 +56,7 @@ export function getLibraryBrowseScope(): LibraryBrowseScope { multiServer: orderedServers.length > 1, }; } + +export function getLibraryBrowseScope(): LibraryBrowseScope { + return deriveLibraryBrowseScope(readLibraryBrowseScopeSource()); +} diff --git a/src/lib/library/newReleasesLocal.test.ts b/src/lib/library/newReleasesLocal.test.ts new file mode 100644 index 00000000..2b11b178 --- /dev/null +++ b/src/lib/library/newReleasesLocal.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { loadLocalNewReleases } from '@/lib/library/newReleasesLocal'; + +const libraryScopeListMainstageAlbums = vi.fn(); + +vi.mock('@/lib/api/library/scopeReads', () => ({ + libraryScopeListMainstageAlbums: (...args: unknown[]) => libraryScopeListMainstageAlbums(...args), +})); + +describe('loadLocalNewReleases', () => { + beforeEach(() => libraryScopeListMainstageAlbums.mockReset()); + + it('keeps globally ordered owner-stamped rows from the local feed', async () => { + libraryScopeListMainstageAlbums.mockResolvedValue({ + albums: [ + { serverId: 'server-b', id: 'newer', name: 'Newer', syncedAt: 1, rawJson: {} }, + { serverId: 'server-a', id: 'older', name: 'Older', syncedAt: 1, rawJson: {} }, + ], + hasMore: true, + }); + + const result = await loadLocalNewReleases('server-a', [ + { serverId: 'server-a', libraryId: 'a1' }, + { serverId: 'server-b', libraryId: 'b1' }, + ], 30, 60); + + expect(libraryScopeListMainstageAlbums).toHaveBeenCalledWith('server-a', { + scopes: [{ serverId: 'server-a', libraryId: 'a1' }, { serverId: 'server-b', libraryId: 'b1' }], + feed: 'newReleases', + limit: 30, + offset: 60, + }); + expect(result.albums.map(album => [album.serverId, album.id])).toEqual([ + ['server-b', 'newer'], + ['server-a', 'older'], + ]); + expect(result.hasMore).toBe(true); + }); + + it('skips IPC for an empty selected scope', async () => { + await expect(loadLocalNewReleases('', [], 30)).resolves.toEqual({ albums: [], hasMore: false }); + expect(libraryScopeListMainstageAlbums).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/library/newReleasesLocal.ts b/src/lib/library/newReleasesLocal.ts new file mode 100644 index 00000000..92054c0a --- /dev/null +++ b/src/lib/library/newReleasesLocal.ts @@ -0,0 +1,18 @@ +import { libraryScopeListMainstageAlbums, type LibraryScopePair } from '@/lib/api/library/scopeReads'; +import { albumToAlbum } from '@/lib/library/advancedSearchLocal'; + +export async function loadLocalNewReleases( + anchorServerId: string, + scopes: LibraryScopePair[], + limit: number, + offset = 0, +) { + if (!anchorServerId || scopes.length === 0) return { albums: [], hasMore: false }; + const response = await libraryScopeListMainstageAlbums(anchorServerId, { + scopes, + feed: 'newReleases', + limit, + offset, + }); + return { albums: response.albums.map(albumToAlbum), hasMore: response.hasMore }; +}