feat(releases): merge selected server catalog feeds

This commit is contained in:
cucadmuh
2026-07-17 01:45:04 +03:00
parent ba635c5d4e
commit 49170898ea
9 changed files with 176 additions and 98 deletions
+29 -12
View File
@@ -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(() => {
+17 -2
View File
@@ -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,
});
@@ -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<number | null>(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(() => {
@@ -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',
]);
});
});
+5 -5
View File
@@ -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<string>();
+13
View File
@@ -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()}`;
}
+6 -3
View File
@@ -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());
}
+44
View File
@@ -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();
});
});
+18
View File
@@ -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 };
}