mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
feat(releases): merge selected server catalog feeds
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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()}`;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user