mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
refactor(lib): relocate the local-library subsystem utils/library -> lib/library
utils/library is the local-index query/browse/sync engine -- feature-free shared infra consumed by the album, search, genre, artist and offline features. With its last feature edges removed (queue resolver, shuffleArray, albumBrowseCatalogChunk, genreBrowsePlayback all relocated earlier this branch), the whole directory is pure infra and belongs under lib/. Whole-directory move (58 files, 76 consumers rewritten) via resolver-based rewrite: cross-cluster relatives absolutized to @/, intra-cluster siblings kept relative. utils/library is gone; lib/library imports zero @/features. Three stale doc comments in the album/search/artist barrels repointed. tsc 0, lint 0, full suite 2353/2353, production build OK.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import { parseItemGenres } from '@/utils/library/genreTags';
|
||||
import { parseItemGenres } from '@/lib/library/genreTags';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { ndLogin } from '@/lib/api/navidromeAdmin';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { api, apiForServer } from '@/lib/api/subsonicClient';
|
||||
import type { PlaybackReportState, SubsonicNowPlaying } from '@/lib/api/subsonicTypes';
|
||||
import { patchLibraryTrackOnUse } from '@/utils/library/patchOnUse';
|
||||
import { patchLibraryTrackOnUse } from '@/lib/library/patchOnUse';
|
||||
import { shouldAttemptSubsonicForServer } from '@/utils/network/subsonicNetworkGuard';
|
||||
|
||||
async function scrobbleOnServer(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { api, libraryFilterParams } from '@/lib/api/subsonicClient';
|
||||
import { searchQueryIsFtsSafe } from '@/utils/library/searchQueryFtsSafe';
|
||||
import { searchQueryIsFtsSafe } from '@/lib/library/searchQueryFtsSafe';
|
||||
import type {
|
||||
SearchResults,
|
||||
SubsonicAlbum,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer } from '@/lib/api/subsonicClient';
|
||||
import { invalidateEntityUserRatingCaches } from '@/lib/api/subsonicRatings';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { patchLibraryTrackOnUse, type StarPatchMeta } from '@/utils/library/patchOnUse';
|
||||
import { patchLibraryTrackOnUse, type StarPatchMeta } from '@/lib/library/patchOnUse';
|
||||
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
|
||||
import {
|
||||
invalidateStarredAlbumBrowse,
|
||||
refreshStarredAlbumIndexFromServer,
|
||||
} from '@/utils/library/starredAlbumIndexSync';
|
||||
} from '@/lib/library/starredAlbumIndexSync';
|
||||
import type {
|
||||
EntityRatingSupportLevel,
|
||||
StarredResults,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { genreTagsFor } from '@/utils/library/genreTags';
|
||||
import { genreTagsFor } from '@/lib/library/genreTags';
|
||||
import { getArtists } from '@/lib/api/subsonicArtists';
|
||||
import { getAlbumList, getRandomSongs } from '@/lib/api/subsonicLibrary';
|
||||
import type {
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { onInvoke } from '@/test/mocks/tauri';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
|
||||
import {
|
||||
resolveTrackCoverArtId,
|
||||
runLocalAdvancedSearch,
|
||||
runLocalSongBrowse,
|
||||
runNetworkAdvancedYearAlbums,
|
||||
trackToSong,
|
||||
tryRunLocalAdvancedSearch,
|
||||
} from './advancedSearchLocal';
|
||||
import * as albumBrowseNetwork from './albumBrowseNetwork';
|
||||
|
||||
const opts = (over: Partial<Parameters<typeof runLocalAdvancedSearch>[1]> = {}) => ({
|
||||
query: '',
|
||||
genre: '',
|
||||
yearFrom: '',
|
||||
yearTo: '',
|
||||
bpmFrom: '',
|
||||
bpmTo: '',
|
||||
moodGroup: '',
|
||||
losslessOnly: false,
|
||||
resultType: 'all' as const,
|
||||
...over,
|
||||
});
|
||||
|
||||
const ready = () =>
|
||||
onInvoke('library_get_status', () => ({
|
||||
serverId: 's1',
|
||||
libraryScope: '',
|
||||
syncPhase: 'ready',
|
||||
capabilityFlags: 0,
|
||||
libraryTier: 'unknown',
|
||||
syncedAt: 0,
|
||||
}));
|
||||
|
||||
describe('runLocalAdvancedSearch', () => {
|
||||
beforeEach(() => {
|
||||
useLibraryIndexStore.setState({ masterEnabled: true });
|
||||
});
|
||||
|
||||
it('returns null (→ network fallback) when the index is not ready', async () => {
|
||||
onInvoke('library_get_status', () => ({ serverId: 's1', libraryScope: '', syncPhase: 'initial_sync' }));
|
||||
const res = await runLocalAdvancedSearch('s1', opts({ query: 'x' }), 100);
|
||||
expect(res).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the index is disabled for the server', async () => {
|
||||
useLibraryIndexStore.setState({ masterEnabled: false });
|
||||
const res = await runLocalAdvancedSearch('s1', opts({ query: 'x' }), 100);
|
||||
expect(res).toBeNull();
|
||||
});
|
||||
|
||||
it('passes libraryScope from the sidebar music library filter', async () => {
|
||||
useAuthStore.setState({ musicLibraryFilterByServer: { s1: 'lib7' } });
|
||||
ready();
|
||||
let captured: unknown;
|
||||
onInvoke('library_advanced_search', (args) => {
|
||||
captured = args;
|
||||
return {
|
||||
artists: [],
|
||||
albums: [],
|
||||
tracks: [],
|
||||
totals: { artists: 0, albums: 0, tracks: 0 },
|
||||
source: 'local',
|
||||
};
|
||||
});
|
||||
await runLocalAdvancedSearch('s1', opts({ query: 'x' }), 100);
|
||||
expect(captured).toMatchObject({ request: { libraryScope: 'lib7' } });
|
||||
});
|
||||
|
||||
it('passes lossless is_true filter to library_advanced_search', async () => {
|
||||
ready();
|
||||
let captured: unknown;
|
||||
onInvoke('library_advanced_search', (args) => {
|
||||
captured = args;
|
||||
return {
|
||||
artists: [],
|
||||
albums: [],
|
||||
tracks: [],
|
||||
totals: { artists: 0, albums: 0, tracks: 0 },
|
||||
source: 'local',
|
||||
};
|
||||
});
|
||||
await runLocalAdvancedSearch('s1', opts({ losslessOnly: true }), 100);
|
||||
expect(captured).toMatchObject({
|
||||
request: { filters: [{ field: 'lossless', op: 'is_true' }] },
|
||||
});
|
||||
});
|
||||
|
||||
it('passes bpm between filter to library_advanced_search', async () => {
|
||||
ready();
|
||||
let captured: unknown;
|
||||
onInvoke('library_advanced_search', (args) => {
|
||||
captured = args;
|
||||
return {
|
||||
artists: [],
|
||||
albums: [],
|
||||
tracks: [],
|
||||
totals: { artists: 0, albums: 0, tracks: 0 },
|
||||
source: 'local',
|
||||
};
|
||||
});
|
||||
await runLocalAdvancedSearch('s1', opts({ bpmFrom: '120', bpmTo: '130' }), 100);
|
||||
expect(captured).toMatchObject({
|
||||
request: { filters: [{ field: 'bpm', op: 'between', value: 120, valueTo: 130 }] },
|
||||
});
|
||||
});
|
||||
|
||||
it('resolveTrackCoverArtId falls back to albumId when coverArtId is empty', () => {
|
||||
expect(
|
||||
resolveTrackCoverArtId(
|
||||
{ coverArtId: undefined, albumId: 'al-42' },
|
||||
{ coverArt: '', albumId: 'al-42' },
|
||||
),
|
||||
).toBe('al-42');
|
||||
expect(resolveTrackCoverArtId({ coverArtId: 'cv1', albumId: 'al-42' })).toBe('cv1');
|
||||
});
|
||||
|
||||
it('resolveTrackCoverArtId prefers raw_json mf art over stale index column', () => {
|
||||
expect(
|
||||
resolveTrackCoverArtId(
|
||||
{ coverArtId: 'mf-disc1', albumId: 'al-box' },
|
||||
{ coverArt: 'mf-disc2', albumId: 'al-box', discNumber: 2 },
|
||||
),
|
||||
).toBe('mf-disc2');
|
||||
});
|
||||
|
||||
it('trackToSong sets coverArt from albumId when the index row has no cover_art_id', () => {
|
||||
const song = trackToSong({
|
||||
serverId: 's1',
|
||||
id: 't1',
|
||||
title: 'T',
|
||||
album: 'Alb',
|
||||
albumId: 'al-42',
|
||||
durationSec: 100,
|
||||
syncedAt: 0,
|
||||
rawJson: { id: 't1', title: 'T', artist: 'A', album: 'Alb', albumId: 'al-42', duration: 100 },
|
||||
});
|
||||
expect(song.coverArt).toBe('al-42');
|
||||
});
|
||||
|
||||
it('trackToSong keeps resolved bpm and source over rawJson tag', () => {
|
||||
const song = trackToSong({
|
||||
serverId: 's1',
|
||||
id: 't1',
|
||||
title: 'T',
|
||||
album: 'Alb',
|
||||
durationSec: 100,
|
||||
syncedAt: 0,
|
||||
bpm: 128,
|
||||
bpmSource: 'analysis',
|
||||
rawJson: { id: 't1', title: 'T', artist: 'A', album: 'Alb', albumId: 'al1', duration: 100, bpm: 90 },
|
||||
});
|
||||
expect(song.bpm).toBe(128);
|
||||
expect(song.localBpmSource).toBe('analysis');
|
||||
});
|
||||
|
||||
it('prefers rawJson, falls back to hot columns, and reports the full total', async () => {
|
||||
ready();
|
||||
onInvoke('library_advanced_search', () => ({
|
||||
artists: [],
|
||||
albums: [],
|
||||
tracks: [
|
||||
{
|
||||
serverId: 's1', id: 't1', title: 'Hot Title', album: 'Alb', albumId: 'al1',
|
||||
durationSec: 100, syncedAt: 0,
|
||||
// rawJson is the authoritative original song — must win.
|
||||
rawJson: {
|
||||
id: 't1', title: 'Raw Title', artist: 'Raw Artist', album: 'Alb', albumId: 'al1',
|
||||
duration: 100, contributors: [{ role: 'composer', artist: { name: 'C' } }],
|
||||
},
|
||||
},
|
||||
{
|
||||
serverId: 's1', id: 't2', title: 'Only Hot', album: 'Alb2', albumId: 'al2',
|
||||
artist: 'Hot Artist', durationSec: 200, year: 1999, genre: 'Rock',
|
||||
starredAt: 1_700_000_000_000, syncedAt: 0,
|
||||
rawJson: {}, // sparse → hot-column fallback
|
||||
},
|
||||
],
|
||||
totals: { artists: 0, albums: 0, tracks: 42 },
|
||||
appliedFilters: [],
|
||||
source: 'local',
|
||||
}));
|
||||
|
||||
const res = await runLocalAdvancedSearch('s1', opts({ resultType: 'songs' }), 100);
|
||||
expect(res).not.toBeNull();
|
||||
expect(res!.songs).toHaveLength(2);
|
||||
|
||||
// rawJson wins where present + carries OpenSubsonic extras.
|
||||
expect(res!.songs[0].title).toBe('Raw Title');
|
||||
expect(res!.songs[0].artist).toBe('Raw Artist');
|
||||
expect(res!.songs[0].contributors).toBeDefined();
|
||||
|
||||
// hot-column fallback when rawJson is sparse.
|
||||
expect(res!.songs[1].title).toBe('Only Hot');
|
||||
expect(res!.songs[1].artist).toBe('Hot Artist');
|
||||
expect(res!.songs[1].year).toBe(1999);
|
||||
expect(res!.songs[1].genre).toBe('Rock');
|
||||
expect(res!.songs[1].starred).toBeTruthy();
|
||||
|
||||
// Total is the full match count, not the page size.
|
||||
expect(res!.songsTotal).toBe(42);
|
||||
});
|
||||
|
||||
it('returns null without throwing when the local query errors', async () => {
|
||||
ready();
|
||||
onInvoke('library_advanced_search', () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
const res = await runLocalAdvancedSearch('s1', opts({ query: 'x' }), 100);
|
||||
expect(res).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('runLocalSongBrowse', () => {
|
||||
beforeEach(() => {
|
||||
useLibraryIndexStore.setState({ masterEnabled: true });
|
||||
});
|
||||
|
||||
it('returns null for a missing server id (→ network browse)', async () => {
|
||||
expect(await runLocalSongBrowse(null, 0, 50)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null (→ network browse) when the index is not ready', async () => {
|
||||
onInvoke('library_get_status', () => ({ serverId: 's1', libraryScope: '', syncPhase: 'initial_sync' }));
|
||||
expect(await runLocalSongBrowse('s1', 0, 50)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the response is not local', async () => {
|
||||
ready();
|
||||
onInvoke('library_advanced_search', () => ({
|
||||
artists: [], albums: [], tracks: [],
|
||||
totals: { artists: 0, albums: 0, tracks: 0 }, appliedFilters: [], source: 'network',
|
||||
}));
|
||||
expect(await runLocalSongBrowse('s1', 0, 50)).toBeNull();
|
||||
});
|
||||
|
||||
it('maps the local browse page to Subsonic songs (rawJson wins)', async () => {
|
||||
ready();
|
||||
onInvoke('library_advanced_search', () => ({
|
||||
artists: [],
|
||||
albums: [],
|
||||
tracks: [
|
||||
{
|
||||
serverId: 's1', id: 't1', title: 'Hot', album: 'Alb', albumId: 'al1',
|
||||
durationSec: 100, syncedAt: 0,
|
||||
rawJson: { id: 't1', title: 'Raw', artist: 'Raw Artist', album: 'Alb', albumId: 'al1', duration: 100 },
|
||||
},
|
||||
],
|
||||
totals: { artists: 0, albums: 0, tracks: 1 }, appliedFilters: [], source: 'local',
|
||||
}));
|
||||
const songs = await runLocalSongBrowse('s1', 0, 50);
|
||||
expect(songs).not.toBeNull();
|
||||
expect(songs!).toHaveLength(1);
|
||||
expect(songs![0].title).toBe('Raw');
|
||||
expect(songs![0].artist).toBe('Raw Artist');
|
||||
});
|
||||
|
||||
it('returns null without throwing on error', async () => {
|
||||
ready();
|
||||
onInvoke('library_advanced_search', () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
expect(await runLocalSongBrowse('s1', 0, 50)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tryRunLocalAdvancedSearch', () => {
|
||||
beforeEach(() => {
|
||||
useLibraryIndexStore.setState({ masterEnabled: true });
|
||||
});
|
||||
|
||||
it('retries without the ready gate when sync is still in progress', async () => {
|
||||
onInvoke('library_get_status', () => ({
|
||||
serverId: 's1',
|
||||
libraryScope: '',
|
||||
syncPhase: 'initial_sync',
|
||||
localTrackCount: 100,
|
||||
serverTrackCount: 1000,
|
||||
capabilityFlags: 0,
|
||||
libraryTier: 'unknown',
|
||||
syncedAt: 0,
|
||||
}));
|
||||
let searchCalls = 0;
|
||||
onInvoke('library_advanced_search', () => {
|
||||
searchCalls += 1;
|
||||
return {
|
||||
source: 'local',
|
||||
artists: [],
|
||||
albums: [],
|
||||
tracks: [],
|
||||
totals: { artists: 0, albums: 0, tracks: 0 },
|
||||
appliedFilters: ['year'],
|
||||
};
|
||||
});
|
||||
const res = await tryRunLocalAdvancedSearch('s1', opts({ yearFrom: '2020' }), 100);
|
||||
expect(res).not.toBeNull();
|
||||
expect(searchCalls).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runNetworkAdvancedYearAlbums', () => {
|
||||
it('passes open-ended year bounds to album browse (not 1900…now defaults)', async () => {
|
||||
const spy = vi.spyOn(albumBrowseNetwork, 'fetchAlbumBrowseNetwork').mockResolvedValue({
|
||||
albums: [{
|
||||
id: 'a1',
|
||||
name: 'Al',
|
||||
artist: 'Ar',
|
||||
artistId: 'ar1',
|
||||
songCount: 1,
|
||||
duration: 100,
|
||||
}],
|
||||
hasMore: false,
|
||||
});
|
||||
await runNetworkAdvancedYearAlbums(opts({ yearTo: '1990' }), 100);
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ year: { to: 1990 } }),
|
||||
0,
|
||||
100,
|
||||
);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* Advanced Search against the local library index (spec §5.13 / F2).
|
||||
*
|
||||
* Maps the SearchBrowsePage filter inputs to a `library_advanced_search` request and
|
||||
* the response back to the Subsonic shapes the existing rows render. The sync
|
||||
* engine stores each entity's original Subsonic JSON in `rawJson` (ADR-7), so
|
||||
* that's preferred verbatim; the flat hot columns are a fallback when a row's
|
||||
* `rawJson` is sparse.
|
||||
*
|
||||
* `runLocalAdvancedSearch` returns `null` when the index isn't ready or the
|
||||
* query can't be served locally — the caller then falls back to the network
|
||||
* path unchanged (§5.13.6).
|
||||
*/
|
||||
import {
|
||||
libraryAdvancedSearch,
|
||||
type LibraryAdvancedSearchRequest,
|
||||
type LibraryAlbumDto,
|
||||
type LibraryArtistDto,
|
||||
type LibraryEntityType,
|
||||
type LibraryFilterClause,
|
||||
type LibraryTrackDto,
|
||||
} from '@/lib/api/library';
|
||||
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import { search } from '@/lib/api/subsonicSearch';
|
||||
import { libraryScopeForServer } from '@/lib/api/subsonicClient';
|
||||
import { fetchAlbumBrowseNetwork } from './albumBrowseNetwork';
|
||||
import type { AlbumBrowseQuery } from './albumBrowseTypes';
|
||||
import { resolveAlbumYearBounds } from './albumYearFilter';
|
||||
import { libraryIsReady } from './libraryReady';
|
||||
import { logLibrarySearch, timed } from './libraryDevLog';
|
||||
import { isLosslessSuffix } from './losslessFormats';
|
||||
import { albumIsCompilation } from './albumCompilation';
|
||||
import { OXIMEDIA_MOOD_SEARCH_ENABLED } from './trackEnrichment';
|
||||
|
||||
export const ADVANCED_SEARCH_YEAR_ALBUM_LIMIT = 100;
|
||||
|
||||
export type AdvancedResultType = 'all' | 'artists' | 'albums' | 'songs';
|
||||
|
||||
/** UI opts for Advanced Search — BPM/mood filters require local index. */
|
||||
export interface LocalSearchOpts {
|
||||
query: string;
|
||||
genre: string;
|
||||
yearFrom: string;
|
||||
yearTo: string;
|
||||
bpmFrom: string;
|
||||
bpmTo: string;
|
||||
moodGroup: string;
|
||||
losslessOnly?: boolean;
|
||||
resultType: AdvancedResultType;
|
||||
/** When searching albums, match album title only (not album artist). */
|
||||
albumTitleOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface LocalAdvancedSearchPage {
|
||||
artists: SubsonicArtist[];
|
||||
albums: SubsonicAlbum[];
|
||||
songs: SubsonicSong[];
|
||||
/** Full track match count (not page size) — drives "load more". */
|
||||
songsTotal: number;
|
||||
}
|
||||
|
||||
const isObject = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
|
||||
function entityTypesFor(rt: AdvancedResultType): LibraryEntityType[] {
|
||||
switch (rt) {
|
||||
case 'artists':
|
||||
return ['artist'];
|
||||
case 'albums':
|
||||
return ['album'];
|
||||
case 'songs':
|
||||
return ['track'];
|
||||
default:
|
||||
return ['artist', 'album', 'track'];
|
||||
}
|
||||
}
|
||||
|
||||
function buildFilters(opts: LocalSearchOpts): LibraryFilterClause[] {
|
||||
const filters: LibraryFilterClause[] = [];
|
||||
if (opts.genre) filters.push({ field: 'genre', op: 'eq', value: opts.genre });
|
||||
const from = opts.yearFrom ? parseInt(opts.yearFrom, 10) : null;
|
||||
const to = opts.yearTo ? parseInt(opts.yearTo, 10) : null;
|
||||
if (from !== null && to !== null) {
|
||||
filters.push({ field: 'year', op: 'between', value: from, valueTo: to });
|
||||
} else if (from !== null) {
|
||||
filters.push({ field: 'year', op: 'gte', value: from });
|
||||
} else if (to !== null) {
|
||||
filters.push({ field: 'year', op: 'lte', value: to });
|
||||
}
|
||||
const bpmFrom = opts.bpmFrom ? parseInt(opts.bpmFrom, 10) : null;
|
||||
const bpmTo = opts.bpmTo ? parseInt(opts.bpmTo, 10) : null;
|
||||
if (bpmFrom !== null && bpmTo !== null) {
|
||||
filters.push({ field: 'bpm', op: 'between', value: bpmFrom, valueTo: bpmTo });
|
||||
} else if (bpmFrom !== null) {
|
||||
filters.push({ field: 'bpm', op: 'gte', value: bpmFrom });
|
||||
} else if (bpmTo !== null) {
|
||||
filters.push({ field: 'bpm', op: 'lte', value: bpmTo });
|
||||
}
|
||||
if (OXIMEDIA_MOOD_SEARCH_ENABLED && opts.moodGroup) {
|
||||
filters.push({ field: 'mood_group', op: 'eq', value: opts.moodGroup });
|
||||
}
|
||||
if (opts.losslessOnly) {
|
||||
filters.push({ field: 'lossless', op: 'is_true' });
|
||||
}
|
||||
return filters;
|
||||
}
|
||||
|
||||
function applyClientSongFilters(
|
||||
list: SubsonicSong[],
|
||||
opts: LocalSearchOpts,
|
||||
): SubsonicSong[] {
|
||||
let r = list;
|
||||
const g = opts.genre;
|
||||
const from = opts.yearFrom ? parseInt(opts.yearFrom, 10) : null;
|
||||
const to = opts.yearTo ? parseInt(opts.yearTo, 10) : null;
|
||||
const bpmFrom = opts.bpmFrom ? parseInt(opts.bpmFrom, 10) : null;
|
||||
const bpmTo = opts.bpmTo ? parseInt(opts.bpmTo, 10) : null;
|
||||
if (g) r = r.filter(s => s.genre?.toLowerCase() === g.toLowerCase());
|
||||
if (from !== null) r = r.filter(s => !s.year || s.year >= from);
|
||||
if (to !== null) r = r.filter(s => !s.year || s.year <= to);
|
||||
if (bpmFrom !== null) r = r.filter(s => s.bpm != null && s.bpm > 0 && s.bpm >= bpmFrom);
|
||||
if (bpmTo !== null) r = r.filter(s => s.bpm != null && s.bpm > 0 && s.bpm <= bpmTo);
|
||||
if (opts.losslessOnly) r = r.filter(s => isLosslessSuffix(s.suffix));
|
||||
return r;
|
||||
}
|
||||
|
||||
function buildRequest(
|
||||
serverId: string,
|
||||
opts: LocalSearchOpts,
|
||||
entityTypes: LibraryEntityType[],
|
||||
limit: number,
|
||||
offset: number,
|
||||
skipTotals = false,
|
||||
): LibraryAdvancedSearchRequest {
|
||||
const q = opts.query.trim();
|
||||
const libraryScope = libraryScopeForServer(serverId);
|
||||
return {
|
||||
serverId,
|
||||
libraryScope: libraryScope ?? undefined,
|
||||
query: q || undefined,
|
||||
entityTypes,
|
||||
filters: buildFilters(opts),
|
||||
limit,
|
||||
offset,
|
||||
skipTotals,
|
||||
...(opts.resultType === 'albums' && opts.albumTitleOnly
|
||||
? { queryAlbumTitleOnly: true }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cover art id for a library track — mirrors Rust cover backfill
|
||||
* (`COALESCE(cover_art_id, album_id)`). Many servers only expose album art.
|
||||
*/
|
||||
export function resolveTrackCoverArtId(
|
||||
hot: Pick<LibraryTrackDto, 'coverArtId' | 'albumId'>,
|
||||
song: Partial<SubsonicSong> = {},
|
||||
): string | undefined {
|
||||
const songArt = typeof song.coverArt === 'string' ? song.coverArt.trim() : '';
|
||||
const hotArt = typeof hot.coverArtId === 'string' ? hot.coverArtId.trim() : '';
|
||||
// `raw_json` per-disc `coverArt` wins over a stale index `cover_art_id` (often disc 1).
|
||||
if (songArt && hotArt && songArt !== hotArt && songArt.startsWith('mf-')) {
|
||||
return songArt;
|
||||
}
|
||||
for (const c of [hot.coverArtId, song.coverArt, hot.albumId, song.albumId]) {
|
||||
const id = typeof c === 'string' ? c.trim() : '';
|
||||
if (id) return id;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function trackToSong(t: LibraryTrackDto): SubsonicSong {
|
||||
const raw = isObject(t.rawJson) ? t.rawJson : {};
|
||||
const resolvedBpm = t.bpm != null && t.bpm > 0 ? t.bpm : undefined;
|
||||
const base: SubsonicSong = {
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
artist: t.artist ?? '',
|
||||
album: t.album,
|
||||
albumId: t.albumId ?? '',
|
||||
artistId: t.artistId ?? undefined,
|
||||
duration: t.durationSec,
|
||||
track: t.trackNumber ?? undefined,
|
||||
discNumber: t.discNumber ?? undefined,
|
||||
coverArt: resolveTrackCoverArtId(t),
|
||||
year: t.year ?? undefined,
|
||||
genre: t.genre ?? undefined,
|
||||
suffix: t.suffix ?? undefined,
|
||||
bitRate: t.bitRate ?? undefined,
|
||||
size: t.sizeBytes ?? undefined,
|
||||
starred: t.starredAt != null ? new Date(t.starredAt).toISOString() : undefined,
|
||||
userRating: t.userRating ?? undefined,
|
||||
playCount: t.playCount ?? undefined,
|
||||
bpm: resolvedBpm,
|
||||
isrc: t.isrc ?? undefined,
|
||||
albumArtist: t.albumArtist ?? undefined,
|
||||
};
|
||||
// `rawJson` is the authoritative original song — let it override the
|
||||
// hot-column fallbacks (it carries OpenSubsonic extras too).
|
||||
const merged: SubsonicSong = { ...base, ...(raw as Partial<SubsonicSong>) };
|
||||
const coverArt = resolveTrackCoverArtId(t, merged);
|
||||
if (coverArt) merged.coverArt = coverArt;
|
||||
if (resolvedBpm != null) merged.bpm = resolvedBpm;
|
||||
if (t.bpmSource === 'analysis' || t.bpmSource === 'tag') {
|
||||
merged.localBpmSource = t.bpmSource;
|
||||
}
|
||||
if (t.serverId) merged.serverId = t.serverId;
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Merge `raw_json` without nullish Subsonic fields wiping hot columns (e.g. year). */
|
||||
function mergeAlbumRawJson(base: SubsonicAlbum, raw: Partial<SubsonicAlbum>): SubsonicAlbum {
|
||||
const merged = { ...base } as SubsonicAlbum & Record<string, unknown>;
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (value != null && value !== '') merged[key] = value;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function albumToAlbum(a: LibraryAlbumDto): SubsonicAlbum {
|
||||
const raw = isObject(a.rawJson) ? a.rawJson : {};
|
||||
const base: SubsonicAlbum = {
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
artist: a.artist ?? '',
|
||||
artistId: a.artistId ?? '',
|
||||
songCount: a.songCount ?? 0,
|
||||
duration: a.durationSec ?? 0,
|
||||
year: a.year ?? undefined,
|
||||
genre: a.genre ?? undefined,
|
||||
coverArt: a.coverArtId ?? a.id,
|
||||
starred: a.starredAt != null ? new Date(a.starredAt).toISOString() : undefined,
|
||||
};
|
||||
const merged = mergeAlbumRawJson(base, raw as Partial<SubsonicAlbum>);
|
||||
if (albumIsCompilation(merged)) merged.isCompilation = true;
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function artistToArtist(ar: LibraryArtistDto): SubsonicArtist {
|
||||
const raw = isObject(ar.rawJson) ? ar.rawJson : {};
|
||||
const base: SubsonicArtist = {
|
||||
id: ar.id,
|
||||
name: ar.name,
|
||||
nameSort: ar.nameSort ?? undefined,
|
||||
albumCount: ar.albumCount ?? undefined,
|
||||
coverArt: ar.id,
|
||||
};
|
||||
const merged = mergeArtistRawJson(base, raw as Partial<SubsonicArtist>);
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Hot columns from SQLite win over sparse `raw_json` (ADR-7). */
|
||||
function mergeArtistRawJson(base: SubsonicArtist, raw: Partial<SubsonicArtist>): SubsonicArtist {
|
||||
return { ...raw, ...base };
|
||||
}
|
||||
|
||||
/**
|
||||
* Network search3 path for Advanced Search free-text (mirrors SearchBrowsePage.tsx filters).
|
||||
*/
|
||||
export async function runNetworkAdvancedTextSearch(
|
||||
opts: LocalSearchOpts,
|
||||
songsLimit: number,
|
||||
): Promise<LocalAdvancedSearchPage | null> {
|
||||
const q = opts.query.trim();
|
||||
if (!q) return null;
|
||||
const rt = opts.resultType;
|
||||
|
||||
const r = await search(q, {
|
||||
artistCount: 30,
|
||||
albumCount: 50,
|
||||
songCount: songsLimit,
|
||||
});
|
||||
|
||||
let artists = r.artists;
|
||||
let albums = r.albums;
|
||||
const songs = applyClientSongFilters(r.songs, opts);
|
||||
|
||||
const g = opts.genre;
|
||||
const from = opts.yearFrom ? parseInt(opts.yearFrom, 10) : null;
|
||||
const to = opts.yearTo ? parseInt(opts.yearTo, 10) : null;
|
||||
if (g) albums = albums.filter(a => a.genre?.toLowerCase() === g.toLowerCase());
|
||||
if (from !== null) albums = albums.filter(a => !a.year || a.year >= from);
|
||||
if (to !== null) albums = albums.filter(a => !a.year || a.year <= to);
|
||||
if (opts.losslessOnly) {
|
||||
const albumIds = new Set(songs.map(s => s.albumId).filter(Boolean));
|
||||
albums = albums.filter(a => albumIds.has(a.id));
|
||||
const artistIds = new Set(songs.map(s => s.artistId).filter(Boolean));
|
||||
artists = artists.filter(a => artistIds.has(a.id));
|
||||
}
|
||||
|
||||
return {
|
||||
artists: rt === 'albums' || rt === 'songs' ? [] : artists,
|
||||
albums: rt === 'artists' || rt === 'songs' ? [] : albums,
|
||||
songs: rt === 'artists' || rt === 'albums' ? [] : songs,
|
||||
songsTotal: rt === 'artists' || rt === 'albums' ? 0 : songs.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Full first-page Advanced Search against the local index. Returns `null`
|
||||
* when the index isn't ready or the local query fails — caller falls back to
|
||||
* the network path.
|
||||
*/
|
||||
export async function runLocalAdvancedSearch(
|
||||
serverId: string | null | undefined,
|
||||
opts: LocalSearchOpts,
|
||||
songsLimit: number,
|
||||
skipReadyCheck = false,
|
||||
skipTotals = true,
|
||||
suppressLog = false,
|
||||
): Promise<LocalAdvancedSearchPage | null> {
|
||||
if (!serverId) return null;
|
||||
if (!skipReadyCheck && !(await libraryIsReady(serverId))) return null;
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
const req = buildRequest(
|
||||
serverId,
|
||||
opts,
|
||||
entityTypesFor(opts.resultType),
|
||||
songsLimit,
|
||||
0,
|
||||
skipTotals,
|
||||
);
|
||||
const { result: resp, ms: invokeMs } = await timed(() => libraryAdvancedSearch(req));
|
||||
if (resp.source !== 'local') return null;
|
||||
const page = {
|
||||
artists: resp.artists.map(artistToArtist),
|
||||
albums: resp.albums.map(albumToAlbum),
|
||||
songs: resp.tracks.map(trackToSong),
|
||||
songsTotal: resp.totals.tracks,
|
||||
};
|
||||
if (!suppressLog) {
|
||||
logLibrarySearch({
|
||||
at: new Date().toISOString(),
|
||||
query: opts.query.trim(),
|
||||
path: 'library_advanced_search',
|
||||
surface: 'advanced_search',
|
||||
source: 'local',
|
||||
durationMs: Math.round(performance.now() - t0),
|
||||
invokeMs,
|
||||
counts: {
|
||||
artists: page.artists.length,
|
||||
albums: page.albums.length,
|
||||
songs: page.songs.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
return page;
|
||||
} catch (err) {
|
||||
if (!suppressLog) {
|
||||
logLibrarySearch({
|
||||
at: new Date().toISOString(),
|
||||
query: opts.query.trim(),
|
||||
path: 'library_advanced_search',
|
||||
surface: 'advanced_search',
|
||||
source: 'local',
|
||||
durationMs: Math.round(performance.now() - t0),
|
||||
error: String(err),
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse-all songs against the local index for `VirtualSongList` (F1). An empty
|
||||
* query falls through to the Rust builder's default track order
|
||||
* (`t.title COLLATE NOCASE ASC`) — the same alphabetical browse as the network
|
||||
* `ndListSongs('title','ASC')` path, so paging stays coherent even if a later
|
||||
* page falls back to the network. Returns `null` when the index isn't ready or
|
||||
* the page can't be served locally; the caller then uses the network path
|
||||
* unchanged. Gated per page so a readiness flip mid-scroll degrades gracefully.
|
||||
*/
|
||||
export async function runLocalSongBrowse(
|
||||
serverId: string | null | undefined,
|
||||
offset: number,
|
||||
pageSize: number,
|
||||
): Promise<SubsonicSong[] | null> {
|
||||
if (!serverId) return null;
|
||||
if (!(await libraryIsReady(serverId))) return null;
|
||||
try {
|
||||
const resp = await libraryAdvancedSearch({
|
||||
serverId,
|
||||
libraryScope: libraryScopeForServer(serverId),
|
||||
query: undefined,
|
||||
entityTypes: ['track'],
|
||||
limit: pageSize,
|
||||
offset,
|
||||
skipTotals: true,
|
||||
});
|
||||
if (resp.source !== 'local') return null;
|
||||
return resp.tracks.map(trackToSong);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Songs-only next page for the local path (mirrors the network
|
||||
* `searchSongsPaged` pagination). Throws are surfaced so the caller can stop
|
||||
* the infinite-scroll loop, matching the network branch's behaviour.
|
||||
*/
|
||||
export async function loadMoreLocalSongs(
|
||||
serverId: string,
|
||||
opts: LocalSearchOpts,
|
||||
offset: number,
|
||||
pageSize: number,
|
||||
): Promise<SubsonicSong[]> {
|
||||
const req = buildRequest(serverId, opts, ['track'], pageSize, offset, true);
|
||||
const resp = await libraryAdvancedSearch(req);
|
||||
return resp.tracks.map(trackToSong);
|
||||
}
|
||||
|
||||
/** Local index first; retry without the ready gate when sync is still catching up. */
|
||||
export async function tryRunLocalAdvancedSearch(
|
||||
serverId: string | null | undefined,
|
||||
opts: LocalSearchOpts,
|
||||
songsLimit: number,
|
||||
suppressLog = false,
|
||||
): Promise<LocalAdvancedSearchPage | null> {
|
||||
const readyPage = await runLocalAdvancedSearch(
|
||||
serverId,
|
||||
opts,
|
||||
songsLimit,
|
||||
false,
|
||||
true,
|
||||
suppressLog,
|
||||
);
|
||||
if (readyPage) return readyPage;
|
||||
return runLocalAdvancedSearch(serverId, opts, songsLimit, true, true, suppressLog);
|
||||
}
|
||||
|
||||
function yearOnlyAlbumBrowseQuery(opts: LocalSearchOpts): AlbumBrowseQuery | null {
|
||||
const { active, bounds } = resolveAlbumYearBounds(opts.yearFrom, opts.yearTo);
|
||||
if (!active) return null;
|
||||
return {
|
||||
sort: 'alphabeticalByName',
|
||||
genres: [],
|
||||
year: bounds,
|
||||
losslessOnly: !!opts.losslessOnly,
|
||||
starredOnly: false,
|
||||
compFilter: 'all',
|
||||
};
|
||||
}
|
||||
|
||||
/** Network fallback for year-only Advanced Search albums (open-ended year bounds). */
|
||||
export async function runNetworkAdvancedYearAlbums(
|
||||
opts: LocalSearchOpts,
|
||||
pageSize = ADVANCED_SEARCH_YEAR_ALBUM_LIMIT,
|
||||
): Promise<SubsonicAlbum[]> {
|
||||
const query = yearOnlyAlbumBrowseQuery(opts);
|
||||
if (!query) return [];
|
||||
const page = await fetchAlbumBrowseNetwork(query, 0, pageSize);
|
||||
return page.albums;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { AlbumBrowseQuery } from './albumBrowseTypes';
|
||||
|
||||
const runLocalAlbumBrowse = vi.fn();
|
||||
const fetchStarredAlbumBrowse = vi.fn();
|
||||
|
||||
vi.mock('./albumBrowseLocal', () => ({
|
||||
runLocalAlbumBrowse: (...args: unknown[]) => runLocalAlbumBrowse(...args),
|
||||
}));
|
||||
|
||||
vi.mock('./albumBrowseStarredFetch', () => ({
|
||||
fetchStarredAlbumBrowse: (...args: unknown[]) => fetchStarredAlbumBrowse(...args),
|
||||
}));
|
||||
|
||||
vi.mock('./albumBrowseNetwork', () => ({
|
||||
fetchAlbumBrowseNetwork: vi.fn(),
|
||||
}));
|
||||
|
||||
const { fetchLocalAlbumCatalogChunk } = await import('./albumBrowseLoad');
|
||||
|
||||
describe('fetchLocalAlbumCatalogChunk', () => {
|
||||
const base: AlbumBrowseQuery = {
|
||||
sort: 'alphabeticalByName',
|
||||
genres: [],
|
||||
losslessOnly: false,
|
||||
starredOnly: false,
|
||||
compFilter: 'all',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
runLocalAlbumBrowse.mockReset();
|
||||
fetchStarredAlbumBrowse.mockReset();
|
||||
runLocalAlbumBrowse.mockResolvedValue({ albums: [], hasMore: false });
|
||||
fetchStarredAlbumBrowse.mockResolvedValue({ albums: [], hasMore: false });
|
||||
});
|
||||
|
||||
it('routes starredOnly through fetchStarredAlbumBrowse, not runLocalAlbumBrowse', async () => {
|
||||
await fetchLocalAlbumCatalogChunk(
|
||||
's1',
|
||||
true,
|
||||
{ ...base, starredOnly: true },
|
||||
0,
|
||||
50,
|
||||
);
|
||||
expect(fetchStarredAlbumBrowse).toHaveBeenCalledWith('s1', true, expect.objectContaining({
|
||||
starredOnly: true,
|
||||
}), 0, 50, undefined);
|
||||
expect(runLocalAlbumBrowse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses runLocalAlbumBrowse for non-starred catalog chunks', async () => {
|
||||
await fetchLocalAlbumCatalogChunk('s1', true, { ...base, compFilter: 'only' }, 0, 50);
|
||||
expect(runLocalAlbumBrowse).toHaveBeenCalledWith(
|
||||
's1',
|
||||
expect.objectContaining({ compFilter: 'only' }),
|
||||
0,
|
||||
50,
|
||||
);
|
||||
expect(fetchStarredAlbumBrowse).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { SubsonicAlbum } from '@/lib/api/subsonicTypes';
|
||||
import type { LibraryFilterClause } from '@/lib/api/library';
|
||||
import { albumIsCompilation, type AlbumCompFilter } from './albumCompilation';
|
||||
import { albumYearFilterClauses, type AlbumYearBounds } from './albumYearFilter';
|
||||
import type { AlbumBrowseQuery, GenreFilterOption } from './albumBrowseTypes';
|
||||
import { genreTagsFor } from './genreTags';
|
||||
|
||||
export function albumBrowseHasGenreFilter(query: AlbumBrowseQuery): boolean {
|
||||
return query.genres.length > 0;
|
||||
}
|
||||
|
||||
export function albumBrowseHasServerFilters(query: AlbumBrowseQuery): boolean {
|
||||
return (
|
||||
albumBrowseHasGenreFilter(query)
|
||||
|| query.year != null
|
||||
|| query.losslessOnly
|
||||
|| query.starredOnly
|
||||
);
|
||||
}
|
||||
|
||||
/** Favorites need the local index when combined with lossless or genre (AND). */
|
||||
export function albumBrowseStarredNeedsLocalIntersect(
|
||||
query: AlbumBrowseQuery,
|
||||
indexEnabled: boolean,
|
||||
serverId: string | null | undefined,
|
||||
): boolean {
|
||||
return !!(
|
||||
query.starredOnly
|
||||
&& indexEnabled
|
||||
&& serverId
|
||||
&& (query.losslessOnly || query.genres.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
export function compilationFilterClauses(compFilter: AlbumCompFilter): LibraryFilterClause[] {
|
||||
if (compFilter === 'only') return [{ field: 'compilation', op: 'is_true' }];
|
||||
if (compFilter === 'hide') return [{ field: 'compilation', op: 'eq', value: false }];
|
||||
return [];
|
||||
}
|
||||
|
||||
export function sharedServerFilters(
|
||||
query: AlbumBrowseQuery,
|
||||
useServerStarredIds: boolean,
|
||||
): LibraryFilterClause[] {
|
||||
const filters: LibraryFilterClause[] = [];
|
||||
if (query.year) filters.push(...albumYearFilterClauses(query.year));
|
||||
if (query.losslessOnly) filters.push({ field: 'lossless', op: 'is_true' });
|
||||
filters.push(...compilationFilterClauses(query.compFilter));
|
||||
if (query.starredOnly && !useServerStarredIds) {
|
||||
filters.push({ field: 'starred', op: 'is_true' });
|
||||
}
|
||||
return filters;
|
||||
}
|
||||
|
||||
export function filterAlbumsByStarred(
|
||||
albums: SubsonicAlbum[],
|
||||
starredOverrides: Record<string, boolean>,
|
||||
): SubsonicAlbum[] {
|
||||
return albums.filter(a => {
|
||||
if (a.id in starredOverrides) return starredOverrides[a.id];
|
||||
return !!a.starred;
|
||||
});
|
||||
}
|
||||
|
||||
/** Slice favorites grid: fetch is authoritative; only apply optimistic star/unstar overrides. */
|
||||
export function applyStarredOverridesInSlice(
|
||||
albums: SubsonicAlbum[],
|
||||
starredOverrides: Record<string, boolean>,
|
||||
): SubsonicAlbum[] {
|
||||
return albums.filter(a => {
|
||||
if (a.id in starredOverrides) return starredOverrides[a.id];
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function filterAlbumsByYearBounds(
|
||||
albums: SubsonicAlbum[],
|
||||
bounds: AlbumYearBounds,
|
||||
): SubsonicAlbum[] {
|
||||
return albums.filter(a => {
|
||||
if (a.year == null) return false;
|
||||
if (bounds.from != null && a.year < bounds.from) return false;
|
||||
if (bounds.to != null && a.year > bounds.to) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function filterAlbumsByCompilation(
|
||||
albums: SubsonicAlbum[],
|
||||
compFilter: AlbumCompFilter,
|
||||
): SubsonicAlbum[] {
|
||||
if (compFilter === 'only') return albums.filter(albumIsCompilation);
|
||||
if (compFilter === 'hide') return albums.filter(a => !albumIsCompilation(a));
|
||||
return albums;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client post-filters for the All Albums grid. Slice mode (local index or offline
|
||||
* catalog) already applied compilation / favorites in SQL or `applyAlbumBrowseQuery`.
|
||||
*/
|
||||
export function applyAlbumBrowseClientFilters(
|
||||
albums: SubsonicAlbum[],
|
||||
query: Pick<AlbumBrowseQuery, 'compFilter' | 'starredOnly'>,
|
||||
starredOverrides: Record<string, boolean>,
|
||||
browseMode: 'slice' | 'page',
|
||||
): SubsonicAlbum[] {
|
||||
const fetchAlreadyFiltered = browseMode === 'slice';
|
||||
let out = albums;
|
||||
if (query.compFilter !== 'all' && !fetchAlreadyFiltered) {
|
||||
out = filterAlbumsByCompilation(out, query.compFilter);
|
||||
}
|
||||
if (query.starredOnly && fetchAlreadyFiltered) {
|
||||
out = applyStarredOverridesInSlice(out, starredOverrides);
|
||||
} else if (query.starredOnly) {
|
||||
out = filterAlbumsByStarred(out, starredOverrides);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function filterAlbumsByGenres(
|
||||
albums: SubsonicAlbum[],
|
||||
genres: string[],
|
||||
): SubsonicAlbum[] {
|
||||
if (genres.length === 0) return albums;
|
||||
const wanted = new Set(genres.map(g => g.toLowerCase()));
|
||||
return albums.filter(a => {
|
||||
const tags = genreTagsFor(a);
|
||||
return tags.some(tag => wanted.has(tag.toLowerCase()));
|
||||
});
|
||||
}
|
||||
|
||||
/** Scoped All Albums text search — album title/name only (not performer). */
|
||||
export function filterAlbumsByNameTextQuery(
|
||||
albums: SubsonicAlbum[],
|
||||
query: string,
|
||||
): SubsonicAlbum[] {
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (!needle) return albums;
|
||||
return albums.filter(a => a.name.toLowerCase().includes(needle));
|
||||
}
|
||||
|
||||
export function countGenresFromAlbums(albums: SubsonicAlbum[]): GenreFilterOption[] {
|
||||
const counts = new Map<string, number>();
|
||||
for (const a of albums) {
|
||||
for (const g of genreTagsFor(a)) {
|
||||
counts.set(g, (counts.get(g) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.map(([genre, count]) => ({ genre, count }))
|
||||
.sort((a, b) => b.count - a.count || a.genre.localeCompare(b.genre));
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { AlbumBrowseQuery } from './albumBrowseTypes';
|
||||
|
||||
const libraryGetGenreAlbumCounts = vi.fn();
|
||||
const libraryIsReady = vi.fn();
|
||||
const libraryScopeForServer = vi.fn();
|
||||
const runLocalAlbumBrowse = vi.fn();
|
||||
|
||||
vi.mock('@/lib/api/library', () => ({
|
||||
libraryGetGenreAlbumCounts: (...args: unknown[]) => libraryGetGenreAlbumCounts(...args),
|
||||
}));
|
||||
|
||||
vi.mock('./libraryReady', () => ({
|
||||
libraryIsReady: (...args: unknown[]) => libraryIsReady(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api/subsonicClient', () => ({
|
||||
libraryScopeForServer: (...args: unknown[]) => libraryScopeForServer(...args),
|
||||
}));
|
||||
|
||||
vi.mock('./albumBrowseLocal', () => ({
|
||||
runLocalAlbumBrowse: (...args: unknown[]) => runLocalAlbumBrowse(...args),
|
||||
}));
|
||||
|
||||
import { fetchAlbumBrowseGenreOptions } from './albumBrowseLoad';
|
||||
|
||||
const baseQuery: AlbumBrowseQuery = {
|
||||
sort: 'alphabeticalByName',
|
||||
genres: [],
|
||||
losslessOnly: false,
|
||||
starredOnly: false,
|
||||
compFilter: 'all',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
libraryIsReady.mockResolvedValue(true);
|
||||
libraryScopeForServer.mockReturnValue('lib-a');
|
||||
});
|
||||
|
||||
describe('fetchAlbumBrowseGenreOptions', () => {
|
||||
it('uses scoped local genre counts when only the sidebar library is narrowed', async () => {
|
||||
libraryGetGenreAlbumCounts.mockResolvedValue([
|
||||
{ value: 'Rock', albumCount: 12, songCount: 40 },
|
||||
{ value: 'Jazz', albumCount: 3, songCount: 9 },
|
||||
]);
|
||||
|
||||
await expect(fetchAlbumBrowseGenreOptions('srv-1', true, baseQuery)).resolves.toEqual([
|
||||
{ genre: 'Rock', count: 12 },
|
||||
{ genre: 'Jazz', count: 3 },
|
||||
]);
|
||||
|
||||
expect(libraryGetGenreAlbumCounts).toHaveBeenCalledWith({
|
||||
serverId: 'srv-1',
|
||||
libraryScope: 'lib-a',
|
||||
});
|
||||
expect(runLocalAlbumBrowse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('derives genres from filtered albums when combined filters are active', async () => {
|
||||
runLocalAlbumBrowse.mockResolvedValue({
|
||||
albums: [
|
||||
{ id: '1', name: 'A', artist: 'X', artistId: 'x', songCount: 1, duration: 1, genre: 'Rock' },
|
||||
{ id: '2', name: 'B', artist: 'Y', artistId: 'y', songCount: 1, duration: 1, genre: 'Jazz' },
|
||||
],
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
await expect(
|
||||
fetchAlbumBrowseGenreOptions('srv-1', true, { ...baseQuery, year: { from: 1990 } }),
|
||||
).resolves.toEqual([
|
||||
{ genre: 'Jazz', count: 1 },
|
||||
{ genre: 'Rock', count: 1 },
|
||||
]);
|
||||
|
||||
expect(libraryGetGenreAlbumCounts).not.toHaveBeenCalled();
|
||||
expect(runLocalAlbumBrowse).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { SubsonicAlbum } from '@/lib/api/subsonicTypes';
|
||||
import {
|
||||
albumBrowseHasGenreFilter,
|
||||
albumBrowseHasServerFilters,
|
||||
albumBrowseStarredNeedsLocalIntersect,
|
||||
applyAlbumBrowseClientFilters,
|
||||
compilationFilterClauses,
|
||||
countGenresFromAlbums,
|
||||
filterAlbumsByNameTextQuery,
|
||||
filterAlbumsByStarred,
|
||||
filterAlbumsByYearBounds,
|
||||
} from './albumBrowseFilters';
|
||||
import type { AlbumBrowseQuery } from './albumBrowseTypes';
|
||||
|
||||
describe('albumBrowseLoad', () => {
|
||||
const base: AlbumBrowseQuery = {
|
||||
sort: 'alphabeticalByName',
|
||||
genres: [],
|
||||
losslessOnly: false,
|
||||
starredOnly: false,
|
||||
compFilter: 'all',
|
||||
};
|
||||
|
||||
it('detects combined server filters', () => {
|
||||
expect(albumBrowseHasServerFilters(base)).toBe(false);
|
||||
expect(albumBrowseHasServerFilters({ ...base, genres: ['Rock'] })).toBe(true);
|
||||
expect(albumBrowseHasServerFilters({ ...base, year: { from: 1990 } })).toBe(true);
|
||||
expect(albumBrowseHasServerFilters({ ...base, losslessOnly: true })).toBe(true);
|
||||
expect(albumBrowseHasServerFilters({ ...base, starredOnly: true })).toBe(true);
|
||||
expect(
|
||||
albumBrowseHasServerFilters({
|
||||
...base,
|
||||
genres: ['Jazz'],
|
||||
year: { to: 2000 },
|
||||
losslessOnly: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('genre filter disables pagination path', () => {
|
||||
expect(albumBrowseHasGenreFilter({ ...base, genres: ['Rock'] })).toBe(true);
|
||||
});
|
||||
|
||||
it('starred + lossless uses local intersect when index is on', () => {
|
||||
expect(albumBrowseStarredNeedsLocalIntersect({ ...base, starredOnly: true }, true, 's1')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
albumBrowseStarredNeedsLocalIntersect(
|
||||
{ ...base, starredOnly: true, losslessOnly: true },
|
||||
true,
|
||||
's1',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
albumBrowseStarredNeedsLocalIntersect(
|
||||
{ ...base, starredOnly: true, genres: ['Rock'] },
|
||||
true,
|
||||
's1',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
albumBrowseStarredNeedsLocalIntersect(
|
||||
{ ...base, starredOnly: true, losslessOnly: true },
|
||||
false,
|
||||
's1',
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterAlbumsByStarred', () => {
|
||||
const album: SubsonicAlbum = {
|
||||
id: 'a1',
|
||||
name: 'A',
|
||||
artist: 'X',
|
||||
artistId: 'x',
|
||||
songCount: 1,
|
||||
duration: 1,
|
||||
};
|
||||
|
||||
it('requires starred flag or a positive override', () => {
|
||||
expect(filterAlbumsByStarred([album], {})).toHaveLength(0);
|
||||
expect(filterAlbumsByStarred([{ ...album, starred: '2020-01-01' }], {})).toHaveLength(1);
|
||||
expect(filterAlbumsByStarred([album], { a1: true })).toHaveLength(1);
|
||||
expect(filterAlbumsByStarred([{ ...album, starred: '2020-01-01' }], { a1: false })).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compilationFilterClauses', () => {
|
||||
it('maps only/hide to local index filters', () => {
|
||||
expect(compilationFilterClauses('only')).toEqual([{ field: 'compilation', op: 'is_true' }]);
|
||||
expect(compilationFilterClauses('hide')).toEqual([{ field: 'compilation', op: 'eq', value: false }]);
|
||||
expect(compilationFilterClauses('all')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyAlbumBrowseClientFilters', () => {
|
||||
const base: AlbumBrowseQuery = {
|
||||
sort: 'alphabeticalByName',
|
||||
genres: [],
|
||||
losslessOnly: false,
|
||||
starredOnly: false,
|
||||
compFilter: 'all',
|
||||
};
|
||||
const sqlMatchedComp: SubsonicAlbum = {
|
||||
id: 'c1',
|
||||
name: 'Greatest Hits',
|
||||
artist: 'Various Artists',
|
||||
artistId: 'va',
|
||||
songCount: 12,
|
||||
duration: 3600,
|
||||
};
|
||||
const studio: SubsonicAlbum = {
|
||||
id: 'r1',
|
||||
name: 'Studio',
|
||||
artist: 'Band',
|
||||
artistId: 'b',
|
||||
songCount: 8,
|
||||
duration: 2400,
|
||||
};
|
||||
|
||||
it('does not re-filter compilations in slice mode after SQL pre-filter', () => {
|
||||
const query = { ...base, compFilter: 'only' as const };
|
||||
expect(applyAlbumBrowseClientFilters([sqlMatchedComp, studio], query, {}, 'slice')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('filters compilations client-side in page (network) mode', () => {
|
||||
const query = { ...base, compFilter: 'only' as const };
|
||||
expect(applyAlbumBrowseClientFilters(
|
||||
[{ ...sqlMatchedComp, isCompilation: true }, studio],
|
||||
query,
|
||||
{},
|
||||
'page',
|
||||
)).toEqual([{ ...sqlMatchedComp, isCompilation: true }]);
|
||||
});
|
||||
|
||||
it('does not re-filter favorites in slice mode', () => {
|
||||
const query = { ...base, starredOnly: true };
|
||||
const starred = { ...studio, starred: '2024-01-01' };
|
||||
expect(applyAlbumBrowseClientFilters([starred], query, {}, 'slice')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('applies optimistic unstar overrides in slice favorites mode', () => {
|
||||
const query = { ...base, starredOnly: true };
|
||||
const starred = { ...studio, starred: '2024-01-01' };
|
||||
expect(applyAlbumBrowseClientFilters([starred], query, { r1: false }, 'slice')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('countGenresFromAlbums', () => {
|
||||
const album = (id: string, genre?: string): SubsonicAlbum => ({
|
||||
id,
|
||||
name: 'A',
|
||||
artist: 'X',
|
||||
artistId: 'a',
|
||||
songCount: 1,
|
||||
duration: 1,
|
||||
genre,
|
||||
});
|
||||
|
||||
it('returns genres sorted by album count descending', () => {
|
||||
expect(countGenresFromAlbums([
|
||||
album('1', 'Rock'),
|
||||
album('2', 'Jazz'),
|
||||
album('3', 'Rock'),
|
||||
album('4'),
|
||||
])).toEqual([
|
||||
{ genre: 'Rock', count: 2 },
|
||||
{ genre: 'Jazz', count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('counts atomic genres from compound genre strings', () => {
|
||||
expect(countGenresFromAlbums([
|
||||
album('1', 'Rock/Jazz'),
|
||||
album('2', 'Rock'),
|
||||
])).toEqual([
|
||||
{ genre: 'Rock', count: 2 },
|
||||
{ genre: 'Jazz', count: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterAlbumsByNameTextQuery', () => {
|
||||
const albums: SubsonicAlbum[] = [
|
||||
{ id: '1', name: 'Abbey Road', artist: 'The Beatles', artistId: 'a', songCount: 1, duration: 1 },
|
||||
{ id: '2', name: 'Beatles for Sale', artist: 'The Beatles', artistId: 'a', songCount: 1, duration: 1 },
|
||||
{ id: '3', name: 'Random Title', artist: 'Abbey Road Band', artistId: 'b', songCount: 1, duration: 1 },
|
||||
];
|
||||
|
||||
it('matches album title only, not artist name', () => {
|
||||
expect(filterAlbumsByNameTextQuery(albums, 'abbey').map(a => a.id)).toEqual(['1']);
|
||||
expect(filterAlbumsByNameTextQuery(albums, 'beatles').map(a => a.id)).toEqual(['2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterAlbumsByYearBounds', () => {
|
||||
const albums: SubsonicAlbum[] = [
|
||||
{ id: '1', name: 'A', artist: 'X', artistId: 'a', songCount: 1, duration: 1, year: 1985 },
|
||||
{ id: '2', name: 'B', artist: 'Y', artistId: 'b', songCount: 1, duration: 1, year: 1995 },
|
||||
{ id: '3', name: 'C', artist: 'Z', artistId: 'c', songCount: 1, duration: 1, year: 2005 },
|
||||
];
|
||||
|
||||
it('filters with only from bound', () => {
|
||||
expect(filterAlbumsByYearBounds(albums, { from: 1990 }).map(a => a.id)).toEqual(['2', '3']);
|
||||
});
|
||||
|
||||
it('filters with only to bound', () => {
|
||||
expect(filterAlbumsByYearBounds(albums, { to: 1995 }).map(a => a.id)).toEqual(['1', '2']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Albums browse: local index + Subsonic network paths.
|
||||
* Filters and types live in sibling modules; this file is the fetch entry point.
|
||||
*/
|
||||
export type { AlbumCompFilter } from './albumCompilation';
|
||||
export type {
|
||||
AlbumBrowseFetchCallbacks,
|
||||
AlbumBrowsePageResult,
|
||||
AlbumBrowseQuery,
|
||||
GenreFilterOption,
|
||||
} from './albumBrowseTypes';
|
||||
export {
|
||||
albumBrowseHasGenreFilter,
|
||||
albumBrowseHasServerFilters,
|
||||
applyAlbumBrowseClientFilters,
|
||||
filterAlbumsByCompilation,
|
||||
filterAlbumsByStarred,
|
||||
} from './albumBrowseFilters';
|
||||
export { runLocalAlbumBrowse } from './albumBrowseLocal';
|
||||
|
||||
import { albumBrowseHasServerFilters, countGenresFromAlbums, filterAlbumsByCompilation } from './albumBrowseFilters';
|
||||
import { runLocalAlbumBrowse } from './albumBrowseLocal';
|
||||
import { fetchAlbumBrowseNetwork } from './albumBrowseNetwork';
|
||||
import { fetchStarredAlbumBrowse } from './albumBrowseStarredFetch';
|
||||
import { libraryGetGenreAlbumCounts } from '@/lib/api/library';
|
||||
import { libraryScopeForServer } from '@/lib/api/subsonicClient';
|
||||
import { libraryIsReady } from './libraryReady';
|
||||
import type {
|
||||
AlbumBrowseFetchCallbacks,
|
||||
AlbumBrowsePageResult,
|
||||
AlbumBrowseQuery,
|
||||
GenreFilterOption,
|
||||
} from './albumBrowseTypes';
|
||||
import { GENRE_ALBUM_FETCH_LIMIT } from './albumBrowseTypes';
|
||||
|
||||
/** One local-index chunk for lazy catalog loading (All Albums slice mode). */
|
||||
export async function fetchLocalAlbumCatalogChunk(
|
||||
serverId: string,
|
||||
indexEnabled: boolean,
|
||||
query: AlbumBrowseQuery,
|
||||
offset: number,
|
||||
chunkSize: number,
|
||||
): Promise<AlbumBrowsePageResult | null> {
|
||||
if (query.starredOnly) {
|
||||
return fetchAlbumBrowsePage(serverId, indexEnabled, query, offset, chunkSize);
|
||||
}
|
||||
const singleGenre = query.genres.length === 1;
|
||||
if (query.genres.length > 1 && offset > 0) {
|
||||
return { albums: [], hasMore: false };
|
||||
}
|
||||
const limit = singleGenre
|
||||
? chunkSize
|
||||
: query.genres.length > 0 && offset === 0
|
||||
? GENRE_ALBUM_FETCH_LIMIT
|
||||
: chunkSize;
|
||||
return runLocalAlbumBrowse(serverId, query, offset, limit);
|
||||
}
|
||||
|
||||
/** Genres in albums matching all filters except genre (for combined-filter UI). */
|
||||
export async function fetchAlbumBrowseGenreOptions(
|
||||
serverId: string,
|
||||
indexEnabled: boolean,
|
||||
query: AlbumBrowseQuery,
|
||||
): Promise<GenreFilterOption[]> {
|
||||
const withoutGenre: AlbumBrowseQuery = { ...query, genres: [] };
|
||||
const scope = libraryScopeForServer(serverId);
|
||||
const hasCombinedFilters =
|
||||
albumBrowseHasServerFilters(withoutGenre) || query.compFilter !== 'all';
|
||||
|
||||
// Sidebar library scope only: use the full scoped genre catalog from the local
|
||||
// index instead of getGenres() (server-wide) or a 500-album sample.
|
||||
if (indexEnabled && serverId && scope && !hasCombinedFilters && (await libraryIsReady(serverId))) {
|
||||
try {
|
||||
const rows = await libraryGetGenreAlbumCounts({
|
||||
serverId,
|
||||
libraryScope: scope,
|
||||
});
|
||||
return rows.map(row => ({ genre: row.value, count: row.albumCount }));
|
||||
} catch {
|
||||
/* fall through to album-derived options */
|
||||
}
|
||||
}
|
||||
|
||||
const page = await fetchAlbumBrowsePage(
|
||||
serverId,
|
||||
indexEnabled,
|
||||
withoutGenre,
|
||||
0,
|
||||
GENRE_ALBUM_FETCH_LIMIT,
|
||||
);
|
||||
return countGenresFromAlbums(filterAlbumsByCompilation(page.albums, query.compFilter));
|
||||
}
|
||||
|
||||
export async function fetchAlbumBrowsePage(
|
||||
serverId: string,
|
||||
indexEnabled: boolean,
|
||||
query: AlbumBrowseQuery,
|
||||
offset: number,
|
||||
pageSize: number,
|
||||
callbacks?: AlbumBrowseFetchCallbacks,
|
||||
): Promise<AlbumBrowsePageResult> {
|
||||
if (query.losslessOnly && (!indexEnabled || !serverId)) {
|
||||
return { albums: [], hasMore: false };
|
||||
}
|
||||
|
||||
if (query.starredOnly) {
|
||||
return fetchStarredAlbumBrowse(serverId, indexEnabled, query, offset, pageSize, callbacks);
|
||||
}
|
||||
|
||||
if (indexEnabled && serverId) {
|
||||
const local = await runLocalAlbumBrowse(serverId, query, offset, pageSize);
|
||||
if (local != null) return local;
|
||||
}
|
||||
|
||||
return fetchAlbumBrowseNetwork(query, offset, pageSize);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { libraryAdvancedSearch, libraryListAlbumsByGenre } from '@/lib/api/library';
|
||||
import type { SubsonicAlbum } from '@/lib/api/subsonicTypes';
|
||||
import { libraryScopeForServer } from '@/lib/api/subsonicClient';
|
||||
import { dedupeById } from '@/lib/util/dedupeById';
|
||||
import { albumToAlbum } from './advancedSearchLocal';
|
||||
import { sharedServerFilters } from './albumBrowseFilters';
|
||||
import { albumSortClauses, sortSubsonicAlbums } from './albumBrowseSort';
|
||||
import { libraryIsReady } from './libraryReady';
|
||||
import type { AlbumBrowsePageResult, AlbumBrowseQuery } from './albumBrowseTypes';
|
||||
import { GENRE_ALBUM_FETCH_LIMIT } from './albumBrowseTypes';
|
||||
|
||||
function markServerStarredAlbums(albums: SubsonicAlbum[]) {
|
||||
return albums.map(a => ({ ...a, starred: a.starred ?? 'true' }));
|
||||
}
|
||||
|
||||
/** Local index: combined genre + year + lossless filters (AND), genres OR union. */
|
||||
export async function runLocalAlbumBrowse(
|
||||
serverId: string,
|
||||
query: AlbumBrowseQuery,
|
||||
offset: number,
|
||||
pageSize: number,
|
||||
restrictAlbumIds?: string[],
|
||||
): Promise<AlbumBrowsePageResult | null> {
|
||||
if (!serverId || !(await libraryIsReady(serverId))) return null;
|
||||
|
||||
const scope = libraryScopeForServer(serverId) ?? undefined;
|
||||
const useServerStarredIds = restrictAlbumIds != null;
|
||||
const shared = sharedServerFilters(query, useServerStarredIds);
|
||||
const starredOnly = useServerStarredIds ? undefined : (query.starredOnly || undefined);
|
||||
|
||||
if (query.genres.length > 0) {
|
||||
if (query.genres.length === 1) {
|
||||
// Genre-only fast path; combined filters (year / lossless / compilation) need advanced search.
|
||||
if (shared.length === 0) {
|
||||
try {
|
||||
const resp = await libraryListAlbumsByGenre({
|
||||
serverId,
|
||||
genre: query.genres[0],
|
||||
libraryScope: scope,
|
||||
sort: albumSortClauses(query.sort),
|
||||
limit: pageSize,
|
||||
offset,
|
||||
});
|
||||
if (resp.source !== 'local') return null;
|
||||
let albums = resp.albums.map(albumToAlbum);
|
||||
if (useServerStarredIds) albums = markServerStarredAlbums(albums);
|
||||
return { albums, hasMore: resp.hasMore };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const resp = await libraryAdvancedSearch({
|
||||
serverId,
|
||||
libraryScope: scope,
|
||||
entityTypes: ['album'],
|
||||
filters: [{ field: 'genre', op: 'eq', value: query.genres[0] }, ...shared],
|
||||
starredOnly,
|
||||
restrictAlbumIds: useServerStarredIds ? restrictAlbumIds : undefined,
|
||||
sort: albumSortClauses(query.sort),
|
||||
limit: pageSize,
|
||||
offset,
|
||||
skipTotals: true,
|
||||
});
|
||||
if (resp.source !== 'local') return null;
|
||||
let albums = resp.albums.map(albumToAlbum);
|
||||
if (useServerStarredIds) albums = markServerStarredAlbums(albums);
|
||||
return { albums, hasMore: albums.length === pageSize };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (offset > 0) return { albums: [], hasMore: false };
|
||||
try {
|
||||
const pages = await Promise.all(
|
||||
query.genres.map(genre =>
|
||||
libraryAdvancedSearch({
|
||||
serverId,
|
||||
libraryScope: scope,
|
||||
entityTypes: ['album'],
|
||||
filters: [{ field: 'genre', op: 'eq', value: genre }, ...shared],
|
||||
starredOnly,
|
||||
restrictAlbumIds: useServerStarredIds ? restrictAlbumIds : undefined,
|
||||
sort: albumSortClauses(query.sort),
|
||||
limit: GENRE_ALBUM_FETCH_LIMIT,
|
||||
offset: 0,
|
||||
skipTotals: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
if (pages.some(p => p.source !== 'local')) return null;
|
||||
let merged = dedupeById(pages.flatMap(p => p.albums.map(albumToAlbum)));
|
||||
if (useServerStarredIds) merged = markServerStarredAlbums(merged);
|
||||
return {
|
||||
albums: sortSubsonicAlbums(merged, query.sort),
|
||||
hasMore: false,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await libraryAdvancedSearch({
|
||||
serverId,
|
||||
libraryScope: scope,
|
||||
entityTypes: ['album'],
|
||||
filters: shared,
|
||||
starredOnly,
|
||||
restrictAlbumIds: useServerStarredIds ? restrictAlbumIds : undefined,
|
||||
sort: albumSortClauses(query.sort),
|
||||
limit: pageSize,
|
||||
offset,
|
||||
skipTotals: true,
|
||||
});
|
||||
if (resp.source !== 'local') return null;
|
||||
let albums = resp.albums.map(albumToAlbum);
|
||||
if (useServerStarredIds) albums = markServerStarredAlbums(albums);
|
||||
return { albums, hasMore: albums.length === pageSize };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { getAlbumList } from '@/lib/api/subsonicLibrary';
|
||||
import { getAlbumsByGenre } from '@/lib/api/subsonicGenres';
|
||||
import type { SubsonicAlbum } from '@/lib/api/subsonicTypes';
|
||||
import { dedupeById } from '@/lib/util/dedupeById';
|
||||
import {
|
||||
filterAlbumsByCompilation,
|
||||
filterAlbumsByYearBounds,
|
||||
} from './albumBrowseFilters';
|
||||
import { albumYearSubsonicParams } from './albumYearFilter';
|
||||
import { albumListFetchType, sortSubsonicAlbums } from './albumBrowseSort';
|
||||
import type { AlbumBrowsePageResult, AlbumBrowseQuery } from './albumBrowseTypes';
|
||||
import { GENRE_ALBUM_FETCH_LIMIT } from './albumBrowseTypes';
|
||||
|
||||
async function fetchByGenres(genres: string[]) {
|
||||
const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, GENRE_ALBUM_FETCH_LIMIT, 0)));
|
||||
return dedupeById(results.flat());
|
||||
}
|
||||
|
||||
function applyNetworkPostFilters(albums: SubsonicAlbum[], query: AlbumBrowseQuery) {
|
||||
let out = albums;
|
||||
if (query.year) out = filterAlbumsByYearBounds(out, query.year);
|
||||
out = filterAlbumsByCompilation(out, query.compFilter);
|
||||
if (query.starredOnly) out = out.filter(a => !!a.starred);
|
||||
return sortSubsonicAlbums(out, query.sort);
|
||||
}
|
||||
|
||||
export async function fetchAlbumBrowseNetwork(
|
||||
query: AlbumBrowseQuery,
|
||||
offset: number,
|
||||
pageSize: number,
|
||||
): Promise<AlbumBrowsePageResult> {
|
||||
if (query.genres.length > 0) {
|
||||
if (query.genres.length === 1) {
|
||||
const data = applyNetworkPostFilters(
|
||||
await getAlbumsByGenre(query.genres[0], pageSize, offset),
|
||||
query,
|
||||
);
|
||||
return { albums: data, hasMore: data.length === pageSize };
|
||||
}
|
||||
if (offset > 0) return { albums: [], hasMore: false };
|
||||
const data = applyNetworkPostFilters(await fetchByGenres(query.genres), query);
|
||||
return { albums: data, hasMore: false };
|
||||
}
|
||||
|
||||
if (query.starredOnly) {
|
||||
const extra = query.year ? albumYearSubsonicParams(query.year) : {};
|
||||
const data = applyNetworkPostFilters(
|
||||
await getAlbumList('starred', pageSize, offset, extra),
|
||||
query,
|
||||
);
|
||||
return { albums: data, hasMore: data.length === pageSize };
|
||||
}
|
||||
|
||||
if (query.year) {
|
||||
const data = applyNetworkPostFilters(
|
||||
await getAlbumList(
|
||||
'byYear',
|
||||
pageSize,
|
||||
offset,
|
||||
albumYearSubsonicParams(query.year),
|
||||
),
|
||||
query,
|
||||
);
|
||||
return { albums: data, hasMore: data.length === pageSize };
|
||||
}
|
||||
|
||||
const data = applyNetworkPostFilters(
|
||||
await getAlbumList(albumListFetchType(query.sort), pageSize, offset, {}),
|
||||
query,
|
||||
);
|
||||
return { albums: data, hasMore: data.length === pageSize };
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { SubsonicAlbum } from '@/lib/api/subsonicTypes';
|
||||
import { albumSortClauses, sortSubsonicAlbums } from './albumBrowseSort';
|
||||
|
||||
const album = (artist: string, name: string, year?: number): SubsonicAlbum =>
|
||||
({ id: `${artist}-${name}`, artist, name, year }) as SubsonicAlbum;
|
||||
|
||||
describe('albumSortClauses', () => {
|
||||
it('sorts by artist then album name', () => {
|
||||
expect(albumSortClauses('alphabeticalByArtist')).toEqual([
|
||||
{ field: 'artist', dir: 'asc' },
|
||||
{ field: 'name', dir: 'asc' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('sorts by album name then artist', () => {
|
||||
expect(albumSortClauses('alphabeticalByName')).toEqual([
|
||||
{ field: 'name', dir: 'asc' },
|
||||
{ field: 'artist', dir: 'asc' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('sorts by artist, then year, then album name', () => {
|
||||
expect(albumSortClauses('byArtistThenYear')).toEqual([
|
||||
{ field: 'artist', dir: 'asc' },
|
||||
{ field: 'year', dir: 'asc' },
|
||||
{ field: 'name', dir: 'asc' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortSubsonicAlbums', () => {
|
||||
it('orders each artist group by album name when sorting by artist', () => {
|
||||
const input = [
|
||||
album('Artist B', 'Solitude'),
|
||||
album('Artist A', 'Mirage'),
|
||||
album('Artist B', 'Cascade'),
|
||||
album('Artist A', 'Ember'),
|
||||
album('Artist A', 'Vertex'),
|
||||
];
|
||||
const ordered = sortSubsonicAlbums(input, 'alphabeticalByArtist').map(a => `${a.artist} - ${a.name}`);
|
||||
expect(ordered).toEqual([
|
||||
'Artist A - Ember',
|
||||
'Artist A - Mirage',
|
||||
'Artist A - Vertex',
|
||||
'Artist B - Cascade',
|
||||
'Artist B - Solitude',
|
||||
]);
|
||||
});
|
||||
|
||||
it('breaks album-name ties by artist when sorting by name', () => {
|
||||
const input = [
|
||||
album('Artist Z', 'Greatest Hits'),
|
||||
album('Artist A', 'Greatest Hits'),
|
||||
];
|
||||
const ordered = sortSubsonicAlbums(input, 'alphabeticalByName').map(a => a.artist);
|
||||
expect(ordered).toEqual(['Artist A', 'Artist Z']);
|
||||
});
|
||||
|
||||
it('orders each artist chronologically (then by title) when sorting by artist+year', () => {
|
||||
const input = [
|
||||
album('Artist A', 'Mirage', 1982),
|
||||
album('Artist B', 'Nocturne', 1997),
|
||||
album('Artist A', 'Debut', 1981),
|
||||
album('Artist B', 'Aftermath', 2001),
|
||||
album('Artist A', 'Reprise', 1982), // same year as Mirage → title tiebreak
|
||||
];
|
||||
const ordered = sortSubsonicAlbums(input, 'byArtistThenYear').map(a => `${a.artist} - ${a.name}`);
|
||||
expect(ordered).toEqual([
|
||||
'Artist A - Debut',
|
||||
'Artist A - Mirage',
|
||||
'Artist A - Reprise',
|
||||
'Artist B - Nocturne',
|
||||
'Artist B - Aftermath',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { SubsonicAlbum } from '@/lib/api/subsonicTypes';
|
||||
import type { LibrarySortClause } from '@/lib/api/library';
|
||||
|
||||
export type AlbumBrowseSort = 'alphabeticalByName' | 'alphabeticalByArtist' | 'byArtistThenYear';
|
||||
|
||||
export function albumSortClauses(sort: AlbumBrowseSort): LibrarySortClause[] {
|
||||
// Always append secondary keys so albums sharing the primary key keep a stable
|
||||
// order (mirrors `sortSubsonicAlbums`).
|
||||
if (sort === 'byArtistThenYear') {
|
||||
// Artist, then chronological (oldest first), then title as a same-year tiebreak.
|
||||
return [
|
||||
{ field: 'artist', dir: 'asc' },
|
||||
{ field: 'year', dir: 'asc' },
|
||||
{ field: 'name', dir: 'asc' },
|
||||
];
|
||||
}
|
||||
if (sort === 'alphabeticalByArtist') {
|
||||
return [
|
||||
{ field: 'artist', dir: 'asc' },
|
||||
{ field: 'name', dir: 'asc' },
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ field: 'name', dir: 'asc' },
|
||||
{ field: 'artist', dir: 'asc' },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Subsonic `getAlbumList` type to fetch with for a browse sort (server fallback
|
||||
* path only — the local index handles sorting itself). `byArtistThenYear` has
|
||||
* no server equivalent, so fetch by artist and let `sortSubsonicAlbums` apply
|
||||
* the per-page year ordering on top.
|
||||
*/
|
||||
export function albumListFetchType(
|
||||
sort: AlbumBrowseSort,
|
||||
): 'alphabeticalByName' | 'alphabeticalByArtist' {
|
||||
return sort === 'alphabeticalByName' ? 'alphabeticalByName' : 'alphabeticalByArtist';
|
||||
}
|
||||
|
||||
export function sortSubsonicAlbums(albums: SubsonicAlbum[], sort: AlbumBrowseSort): SubsonicAlbum[] {
|
||||
const out = [...albums];
|
||||
out.sort((a, b) => {
|
||||
if (sort === 'byArtistThenYear') {
|
||||
return (
|
||||
a.artist.localeCompare(b.artist) ||
|
||||
(a.year ?? 0) - (b.year ?? 0) ||
|
||||
a.name.localeCompare(b.name)
|
||||
);
|
||||
}
|
||||
return sort === 'alphabeticalByArtist'
|
||||
? a.artist.localeCompare(b.artist) || a.name.localeCompare(b.name)
|
||||
: a.name.localeCompare(b.name) || a.artist.localeCompare(b.artist);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { SubsonicAlbum } from '@/lib/api/subsonicTypes';
|
||||
|
||||
type StarredCacheEntry = {
|
||||
albums: SubsonicAlbum[];
|
||||
fetchedAt: number;
|
||||
};
|
||||
|
||||
const starredAlbumsByServer = new Map<string, StarredCacheEntry>();
|
||||
|
||||
/** Drop cached favorites for a server (after star/unstar or server switch). */
|
||||
export function invalidateStarredAlbumBrowseCache(serverId: string | null | undefined): void {
|
||||
if (!serverId) return;
|
||||
starredAlbumsByServer.delete(serverId);
|
||||
}
|
||||
|
||||
export function peekStarredAlbumBrowseCache(serverId: string): SubsonicAlbum[] | null {
|
||||
const entry = starredAlbumsByServer.get(serverId);
|
||||
return entry?.albums ?? null;
|
||||
}
|
||||
|
||||
export function setStarredAlbumBrowseCache(serverId: string, albums: SubsonicAlbum[]): void {
|
||||
starredAlbumsByServer.set(serverId, { albums, fetchedAt: Date.now() });
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { SubsonicAlbum } from '@/lib/api/subsonicTypes';
|
||||
import { peekStarredAlbumBrowseCache } from './albumBrowseStarredCache';
|
||||
import { refreshStarredAlbumIndexFromServer } from './starredAlbumIndexSync';
|
||||
import {
|
||||
albumBrowseStarredNeedsLocalIntersect,
|
||||
filterAlbumsByCompilation,
|
||||
filterAlbumsByYearBounds,
|
||||
} from './albumBrowseFilters';
|
||||
import { runLocalAlbumBrowse } from './albumBrowseLocal';
|
||||
import { sortSubsonicAlbums } from './albumBrowseSort';
|
||||
import type {
|
||||
AlbumBrowseFetchCallbacks,
|
||||
AlbumBrowsePageResult,
|
||||
AlbumBrowseQuery,
|
||||
} from './albumBrowseTypes';
|
||||
|
||||
function applyStarredNetworkPostFilters(
|
||||
albums: SubsonicAlbum[],
|
||||
query: AlbumBrowseQuery,
|
||||
): SubsonicAlbum[] {
|
||||
let out = albums;
|
||||
if (query.year) out = filterAlbumsByYearBounds(out, query.year);
|
||||
out = filterAlbumsByCompilation(out, query.compFilter);
|
||||
if (query.starredOnly) out = out.filter(a => !!a.starred);
|
||||
return sortSubsonicAlbums(out, query.sort);
|
||||
}
|
||||
|
||||
function paginateStarredAlbums(
|
||||
all: SubsonicAlbum[],
|
||||
query: AlbumBrowseQuery,
|
||||
offset: number,
|
||||
pageSize: number,
|
||||
): AlbumBrowsePageResult {
|
||||
const filtered = applyStarredNetworkPostFilters(all, query);
|
||||
const page = filtered.slice(offset, offset + pageSize);
|
||||
return { albums: page, hasMore: offset + pageSize < filtered.length };
|
||||
}
|
||||
|
||||
export async function fetchStarredAlbumBrowse(
|
||||
serverId: string,
|
||||
indexEnabled: boolean,
|
||||
query: AlbumBrowseQuery,
|
||||
offset: number,
|
||||
pageSize: number,
|
||||
callbacks?: AlbumBrowseFetchCallbacks,
|
||||
): Promise<AlbumBrowsePageResult> {
|
||||
const emitPartial = (page: AlbumBrowsePageResult | null) => {
|
||||
if (page && offset === 0 && page.albums.length > 0) {
|
||||
callbacks?.onPartial?.(page);
|
||||
}
|
||||
};
|
||||
|
||||
if (offset === 0) {
|
||||
const cached = peekStarredAlbumBrowseCache(serverId);
|
||||
if (cached?.length) {
|
||||
if (albumBrowseStarredNeedsLocalIntersect(query, indexEnabled, serverId)) {
|
||||
const fromCache = await runLocalAlbumBrowse(
|
||||
serverId,
|
||||
query,
|
||||
0,
|
||||
pageSize,
|
||||
cached.map(a => a.id),
|
||||
);
|
||||
emitPartial(fromCache);
|
||||
} else {
|
||||
emitPartial(paginateStarredAlbums(cached, query, 0, pageSize));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const serverAlbums = await refreshStarredAlbumIndexFromServer(serverId, indexEnabled);
|
||||
|
||||
if (albumBrowseStarredNeedsLocalIntersect(query, indexEnabled, serverId)) {
|
||||
const serverIds = serverAlbums.map(a => a.id);
|
||||
const authoritative = await runLocalAlbumBrowse(serverId, query, offset, pageSize, serverIds);
|
||||
if (authoritative != null) return authoritative;
|
||||
if (query.losslessOnly) return { albums: [], hasMore: false };
|
||||
}
|
||||
|
||||
return paginateStarredAlbums(serverAlbums, query, offset, pageSize);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { SubsonicAlbum } from '@/lib/api/subsonicTypes';
|
||||
import type { AlbumYearBounds } from './albumYearFilter';
|
||||
import type { AlbumCompFilter } from './albumCompilation';
|
||||
import type { AlbumBrowseSort } from './albumBrowseSort';
|
||||
|
||||
export const GENRE_ALBUM_FETCH_LIMIT = 500;
|
||||
|
||||
export type AlbumBrowseQuery = {
|
||||
sort: AlbumBrowseSort;
|
||||
genres: string[];
|
||||
year?: AlbumYearBounds;
|
||||
losslessOnly: boolean;
|
||||
starredOnly: boolean;
|
||||
compFilter: AlbumCompFilter;
|
||||
};
|
||||
|
||||
export type AlbumBrowsePageResult = {
|
||||
albums: SubsonicAlbum[];
|
||||
hasMore: boolean;
|
||||
};
|
||||
|
||||
export type AlbumBrowseFetchCallbacks = {
|
||||
/** Earlier page (cache / local index) before server favorites refresh finishes. */
|
||||
onPartial?: (page: AlbumBrowsePageResult) => void;
|
||||
};
|
||||
|
||||
export type GenreFilterOption = {
|
||||
genre: string;
|
||||
count: number;
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { libraryGetCatalogYearBounds } from '@/lib/api/library';
|
||||
import { ALBUM_YEAR_MAX, ALBUM_YEAR_MIN, type AlbumCatalogYearRange } from './albumYearFilter';
|
||||
import { libraryIsReady } from './libraryReady';
|
||||
|
||||
const FALLBACK: AlbumCatalogYearRange = { min: ALBUM_YEAR_MIN, max: ALBUM_YEAR_MAX };
|
||||
|
||||
/** Indexed track years for Albums filter spinners (falls back when index is off). */
|
||||
export async function fetchAlbumCatalogYearBounds(
|
||||
serverId: string,
|
||||
indexEnabled: boolean,
|
||||
): Promise<AlbumCatalogYearRange> {
|
||||
if (!serverId || !indexEnabled || !(await libraryIsReady(serverId))) {
|
||||
return FALLBACK;
|
||||
}
|
||||
try {
|
||||
const b = await libraryGetCatalogYearBounds({ serverId });
|
||||
if (b.minYear != null && b.maxYear != null && b.minYear <= b.maxYear) {
|
||||
return { min: b.minYear, max: b.maxYear };
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return FALLBACK;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { SubsonicAlbum } from '@/lib/api/subsonicTypes';
|
||||
import {
|
||||
albumBrowseCompFilterClientOnly,
|
||||
albumBrowseCompScanComplete,
|
||||
albumIsCompilation,
|
||||
albumIsCompilationFromTrackDtos,
|
||||
ALBUM_COMP_FILTER_MAX_SCAN_ALBUMS,
|
||||
} from './albumCompilation';
|
||||
import { filterAlbumsByCompilation } from './albumBrowseFilters';
|
||||
|
||||
const album = (
|
||||
overrides: Partial<SubsonicAlbum> & { compilation?: boolean; albumArtist?: string } = {},
|
||||
): SubsonicAlbum => ({
|
||||
id: '1',
|
||||
name: 'A',
|
||||
artist: 'X',
|
||||
artistId: 'a',
|
||||
songCount: 1,
|
||||
duration: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('albumIsCompilation', () => {
|
||||
it('reads isCompilation, compilation, releaseTypes, and VA artist', () => {
|
||||
expect(albumIsCompilation(album({ isCompilation: true }))).toBe(true);
|
||||
expect(albumIsCompilation(album({ compilation: true }))).toBe(true);
|
||||
expect(albumIsCompilation(album({ releaseTypes: ['Live', 'Compilation'] }))).toBe(true);
|
||||
expect(albumIsCompilation(album({ artist: 'Various Artists' }))).toBe(true);
|
||||
expect(albumIsCompilation(album({ albumArtist: 'Various Artists' }))).toBe(true);
|
||||
expect(albumIsCompilation(album())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterAlbumsByCompilation', () => {
|
||||
const albums = [
|
||||
album({ id: 'c', isCompilation: true }),
|
||||
album({ id: 'n' }),
|
||||
];
|
||||
|
||||
it('keeps only compilations', () => {
|
||||
expect(filterAlbumsByCompilation(albums, 'only').map(a => a.id)).toEqual(['c']);
|
||||
});
|
||||
|
||||
it('hides compilations', () => {
|
||||
expect(filterAlbumsByCompilation(albums, 'hide').map(a => a.id)).toEqual(['n']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('albumBrowseCompScanComplete', () => {
|
||||
it('stops after scan budget when more pages exist', () => {
|
||||
const loaded = Array.from({ length: ALBUM_COMP_FILTER_MAX_SCAN_ALBUMS }, (_, i) =>
|
||||
album({ id: String(i) }),
|
||||
);
|
||||
expect(albumBrowseCompScanComplete(loaded, 'only', true)).toBe(true);
|
||||
});
|
||||
|
||||
it('continues while under budget', () => {
|
||||
expect(albumBrowseCompScanComplete([album()], 'only', true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('albumBrowseCompFilterClientOnly', () => {
|
||||
it('matches page mode only', () => {
|
||||
expect(albumBrowseCompFilterClientOnly('only', 'page')).toBe(true);
|
||||
expect(albumBrowseCompFilterClientOnly('only', 'slice')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('albumIsCompilationFromTrackDtos', () => {
|
||||
it('detects compilation flag in track rawJson', () => {
|
||||
expect(albumIsCompilationFromTrackDtos([{
|
||||
serverId: 's1',
|
||||
id: 't1',
|
||||
title: 'Hit',
|
||||
album: 'Comp Album',
|
||||
albumId: 'al1',
|
||||
durationSec: 1,
|
||||
syncedAt: 1,
|
||||
rawJson: { compilation: true },
|
||||
}])).toBe(true);
|
||||
expect(albumIsCompilationFromTrackDtos([{
|
||||
serverId: 's1',
|
||||
id: 't2',
|
||||
title: 'Song',
|
||||
album: 'Studio',
|
||||
albumId: 'al2',
|
||||
durationSec: 1,
|
||||
syncedAt: 1,
|
||||
rawJson: {},
|
||||
}])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { LibraryTrackDto } from '@/lib/api/library';
|
||||
import type { SubsonicAlbum } from '@/lib/api/subsonicTypes';
|
||||
|
||||
export type AlbumCompFilter = 'all' | 'only' | 'hide';
|
||||
|
||||
const isObject = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
|
||||
/** Max albums to scan client-side for compilation filter before showing empty. */
|
||||
export const ALBUM_COMP_FILTER_MAX_SCAN_ALBUMS = 500;
|
||||
|
||||
const VARIOUS_ARTISTS = /\bvarious artists\b/i;
|
||||
|
||||
/** OpenSubsonic / Navidrome: `compilation`, `isCompilation`, `releaseTypes`, or VA artist. */
|
||||
export function albumIsCompilation(a: SubsonicAlbum): boolean {
|
||||
if (a.isCompilation === true) return true;
|
||||
const loose = a as SubsonicAlbum & { compilation?: boolean; albumArtist?: string };
|
||||
if (loose.compilation === true) return true;
|
||||
if (a.releaseTypes?.some(t => /^compilation$/i.test(t.trim()))) return true;
|
||||
const artist = (a.artist ?? '').trim();
|
||||
const displayArtist = (a.displayArtist ?? '').trim();
|
||||
const albumArtist = (loose.albumArtist ?? '').trim();
|
||||
return VARIOUS_ARTISTS.test(artist)
|
||||
|| VARIOUS_ARTISTS.test(displayArtist)
|
||||
|| VARIOUS_ARTISTS.test(albumArtist);
|
||||
}
|
||||
|
||||
/** Any track in a grouped album matches compilation signals (offline / local aggregate). */
|
||||
export function albumIsCompilationFromTrackDtos(tracks: LibraryTrackDto[]): boolean {
|
||||
for (const t of tracks) {
|
||||
const raw = isObject(t.rawJson) ? t.rawJson : {};
|
||||
const loose = raw as Partial<SubsonicAlbum> & { compilation?: boolean; albumArtist?: string };
|
||||
const probe: SubsonicAlbum = {
|
||||
id: t.albumId ?? '',
|
||||
name: t.album ?? '',
|
||||
artist: t.albumArtist ?? t.artist ?? '',
|
||||
artistId: t.artistId ?? '',
|
||||
songCount: 0,
|
||||
duration: 0,
|
||||
...loose,
|
||||
displayArtist: typeof loose.displayArtist === 'string' ? loose.displayArtist : undefined,
|
||||
};
|
||||
if (albumIsCompilation(probe)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Network page mode: compilation filter runs client-side on each getAlbumList2 page. */
|
||||
export function albumBrowseCompFilterClientOnly(
|
||||
compFilter: AlbumCompFilter,
|
||||
browseMode: 'slice' | 'page',
|
||||
): boolean {
|
||||
return compFilter !== 'all' && browseMode === 'page';
|
||||
}
|
||||
|
||||
/** Stop paginating when the catalog tail is reached or the scan budget is spent. */
|
||||
export function albumBrowseCompScanComplete(
|
||||
loadedAlbums: SubsonicAlbum[],
|
||||
compFilter: AlbumCompFilter,
|
||||
hasMore: boolean,
|
||||
): boolean {
|
||||
if (compFilter === 'all') return true;
|
||||
if (!hasMore) return true;
|
||||
if (loadedAlbums.length >= ALBUM_COMP_FILTER_MAX_SCAN_ALBUMS) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
albumYearFilterClauses,
|
||||
albumYearSubsonicParams,
|
||||
clampAlbumYearFieldInput,
|
||||
formatAlbumYearFilterLabel,
|
||||
normalizeAlbumYearToFieldChange,
|
||||
resolveAlbumYearBounds,
|
||||
stepAlbumYearField,
|
||||
} from './albumYearFilter';
|
||||
|
||||
describe('resolveAlbumYearBounds', () => {
|
||||
it('is inactive when both fields are empty', () => {
|
||||
expect(resolveAlbumYearBounds('', '')).toEqual({ active: false, bounds: {} });
|
||||
});
|
||||
|
||||
it('is active with only from', () => {
|
||||
expect(resolveAlbumYearBounds('1990', '')).toEqual({
|
||||
active: true,
|
||||
bounds: { from: 1990 },
|
||||
});
|
||||
});
|
||||
|
||||
it('is active with only to', () => {
|
||||
expect(resolveAlbumYearBounds('', '2005')).toEqual({
|
||||
active: true,
|
||||
bounds: { to: 2005 },
|
||||
});
|
||||
});
|
||||
|
||||
it('is active with both bounds', () => {
|
||||
expect(resolveAlbumYearBounds('1980', '1999')).toEqual({
|
||||
active: true,
|
||||
bounds: { from: 1980, to: 1999 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('albumYearFilterClauses', () => {
|
||||
it('uses gte for open-ended from', () => {
|
||||
expect(albumYearFilterClauses({ from: 2000 })).toEqual([
|
||||
{ field: 'year', op: 'gte', value: 2000 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses lte for open-ended to', () => {
|
||||
expect(albumYearFilterClauses({ to: 2010 })).toEqual([
|
||||
{ field: 'year', op: 'lte', value: 2010 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatAlbumYearFilterLabel', () => {
|
||||
const catalog = { min: 1975, max: 2020 };
|
||||
|
||||
it('formats partial ranges using catalog edges', () => {
|
||||
expect(formatAlbumYearFilterLabel({ from: 1990 }, catalog)).toBe('1990–2020');
|
||||
expect(formatAlbumYearFilterLabel({ to: 2000 }, catalog)).toBe('1975–2000');
|
||||
expect(formatAlbumYearFilterLabel({ from: 2000, to: 2010 }, catalog)).toBe('2000–2010');
|
||||
});
|
||||
|
||||
it('collapses when the only bound equals the implied catalog edge', () => {
|
||||
expect(formatAlbumYearFilterLabel({ from: 2020 }, catalog)).toBe('2020');
|
||||
expect(formatAlbumYearFilterLabel({ to: 1975 }, catalog)).toBe('1975');
|
||||
});
|
||||
});
|
||||
|
||||
describe('albumYearSubsonicParams', () => {
|
||||
it('omits unset bounds', () => {
|
||||
expect(albumYearSubsonicParams({ from: 1995 })).toEqual({ fromYear: 1995 });
|
||||
expect(albumYearSubsonicParams({ to: 2010 })).toEqual({ toYear: 2010 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('album year spinner helpers', () => {
|
||||
const min = 1975;
|
||||
const max = 2020;
|
||||
|
||||
it('steps from field from catalog min when empty', () => {
|
||||
expect(stepAlbumYearField('', 1, min, max, 'min')).toBe('1976');
|
||||
expect(stepAlbumYearField('', 0, min, max, 'min')).toBe('1975');
|
||||
});
|
||||
|
||||
it('steps to field from catalog max when empty', () => {
|
||||
expect(stepAlbumYearField('', -1, min, max, 'max')).toBe('2019');
|
||||
expect(stepAlbumYearField('', 0, min, max, 'max')).toBe('2020');
|
||||
});
|
||||
|
||||
it('clamps typed values to catalog bounds', () => {
|
||||
expect(clampAlbumYearFieldInput('1960', min, max)).toBe('1975');
|
||||
expect(clampAlbumYearFieldInput('2030', min, max)).toBe('2020');
|
||||
});
|
||||
|
||||
it('maps first native spinner tick on empty to field to max', () => {
|
||||
expect(normalizeAlbumYearToFieldChange('', '1975', min, max)).toBe('2020');
|
||||
expect(normalizeAlbumYearToFieldChange('2010', '1975', min, max)).toBe('1975');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { LibraryFilterClause } from '@/lib/api/library';
|
||||
|
||||
export const ALBUM_YEAR_MIN = 1900;
|
||||
export const ALBUM_YEAR_MAX = new Date().getFullYear();
|
||||
/** Delay before year filter triggers album browse reload. */
|
||||
export const ALBUM_YEAR_FILTER_DEBOUNCE_MS = 350;
|
||||
|
||||
export type AlbumCatalogYearRange = { min: number; max: number };
|
||||
|
||||
export function clampAlbumYear(n: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, n));
|
||||
}
|
||||
|
||||
/** Spinner / wheel step; empty field starts at `startEdge` of the catalog range. */
|
||||
export function stepAlbumYearField(
|
||||
raw: string,
|
||||
delta: number,
|
||||
min: number,
|
||||
max: number,
|
||||
startEdge: 'min' | 'max',
|
||||
): string {
|
||||
const start = startEdge === 'min' ? min : max;
|
||||
const current = raw.trim() ? (parseAlbumYearField(raw) ?? start) : start;
|
||||
return String(clampAlbumYear(current + delta, min, max));
|
||||
}
|
||||
|
||||
export function clampAlbumYearFieldInput(
|
||||
raw: string,
|
||||
min: number,
|
||||
max: number,
|
||||
): string {
|
||||
if (!raw.trim()) return '';
|
||||
const n = parseAlbumYearField(raw);
|
||||
if (n == null) return '';
|
||||
return String(clampAlbumYear(n, min, max));
|
||||
}
|
||||
|
||||
/** Native number spinners jump to `min` from empty — map the "to" field to catalog max. */
|
||||
export function normalizeAlbumYearToFieldChange(
|
||||
prevTo: string,
|
||||
nextRaw: string,
|
||||
catalogMin: number,
|
||||
catalogMax: number,
|
||||
): string {
|
||||
if (!prevTo.trim() && nextRaw === String(catalogMin) && catalogMin !== catalogMax) {
|
||||
return String(catalogMax);
|
||||
}
|
||||
return clampAlbumYearFieldInput(nextRaw, catalogMin, catalogMax);
|
||||
}
|
||||
|
||||
export type AlbumYearBounds = { from?: number; to?: number };
|
||||
|
||||
export function parseAlbumYearField(raw: string): number | null {
|
||||
const n = parseInt(raw.trim(), 10);
|
||||
if (Number.isNaN(n) || n < 1) return null;
|
||||
return n;
|
||||
}
|
||||
|
||||
export function resolveAlbumYearBounds(from: string, to: string): {
|
||||
active: boolean;
|
||||
bounds: AlbumYearBounds;
|
||||
} {
|
||||
const fromN = parseAlbumYearField(from);
|
||||
const toN = parseAlbumYearField(to);
|
||||
if (fromN == null && toN == null) {
|
||||
return { active: false, bounds: {} };
|
||||
}
|
||||
return {
|
||||
active: true,
|
||||
bounds: {
|
||||
...(fromN != null ? { from: fromN } : {}),
|
||||
...(toN != null ? { to: toN } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Chip label; open-ended bounds show catalog (or default) min/max on the missing side. */
|
||||
export function formatAlbumYearFilterLabel(
|
||||
bounds: AlbumYearBounds,
|
||||
catalog?: AlbumCatalogYearRange,
|
||||
): string | null {
|
||||
const catalogMin = catalog?.min ?? ALBUM_YEAR_MIN;
|
||||
const catalogMax = catalog?.max ?? ALBUM_YEAR_MAX;
|
||||
|
||||
if (bounds.from != null && bounds.to != null) {
|
||||
const lo = Math.min(bounds.from, bounds.to);
|
||||
const hi = Math.max(bounds.from, bounds.to);
|
||||
return lo === hi ? String(lo) : `${lo}–${hi}`;
|
||||
}
|
||||
if (bounds.from != null) {
|
||||
const hi = catalogMax;
|
||||
return bounds.from === hi ? String(bounds.from) : `${bounds.from}–${hi}`;
|
||||
}
|
||||
if (bounds.to != null) {
|
||||
const lo = catalogMin;
|
||||
return bounds.to === lo ? String(bounds.to) : `${lo}–${bounds.to}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function albumYearFilterClauses(bounds: AlbumYearBounds): LibraryFilterClause[] {
|
||||
const clauses: LibraryFilterClause[] = [];
|
||||
if (bounds.from != null && bounds.to != null) {
|
||||
const lo = Math.min(bounds.from, bounds.to);
|
||||
const hi = Math.max(bounds.from, bounds.to);
|
||||
clauses.push({ field: 'year', op: 'between', value: lo, valueTo: hi });
|
||||
} else if (bounds.from != null) {
|
||||
clauses.push({ field: 'year', op: 'gte', value: bounds.from });
|
||||
} else if (bounds.to != null) {
|
||||
clauses.push({ field: 'year', op: 'lte', value: bounds.to });
|
||||
}
|
||||
return clauses;
|
||||
}
|
||||
|
||||
/** Params for Subsonic `getAlbumList2` `byYear` when the local index is unavailable. */
|
||||
export function albumYearSubsonicParams(bounds: AlbumYearBounds): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
if (bounds.from != null) out.fromYear = bounds.from;
|
||||
if (bounds.to != null) out.toYear = bounds.to;
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export const ANALYTICS_STRATEGIES = ['lazy', 'advanced'] as const;
|
||||
|
||||
export type AnalyticsStrategy = (typeof ANALYTICS_STRATEGIES)[number];
|
||||
|
||||
export const DEFAULT_ANALYTICS_STRATEGY: AnalyticsStrategy = 'lazy';
|
||||
|
||||
export const ADVANCED_PARALLELISM_MIN = 1;
|
||||
export const ADVANCED_PARALLELISM_MAX = 20;
|
||||
export const DEFAULT_ADVANCED_PARALLELISM = 1;
|
||||
|
||||
export function clampAdvancedParallelism(value: number): number {
|
||||
const rounded = Math.round(value);
|
||||
return Math.min(
|
||||
ADVANCED_PARALLELISM_MAX,
|
||||
Math.max(ADVANCED_PARALLELISM_MIN, rounded),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { browseRaceCountsArtists, raceBrowseWithLocalFallback } from './browseTextSearch';
|
||||
|
||||
describe('raceBrowseWithLocalFallback', () => {
|
||||
it('returns local when network throws and local has data', async () => {
|
||||
const outcome = await raceBrowseWithLocalFallback(
|
||||
() => false,
|
||||
async () => [{ id: 'a1', name: 'Local Artist' }],
|
||||
async () => {
|
||||
throw new Error('server down');
|
||||
},
|
||||
{
|
||||
surface: 'artists_browse',
|
||||
query: 'test',
|
||||
counts: browseRaceCountsArtists,
|
||||
},
|
||||
);
|
||||
expect(outcome?.source).toBe('local');
|
||||
expect(outcome?.result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('falls back to local after race when network was faster but returned null', async () => {
|
||||
let localCalls = 0;
|
||||
const outcome = await raceBrowseWithLocalFallback(
|
||||
() => false,
|
||||
async () => {
|
||||
localCalls += 1;
|
||||
return localCalls >= 2 ? ['hit'] : null;
|
||||
},
|
||||
async () => null,
|
||||
);
|
||||
expect(outcome?.source).toBe('local');
|
||||
expect(outcome?.result).toEqual(['hit']);
|
||||
});
|
||||
|
||||
it('returns network when local is unavailable', async () => {
|
||||
const outcome = await raceBrowseWithLocalFallback(
|
||||
() => false,
|
||||
async () => null,
|
||||
async () => ['network'],
|
||||
);
|
||||
expect(outcome?.source).toBe('network');
|
||||
expect(outcome?.result).toEqual(['network']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,588 @@
|
||||
/**
|
||||
* Browse-page text search — local index vs network race (LiveSearch / SearchBrowsePage pattern).
|
||||
*/
|
||||
import { getStarred } from '@/lib/api/subsonicStarRating';
|
||||
import { search, searchSongsPaged } from '@/lib/api/subsonicSearch';
|
||||
import type { SearchResults, SubsonicAlbum, SubsonicArtist, SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import { libraryAdvancedSearch, libraryGetArtistLosslessBrowse, libraryListLosslessAlbums } from '@/lib/api/library';
|
||||
import { libraryScopeForServer } from '@/lib/api/subsonicClient';
|
||||
import {
|
||||
LIVE_SEARCH_DEBOUNCE_NETWORK_MS,
|
||||
LIVE_SEARCH_DEBOUNCE_RACE_MS,
|
||||
} from './liveSearchLocal';
|
||||
import {
|
||||
albumToAlbum,
|
||||
artistToArtist,
|
||||
loadMoreLocalSongs,
|
||||
runLocalAdvancedSearch,
|
||||
runNetworkAdvancedTextSearch,
|
||||
trackToSong,
|
||||
type LocalSearchOpts,
|
||||
} from './advancedSearchLocal';
|
||||
import type { AlbumYearBounds } from './albumYearFilter';
|
||||
import {
|
||||
logLibrarySearch,
|
||||
timed,
|
||||
type LibrarySearchDebugEntry,
|
||||
type LibrarySearchSurface,
|
||||
} from './libraryDevLog';
|
||||
import { libraryIsReady } from './libraryReady';
|
||||
import { raceSearchSources, type SearchRaceWinner } from './searchRace';
|
||||
|
||||
export type { LibrarySearchSurface };
|
||||
|
||||
export interface BrowseRaceLogOptions {
|
||||
surface: LibrarySearchSurface;
|
||||
query: string;
|
||||
indexEnabled?: boolean;
|
||||
counts?: (result: unknown) => LibrarySearchDebugEntry['counts'];
|
||||
}
|
||||
|
||||
function logBrowseRaceOutcome(
|
||||
log: BrowseRaceLogOptions | undefined,
|
||||
path: LibrarySearchDebugEntry['path'],
|
||||
winner: SearchRaceWinner<unknown> | null,
|
||||
durationMs: number,
|
||||
fallbackReason?: string,
|
||||
): void {
|
||||
if (!log) return;
|
||||
logLibrarySearch({
|
||||
at: new Date().toISOString(),
|
||||
query: log.query,
|
||||
path,
|
||||
durationMs,
|
||||
indexEnabled: log.indexEnabled,
|
||||
surface: log.surface,
|
||||
raceWinner: winner?.source,
|
||||
raceWinnerMs: winner?.durationMs,
|
||||
counts: winner && log.counts ? log.counts(winner.result) : undefined,
|
||||
fallbackReason,
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
LIVE_SEARCH_DEBOUNCE_RACE_MS as BROWSE_TEXT_DEBOUNCE_RACE_MS,
|
||||
LIVE_SEARCH_DEBOUNCE_NETWORK_MS as BROWSE_TEXT_DEBOUNCE_NETWORK_MS,
|
||||
};
|
||||
|
||||
/** Network arm for browse races — errors become null, never reject the race. */
|
||||
async function safeNetwork<T>(run: () => Promise<T | null>): Promise<T | null> {
|
||||
try {
|
||||
return await run();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parallel local vs network browse search. Network failures are swallowed. When
|
||||
* the race does not pick a winner (or rejects because local threw), local is
|
||||
* tried again so a down remote server does not block a ready index.
|
||||
*/
|
||||
export async function raceBrowseWithLocalFallback<T>(
|
||||
isStale: () => boolean,
|
||||
local: () => Promise<T | null>,
|
||||
network: () => Promise<T | null>,
|
||||
log?: BrowseRaceLogOptions,
|
||||
): Promise<SearchRaceWinner<T> | null> {
|
||||
if (isStale()) return null;
|
||||
|
||||
const t0 = performance.now();
|
||||
let winner: SearchRaceWinner<T> | null = null;
|
||||
try {
|
||||
winner = await raceSearchSources(
|
||||
[
|
||||
{ source: 'local', run: local },
|
||||
{ source: 'network', run: () => safeNetwork(network) },
|
||||
],
|
||||
isStale,
|
||||
);
|
||||
} catch {
|
||||
// Local threw — fall through to explicit local retry below.
|
||||
}
|
||||
|
||||
if (winner && !isStale()) {
|
||||
logBrowseRaceOutcome(log, 'browse_race', winner, Math.round(performance.now() - t0));
|
||||
return winner;
|
||||
}
|
||||
|
||||
const { result: localResult, ms: localMs } = await timed(local);
|
||||
if (localResult != null && !isStale()) {
|
||||
const outcome: SearchRaceWinner<T> = {
|
||||
source: 'local',
|
||||
result: localResult,
|
||||
durationMs: localMs,
|
||||
};
|
||||
logBrowseRaceOutcome(
|
||||
log,
|
||||
'browse_local_fallback',
|
||||
outcome,
|
||||
Math.round(performance.now() - t0),
|
||||
'race_no_winner',
|
||||
);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
const { result: networkResult, ms: networkMs } = await timed(() => safeNetwork(network));
|
||||
if (networkResult != null && !isStale()) {
|
||||
const outcome: SearchRaceWinner<T> = {
|
||||
source: 'network',
|
||||
result: networkResult,
|
||||
durationMs: networkMs,
|
||||
};
|
||||
logBrowseRaceOutcome(
|
||||
log,
|
||||
'browse_network_fallback',
|
||||
outcome,
|
||||
Math.round(performance.now() - t0),
|
||||
'local_unavailable',
|
||||
);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
logBrowseRaceOutcome(
|
||||
log,
|
||||
'browse_race_miss',
|
||||
null,
|
||||
Math.round(performance.now() - t0),
|
||||
'all_sources_empty',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function browseRaceCountsArtists(result: unknown): LibrarySearchDebugEntry['counts'] {
|
||||
const n = Array.isArray(result) ? result.length : 0;
|
||||
return { artists: n, albums: 0, songs: 0 };
|
||||
}
|
||||
|
||||
export function browseRaceCountsAlbums(result: unknown): LibrarySearchDebugEntry['counts'] {
|
||||
const n = Array.isArray(result) ? result.length : 0;
|
||||
return { artists: 0, albums: n, songs: 0 };
|
||||
}
|
||||
|
||||
export function browseRaceCountsSongs(result: unknown): LibrarySearchDebugEntry['counts'] {
|
||||
const n = Array.isArray(result) ? result.length : 0;
|
||||
return { artists: 0, albums: 0, songs: n };
|
||||
}
|
||||
|
||||
export function browseRaceCountsFullSearch(result: unknown): LibrarySearchDebugEntry['counts'] {
|
||||
const r = result as SearchResults;
|
||||
return {
|
||||
artists: r.artists?.length ?? 0,
|
||||
albums: r.albums?.length ?? 0,
|
||||
songs: r.songs?.length ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
const ARTIST_BROWSE_LIMIT = 500;
|
||||
const ALBUM_BROWSE_LIMIT = 500;
|
||||
|
||||
const emptyBrowseOpts = (query: string): LocalSearchOpts => ({
|
||||
query,
|
||||
genre: '',
|
||||
yearFrom: '',
|
||||
yearTo: '',
|
||||
bpmFrom: '',
|
||||
bpmTo: '',
|
||||
moodGroup: '',
|
||||
resultType: 'artists',
|
||||
});
|
||||
|
||||
const albumBrowseOpts = (query: string, losslessOnly = false): LocalSearchOpts => ({
|
||||
query,
|
||||
genre: '',
|
||||
yearFrom: '',
|
||||
yearTo: '',
|
||||
bpmFrom: '',
|
||||
bpmTo: '',
|
||||
moodGroup: '',
|
||||
losslessOnly,
|
||||
albumTitleOnly: true,
|
||||
resultType: 'albums',
|
||||
});
|
||||
|
||||
const songBrowseOpts = (query: string): LocalSearchOpts => ({
|
||||
query,
|
||||
genre: '',
|
||||
yearFrom: '',
|
||||
yearTo: '',
|
||||
bpmFrom: '',
|
||||
bpmTo: '',
|
||||
moodGroup: '',
|
||||
resultType: 'songs',
|
||||
});
|
||||
|
||||
const fullSearchOpts = (query: string): LocalSearchOpts => ({
|
||||
query,
|
||||
genre: '',
|
||||
yearFrom: '',
|
||||
yearTo: '',
|
||||
bpmFrom: '',
|
||||
bpmTo: '',
|
||||
moodGroup: '',
|
||||
resultType: 'all',
|
||||
});
|
||||
|
||||
/** Local artist name search for Artists / Composers browse pages. */
|
||||
export async function runLocalBrowseArtists(
|
||||
serverId: string | null | undefined,
|
||||
query: string,
|
||||
limit = ARTIST_BROWSE_LIMIT,
|
||||
): Promise<SubsonicArtist[] | null> {
|
||||
const page = await runLocalAdvancedSearch(
|
||||
serverId,
|
||||
emptyBrowseOpts(query),
|
||||
limit,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
if (!page) return null;
|
||||
return page.artists;
|
||||
}
|
||||
|
||||
/** Network search3 artist slice for browse pages. */
|
||||
export async function runNetworkBrowseArtists(
|
||||
query: string,
|
||||
limit = ARTIST_BROWSE_LIMIT,
|
||||
): Promise<SubsonicArtist[] | null> {
|
||||
const q = query.trim();
|
||||
if (!q) return null;
|
||||
try {
|
||||
const r = await search(q, { artistCount: limit, albumCount: 0, songCount: 0 });
|
||||
return r.artists;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Local album title/artist search for All Albums browse. */
|
||||
export async function runLocalBrowseAlbums(
|
||||
serverId: string | null | undefined,
|
||||
query: string,
|
||||
limit = ALBUM_BROWSE_LIMIT,
|
||||
losslessOnly = false,
|
||||
): Promise<SubsonicAlbum[] | null> {
|
||||
const page = await runLocalAdvancedSearch(
|
||||
serverId,
|
||||
albumBrowseOpts(query, losslessOnly),
|
||||
limit,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
if (!page) return null;
|
||||
return page.albums;
|
||||
}
|
||||
|
||||
/** Network search3 album slice for All Albums browse (title match only). */
|
||||
export async function runNetworkBrowseAlbums(
|
||||
query: string,
|
||||
limit = ALBUM_BROWSE_LIMIT,
|
||||
): Promise<SubsonicAlbum[] | null> {
|
||||
const q = query.trim();
|
||||
if (!q) return null;
|
||||
try {
|
||||
const r = await search(q, { artistCount: 0, albumCount: limit, songCount: 0 });
|
||||
return filterAlbumsByNameTextQuery(r.albums, q);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Paginated local track text search (Tracks browse / VirtualSongList). */
|
||||
export async function runLocalBrowseSongPage(
|
||||
serverId: string | null | undefined,
|
||||
query: string,
|
||||
offset: number,
|
||||
pageSize: number,
|
||||
): Promise<SubsonicSong[] | null> {
|
||||
if (!serverId || !(await libraryIsReady(serverId))) return null;
|
||||
const q = query.trim();
|
||||
if (!q) return null;
|
||||
try {
|
||||
const resp = await libraryAdvancedSearch({
|
||||
serverId,
|
||||
libraryScope: libraryScopeForServer(serverId) ?? undefined,
|
||||
query: q,
|
||||
entityTypes: ['track'],
|
||||
limit: pageSize,
|
||||
offset,
|
||||
skipTotals: true,
|
||||
});
|
||||
if (resp.source !== 'local') return null;
|
||||
return resp.tracks.map(trackToSong);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Paginated network track text search. */
|
||||
export async function runNetworkBrowseSongPage(
|
||||
query: string,
|
||||
offset: number,
|
||||
pageSize: number,
|
||||
): Promise<SubsonicSong[] | null> {
|
||||
const q = query.trim();
|
||||
if (!q) return null;
|
||||
try {
|
||||
return await searchSongsPaged(q, pageSize, offset);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Full SearchResults page — local advanced search (all entity types). */
|
||||
export async function runLocalBrowseFullSearch(
|
||||
serverId: string | null | undefined,
|
||||
query: string,
|
||||
songsLimit: number,
|
||||
): Promise<SearchResults | null> {
|
||||
const page = await runLocalAdvancedSearch(
|
||||
serverId,
|
||||
fullSearchOpts(query),
|
||||
songsLimit,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
if (!page) return null;
|
||||
return {
|
||||
artists: page.artists,
|
||||
albums: page.albums,
|
||||
songs: page.songs,
|
||||
};
|
||||
}
|
||||
|
||||
/** Full SearchResults page — network search3. */
|
||||
export async function runNetworkBrowseFullSearch(
|
||||
query: string,
|
||||
songsLimit: number,
|
||||
): Promise<SearchResults | null> {
|
||||
try {
|
||||
const page = await runNetworkAdvancedTextSearch(fullSearchOpts(query), songsLimit);
|
||||
if (!page) return null;
|
||||
return {
|
||||
artists: page.artists,
|
||||
albums: page.albums,
|
||||
songs: page.songs,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Next song page when the race winner was local (SearchResults / Tracks). */
|
||||
export async function loadMoreLocalBrowseSongs(
|
||||
serverId: string,
|
||||
query: string,
|
||||
offset: number,
|
||||
pageSize: number,
|
||||
): Promise<SubsonicSong[]> {
|
||||
return loadMoreLocalSongs(serverId, songBrowseOpts(query), offset, pageSize);
|
||||
}
|
||||
|
||||
export type { AlbumBrowseSort } from './albumBrowseSort';
|
||||
export { albumSortClauses, sortSubsonicAlbums } from './albumBrowseSort';
|
||||
import { type AlbumBrowseSort } from './albumBrowseSort';
|
||||
import { filterAlbumsByNameTextQuery } from './albumBrowseFilters';
|
||||
import { runLocalAlbumBrowse, type AlbumBrowseQuery } from './albumBrowseLoad';
|
||||
import { GENRE_ALBUM_FETCH_LIMIT } from './albumBrowseTypes';
|
||||
|
||||
/**
|
||||
* Random track sample from the local `track` table — SQLite `ORDER BY RANDOM() LIMIT N`.
|
||||
* Returns null when the index is unavailable (caller falls back to the network).
|
||||
*/
|
||||
export async function runLocalRandomSongs(
|
||||
serverId: string | null | undefined,
|
||||
limit: number,
|
||||
): Promise<SubsonicSong[] | null> {
|
||||
if (!serverId || !(await libraryIsReady(serverId))) return null;
|
||||
try {
|
||||
const resp = await libraryAdvancedSearch({
|
||||
serverId,
|
||||
libraryScope: libraryScopeForServer(serverId) ?? undefined,
|
||||
entityTypes: ['track'],
|
||||
sort: [{ field: 'random', dir: 'asc' }],
|
||||
limit,
|
||||
offset: 0,
|
||||
skipTotals: true,
|
||||
});
|
||||
if (resp.source !== 'local') return null;
|
||||
return resp.tracks.map(trackToSong);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Paginated lossless albums from the local index. Returns null when unavailable. */
|
||||
export async function runLocalLosslessAlbums(
|
||||
serverId: string | null | undefined,
|
||||
limit: number,
|
||||
offset: number,
|
||||
): Promise<{ albums: SubsonicAlbum[]; hasMore: boolean } | null> {
|
||||
if (!serverId || !(await libraryIsReady(serverId))) return null;
|
||||
try {
|
||||
const resp = await libraryListLosslessAlbums({
|
||||
serverId,
|
||||
libraryScope: libraryScopeForServer(serverId) ?? undefined,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
if (resp.source !== 'local') return null;
|
||||
return {
|
||||
albums: resp.albums.map(albumToAlbum),
|
||||
hasMore: resp.hasMore,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Lossless albums + tracks for one artist. Returns null when the index is unavailable. */
|
||||
export async function runLocalArtistLosslessBrowse(
|
||||
serverId: string | null | undefined,
|
||||
artistId: string,
|
||||
): Promise<{ albums: SubsonicAlbum[]; songs: SubsonicSong[] } | null> {
|
||||
if (!serverId || !artistId || !(await libraryIsReady(serverId))) return null;
|
||||
try {
|
||||
const resp = await libraryGetArtistLosslessBrowse({
|
||||
serverId,
|
||||
artistId,
|
||||
libraryScope: libraryScopeForServer(serverId) ?? undefined,
|
||||
});
|
||||
if (resp.source !== 'local') return null;
|
||||
return {
|
||||
albums: resp.albums.map(albumToAlbum),
|
||||
songs: resp.tracks.map(trackToSong),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Random album sample from the local `album` table — SQLite `ORDER BY RANDOM() LIMIT N`.
|
||||
* Returns null when the index is unavailable (caller falls back to the network).
|
||||
*/
|
||||
export async function runLocalRandomAlbums(
|
||||
serverId: string | null | undefined,
|
||||
limit: number,
|
||||
): Promise<SubsonicAlbum[] | null> {
|
||||
if (!serverId || !(await libraryIsReady(serverId))) return null;
|
||||
try {
|
||||
const resp = await libraryAdvancedSearch({
|
||||
serverId,
|
||||
libraryScope: libraryScopeForServer(serverId) ?? undefined,
|
||||
entityTypes: ['album'],
|
||||
sort: [{ field: 'random', dir: 'asc' }],
|
||||
limit,
|
||||
offset: 0,
|
||||
skipTotals: true,
|
||||
});
|
||||
if (resp.source !== 'local') return null;
|
||||
return resp.albums.map(albumToAlbum);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Paginated All Albums browse from the local `album` table (F1). */
|
||||
export async function runLocalAlbumBrowsePage(
|
||||
serverId: string | null | undefined,
|
||||
sort: AlbumBrowseSort,
|
||||
offset: number,
|
||||
pageSize: number,
|
||||
yearFilter?: AlbumYearBounds,
|
||||
losslessOnly?: boolean,
|
||||
): Promise<SubsonicAlbum[] | null> {
|
||||
if (!serverId) return null;
|
||||
const query: AlbumBrowseQuery = {
|
||||
sort,
|
||||
genres: [],
|
||||
year: yearFilter,
|
||||
losslessOnly: !!losslessOnly,
|
||||
starredOnly: false,
|
||||
compFilter: 'all',
|
||||
};
|
||||
const page = await runLocalAlbumBrowse(serverId, query, offset, pageSize);
|
||||
return page?.albums ?? null;
|
||||
}
|
||||
|
||||
/** Genre-filtered album union for All Albums / Random Albums genre bar. */
|
||||
export async function runLocalAlbumsByGenres(
|
||||
serverId: string | null | undefined,
|
||||
genres: string[],
|
||||
sort: AlbumBrowseSort,
|
||||
limitPerGenre = GENRE_ALBUM_FETCH_LIMIT,
|
||||
losslessOnly?: boolean,
|
||||
): Promise<SubsonicAlbum[] | null> {
|
||||
if (!serverId || genres.length === 0) return null;
|
||||
const query: AlbumBrowseQuery = {
|
||||
sort,
|
||||
genres,
|
||||
losslessOnly: !!losslessOnly,
|
||||
starredOnly: false,
|
||||
compFilter: 'all',
|
||||
};
|
||||
const page = await runLocalAlbumBrowse(serverId, query, 0, limitPerGenre);
|
||||
return page?.albums ?? null;
|
||||
}
|
||||
|
||||
/** Local artist table browse-all when the index is ready (optional fast path). */
|
||||
export async function runLocalBrowseAllArtists(
|
||||
serverId: string | null | undefined,
|
||||
limit = 10_000,
|
||||
): Promise<SubsonicArtist[] | null> {
|
||||
if (!serverId || !(await libraryIsReady(serverId))) return null;
|
||||
try {
|
||||
const resp = await libraryAdvancedSearch({
|
||||
serverId,
|
||||
libraryScope: libraryScopeForServer(serverId) ?? undefined,
|
||||
entityTypes: ['artist'],
|
||||
limit,
|
||||
offset: 0,
|
||||
skipTotals: true,
|
||||
});
|
||||
if (resp.source !== 'local') return null;
|
||||
return resp.artists.map(artistToArtist);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type ArtistCatalogChunkResult = {
|
||||
artists: SubsonicArtist[];
|
||||
hasMore: boolean;
|
||||
};
|
||||
|
||||
/** One local-index chunk for lazy artist catalog loading (Artists browse slice mode). */
|
||||
export async function fetchLocalArtistCatalogChunk(
|
||||
serverId: string,
|
||||
offset: number,
|
||||
chunkSize: number,
|
||||
): Promise<ArtistCatalogChunkResult | null> {
|
||||
if (!serverId || !(await libraryIsReady(serverId))) return null;
|
||||
try {
|
||||
const resp = await libraryAdvancedSearch({
|
||||
serverId,
|
||||
libraryScope: libraryScopeForServer(serverId) ?? undefined,
|
||||
entityTypes: ['artist'],
|
||||
sort: [{ field: 'name', dir: 'asc' }],
|
||||
limit: chunkSize,
|
||||
offset,
|
||||
skipTotals: true,
|
||||
});
|
||||
if (resp.source !== 'local') return null;
|
||||
const artists = resp.artists.map(artistToArtist);
|
||||
return { artists, hasMore: artists.length === chunkSize };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Starred artists from `getStarred2` (artist-level only; server is source of truth). */
|
||||
export async function fetchNetworkStarredArtists(): Promise<SubsonicArtist[]> {
|
||||
const { artists } = await getStarred();
|
||||
return artists.map(a => ({ ...a, starred: a.starred ?? 'true' }));
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { filterArtistsWithRoleAlbumCredits } from './composerBrowse';
|
||||
|
||||
describe('filterArtistsWithRoleAlbumCredits', () => {
|
||||
it('removes artists with zero role-scoped album count', () => {
|
||||
const artists = [
|
||||
{ id: '1', name: 'Bach', albumCount: 12 },
|
||||
{ id: '2', name: 'Apollo 440', albumCount: 0 },
|
||||
];
|
||||
expect(filterArtistsWithRoleAlbumCredits(artists)).toEqual([artists[0]]);
|
||||
});
|
||||
|
||||
it('removes artists when role album count is missing', () => {
|
||||
const artists = [{ id: '1', name: 'Ghost', albumCount: undefined }];
|
||||
expect(filterArtistsWithRoleAlbumCredits(artists)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { SubsonicArtist } from '@/lib/api/subsonicTypes';
|
||||
|
||||
/**
|
||||
* Navidrome's `/api/artist?role=composer` can include artists whose
|
||||
* `stats.composer.albumCount` is zero (performer-only credits with no composer
|
||||
* tags). Drop them from the Composers browse catalog.
|
||||
*/
|
||||
export function filterArtistsWithRoleAlbumCredits(artists: SubsonicArtist[]): SubsonicArtist[] {
|
||||
return artists.filter(a => (a.albumCount ?? 0) > 0);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Wake a new full library cover pass (until catalog cursor exhausted).
|
||||
* Not used for periodic revalidate — that stays in `cover_revalidate_*`.
|
||||
*/
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function wakeLibraryCoverBackfill(): void {
|
||||
for (const fn of listeners) {
|
||||
fn();
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeLibraryCoverBackfillWake(handler: () => void): () => void {
|
||||
listeners.add(handler);
|
||||
return () => listeners.delete(handler);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export const COVER_CACHE_STRATEGIES = ['lazy', 'aggressive'] as const;
|
||||
|
||||
export type CoverCacheStrategy = (typeof COVER_CACHE_STRATEGIES)[number];
|
||||
|
||||
export const DEFAULT_COVER_CACHE_STRATEGY: CoverCacheStrategy = 'lazy';
|
||||
|
||||
export function coverStrategyAllowsRoutePrefetch(_strategy: CoverCacheStrategy): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function coverStrategyAllowsLibraryBackfill(strategy: CoverCacheStrategy): boolean {
|
||||
return strategy === 'aggressive';
|
||||
}
|
||||
|
||||
/** Map legacy auth-store `coverPrefetchStrategy` to per-server strategy. */
|
||||
export function coverStrategyFromLegacyPrefetch(
|
||||
legacy: string | undefined,
|
||||
): CoverCacheStrategy {
|
||||
if (legacy === 'library') return 'aggressive';
|
||||
return 'lazy';
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fetchGenreAlbumPage, fetchGenreAlbumTotal } from './genreAlbumBrowse';
|
||||
|
||||
vi.mock('@/lib/api/library', () => ({
|
||||
libraryListAlbumsByGenre: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api/subsonicGenres', () => ({
|
||||
getAlbumsByGenre: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api/subsonicClient', () => ({
|
||||
libraryScopeForServer: vi.fn(() => 'lib-a'),
|
||||
}));
|
||||
|
||||
vi.mock('./libraryReady', () => ({
|
||||
libraryIsReady: vi.fn(),
|
||||
}));
|
||||
|
||||
import { libraryListAlbumsByGenre } from '@/lib/api/library';
|
||||
import { getAlbumsByGenre } from '@/lib/api/subsonicGenres';
|
||||
import { libraryIsReady } from './libraryReady';
|
||||
|
||||
describe('genreAlbumBrowse', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(libraryIsReady).mockReset();
|
||||
vi.mocked(libraryListAlbumsByGenre).mockReset();
|
||||
vi.mocked(getAlbumsByGenre).mockReset();
|
||||
});
|
||||
|
||||
it('loads albums from the local genre browse command when the index is ready', async () => {
|
||||
vi.mocked(libraryIsReady).mockResolvedValue(true);
|
||||
vi.mocked(libraryListAlbumsByGenre).mockResolvedValue({
|
||||
source: 'local',
|
||||
hasMore: true,
|
||||
albums: [{
|
||||
serverId: 'srv-1',
|
||||
id: 'al-1',
|
||||
name: 'Album',
|
||||
artist: 'Artist',
|
||||
artistId: 'ar-1',
|
||||
songCount: 8,
|
||||
durationSec: 100,
|
||||
syncedAt: 0,
|
||||
rawJson: {},
|
||||
}],
|
||||
});
|
||||
|
||||
const page = await fetchGenreAlbumPage('srv-1', 'Rock', true, 0, 60, 'alphabeticalByName');
|
||||
|
||||
expect(libraryListAlbumsByGenre).toHaveBeenCalledWith(expect.objectContaining({
|
||||
serverId: 'srv-1',
|
||||
genre: 'Rock',
|
||||
libraryScope: 'lib-a',
|
||||
offset: 0,
|
||||
limit: 60,
|
||||
}));
|
||||
expect(getAlbumsByGenre).not.toHaveBeenCalled();
|
||||
expect(page.albums).toHaveLength(1);
|
||||
expect(page.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to Subsonic byGenre when the local index is unavailable', async () => {
|
||||
vi.mocked(libraryIsReady).mockResolvedValue(false);
|
||||
vi.mocked(getAlbumsByGenre).mockResolvedValue([
|
||||
{ id: 'al-1', name: 'A', artist: 'X', artistId: 'x', songCount: 1, duration: 1 },
|
||||
]);
|
||||
|
||||
const page = await fetchGenreAlbumPage('srv-1', 'Rock', true, 0, 60, 'alphabeticalByName');
|
||||
|
||||
expect(libraryListAlbumsByGenre).not.toHaveBeenCalled();
|
||||
expect(getAlbumsByGenre).toHaveBeenCalledWith('Rock', 60, 0);
|
||||
expect(page.albums).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('uses Subsonic when the local index is disabled', async () => {
|
||||
vi.mocked(getAlbumsByGenre).mockResolvedValue([
|
||||
{ id: 'al-1', name: 'A', artist: 'X', artistId: 'x', songCount: 1, duration: 1 },
|
||||
]);
|
||||
|
||||
const page = await fetchGenreAlbumPage('srv-1', 'Rock', false, 0, 60, 'alphabeticalByName');
|
||||
|
||||
expect(libraryIsReady).not.toHaveBeenCalled();
|
||||
expect(getAlbumsByGenre).toHaveBeenCalledWith('Rock', 60, 0);
|
||||
expect(page.albums).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reads album totals from the local genre browse command when needed', async () => {
|
||||
vi.mocked(libraryIsReady).mockResolvedValue(true);
|
||||
vi.mocked(libraryListAlbumsByGenre).mockResolvedValue({
|
||||
source: 'local',
|
||||
hasMore: false,
|
||||
total: 42,
|
||||
albums: [],
|
||||
});
|
||||
|
||||
await expect(fetchGenreAlbumTotal('srv-1', 'Rock', true, 'alphabeticalByName')).resolves.toBe(42);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { getAlbumsByGenre } from '@/lib/api/subsonicGenres';
|
||||
import { libraryListAlbumsByGenre } from '@/lib/api/library';
|
||||
import { libraryScopeForServer } from '@/lib/api/subsonicClient';
|
||||
import { albumToAlbum } from './advancedSearchLocal';
|
||||
import { albumSortClauses, sortSubsonicAlbums, type AlbumBrowseSort } from './albumBrowseSort';
|
||||
import type { AlbumBrowsePageResult } from './albumBrowseTypes';
|
||||
import { libraryIsReady } from './libraryReady';
|
||||
|
||||
/** First paint — one visible slice only. */
|
||||
export const GENRE_ALBUM_FIRST_PAGE = 60;
|
||||
/** Background SQL chunk when the in-memory buffer is exhausted. */
|
||||
export const GENRE_ALBUM_CATALOG_CHUNK = 200;
|
||||
|
||||
async function fetchLocalGenreAlbumPage(
|
||||
serverId: string,
|
||||
genre: string,
|
||||
offset: number,
|
||||
pageSize: number,
|
||||
sort: AlbumBrowseSort,
|
||||
): Promise<AlbumBrowsePageResult | null> {
|
||||
const scope = libraryScopeForServer(serverId) ?? undefined;
|
||||
if (!(await libraryIsReady(serverId))) return null;
|
||||
try {
|
||||
const resp = await libraryListAlbumsByGenre({
|
||||
serverId,
|
||||
genre,
|
||||
libraryScope: scope,
|
||||
sort: albumSortClauses(sort),
|
||||
limit: pageSize,
|
||||
offset,
|
||||
});
|
||||
if (resp.source !== 'local') return null;
|
||||
return {
|
||||
albums: resp.albums.map(albumToAlbum),
|
||||
hasMore: resp.hasMore,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchNetworkGenreAlbumPage(
|
||||
genre: string,
|
||||
offset: number,
|
||||
pageSize: number,
|
||||
sort: AlbumBrowseSort,
|
||||
): Promise<AlbumBrowsePageResult> {
|
||||
try {
|
||||
const albums = await getAlbumsByGenre(genre, pageSize, offset);
|
||||
return {
|
||||
albums: sortSubsonicAlbums(albums, sort),
|
||||
hasMore: albums.length === pageSize,
|
||||
};
|
||||
} catch {
|
||||
return { albums: [], hasMore: false };
|
||||
}
|
||||
}
|
||||
|
||||
/** Album grid for genre detail — local index when ready, else Subsonic `byGenre`. */
|
||||
export async function fetchGenreAlbumPage(
|
||||
serverId: string,
|
||||
genre: string,
|
||||
indexEnabled: boolean,
|
||||
offset: number,
|
||||
pageSize: number,
|
||||
sort: AlbumBrowseSort,
|
||||
): Promise<AlbumBrowsePageResult> {
|
||||
if (!serverId || !genre.trim()) {
|
||||
return { albums: [], hasMore: false };
|
||||
}
|
||||
|
||||
if (indexEnabled) {
|
||||
const local = await fetchLocalGenreAlbumPage(serverId, genre, offset, pageSize, sort);
|
||||
if (local != null) return local;
|
||||
}
|
||||
|
||||
return fetchNetworkGenreAlbumPage(genre, offset, pageSize, sort);
|
||||
}
|
||||
|
||||
export async function fetchGenreAlbumTotal(
|
||||
serverId: string,
|
||||
genre: string,
|
||||
indexEnabled: boolean,
|
||||
sort: AlbumBrowseSort,
|
||||
): Promise<number | null> {
|
||||
if (!genre.trim()) return null;
|
||||
if (indexEnabled && serverId && (await libraryIsReady(serverId))) {
|
||||
const scope = libraryScopeForServer(serverId) ?? undefined;
|
||||
try {
|
||||
const resp = await libraryListAlbumsByGenre({
|
||||
serverId,
|
||||
genre,
|
||||
libraryScope: scope,
|
||||
sort: albumSortClauses(sort),
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
includeTotal: true,
|
||||
});
|
||||
if (resp.source === 'local' && resp.total != null) return resp.total;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
genreCatalogCacheKey,
|
||||
invalidateGenreCatalogCache,
|
||||
lookupGenreAlbumCount,
|
||||
peekGenreCatalogCache,
|
||||
resetGenreCatalogCountsCacheForTests,
|
||||
writeGenreCatalogCache,
|
||||
} from './genreCatalogCountsCache';
|
||||
|
||||
describe('genreCatalogCountsCache', () => {
|
||||
afterEach(() => {
|
||||
resetGenreCatalogCountsCacheForTests();
|
||||
});
|
||||
|
||||
it('keys by server and library scope', () => {
|
||||
expect(genreCatalogCacheKey('srv-1', undefined)).toBe('srv-1:all');
|
||||
expect(genreCatalogCacheKey('srv-1', 'lib-a')).toBe('srv-1:lib-a');
|
||||
});
|
||||
|
||||
it('serves fresh and stale catalog entries', () => {
|
||||
const genres = [{ value: 'Rock', albumCount: 3, songCount: 10 }];
|
||||
writeGenreCatalogCache('srv-1', 'lib-a', genres);
|
||||
expect(peekGenreCatalogCache('srv-1', 'lib-a')).toEqual(genres);
|
||||
expect(peekGenreCatalogCache('srv-1', 'lib-a', true)).toEqual(genres);
|
||||
expect(peekGenreCatalogCache('srv-1', 'lib-b')).toBeNull();
|
||||
});
|
||||
|
||||
it('looks up album counts from cached catalog', () => {
|
||||
writeGenreCatalogCache('srv-1', 'all', [
|
||||
{ value: 'Rock', albumCount: 12, songCount: 40 },
|
||||
]);
|
||||
expect(lookupGenreAlbumCount('srv-1', 'rock', 'all')).toBe(12);
|
||||
expect(lookupGenreAlbumCount('srv-1', 'Jazz', 'all')).toBeNull();
|
||||
});
|
||||
|
||||
it('invalidates per server', () => {
|
||||
writeGenreCatalogCache('srv-1', 'all', [{ value: 'A', albumCount: 1, songCount: 1 }]);
|
||||
writeGenreCatalogCache('srv-2', 'all', [{ value: 'B', albumCount: 2, songCount: 2 }]);
|
||||
invalidateGenreCatalogCache('srv-1');
|
||||
expect(peekGenreCatalogCache('srv-1', 'all')).toBeNull();
|
||||
expect(peekGenreCatalogCache('srv-2', 'all')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { SubsonicGenre } from '@/lib/api/subsonicTypes';
|
||||
import { resolveServerIdForIndexKey } from '@/utils/server/serverLookup';
|
||||
|
||||
/** Fresh hits skip SQLite entirely. */
|
||||
const FRESH_TTL_MS = 60 * 60 * 1000;
|
||||
/** Stale entries still render while a background refresh runs. */
|
||||
const STALE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
type CacheEntry = {
|
||||
genres: SubsonicGenre[];
|
||||
fetchedAt: number;
|
||||
};
|
||||
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
const inflight = new Map<string, Promise<SubsonicGenre[]>>();
|
||||
|
||||
export function genreCatalogCacheKey(serverId: string, libraryScope?: string): string {
|
||||
const resolved = resolveServerIdForIndexKey(serverId);
|
||||
const folder = libraryScope?.trim() ? libraryScope.trim() : 'all';
|
||||
return `${resolved}:${folder}`;
|
||||
}
|
||||
|
||||
function entryAge(entry: CacheEntry): number {
|
||||
return Date.now() - entry.fetchedAt;
|
||||
}
|
||||
|
||||
function findGenreInCatalog(genres: SubsonicGenre[], genre: string): SubsonicGenre | undefined {
|
||||
return genres.find(g => g.value.localeCompare(genre, undefined, { sensitivity: 'accent' }) === 0);
|
||||
}
|
||||
|
||||
export function peekGenreCatalogCache(
|
||||
serverId: string,
|
||||
libraryScope?: string,
|
||||
allowStale = false,
|
||||
): SubsonicGenre[] | null {
|
||||
const entry = cache.get(genreCatalogCacheKey(serverId, libraryScope));
|
||||
if (!entry) return null;
|
||||
const age = entryAge(entry);
|
||||
if (age <= FRESH_TTL_MS) return entry.genres;
|
||||
if (allowStale && age <= STALE_TTL_MS) return entry.genres;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function lookupGenreAlbumCount(
|
||||
serverId: string,
|
||||
genre: string,
|
||||
libraryScope?: string,
|
||||
): number | null {
|
||||
const entry = cache.get(genreCatalogCacheKey(serverId, libraryScope));
|
||||
if (!entry || entryAge(entry) > STALE_TTL_MS) return null;
|
||||
return findGenreInCatalog(entry.genres, genre)?.albumCount ?? null;
|
||||
}
|
||||
|
||||
export function writeGenreCatalogCache(
|
||||
serverId: string,
|
||||
libraryScope: string | undefined,
|
||||
genres: SubsonicGenre[],
|
||||
): void {
|
||||
cache.set(genreCatalogCacheKey(serverId, libraryScope), {
|
||||
genres,
|
||||
fetchedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export function invalidateGenreCatalogCache(serverId?: string): void {
|
||||
if (!serverId) {
|
||||
cache.clear();
|
||||
inflight.clear();
|
||||
return;
|
||||
}
|
||||
const resolved = resolveServerIdForIndexKey(serverId);
|
||||
const prefix = `${resolved}:`;
|
||||
for (const key of [...cache.keys()]) {
|
||||
if (key.startsWith(prefix)) cache.delete(key);
|
||||
}
|
||||
for (const key of [...inflight.keys()]) {
|
||||
if (key.startsWith(prefix)) inflight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export function getInflightGenreCatalog(key: string): Promise<SubsonicGenre[]> | undefined {
|
||||
return inflight.get(key);
|
||||
}
|
||||
|
||||
export function trackInflightGenreCatalog(key: string, promise: Promise<SubsonicGenre[]>): void {
|
||||
inflight.set(key, promise);
|
||||
void promise.finally(() => {
|
||||
if (inflight.get(key) === promise) inflight.delete(key);
|
||||
});
|
||||
}
|
||||
|
||||
/** Test-only reset. */
|
||||
export function resetGenreCatalogCountsCacheForTests(): void {
|
||||
cache.clear();
|
||||
inflight.clear();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
const CTP_COLORS = [
|
||||
'var(--ctp-rosewater)', 'var(--ctp-flamingo)', 'var(--ctp-pink)', 'var(--ctp-mauve)',
|
||||
'var(--ctp-red)', 'var(--ctp-maroon)', 'var(--ctp-peach)', 'var(--ctp-yellow)',
|
||||
'var(--ctp-green)', 'var(--ctp-teal)', 'var(--ctp-sky)', 'var(--ctp-sapphire)',
|
||||
'var(--ctp-blue)', 'var(--ctp-lavender)',
|
||||
];
|
||||
|
||||
/** Stable Catppuccin-palette colour for a genre name — same hash on the Genres page and album detail. */
|
||||
export function genreColor(name: string): string {
|
||||
let h = 0;
|
||||
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
||||
return CTP_COLORS[h % CTP_COLORS.length];
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { deriveAlbumGenreTags, genreTagsFor, parseItemGenres, splitGenreTags } from './genreTags';
|
||||
|
||||
describe('splitGenreTags', () => {
|
||||
it('splits Navidrome-default separators and dedupes case-insensitively', () => {
|
||||
expect(splitGenreTags('Rock/Jazz')).toEqual(['Rock', 'Jazz']);
|
||||
expect(splitGenreTags('Rock; Jazz, Electronic')).toEqual(['Rock', 'Jazz', 'Electronic']);
|
||||
expect(splitGenreTags('Rock/rock/ROCK')).toEqual(['Rock']);
|
||||
expect(splitGenreTags('')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseItemGenres', () => {
|
||||
it('accepts ItemGenre objects and bare strings', () => {
|
||||
expect(parseItemGenres([{ name: 'A' }, { name: 'B' }])).toEqual([{ name: 'A' }, { name: 'B' }]);
|
||||
expect(parseItemGenres(['A', 'B'])).toEqual([{ name: 'A' }, { name: 'B' }]);
|
||||
expect(parseItemGenres([])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts a single genre object (Subsonic one-element quirk)', () => {
|
||||
expect(parseItemGenres({ name: 'Jazz' })).toEqual([{ name: 'Jazz' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('genreTagsFor', () => {
|
||||
it('prefers genres[] over the compound genre string', () => {
|
||||
expect(genreTagsFor({
|
||||
genre: 'Noise Metal/Dark Ambient/Experimental Black Metal',
|
||||
genres: [{ name: 'Dark Ambient' }, { name: 'Noise Metal' }],
|
||||
})).toEqual(['Dark Ambient', 'Noise Metal']);
|
||||
});
|
||||
|
||||
it('tolerates raw genres shapes from getAlbumList2 passthrough', () => {
|
||||
expect(genreTagsFor({
|
||||
genre: 'Ignored/Compound',
|
||||
genres: { name: 'Rock' },
|
||||
})).toEqual(['Rock']);
|
||||
expect(genreTagsFor({
|
||||
genre: 'Ignored',
|
||||
genres: ['Jazz', 'Blues'],
|
||||
})).toEqual(['Jazz', 'Blues']);
|
||||
});
|
||||
|
||||
it('falls back to splitGenreTags when genres[] is absent', () => {
|
||||
expect(genreTagsFor({
|
||||
genre: 'Noise Metal/Dark Ambient/Experimental Black Metal',
|
||||
})).toEqual([
|
||||
'Noise Metal',
|
||||
'Dark Ambient',
|
||||
'Experimental Black Metal',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveAlbumGenreTags', () => {
|
||||
it('returns album tags only when songs carry no extra genres', () => {
|
||||
expect(deriveAlbumGenreTags(
|
||||
{ genres: [{ name: 'Power Metal' }, { name: 'Rock' }] },
|
||||
[{ genres: [{ name: 'Rock' }] }],
|
||||
)).toEqual(['Power Metal', 'Rock']);
|
||||
});
|
||||
|
||||
it('surfaces track-only genres when the album has none', () => {
|
||||
expect(deriveAlbumGenreTags(
|
||||
{},
|
||||
[{ genres: [{ name: 'Metal' }] }, { genres: [{ name: 'Jazz' }] }],
|
||||
)).toEqual(['Metal', 'Jazz']);
|
||||
});
|
||||
|
||||
it('appends additional track genres after the primary album genres', () => {
|
||||
expect(deriveAlbumGenreTags(
|
||||
{ genre: 'Rock' },
|
||||
[{ genre: 'Metal' }],
|
||||
)).toEqual(['Rock', 'Metal']);
|
||||
});
|
||||
|
||||
it('dedupes a track genre that equals an album genre, keeping primary position', () => {
|
||||
expect(deriveAlbumGenreTags(
|
||||
{ genres: [{ name: 'Power Metal' }] },
|
||||
[{ genres: [{ name: 'power metal' }] }, { genres: [{ name: 'Heavy Metal' }] }],
|
||||
)).toEqual(['Power Metal', 'Heavy Metal']);
|
||||
});
|
||||
|
||||
it('splits a legacy compound album genre string via genreTagsFor', () => {
|
||||
expect(deriveAlbumGenreTags({ genre: 'Rock; Metal' }, [])).toEqual(['Rock', 'Metal']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { SubsonicAlbum, SubsonicItemGenre, SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
|
||||
const GENRE_SEPARATORS = [';', '/', ','] as const;
|
||||
|
||||
function dedupeGenres(genres: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const g of genres) {
|
||||
const t = g.trim();
|
||||
if (!t) continue;
|
||||
const key = t.toLocaleLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(t);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Parse OpenSubsonic `genres` from a raw API payload fragment. */
|
||||
export function parseItemGenres(raw: unknown): SubsonicItemGenre[] | undefined {
|
||||
if (raw == null) return undefined;
|
||||
const items = Array.isArray(raw) ? raw : [raw];
|
||||
if (items.length === 0) return undefined;
|
||||
const names: string[] = [];
|
||||
for (const item of items) {
|
||||
if (item && typeof item === 'object' && !Array.isArray(item)) {
|
||||
const name = (item as { name?: unknown }).name;
|
||||
if (typeof name === 'string' && name.trim()) names.push(name.trim());
|
||||
} else if (typeof item === 'string' && item.trim()) {
|
||||
names.push(item.trim());
|
||||
}
|
||||
}
|
||||
const deduped = dedupeGenres(names);
|
||||
return deduped.length > 0 ? deduped.map(name => ({ name })) : undefined;
|
||||
}
|
||||
|
||||
/** Navidrome-default split when the server sent no `genres[]` array. */
|
||||
export function splitGenreTags(raw: string): string[] {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return [];
|
||||
let parts = [trimmed];
|
||||
for (const sep of GENRE_SEPARATORS) {
|
||||
const next: string[] = [];
|
||||
for (const part of parts) {
|
||||
for (const sub of part.split(sep)) next.push(sub);
|
||||
}
|
||||
parts = next;
|
||||
}
|
||||
return dedupeGenres(parts);
|
||||
}
|
||||
|
||||
type GenreTagSource = Pick<SubsonicSong | SubsonicAlbum, 'genre'> & {
|
||||
/** Runtime shape may be ItemGenre[], a single object, or bare strings (Subsonic JSON). */
|
||||
genres?: unknown;
|
||||
};
|
||||
|
||||
/** Server-authoritative genres when present; otherwise split the legacy `genre` string. */
|
||||
export function genreTagsFor(item: GenreTagSource): string[] {
|
||||
const parsed = parseItemGenres(item.genres);
|
||||
if (parsed && parsed.length > 0) {
|
||||
return dedupeGenres(parsed.map(g => g.name));
|
||||
}
|
||||
const g = item.genre?.trim();
|
||||
return g ? splitGenreTags(g) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* All genres a release should surface: album-level tags first (authoritative for
|
||||
* order and "what the release is"), then track-only tags appended in track order,
|
||||
* case-insensitively deduped against what is already shown. Mirrors what genre
|
||||
* browse derives from `track_genre`, but in-memory from the already-loaded songs —
|
||||
* no extra SQL round-trip.
|
||||
*/
|
||||
export function deriveAlbumGenreTags(
|
||||
album: GenreTagSource,
|
||||
songs: GenreTagSource[] = [],
|
||||
): string[] {
|
||||
const primary = genreTagsFor(album);
|
||||
const seen = new Set(primary.map(g => g.toLocaleLowerCase()));
|
||||
const out = [...primary];
|
||||
for (const song of songs) {
|
||||
for (const g of genreTagsFor(song)) {
|
||||
const key = g.toLocaleLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(g);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { coverEnsureQueueBacklog } from '@/cover/ensureQueue';
|
||||
|
||||
/** Target in-flight + queued ensures ≈ workers × multiplier (mirror analysis backfill). */
|
||||
export const LIBRARY_COVER_BACKLOG_DEPTH_MULTIPLIER = 3;
|
||||
export const LIBRARY_COVER_BACKLOG_MIN = 8;
|
||||
export const LIBRARY_COVER_BACKLOG_MAX = 48;
|
||||
|
||||
export function computeLibraryCoverBackfillTargetDepth(workers: number): number {
|
||||
const w = Math.max(1, Math.round(workers));
|
||||
return Math.min(
|
||||
LIBRARY_COVER_BACKLOG_MAX,
|
||||
Math.max(LIBRARY_COVER_BACKLOG_MIN, w * LIBRARY_COVER_BACKLOG_DEPTH_MULTIPLIER),
|
||||
);
|
||||
}
|
||||
|
||||
export function libraryCoverBackfillNeedsTopUp(workers: number): boolean {
|
||||
return coverEnsureQueueBacklog() < computeLibraryCoverBackfillTargetDepth(workers);
|
||||
}
|
||||
|
||||
export function libraryCoverBackfillTopUpLimit(workers: number, maxBatch: number): number {
|
||||
const target = computeLibraryCoverBackfillTargetDepth(workers);
|
||||
const deficit = target - coverEnsureQueueBacklog();
|
||||
if (deficit <= 0) return 0;
|
||||
return Math.min(maxBatch, deficit);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
decodeCapabilityFlags,
|
||||
explainLibraryReady,
|
||||
formatLibrarySearchLine,
|
||||
ingestStallHint,
|
||||
normalizeIngestMetrics,
|
||||
} from './libraryDevLog';
|
||||
import type { SyncStateDto } from '@/lib/api/library';
|
||||
|
||||
describe('libraryDevLog', () => {
|
||||
it('decodeCapabilityFlags maps known bits', () => {
|
||||
expect(decodeCapabilityFlags(0x003)).toEqual([
|
||||
'navidromeNativeBulk(N1)',
|
||||
'subsonicSearch3Bulk(S1)',
|
||||
]);
|
||||
expect(decodeCapabilityFlags(0)).toEqual(['none']);
|
||||
});
|
||||
|
||||
it('explainLibraryReady covers ready and idle paths', () => {
|
||||
expect(explainLibraryReady({ syncPhase: 'ready' } as SyncStateDto)).toBe('syncPhase=ready');
|
||||
expect(
|
||||
explainLibraryReady({
|
||||
syncPhase: 'initial_sync',
|
||||
localTrackCount: 950,
|
||||
serverTrackCount: 1000,
|
||||
} as SyncStateDto),
|
||||
).toContain('≥95%');
|
||||
expect(
|
||||
explainLibraryReady({
|
||||
syncPhase: 'initial_sync',
|
||||
cursorIngestedCount: 68000,
|
||||
localTrackCount: 69500,
|
||||
serverTrackCount: 170148,
|
||||
} as SyncStateDto),
|
||||
).toContain('69500/170148');
|
||||
expect(
|
||||
explainLibraryReady({
|
||||
syncPhase: 'idle',
|
||||
hasLocalTracks: true,
|
||||
} as SyncStateDto),
|
||||
).toBe('idle + hasLocalTracks');
|
||||
});
|
||||
|
||||
it('normalizeIngestMetrics accepts camelCase and snake_case', () => {
|
||||
expect(
|
||||
normalizeIngestMetrics({
|
||||
offset: 4000,
|
||||
fetchMs: 9000,
|
||||
lockWaitMs: 8500,
|
||||
writeMs: 8510,
|
||||
sqlExecMs: 10,
|
||||
persistMs: 0,
|
||||
rowCount: 500,
|
||||
bulkIngestActive: true,
|
||||
strategy: 's1',
|
||||
}),
|
||||
).toMatchObject({ fetchMs: 9000, lockWaitMs: 8500 });
|
||||
expect(
|
||||
normalizeIngestMetrics({
|
||||
offset: 4000,
|
||||
fetch_ms: 9000,
|
||||
lock_wait_ms: 8500,
|
||||
write_ms: 8510,
|
||||
sql_exec_ms: 10,
|
||||
persist_ms: 0,
|
||||
row_count: 500,
|
||||
bulk_ingest_active: true,
|
||||
strategy: 's1',
|
||||
}),
|
||||
).toMatchObject({ fetchMs: 9000, lockWaitMs: 8500 });
|
||||
});
|
||||
|
||||
it('ingestStallHint flags lock wait vs fetch vs sql', () => {
|
||||
expect(
|
||||
ingestStallHint({
|
||||
offset: 60500,
|
||||
strategy: 's1',
|
||||
fetchMs: 200,
|
||||
writeMs: 61319,
|
||||
lockWaitMs: 61308,
|
||||
sqlExecMs: 11,
|
||||
persistMs: 0,
|
||||
rowCount: 500,
|
||||
bulkIngestActive: true,
|
||||
}),
|
||||
).toBe('write_lock_held_by_other_op');
|
||||
});
|
||||
|
||||
it('formatLibrarySearchLine uses unified search prefix', () => {
|
||||
expect(
|
||||
formatLibrarySearchLine({
|
||||
at: '',
|
||||
query: 'foo',
|
||||
path: 'search_race',
|
||||
surface: 'live_search',
|
||||
durationMs: 14,
|
||||
raceWinner: 'local',
|
||||
raceWinnerMs: 9,
|
||||
counts: { artists: 1, albums: 2, songs: 3 },
|
||||
}),
|
||||
).toBe(
|
||||
'search [live_search] path=search_race winner=local raceMs=9 totalMs=14 hits=1/2/3',
|
||||
);
|
||||
expect(
|
||||
formatLibrarySearchLine({
|
||||
at: '',
|
||||
query: 'bar',
|
||||
path: 'search3',
|
||||
surface: 'advanced_search',
|
||||
source: 'network',
|
||||
durationMs: 120,
|
||||
invokeMs: 80,
|
||||
}),
|
||||
).toBe(
|
||||
'search [advanced_search] path=search3 source=network totalMs=120 invokeMs=80',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
/** DevTools diagnostics for local library index + search (DEV only).
|
||||
* Filter console: `[psysonic][library]`
|
||||
* Search one-liner: `search [surface] path=… winner=…` or `source=…`
|
||||
* Ring buffer: `window.__PSYSONIC_LIBRARY_DEBUG__`
|
||||
*/
|
||||
import type { SyncStateDto } from '@/lib/api/library';
|
||||
import { syncIngestDisplayCount } from './libraryReady';
|
||||
|
||||
const PREFIX = '[psysonic][library]';
|
||||
const MAX_RING = 40;
|
||||
|
||||
export type LibrarySearchPath =
|
||||
| 'library_live_search'
|
||||
| 'library_advanced_search'
|
||||
| 'search3'
|
||||
| 'search_race'
|
||||
| 'browse_race'
|
||||
| 'browse_local_fallback'
|
||||
| 'browse_network_fallback'
|
||||
| 'browse_race_miss'
|
||||
| 'skipped_not_ready'
|
||||
| 'local_empty_fallback';
|
||||
|
||||
/** UI surface for unified search DevTools lines (`search [surface] …`). */
|
||||
export type LibrarySearchSurface =
|
||||
| 'live_search'
|
||||
| 'advanced_search'
|
||||
| 'artists_browse'
|
||||
| 'albums_browse'
|
||||
| 'composers_browse'
|
||||
| 'tracks_browse'
|
||||
| 'search_results';
|
||||
|
||||
export interface LibrarySearchDebugEntry {
|
||||
at: string;
|
||||
query: string;
|
||||
path: LibrarySearchPath;
|
||||
durationMs: number;
|
||||
debounceMs?: number;
|
||||
indexEnabled?: boolean;
|
||||
localReadyCached?: boolean;
|
||||
ready?: boolean;
|
||||
readyReason?: string;
|
||||
readyCheckMs?: number;
|
||||
invokeMs?: number;
|
||||
counts?: { artists: number; albums: number; songs: number };
|
||||
fallbackReason?: string;
|
||||
error?: string;
|
||||
/** Winner when local + network ran in parallel. */
|
||||
raceWinner?: 'local' | 'network';
|
||||
raceWinnerMs?: number;
|
||||
/** Direct (non-race) path source. */
|
||||
source?: 'local' | 'network';
|
||||
surface?: LibrarySearchSurface;
|
||||
}
|
||||
|
||||
export interface LibrarySyncDebugEntry {
|
||||
at: string;
|
||||
kind: string;
|
||||
serverId: string;
|
||||
libraryScope?: string;
|
||||
ingestStrategy?: string | null;
|
||||
ingestPhase?: string | null;
|
||||
syncPhase?: string;
|
||||
n1BulkUnreliable?: boolean | null;
|
||||
ingestedTotal?: number | null;
|
||||
batchCount?: number | null;
|
||||
localTrackCount?: number | null;
|
||||
serverTrackCount?: number | null;
|
||||
message?: string | null;
|
||||
durationMs?: number;
|
||||
/** ms since the previous ingest_page event (UI stall detector). */
|
||||
sinceLastIngestMs?: number;
|
||||
ingestMetrics?: IngestBatchMetrics | null;
|
||||
stallHint?: string;
|
||||
}
|
||||
|
||||
export interface IngestBatchMetrics {
|
||||
offset: number;
|
||||
strategy: string;
|
||||
fetchMs: number;
|
||||
writeMs: number;
|
||||
lockWaitMs: number;
|
||||
sqlExecMs: number;
|
||||
persistMs: number;
|
||||
rowCount: number;
|
||||
bulkIngestActive: boolean;
|
||||
}
|
||||
|
||||
/** Accept camelCase (wire) or legacy snake_case ingest metrics. */
|
||||
export function normalizeIngestMetrics(raw: unknown): IngestBatchMetrics | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const m = raw as Record<string, unknown>;
|
||||
const num = (camel: string, snake: string) =>
|
||||
Number(m[camel] ?? m[snake] ?? 0);
|
||||
return {
|
||||
offset: num('offset', 'offset'),
|
||||
strategy: String(m.strategy ?? ''),
|
||||
fetchMs: num('fetchMs', 'fetch_ms'),
|
||||
writeMs: num('writeMs', 'write_ms'),
|
||||
lockWaitMs: num('lockWaitMs', 'lock_wait_ms'),
|
||||
sqlExecMs: num('sqlExecMs', 'sql_exec_ms'),
|
||||
persistMs: num('persistMs', 'persist_ms'),
|
||||
rowCount: num('rowCount', 'row_count'),
|
||||
bulkIngestActive: Boolean(m.bulkIngestActive ?? m.bulk_ingest_active ?? false),
|
||||
};
|
||||
}
|
||||
|
||||
type LibraryDebugRing = {
|
||||
search: LibrarySearchDebugEntry[];
|
||||
sync: LibrarySyncDebugEntry[];
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__PSYSONIC_LIBRARY_DEBUG__?: LibraryDebugRing;
|
||||
}
|
||||
}
|
||||
|
||||
function ring(): LibraryDebugRing {
|
||||
if (typeof window === 'undefined') {
|
||||
return { search: [], sync: [] };
|
||||
}
|
||||
if (!window.__PSYSONIC_LIBRARY_DEBUG__) {
|
||||
window.__PSYSONIC_LIBRARY_DEBUG__ = { search: [], sync: [] };
|
||||
}
|
||||
return window.__PSYSONIC_LIBRARY_DEBUG__;
|
||||
}
|
||||
|
||||
function pushRing<T>(key: 'search' | 'sync', entry: T): void {
|
||||
if (!import.meta.env.DEV) return;
|
||||
const buf = ring()[key] as T[];
|
||||
buf.push(entry);
|
||||
if (buf.length > MAX_RING) buf.splice(0, buf.length - MAX_RING);
|
||||
}
|
||||
|
||||
export function libraryDevEnabled(): boolean {
|
||||
return import.meta.env.DEV;
|
||||
}
|
||||
|
||||
const LARGE_LIBRARY_THRESHOLD = 40_000;
|
||||
|
||||
/** Label for cursor strategy tag (`n1` / `s1` / `s2`). */
|
||||
export function formatIngestStrategyLabel(tag: string | null | undefined): string {
|
||||
switch (tag) {
|
||||
case 'n1':
|
||||
return 'N1 — Navidrome GET /api/song (bulk)';
|
||||
case 's1':
|
||||
return 'S1 — Subsonic search3 empty query';
|
||||
case 's2':
|
||||
return 'S2 — getAlbumList2 + getAlbum per album';
|
||||
default:
|
||||
return tag ?? '(cursor not written yet)';
|
||||
}
|
||||
}
|
||||
|
||||
/** Best-effort when cursor.strategy is still empty (before first persist). */
|
||||
export function inferInitialIngestStrategy(status: SyncStateDto): string {
|
||||
const flags = status.capabilityFlags ?? 0;
|
||||
const n1 = (flags & 0x001) !== 0;
|
||||
const s1 = (flags & 0x002) !== 0;
|
||||
const server = status.serverTrackCount ?? 0;
|
||||
const large = server > LARGE_LIBRARY_THRESHOLD;
|
||||
const unreliable = status.n1BulkUnreliable === true;
|
||||
if (!unreliable && !large && n1) return 'n1';
|
||||
if (s1) return 's1';
|
||||
return 's2';
|
||||
}
|
||||
|
||||
export function activeIngestStrategy(status: SyncStateDto): {
|
||||
tag: string;
|
||||
label: string;
|
||||
fromCursor: boolean;
|
||||
} {
|
||||
const tag = status.ingestStrategy ?? inferInitialIngestStrategy(status);
|
||||
return {
|
||||
tag,
|
||||
label: formatIngestStrategyLabel(tag),
|
||||
fromCursor: status.ingestStrategy != null,
|
||||
};
|
||||
}
|
||||
|
||||
export function ingestParallelismNote(
|
||||
strategy: string,
|
||||
playbackHint: 'idle' | 'playing' | 'prefetch_active',
|
||||
): string {
|
||||
const depth =
|
||||
playbackHint === 'idle' ? 4 : playbackHint === 'playing' ? 1 : 0;
|
||||
if (playbackHint === 'prefetch_active') {
|
||||
return 'bulk crawl paused (waveform/queue prefetch active)';
|
||||
}
|
||||
if (playbackHint === 'playing') {
|
||||
return `${strategy.toUpperCase()}: sequential HTTP (playback active, max 1)`;
|
||||
}
|
||||
if (strategy === 's2') {
|
||||
return 'S2: parallel getAlbum up to 4 per album-list page';
|
||||
}
|
||||
return `${strategy.toUpperCase()}: prefetch up to ${depth} HTTP pages; IS-3 writes upsert-only (remap/canonical deferred)`;
|
||||
}
|
||||
|
||||
export function decodeCapabilityFlags(flags: number): string[] {
|
||||
const out: string[] = [];
|
||||
if (flags & 0x001) out.push('navidromeNativeBulk(N1)');
|
||||
if (flags & 0x002) out.push('subsonicSearch3Bulk(S1)');
|
||||
if (flags & 0x004) out.push('scanStatus');
|
||||
if (flags & 0x008) out.push('openSubsonic');
|
||||
if (flags & 0x010) out.push('unstableTrackIds');
|
||||
if (flags & 0x020) out.push('fileTreeBrowse');
|
||||
if (out.length === 0) out.push('none');
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Human-readable reason for `libraryStatusIsReady` (DevTools). */
|
||||
export function explainLibraryReady(status: SyncStateDto): string {
|
||||
if (status.syncPhase === 'ready') return 'syncPhase=ready';
|
||||
if (status.syncPhase === 'initial_sync') {
|
||||
const local = syncIngestDisplayCount(status);
|
||||
const server = status.serverTrackCount ?? 0;
|
||||
if (server > 0 && local / server >= 0.95) {
|
||||
return `initial_sync coverage ${local}/${server} (≥95%)`;
|
||||
}
|
||||
return `initial_sync coverage ${local}/${server} (<95%)`;
|
||||
}
|
||||
if (status.syncPhase === 'idle') {
|
||||
if (status.hasLocalTracks) return 'idle + hasLocalTracks';
|
||||
if (status.lastFullSyncAt != null) return 'idle + lastFullSyncAt';
|
||||
if ((status.localTracksMaxUpdatedMs ?? 0) > 0) return 'idle + localTracksMaxUpdatedMs';
|
||||
if ((status.localTrackCount ?? 0) > 0) return 'idle + localTrackCount';
|
||||
return 'idle, no ready signals';
|
||||
}
|
||||
if (status.syncPhase === 'probing') return 'syncPhase=probing';
|
||||
return `syncPhase=${status.syncPhase}`;
|
||||
}
|
||||
|
||||
export function formatLibrarySearchLine(entry: LibrarySearchDebugEntry): string {
|
||||
const surface = entry.surface ?? '?';
|
||||
const hits = entry.counts
|
||||
? ` hits=${entry.counts.artists}/${entry.counts.albums}/${entry.counts.songs}`
|
||||
: '';
|
||||
const fallback = entry.fallbackReason ? ` fallback=${entry.fallbackReason}` : '';
|
||||
const invoke = entry.invokeMs != null ? ` invokeMs=${entry.invokeMs}` : '';
|
||||
const debounce = entry.debounceMs != null ? ` debounceMs=${entry.debounceMs}` : '';
|
||||
const error = entry.error ? ` error=${entry.error}` : '';
|
||||
|
||||
if (entry.raceWinner) {
|
||||
return (
|
||||
`search [${surface}] path=${entry.path} winner=${entry.raceWinner}` +
|
||||
` raceMs=${entry.raceWinnerMs ?? 0} totalMs=${entry.durationMs}` +
|
||||
`${invoke}${debounce}${hits}${fallback}${error}`
|
||||
);
|
||||
}
|
||||
if (entry.source) {
|
||||
return (
|
||||
`search [${surface}] path=${entry.path} source=${entry.source}` +
|
||||
` totalMs=${entry.durationMs}${invoke}${debounce}${hits}${fallback}${error}`
|
||||
);
|
||||
}
|
||||
return (
|
||||
`search [${surface}] path=${entry.path} totalMs=${entry.durationMs}` +
|
||||
`${invoke}${debounce}${hits}${fallback}${error}`
|
||||
);
|
||||
}
|
||||
|
||||
export function logLibrarySearch(entry: LibrarySearchDebugEntry): void {
|
||||
if (!libraryDevEnabled()) return;
|
||||
pushRing('search', entry);
|
||||
console.debug(PREFIX, formatLibrarySearchLine(entry), entry);
|
||||
}
|
||||
|
||||
export function logLibrarySync(entry: LibrarySyncDebugEntry): void {
|
||||
if (!libraryDevEnabled()) return;
|
||||
pushRing('sync', entry);
|
||||
const m = entry.ingestMetrics;
|
||||
const slow =
|
||||
(m?.lockWaitMs ?? 0) >= 1000 ||
|
||||
(m?.writeMs ?? 0) >= 1000 ||
|
||||
(m?.fetchMs ?? 0) >= 5000 ||
|
||||
(entry.sinceLastIngestMs ?? 0) >= 5000;
|
||||
if (entry.kind === 'ingest_page' && m) {
|
||||
const line = `[ingest] off=${m.offset} fetch=${m.fetchMs}ms write=${m.writeMs}ms lockWait=${m.lockWaitMs}ms sql=${m.sqlExecMs}ms persist=${m.persistMs}ms rows=${m.rowCount} bulk=${m.bulkIngestActive}${entry.sinceLastIngestMs != null ? ` gap=${entry.sinceLastIngestMs}ms` : ''}${entry.stallHint ? ` hint=${entry.stallHint}` : ''}`;
|
||||
if (slow) {
|
||||
console.warn(PREFIX, 'ingest-batch SLOW', line, entry);
|
||||
} else {
|
||||
console.debug(PREFIX, 'ingest-batch', line, entry);
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.debug(PREFIX, 'sync', entry);
|
||||
}
|
||||
|
||||
/** Derive a short hint when batch timings implicate a specific bottleneck. */
|
||||
export function ingestStallHint(metrics: IngestBatchMetrics): string | undefined {
|
||||
if (metrics.lockWaitMs >= 1000 && metrics.sqlExecMs < 200) {
|
||||
return 'write_lock_held_by_other_op';
|
||||
}
|
||||
if (metrics.fetchMs >= 5000 && metrics.lockWaitMs < 500) {
|
||||
return 'slow_subsonic_fetch';
|
||||
}
|
||||
if (metrics.sqlExecMs >= 1000 && metrics.lockWaitMs < 500) {
|
||||
return 'slow_sqlite_upsert';
|
||||
}
|
||||
if (metrics.persistMs >= 500) {
|
||||
return 'slow_cursor_persist';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function logLibraryStatus(
|
||||
serverId: string,
|
||||
status: SyncStateDto,
|
||||
label: string,
|
||||
playbackHint: 'idle' | 'playing' | 'prefetch_active' = 'idle',
|
||||
): void {
|
||||
if (!libraryDevEnabled()) return;
|
||||
const ingest = activeIngestStrategy(status);
|
||||
console.debug(PREFIX, 'status', label, {
|
||||
serverId,
|
||||
syncPhase: status.syncPhase,
|
||||
ready: explainLibraryReady(status),
|
||||
ingestStrategy: ingest.tag,
|
||||
ingestStrategyLabel: ingest.label,
|
||||
ingestFromCursor: ingest.fromCursor,
|
||||
ingestPhase: status.ingestPhase ?? null,
|
||||
cursorIngestedCount: status.cursorIngestedCount ?? null,
|
||||
playbackHint,
|
||||
ingestPrefetchDepth: playbackHint === 'idle' ? 4 : playbackHint === 'playing' ? 1 : 0,
|
||||
parallelismNote: ingestParallelismNote(ingest.tag, playbackHint),
|
||||
n1BulkUnreliable: status.n1BulkUnreliable ?? null,
|
||||
localTrackCount: status.localTrackCount ?? null,
|
||||
serverTrackCount: status.serverTrackCount ?? null,
|
||||
hasLocalTracks: status.hasLocalTracks ?? false,
|
||||
capabilities: decodeCapabilityFlags(status.capabilityFlags ?? 0),
|
||||
});
|
||||
}
|
||||
|
||||
export async function timed<T>(fn: () => Promise<T>): Promise<{ result: T; ms: number }> {
|
||||
const t0 = performance.now();
|
||||
const result = await fn();
|
||||
return { result, ms: Math.round(performance.now() - t0) };
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { SyncStateDto } from '@/lib/api/library';
|
||||
import { libraryStatusIsReady, syncIngestDisplayCount } from './libraryReady';
|
||||
|
||||
const status = (over: Partial<SyncStateDto>): SyncStateDto => ({
|
||||
serverId: 's1',
|
||||
libraryScope: '',
|
||||
syncPhase: 'idle',
|
||||
capabilityFlags: 0,
|
||||
libraryTier: 'unknown',
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('libraryStatusIsReady', () => {
|
||||
it('accepts ready', () => {
|
||||
expect(libraryStatusIsReady(status({ syncPhase: 'ready' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts initial_sync at 95% coverage', () => {
|
||||
expect(
|
||||
libraryStatusIsReady(
|
||||
status({ syncPhase: 'initial_sync', localTrackCount: 950, serverTrackCount: 1000 }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts idle after a completed full sync (legacy bind clobber)', () => {
|
||||
expect(
|
||||
libraryStatusIsReady(
|
||||
status({ syncPhase: 'idle', localTrackCount: 100, lastFullSyncAt: 1 }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts idle with lastFullSyncAt even when count snapshot is stale', () => {
|
||||
expect(
|
||||
libraryStatusIsReady(
|
||||
status({ syncPhase: 'idle', localTrackCount: 0, lastFullSyncAt: 1 }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts idle when tracks exist (localTracksMaxUpdatedMs)', () => {
|
||||
expect(
|
||||
libraryStatusIsReady(
|
||||
status({ syncPhase: 'idle', localTracksMaxUpdatedMs: 42 }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts idle when hasLocalTracks is set', () => {
|
||||
expect(
|
||||
libraryStatusIsReady(
|
||||
status({ syncPhase: 'idle', hasLocalTracks: true, localTrackCount: 0 }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects idle without a prior full sync', () => {
|
||||
expect(libraryStatusIsReady(status({ syncPhase: 'idle', localTrackCount: 0 }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncIngestDisplayCount', () => {
|
||||
it('prefers the highest of live db count, cursor, and event total', () => {
|
||||
expect(
|
||||
syncIngestDisplayCount(
|
||||
{ localTrackCount: 69_500, cursorIngestedCount: 68_000 },
|
||||
67_000,
|
||||
),
|
||||
).toBe(69_500);
|
||||
expect(
|
||||
syncIngestDisplayCount(
|
||||
{ localTrackCount: 1_000, cursorIngestedCount: 8_000 },
|
||||
7_500,
|
||||
),
|
||||
).toBe(8_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Is the local library index usable for `serverId` right now?
|
||||
*
|
||||
* Spec §5.13.6 / §9.3 (`isReady()`): consumers only read from the local
|
||||
* index when it's enabled and synced enough for trustworthy results.
|
||||
*/
|
||||
import { libraryGetStatus, type SyncStateDto } from '@/lib/api/library';
|
||||
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
|
||||
|
||||
/** Spec §9.3 — shared by Live Search, Advanced Search, browse, … */
|
||||
export function libraryStatusIsReady(status: SyncStateDto): boolean {
|
||||
if (status.syncPhase === 'ready') return true;
|
||||
if (status.syncPhase === 'initial_sync') {
|
||||
const local = status.localTrackCount ?? 0;
|
||||
const server = status.serverTrackCount ?? 0;
|
||||
if (server > 0 && local / server >= 0.95) return true;
|
||||
}
|
||||
// Re-bind resets sync_phase to `idle` while SQLite data stays — treat a
|
||||
// completed full sync (or live rows) as ready for local reads.
|
||||
if (status.syncPhase === 'idle') {
|
||||
if (status.hasLocalTracks) return true;
|
||||
if (status.lastFullSyncAt != null) return true;
|
||||
if ((status.localTracksMaxUpdatedMs ?? 0) > 0) return true;
|
||||
if ((status.localTrackCount ?? 0) > 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Track count for Settings status when the index is usable. */
|
||||
export function libraryStatusDisplayTrackCount(
|
||||
status: Pick<SyncStateDto, 'localTrackCount' | 'cursorIngestedCount'>,
|
||||
): number {
|
||||
return syncIngestDisplayCount(status);
|
||||
}
|
||||
|
||||
/** Monotonic ingest counter for Settings progress during `initial_sync`. */
|
||||
export function syncIngestDisplayCount(
|
||||
status: Pick<SyncStateDto, 'localTrackCount' | 'cursorIngestedCount'>,
|
||||
eventTotal?: number | null,
|
||||
): number {
|
||||
return Math.max(
|
||||
status.localTrackCount ?? 0,
|
||||
status.cursorIngestedCount ?? 0,
|
||||
eventTotal ?? 0,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
/** True while library sync holds SQLite — pause cover backfill / heavy cover RPC. */
|
||||
export function librarySyncBlocksCoverWork(
|
||||
status: Pick<SyncStateDto, 'syncPhase'>,
|
||||
): boolean {
|
||||
return status.syncPhase === 'initial_sync' || status.syncPhase === 'probing';
|
||||
}
|
||||
|
||||
export async function libraryIsReady(serverId: string | null | undefined): Promise<boolean> {
|
||||
if (!serverId) return false;
|
||||
if (!useLibraryIndexStore.getState().isIndexEnabled(serverId)) return false;
|
||||
try {
|
||||
const status = await libraryGetStatus(serverId);
|
||||
return libraryStatusIsReady(status);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { emitTauriEvent, onInvoke } from '@/test/mocks/tauri';
|
||||
import { resumeInitialSyncIfIncomplete } from './librarySession';
|
||||
import { resetLibrarySyncQueueForTests } from './librarySyncQueue';
|
||||
|
||||
const status = (over: Record<string, unknown> = {}) => ({
|
||||
serverId: 's1',
|
||||
libraryScope: '',
|
||||
syncPhase: 'idle',
|
||||
capabilityFlags: 0,
|
||||
libraryTier: 'unknown',
|
||||
syncedAt: 0,
|
||||
...over,
|
||||
});
|
||||
|
||||
function mockQueuedStart() {
|
||||
const start = vi.fn(async (args: unknown) => {
|
||||
const { serverId } = args as { serverId: string };
|
||||
queueMicrotask(() =>
|
||||
emitTauriEvent('library:sync-idle', {
|
||||
serverId,
|
||||
libraryScope: '',
|
||||
kind: 'initial_sync',
|
||||
ok: true,
|
||||
}),
|
||||
);
|
||||
return { jobId: 'j1', serverId, kind: 'initial_sync' };
|
||||
});
|
||||
onInvoke('library_sync_start', start);
|
||||
return start;
|
||||
}
|
||||
|
||||
describe('resumeInitialSyncIfIncomplete', () => {
|
||||
beforeEach(() => {
|
||||
resetLibrarySyncQueueForTests();
|
||||
});
|
||||
|
||||
it('resumes when initial sync was interrupted mid-run', async () => {
|
||||
onInvoke('library_get_status', () => status({ syncPhase: 'initial_sync' }));
|
||||
const start = mockQueuedStart();
|
||||
|
||||
await resumeInitialSyncIfIncomplete('s1');
|
||||
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
expect(start).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ serverId: 's1', mode: 'full' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not restart when idle with a completed index (legacy missing lastFullSyncAt)', async () => {
|
||||
onInvoke('library_get_status', () =>
|
||||
status({ syncPhase: 'idle', localTrackCount: 12_000 }),
|
||||
);
|
||||
const start = vi.fn();
|
||||
onInvoke('library_sync_start', start);
|
||||
|
||||
await resumeInitialSyncIfIncomplete('s1');
|
||||
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when a full sync has already completed', async () => {
|
||||
onInvoke('library_get_status', () => status({ syncPhase: 'ready', lastFullSyncAt: 1_716_000_000_000 }));
|
||||
const start = vi.fn();
|
||||
onInvoke('library_sync_start', start);
|
||||
|
||||
await resumeInitialSyncIfIncomplete('s1');
|
||||
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('de-dupes concurrent calls so a second start cannot cancel the first', async () => {
|
||||
onInvoke('library_get_status', () => status({ syncPhase: 'initial_sync' }));
|
||||
const start = mockQueuedStart();
|
||||
|
||||
await Promise.all([
|
||||
resumeInitialSyncIfIncomplete('s1'),
|
||||
resumeInitialSyncIfIncomplete('s1'),
|
||||
]);
|
||||
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stays silent when the status lookup fails', async () => {
|
||||
onInvoke('library_get_status', () => { throw new Error('boom'); });
|
||||
const start = vi.fn();
|
||||
onInvoke('library_sync_start', start);
|
||||
|
||||
await expect(resumeInitialSyncIfIncomplete('s1')).resolves.toBeUndefined();
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import {
|
||||
libraryGetStatus,
|
||||
librarySyncBindSession,
|
||||
} from '@/lib/api/library';
|
||||
import { enqueueLibrarySync, queueInitialSyncIfNeeded } from './librarySyncQueue';
|
||||
import type { ServerProfile } from '@/store/authStoreTypes';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
|
||||
import { ensureConnectUrlResolved } from '@/utils/server/serverEndpoint';
|
||||
import { serverIndexKeyForProfile } from '@/utils/server/serverIndexKey';
|
||||
import { syncServerHttpContextForProfile } from '@/utils/server/syncServerHttpContext';
|
||||
import { libraryDevEnabled, logLibraryStatus, logLibrarySync, timed } from './libraryDevLog';
|
||||
|
||||
export type BindServerResult = 'bound' | 'offline' | 'error';
|
||||
|
||||
/**
|
||||
* Bind one server when it participates in the local index (master on, not excluded).
|
||||
*/
|
||||
export async function bindIndexedServer(server: ServerProfile): Promise<BindServerResult> {
|
||||
if (!useLibraryIndexStore.getState().isIndexEnabled(server.id)) return 'error';
|
||||
|
||||
// Dual-address: resolve the connect URL once (LAN-first, sticky cached) and
|
||||
// hand that to the Rust bind-session command — Rust then sees the reachable
|
||||
// endpoint instead of the literal primary URL. Single-address profiles fall
|
||||
// through to one ping, identical to the legacy path.
|
||||
const probe = await ensureConnectUrlResolved(server);
|
||||
if (!probe.ok) return 'offline';
|
||||
const baseUrl = probe.baseUrl;
|
||||
|
||||
try {
|
||||
const t0 = performance.now();
|
||||
await librarySyncBindSession({
|
||||
serverId: server.id,
|
||||
baseUrl,
|
||||
username: server.username,
|
||||
password: server.password,
|
||||
});
|
||||
if (libraryDevEnabled()) {
|
||||
const { result: status, ms } = await timed(() => libraryGetStatus(server.id));
|
||||
logLibrarySync({
|
||||
at: new Date().toISOString(),
|
||||
kind: 'bind_session',
|
||||
serverId: server.id,
|
||||
ingestStrategy: status.ingestStrategy ?? null,
|
||||
ingestPhase: status.ingestPhase ?? null,
|
||||
syncPhase: status.syncPhase,
|
||||
n1BulkUnreliable: status.n1BulkUnreliable ?? null,
|
||||
durationMs: Math.round(performance.now() - t0),
|
||||
message: `status fetch ${ms}ms`,
|
||||
});
|
||||
logLibraryStatus(server.id, status, 'bind_session');
|
||||
}
|
||||
void syncServerHttpContextForProfile(server);
|
||||
return 'bound';
|
||||
} catch {
|
||||
return 'error';
|
||||
}
|
||||
}
|
||||
|
||||
/** Bind + kick off initial sync for one indexed server. */
|
||||
export async function bootstrapIndexedServer(server: ServerProfile): Promise<BindServerResult> {
|
||||
const bound = await bindIndexedServer(server);
|
||||
if (bound !== 'bound') return bound;
|
||||
const indexKey = serverIndexKeyForProfile(server);
|
||||
await queueInitialSyncIfNeeded(indexKey);
|
||||
return 'bound';
|
||||
}
|
||||
|
||||
/** Bind all indexed servers, then queue initial syncs one server at a time. */
|
||||
export async function bootstrapAllIndexedServers(): Promise<Record<string, BindServerResult>> {
|
||||
const lib = useLibraryIndexStore.getState();
|
||||
if (!lib.masterEnabled) return {};
|
||||
const auth = useAuthStore.getState();
|
||||
const active = auth.activeServerId
|
||||
? auth.servers.find(s => s.id === auth.activeServerId) ?? null
|
||||
: null;
|
||||
const indexed = auth.servers.filter(s => lib.isIndexEnabled(s.id));
|
||||
const primaryByKey = new Map<string, ServerProfile>();
|
||||
for (const server of indexed) {
|
||||
const key = serverIndexKeyForProfile(server);
|
||||
if (!primaryByKey.has(key)) primaryByKey.set(key, server);
|
||||
}
|
||||
if (active) {
|
||||
const key = serverIndexKeyForProfile(active);
|
||||
if (primaryByKey.has(key)) primaryByKey.set(key, active);
|
||||
}
|
||||
const results: Record<string, BindServerResult> = {};
|
||||
for (const server of primaryByKey.values()) {
|
||||
const key = serverIndexKeyForProfile(server);
|
||||
results[key] = await bindIndexedServer(server);
|
||||
}
|
||||
for (const server of primaryByKey.values()) {
|
||||
const key = serverIndexKeyForProfile(server);
|
||||
if (results[key] === 'bound') {
|
||||
await queueInitialSyncIfNeeded(key);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-bind the active server when indexed (legacy entry point for startup hooks).
|
||||
*/
|
||||
export async function ensureActiveServerSessionBound(): Promise<boolean> {
|
||||
const auth = useAuthStore.getState();
|
||||
const server = auth.servers.find(s => s.id === auth.activeServerId);
|
||||
if (!server) return false;
|
||||
if (!useLibraryIndexStore.getState().isIndexEnabled(server.id)) return false;
|
||||
return (await bindIndexedServer(server)) === 'bound';
|
||||
}
|
||||
|
||||
const resumeInFlight = new Set<string>();
|
||||
|
||||
export async function resumeInitialSyncIfIncomplete(serverId: string): Promise<void> {
|
||||
if (resumeInFlight.has(serverId)) return;
|
||||
resumeInFlight.add(serverId);
|
||||
try {
|
||||
const { result: status, ms: statusMs } = await timed(() => libraryGetStatus(serverId));
|
||||
if (status.syncPhase === 'ready' || status.lastFullSyncAt) return;
|
||||
if (status.syncPhase !== 'initial_sync') return;
|
||||
const resumeT0 = performance.now();
|
||||
await enqueueLibrarySync({ serverId, kind: 'full' });
|
||||
if (libraryDevEnabled()) {
|
||||
logLibrarySync({
|
||||
at: new Date().toISOString(),
|
||||
kind: 'resume_initial_sync',
|
||||
serverId,
|
||||
ingestStrategy: status.ingestStrategy ?? null,
|
||||
ingestPhase: status.ingestPhase ?? null,
|
||||
syncPhase: status.syncPhase,
|
||||
n1BulkUnreliable: status.n1BulkUnreliable ?? null,
|
||||
localTrackCount: status.localTrackCount ?? null,
|
||||
serverTrackCount: status.serverTrackCount ?? null,
|
||||
durationMs: Math.round(performance.now() - resumeT0),
|
||||
message: `status ${statusMs}ms`,
|
||||
});
|
||||
logLibraryStatus(serverId, status, 'resume_initial_sync');
|
||||
}
|
||||
} catch {
|
||||
/* best-effort */
|
||||
} finally {
|
||||
resumeInFlight.delete(serverId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { emitTauriEvent, onInvoke } from '@/test/mocks/tauri';
|
||||
import {
|
||||
enqueueLibrarySync,
|
||||
resetLibrarySyncQueueForTests,
|
||||
} from './librarySyncQueue';
|
||||
|
||||
function mockSyncStart() {
|
||||
const start = vi.fn(async (args: unknown) => {
|
||||
const { serverId } = args as { serverId: string; mode: string };
|
||||
queueMicrotask(() =>
|
||||
emitTauriEvent('library:sync-idle', {
|
||||
serverId,
|
||||
libraryScope: '',
|
||||
kind: 'initial_sync',
|
||||
ok: true,
|
||||
}),
|
||||
);
|
||||
return { jobId: `j-${serverId}`, serverId, kind: 'initial_sync' };
|
||||
});
|
||||
onInvoke('library_sync_start', start);
|
||||
return start;
|
||||
}
|
||||
|
||||
describe('librarySyncQueue', () => {
|
||||
beforeEach(() => {
|
||||
resetLibrarySyncQueueForTests();
|
||||
});
|
||||
|
||||
it('runs queued syncs one server at a time', async () => {
|
||||
const order: string[] = [];
|
||||
onInvoke('library_sync_start', async (args: unknown) => {
|
||||
const { serverId } = args as { serverId: string };
|
||||
order.push(`start:${serverId}`);
|
||||
await new Promise(r => setTimeout(r, 5));
|
||||
queueMicrotask(() => {
|
||||
order.push(`idle:${serverId}`);
|
||||
emitTauriEvent('library:sync-idle', {
|
||||
serverId,
|
||||
libraryScope: '',
|
||||
kind: 'initial_sync',
|
||||
ok: true,
|
||||
});
|
||||
});
|
||||
return { jobId: `j-${serverId}`, serverId, kind: 'initial_sync' };
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
enqueueLibrarySync({ serverId: 'a', kind: 'full' }),
|
||||
enqueueLibrarySync({ serverId: 'b', kind: 'full' }),
|
||||
]);
|
||||
|
||||
expect(order).toEqual(['start:a', 'idle:a', 'start:b', 'idle:b']);
|
||||
});
|
||||
|
||||
it('rejects the queue item when sync-idle reports failure', async () => {
|
||||
mockSyncStart();
|
||||
onInvoke('library_sync_start', async (args: unknown) => {
|
||||
const { serverId } = args as { serverId: string };
|
||||
queueMicrotask(() =>
|
||||
emitTauriEvent('library:sync-idle', {
|
||||
serverId,
|
||||
libraryScope: '',
|
||||
kind: 'initial_sync',
|
||||
ok: false,
|
||||
error: 'boom',
|
||||
}),
|
||||
);
|
||||
return { jobId: 'j1', serverId, kind: 'initial_sync' };
|
||||
});
|
||||
|
||||
await expect(enqueueLibrarySync({ serverId: 's1', kind: 'full' })).rejects.toThrow(
|
||||
'boom',
|
||||
);
|
||||
});
|
||||
|
||||
it('routes verify through library_sync_verify_integrity', async () => {
|
||||
const verify = vi.fn(async (args: unknown) => {
|
||||
const { serverId } = args as { serverId: string };
|
||||
queueMicrotask(() =>
|
||||
emitTauriEvent('library:sync-idle', {
|
||||
serverId,
|
||||
libraryScope: '',
|
||||
kind: 'delta_sync',
|
||||
ok: true,
|
||||
}),
|
||||
);
|
||||
return { jobId: 'v1', serverId, kind: 'delta_sync' };
|
||||
});
|
||||
onInvoke('library_sync_verify_integrity', verify);
|
||||
|
||||
await enqueueLibrarySync({ serverId: 's1', kind: 'verify' });
|
||||
|
||||
expect(verify).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import {
|
||||
libraryGetStatus,
|
||||
librarySyncStart,
|
||||
librarySyncVerifyIntegrity,
|
||||
subscribeLibrarySyncIdle,
|
||||
type LibrarySyncIdlePayload,
|
||||
} from '@/lib/api/library';
|
||||
import type { UnlistenFn } from '@tauri-apps/api/event';
|
||||
import { libraryDevEnabled, logLibrarySync } from './libraryDevLog';
|
||||
import { invalidateGenreCatalogCache } from './genreCatalogCountsCache';
|
||||
|
||||
export type LibrarySyncQueueKind = 'full' | 'delta' | 'verify';
|
||||
|
||||
interface QueueItem {
|
||||
serverId: string;
|
||||
kind: LibrarySyncQueueKind;
|
||||
resolve: () => void;
|
||||
reject: (err: unknown) => void;
|
||||
}
|
||||
|
||||
const queue: QueueItem[] = [];
|
||||
let draining = false;
|
||||
let idleListener: Promise<UnlistenFn> | null = null;
|
||||
let waitingForIdle: {
|
||||
serverId: string;
|
||||
resolve: () => void;
|
||||
reject: (err: unknown) => void;
|
||||
} | null = null;
|
||||
|
||||
function logQueue(message: string, serverId?: string, kind?: LibrarySyncQueueKind): void {
|
||||
if (!libraryDevEnabled()) return;
|
||||
logLibrarySync({
|
||||
at: new Date().toISOString(),
|
||||
kind: 'sync_queue',
|
||||
serverId: serverId ?? '',
|
||||
message: `[queue ${queue.length}${draining ? ', draining' : ''}] ${message}${kind ? ` (${kind})` : ''}`,
|
||||
});
|
||||
}
|
||||
|
||||
function ensureIdleListener(): Promise<UnlistenFn> {
|
||||
if (!idleListener) {
|
||||
idleListener = subscribeLibrarySyncIdle(onSyncIdle);
|
||||
}
|
||||
return idleListener;
|
||||
}
|
||||
|
||||
function onSyncIdle(payload: LibrarySyncIdlePayload): void {
|
||||
if (payload.ok) invalidateGenreCatalogCache(payload.serverId);
|
||||
if (!waitingForIdle || waitingForIdle.serverId !== payload.serverId) return;
|
||||
const waiter = waitingForIdle;
|
||||
waitingForIdle = null;
|
||||
if (payload.ok) {
|
||||
logQueue(`idle ok for ${payload.serverId}`, payload.serverId);
|
||||
waiter.resolve();
|
||||
return;
|
||||
}
|
||||
logQueue(`idle error for ${payload.serverId}: ${payload.error ?? 'unknown'}`, payload.serverId);
|
||||
waiter.reject(new Error(payload.error ?? 'library sync failed'));
|
||||
}
|
||||
|
||||
function waitForServerIdle(serverId: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
waitingForIdle = { serverId, resolve, reject };
|
||||
});
|
||||
}
|
||||
|
||||
/** Wait until a server emits `library:sync-idle`, or time out (best-effort). */
|
||||
export function waitForLibrarySyncIdle(serverId: string, timeoutMs = 15_000): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
const timer = setTimeout(() => {
|
||||
unlisten?.();
|
||||
resolve();
|
||||
}, timeoutMs);
|
||||
void subscribeLibrarySyncIdle(p => {
|
||||
if (p.serverId !== serverId) return;
|
||||
clearTimeout(timer);
|
||||
unlisten?.();
|
||||
resolve();
|
||||
}).then(fn => {
|
||||
unlisten = fn;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function invokeSync(serverId: string, kind: LibrarySyncQueueKind): Promise<void> {
|
||||
if (kind === 'verify') {
|
||||
await librarySyncVerifyIntegrity({ serverId });
|
||||
return;
|
||||
}
|
||||
await librarySyncStart({ serverId, mode: kind === 'full' ? 'full' : 'delta' });
|
||||
}
|
||||
|
||||
async function drainQueue(): Promise<void> {
|
||||
if (draining) return;
|
||||
draining = true;
|
||||
await ensureIdleListener();
|
||||
while (queue.length > 0) {
|
||||
const item = queue[0]!;
|
||||
logQueue(`start ${item.serverId}`, item.serverId, item.kind);
|
||||
try {
|
||||
const idlePromise = waitForServerIdle(item.serverId);
|
||||
await invokeSync(item.serverId, item.kind);
|
||||
await idlePromise;
|
||||
queue.shift();
|
||||
item.resolve();
|
||||
} catch (err) {
|
||||
queue.shift();
|
||||
item.reject(err);
|
||||
}
|
||||
}
|
||||
draining = false;
|
||||
if (queue.length > 0) void drainQueue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run library sync jobs one at a time. Waits for `library:sync-idle` before
|
||||
* starting the next server so bulk ingest passes do not cancel each other.
|
||||
*/
|
||||
export function enqueueLibrarySync(args: {
|
||||
serverId: string;
|
||||
kind: LibrarySyncQueueKind;
|
||||
}): Promise<void> {
|
||||
logQueue(`enqueue ${args.serverId}`, args.serverId, args.kind);
|
||||
if (queue.some(item => item.serverId === args.serverId && item.kind === args.kind)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
queue.push({ ...args, resolve, reject });
|
||||
void drainQueue();
|
||||
});
|
||||
}
|
||||
|
||||
/** Skip enqueue when the local index is already complete. */
|
||||
export async function queueInitialSyncIfNeeded(serverId: string): Promise<void> {
|
||||
try {
|
||||
const status = await libraryGetStatus(serverId);
|
||||
if (status.syncPhase === 'initial_sync') return;
|
||||
if (status.syncPhase === 'ready' || status.lastFullSyncAt) return;
|
||||
await enqueueLibrarySync({ serverId, kind: 'full' });
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only reset — clears pending work and idle waiters. */
|
||||
export function resetLibrarySyncQueueForTests(): void {
|
||||
queue.splice(0, queue.length);
|
||||
draining = false;
|
||||
if (waitingForIdle) {
|
||||
waitingForIdle.reject(new Error('queue reset'));
|
||||
waitingForIdle = null;
|
||||
}
|
||||
void idleListener?.then(unlisten => unlisten());
|
||||
idleListener = null;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
import { onInvoke } from '@/test/mocks/tauri';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { emitLiveSearchDebug } from './liveSearchDebug';
|
||||
|
||||
describe('emitLiveSearchDebug', () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({ loggingMode: 'normal' });
|
||||
});
|
||||
|
||||
it('forwards JSON to frontend_debug_log in debug mode', () => {
|
||||
useAuthStore.setState({ loggingMode: 'debug' });
|
||||
let captured: unknown;
|
||||
onInvoke('frontend_debug_log', args => {
|
||||
captured = args;
|
||||
return undefined;
|
||||
});
|
||||
emitLiveSearchDebug('race_winner', { query: 'metal', winner: 'network' });
|
||||
expect(captured).toEqual({
|
||||
scope: 'live-search',
|
||||
message: JSON.stringify({
|
||||
step: 'race_winner',
|
||||
details: { query: 'metal', winner: 'network' },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('is a no-op when logging mode is not debug', () => {
|
||||
let invoked = false;
|
||||
onInvoke('frontend_debug_log', () => {
|
||||
invoked = true;
|
||||
return undefined;
|
||||
});
|
||||
emitLiveSearchDebug('race_winner', { query: 'x' });
|
||||
expect(invoked).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { SearchResults } from '@/lib/api/subsonicTypes';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
export function searchHitCounts(result: SearchResults): string {
|
||||
return `${result.artists.length}/${result.albums.length}/${result.songs.length}`;
|
||||
}
|
||||
|
||||
export function searchResultSamples(result: SearchResults, max = 2) {
|
||||
return {
|
||||
artists: result.artists.slice(0, max).map(a => a.name),
|
||||
albums: result.albums.slice(0, max).map(a => a.name),
|
||||
songs: result.songs.slice(0, max).map(s => s.title),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings → Logging → **Debug** → Rust debug log file (`frontend_debug_log`).
|
||||
* Same transport as normalization / lucky-mix / orbit.
|
||||
*/
|
||||
export function emitLiveSearchDebug(step: string, details?: Record<string, unknown>): void {
|
||||
if (useAuthStore.getState().loggingMode !== 'debug') return;
|
||||
void invoke('frontend_debug_log', {
|
||||
scope: 'live-search',
|
||||
message: JSON.stringify({ step, details }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { onInvoke } from '@/test/mocks/tauri';
|
||||
import type { SearchResults } from '@/lib/api/subsonicTypes';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import {
|
||||
liveSearchQueryRejected,
|
||||
liveSearchQueryTooShort,
|
||||
mergeLiveSearchResults,
|
||||
runLocalLiveSearch,
|
||||
} from './liveSearchLocal';
|
||||
|
||||
const neverStale = { epoch: 1, isStale: () => false };
|
||||
const alwaysStale = { epoch: 1, isStale: () => true };
|
||||
|
||||
describe('runLocalLiveSearch', () => {
|
||||
it('returns null without invoking for a single-character query', async () => {
|
||||
let invoked = false;
|
||||
onInvoke('library_live_search', () => {
|
||||
invoked = true;
|
||||
return { artists: [], albums: [], tracks: [], source: 'local' };
|
||||
});
|
||||
await expect(runLocalLiveSearch('s1', 'а', neverStale)).resolves.toBeNull();
|
||||
expect(invoked).toBe(false);
|
||||
});
|
||||
|
||||
it('returns null when stale before invoke completes', async () => {
|
||||
onInvoke('library_live_search', () => ({
|
||||
artists: [],
|
||||
albums: [],
|
||||
tracks: [{ serverId: 's1', id: 't1', title: 'T', album: 'A', durationSec: 1, syncedAt: 0 }],
|
||||
source: 'local',
|
||||
}));
|
||||
await expect(runLocalLiveSearch('s1', 'foo', alwaysStale)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when live search invoke fails', async () => {
|
||||
onInvoke('library_live_search', () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
await expect(runLocalLiveSearch('s1', 'foo', neverStale)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('maps live search rows to search3-shaped limits', async () => {
|
||||
onInvoke('library_live_search', () => ({
|
||||
artists: Array.from({ length: 8 }, (_, i) => ({
|
||||
serverId: 's1',
|
||||
id: `a${i}`,
|
||||
name: `Artist ${i}`,
|
||||
albumCount: 2,
|
||||
syncedAt: 1,
|
||||
rawJson: {},
|
||||
})),
|
||||
albums: Array.from({ length: 7 }, (_, i) => ({
|
||||
serverId: 's1',
|
||||
id: `al${i}`,
|
||||
name: `Album ${i}`,
|
||||
artist: 'A',
|
||||
artistId: 'a0',
|
||||
songCount: 1,
|
||||
durationSec: 100,
|
||||
syncedAt: 1,
|
||||
rawJson: {},
|
||||
})),
|
||||
tracks: Array.from({ length: 12 }, (_, i) => ({
|
||||
serverId: 's1',
|
||||
id: `t${i}`,
|
||||
title: `Track ${i}`,
|
||||
artist: 'A',
|
||||
album: 'Al',
|
||||
durationSec: 200,
|
||||
syncedAt: 1,
|
||||
rawJson: { id: `t${i}`, title: `Track ${i}`, artist: 'A', album: 'Al', albumId: 'al0', duration: 200 },
|
||||
})),
|
||||
source: 'local',
|
||||
}));
|
||||
|
||||
const res = await runLocalLiveSearch('s1', 'foo', neverStale);
|
||||
expect(res).not.toBeNull();
|
||||
expect(res!.artists).toHaveLength(5);
|
||||
expect(res!.albums).toHaveLength(5);
|
||||
expect(res!.songs).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('passes libraryScope from the sidebar music library filter', async () => {
|
||||
useAuthStore.setState({ musicLibraryFilterByServer: { s1: 'lib7' } });
|
||||
let captured: unknown;
|
||||
onInvoke('library_live_search', (args) => {
|
||||
captured = args;
|
||||
return { artists: [], albums: [], tracks: [], source: 'local' };
|
||||
});
|
||||
await runLocalLiveSearch('s1', 'foo', neverStale);
|
||||
expect(captured).toMatchObject({ request: { serverId: 's1', libraryScope: 'lib7' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('liveSearchQueryRejected', () => {
|
||||
it('rejects syntax junk and single-character queries', () => {
|
||||
expect(liveSearchQueryRejected('**')).toBe(true);
|
||||
expect(liveSearchQueryRejected('1=2')).toBe(true);
|
||||
expect(liveSearchQueryRejected('а')).toBe(true);
|
||||
expect(liveSearchQueryRejected('ab')).toBe(false);
|
||||
expect(liveSearchQueryRejected('metallica')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('liveSearchQueryTooShort', () => {
|
||||
it('treats one grapheme as too short', () => {
|
||||
expect(liveSearchQueryTooShort('а')).toBe(true);
|
||||
expect(liveSearchQueryTooShort('ab')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeLiveSearchResults', () => {
|
||||
it('keeps local order and fills gaps from network', () => {
|
||||
const local: SearchResults = {
|
||||
artists: [{ id: 'a1', name: 'Local' }],
|
||||
albums: [],
|
||||
songs: [{ id: 's1', title: 'Song', artist: 'A', album: 'Al', albumId: 'al0', duration: 1 }],
|
||||
};
|
||||
const network: SearchResults = {
|
||||
artists: [{ id: 'a2', name: 'Net' }],
|
||||
albums: [{ id: 'al1', name: 'Album', artist: 'A', artistId: 'a2', songCount: 1, duration: 100 }],
|
||||
songs: [{ id: 's2', title: 'Other', artist: 'B', album: 'Bl', albumId: 'al1', duration: 2 }],
|
||||
};
|
||||
const merged = mergeLiveSearchResults(local, network);
|
||||
expect(merged.artists.map(a => a.id)).toEqual(['a1', 'a2']);
|
||||
expect(merged.albums.map(a => a.id)).toEqual(['al1']);
|
||||
expect(merged.songs.map(s => s.id)).toEqual(['s1', 's2']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Live Search dropdown against the local library index (spec §5.9 / P24).
|
||||
* Uses column-scoped `library_live_search` FTS — not Advanced Search.
|
||||
* Falls back to search3 when the index isn't ready (caller orchestrates).
|
||||
*/
|
||||
import type { SearchResults } from '@/lib/api/subsonicTypes';
|
||||
import { search } from '@/lib/api/subsonicSearch';
|
||||
import { libraryScopeForServer } from '@/lib/api/subsonicClient';
|
||||
import { libraryLiveSearch } from '@/lib/api/library';
|
||||
import { filterSearchArtistsWithNoAlbums } from '@/lib/api/subsonicSearch';
|
||||
import {
|
||||
albumToAlbum,
|
||||
artistToArtist,
|
||||
trackToSong,
|
||||
} from './advancedSearchLocal';
|
||||
import { logLibrarySearch, timed } from './libraryDevLog';
|
||||
import { searchQueryIsFtsSafe } from './searchQueryFtsSafe';
|
||||
|
||||
export const LIVE_SEARCH_DEBOUNCE_LOCAL_MS = 200;
|
||||
export const LIVE_SEARCH_DEBOUNCE_NETWORK_MS = 300;
|
||||
/** Debounce when local + network run in parallel. */
|
||||
export const LIVE_SEARCH_DEBOUNCE_RACE_MS = 200;
|
||||
|
||||
/** search3 timeout for Live Search network arm (race must not hang on slow servers). */
|
||||
export const LIVE_SEARCH_NETWORK_TIMEOUT_MS = 8000;
|
||||
|
||||
export const LIVE_SEARCH_LIMITS = {
|
||||
artists: 5,
|
||||
albums: 5,
|
||||
songs: 10,
|
||||
} as const;
|
||||
|
||||
/** Local FTS skipped below this length — see `LOCAL_FTS_MIN_QUERY_CHARS` in Rust. */
|
||||
export const LOCAL_FTS_MIN_QUERY_CHARS = 2;
|
||||
|
||||
const ARTIST_LIMIT = 5;
|
||||
const ALBUM_LIMIT = 5;
|
||||
const SONG_LIMIT = 10;
|
||||
|
||||
export function queryGraphemeCount(q: string): number {
|
||||
return [...q].length;
|
||||
}
|
||||
|
||||
export function liveSearchQueryTooShort(query: string): boolean {
|
||||
const q = query.trim();
|
||||
return !q || queryGraphemeCount(q) < LOCAL_FTS_MIN_QUERY_CHARS;
|
||||
}
|
||||
|
||||
/** Skip local FTS and search3 when the query would only produce junk wildcard hits. */
|
||||
export function liveSearchQueryRejected(query: string): boolean {
|
||||
const q = query.trim();
|
||||
if (!q) return true;
|
||||
if (liveSearchQueryTooShort(q)) return true;
|
||||
return !searchQueryIsFtsSafe(q);
|
||||
}
|
||||
|
||||
export type LiveSearchStaleCheck = () => boolean;
|
||||
|
||||
export interface LiveSearchRunContext {
|
||||
epoch: number;
|
||||
isStale: LiveSearchStaleCheck;
|
||||
/** Skip per-path dev log when the caller logs the race winner. */
|
||||
suppressLog?: boolean;
|
||||
}
|
||||
|
||||
export async function runLocalLiveSearch(
|
||||
serverId: string | null | undefined,
|
||||
query: string,
|
||||
ctx: LiveSearchRunContext,
|
||||
): Promise<SearchResults | null> {
|
||||
if (!serverId || ctx.isStale()) return null;
|
||||
const q = query.trim();
|
||||
if (liveSearchQueryRejected(q)) return null;
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
const { result: resp, ms: invokeMs } = await timed(() =>
|
||||
libraryLiveSearch({
|
||||
serverId,
|
||||
query: q,
|
||||
libraryScope: libraryScopeForServer(serverId),
|
||||
artistLimit: ARTIST_LIMIT,
|
||||
albumLimit: ALBUM_LIMIT,
|
||||
songLimit: SONG_LIMIT,
|
||||
requestEpoch: ctx.epoch,
|
||||
}),
|
||||
);
|
||||
if (ctx.isStale()) return null;
|
||||
if (resp.source !== 'local') return null;
|
||||
const mapped: SearchResults = {
|
||||
artists: filterSearchArtistsWithNoAlbums(resp.artists.map(artistToArtist)).slice(
|
||||
0,
|
||||
ARTIST_LIMIT,
|
||||
),
|
||||
albums: resp.albums.map(albumToAlbum).slice(0, ALBUM_LIMIT),
|
||||
songs: resp.tracks.map(trackToSong).slice(0, SONG_LIMIT),
|
||||
};
|
||||
if (!ctx.suppressLog) {
|
||||
logLibrarySearch({
|
||||
at: new Date().toISOString(),
|
||||
query: q,
|
||||
path: 'library_live_search',
|
||||
surface: 'live_search',
|
||||
source: 'local',
|
||||
durationMs: Math.round(performance.now() - t0),
|
||||
invokeMs,
|
||||
counts: {
|
||||
artists: mapped.artists.length,
|
||||
albums: mapped.albums.length,
|
||||
songs: mapped.songs.length,
|
||||
},
|
||||
});
|
||||
}
|
||||
return mapped;
|
||||
} catch (err) {
|
||||
if (ctx.isStale()) return null;
|
||||
if (!ctx.suppressLog) {
|
||||
logLibrarySearch({
|
||||
at: new Date().toISOString(),
|
||||
query: q,
|
||||
path: 'library_live_search',
|
||||
surface: 'live_search',
|
||||
source: 'local',
|
||||
durationMs: Math.round(performance.now() - t0),
|
||||
error: String(err),
|
||||
fallbackReason: 'invoke_failed',
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const EMPTY_SEARCH_RESULTS: SearchResults = {
|
||||
artists: [],
|
||||
albums: [],
|
||||
songs: [],
|
||||
};
|
||||
|
||||
export async function runNetworkLiveSearch(
|
||||
query: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SearchResults | null> {
|
||||
const q = query.trim();
|
||||
if (liveSearchQueryRejected(q)) return null;
|
||||
try {
|
||||
return await search(q, {
|
||||
signal,
|
||||
timeout: LIVE_SEARCH_NETWORK_TIMEOUT_MS,
|
||||
artistCount: LIVE_SEARCH_LIMITS.artists,
|
||||
albumCount: LIVE_SEARCH_LIMITS.albums,
|
||||
songCount: LIVE_SEARCH_LIMITS.songs,
|
||||
});
|
||||
} catch (err) {
|
||||
const name = err instanceof Error ? err.name : '';
|
||||
if (name === 'CanceledError' || name === 'AbortError') return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep local ordering; fill remaining slots from network when search3 returns more hits. */
|
||||
export function mergeLiveSearchResults(
|
||||
primary: SearchResults,
|
||||
supplement: SearchResults,
|
||||
limits: { artists: number; albums: number; songs: number } = LIVE_SEARCH_LIMITS,
|
||||
): SearchResults {
|
||||
const mergeSlice = <T extends { id: string }>(
|
||||
primaryItems: T[],
|
||||
extraItems: T[],
|
||||
limit: number,
|
||||
): T[] => {
|
||||
const seen = new Set(primaryItems.map(i => i.id));
|
||||
const out = [...primaryItems];
|
||||
for (const item of extraItems) {
|
||||
if (out.length >= limit) break;
|
||||
if (seen.has(item.id)) continue;
|
||||
seen.add(item.id);
|
||||
out.push(item);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
return {
|
||||
artists: mergeSlice(primary.artists, supplement.artists, limits.artists),
|
||||
albums: mergeSlice(primary.albums, supplement.albums, limits.albums),
|
||||
songs: mergeSlice(primary.songs, supplement.songs, limits.songs),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Containers that are *only* lossless — keep in sync with Rust `lossless_formats.rs`. */
|
||||
export const LOSSLESS_SUFFIXES = new Set([
|
||||
'flac', 'wav', 'wave', 'aiff', 'aif', 'dsf', 'dff', 'ape', 'wv', 'shn', 'tta',
|
||||
]);
|
||||
|
||||
export function isLosslessSuffix(suffix?: string | null): boolean {
|
||||
if (!suffix) return false;
|
||||
return LOSSLESS_SUFFIXES.has(suffix.toLowerCase());
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export const LOSSLESS_MODE_QUERY = 'lossless=1';
|
||||
|
||||
export function isLosslessMode(searchParams: URLSearchParams): boolean {
|
||||
return searchParams.get('lossless') === '1';
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { onInvoke, invokeMock } from '@/test/mocks/tauri';
|
||||
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
|
||||
import { patchLibraryTrackOnUse } from './patchOnUse';
|
||||
|
||||
describe('patchLibraryTrackOnUse', () => {
|
||||
beforeEach(() => {
|
||||
useLibraryIndexStore.setState({ masterEnabled: true });
|
||||
onInvoke('library_patch_track', () => undefined);
|
||||
});
|
||||
|
||||
it('patches the library track when the index is enabled', async () => {
|
||||
patchLibraryTrackOnUse('s1', 't1', { starredAt: 1700 });
|
||||
await Promise.resolve();
|
||||
expect(invokeMock).toHaveBeenCalledWith('library_patch_track', {
|
||||
serverId: 's1',
|
||||
trackId: 't1',
|
||||
patch: { starredAt: 1700 },
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards an explicit null (unstar) so the column can be cleared', async () => {
|
||||
patchLibraryTrackOnUse('s1', 't1', { starredAt: null });
|
||||
await Promise.resolve();
|
||||
expect(invokeMock).toHaveBeenCalledWith('library_patch_track', {
|
||||
serverId: 's1',
|
||||
trackId: 't1',
|
||||
patch: { starredAt: null },
|
||||
});
|
||||
});
|
||||
|
||||
it('is a no-op when the index is disabled for the server', async () => {
|
||||
useLibraryIndexStore.setState({ masterEnabled: false });
|
||||
patchLibraryTrackOnUse('s1', 't1', { userRating: 4 });
|
||||
await Promise.resolve();
|
||||
expect(invokeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is a no-op without a server id', async () => {
|
||||
patchLibraryTrackOnUse(null, 't1', { userRating: 4 });
|
||||
await Promise.resolve();
|
||||
expect(invokeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never throws when the patch invoke rejects', async () => {
|
||||
onInvoke('library_patch_track', () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
expect(() => patchLibraryTrackOnUse('s1', 't1', { playedAt: 9 })).not.toThrow();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { libraryPatchTrack } from '@/lib/api/library';
|
||||
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
|
||||
|
||||
type TrackPatch = {
|
||||
/** ms epoch when starred, or `null` to clear (unstar). */
|
||||
starredAt?: number | null;
|
||||
userRating?: number | null;
|
||||
playCount?: number | null;
|
||||
/** ms epoch of the last play. */
|
||||
playedAt?: number | null;
|
||||
};
|
||||
|
||||
/** Optional metadata on star/unstar (album/artist); not mirrored into the local index. */
|
||||
export type StarPatchMeta = {
|
||||
/** Owning saved-server profile (cross-server favorites / `?server=` detail). */
|
||||
serverId?: string;
|
||||
name?: string;
|
||||
artist?: string;
|
||||
artistId?: string;
|
||||
coverArtId?: string;
|
||||
year?: number;
|
||||
albumCount?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Patch-on-use (spec §6.5 / F3): after a successful star / rating / scrobble on a
|
||||
* **track**, mirror the change into the local library index. Skipped when the index
|
||||
* is off; Rust no-ops when no row exists. Fire-and-forget.
|
||||
*
|
||||
* Album/artist stars are server-only on browse (no stub rows, no `artist.starred_at`).
|
||||
*/
|
||||
export function patchLibraryTrackOnUse(
|
||||
serverId: string | null | undefined,
|
||||
trackId: string,
|
||||
patch: TrackPatch,
|
||||
): void {
|
||||
if (!serverId || !trackId) return;
|
||||
if (!useLibraryIndexStore.getState().isIndexEnabled(serverId)) return;
|
||||
void libraryPatchTrack({ serverId, trackId, patch }).catch(() => {});
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { searchQueryIsFtsSafe, searchTokenIsFtsSafe } from './searchQueryFtsSafe';
|
||||
|
||||
describe('searchQueryIsFtsSafe', () => {
|
||||
it('rejects equals and wildcard-only junk queries', () => {
|
||||
expect(searchQueryIsFtsSafe('1=2')).toBe(false);
|
||||
expect(searchQueryIsFtsSafe('**')).toBe(false);
|
||||
expect(searchQueryIsFtsSafe('***')).toBe(false);
|
||||
expect(searchQueryIsFtsSafe('****')).toBe(false);
|
||||
expect(searchQueryIsFtsSafe('M=c')).toBe(false);
|
||||
expect(searchQueryIsFtsSafe('V()>P')).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts normal search terms and censorship stars in titles', () => {
|
||||
expect(searchQueryIsFtsSafe('metallica')).toBe(true);
|
||||
expect(searchQueryIsFtsSafe('love supreme')).toBe(true);
|
||||
expect(searchQueryIsFtsSafe('25')).toBe(true);
|
||||
expect(searchQueryIsFtsSafe('AC/DC')).toBe(true);
|
||||
expect(searchQueryIsFtsSafe('***Flawless')).toBe(true);
|
||||
expect(searchQueryIsFtsSafe('B********')).toBe(true);
|
||||
expect(searchQueryIsFtsSafe('F**k This Industry')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects when any token is unsafe', () => {
|
||||
expect(searchQueryIsFtsSafe('dark side')).toBe(true);
|
||||
expect(searchQueryIsFtsSafe('dark = side')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchTokenIsFtsSafe', () => {
|
||||
it('requires at least one letter or digit', () => {
|
||||
expect(searchTokenIsFtsSafe('***')).toBe(false);
|
||||
expect(searchTokenIsFtsSafe('!!!')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Shared guard for local FTS and Subsonic search3.
|
||||
* - Wildcard-only tokens (`**`, `****`) match everything on search3 / FTS5.
|
||||
* - `=` and other query syntax break quoted FTS tokens (`1=2` → junk hits).
|
||||
* - Asterisks in real tags (`***Flawless`, `B********`) stay searchable.
|
||||
*/
|
||||
|
||||
/** FTS5 / search3 syntax — not `*` (censorship stars in titles are valid). */
|
||||
const FTS_QUERY_SYNTAX_CHARS = new Set(['=', ':', '(', ')', '^', '<', '>', '%', '|', '\\']);
|
||||
|
||||
function isWildcardOnlyToken(token: string): boolean {
|
||||
return token.length > 0 && [...token].every(ch => ch === '*');
|
||||
}
|
||||
|
||||
export function searchTokenIsFtsSafe(token: string): boolean {
|
||||
const t = token.trim();
|
||||
if (!t || isWildcardOnlyToken(t)) return false;
|
||||
if ([...t].some(ch => FTS_QUERY_SYNTAX_CHARS.has(ch))) return false;
|
||||
return [...t].some(ch => /\p{L}|\p{N}/u.test(ch) || ch.charCodeAt(0) >= 0x80);
|
||||
}
|
||||
|
||||
/** Every whitespace token must be safe — mirrors `fts_safe_whitespace_tokens` in Rust. */
|
||||
export function searchQueryIsFtsSafe(query: string): boolean {
|
||||
const tokens = query.trim().split(/\s+/).filter(Boolean);
|
||||
return tokens.length > 0 && tokens.every(searchTokenIsFtsSafe);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { SearchResults } from '@/lib/api/subsonicTypes';
|
||||
import { raceLiveSearch, type LiveSearchRaceSettled } from './searchRace';
|
||||
|
||||
const empty: SearchResults = { artists: [], albums: [], songs: [] };
|
||||
const localHits: SearchResults = {
|
||||
artists: [{ id: 'a1', name: 'Local' }],
|
||||
albums: [],
|
||||
songs: [],
|
||||
};
|
||||
const networkHits: SearchResults = {
|
||||
artists: [{ id: 'a2', name: 'Network' }],
|
||||
albums: [],
|
||||
songs: [],
|
||||
};
|
||||
|
||||
describe('raceLiveSearch', () => {
|
||||
it('network wins when it returns hits first', async () => {
|
||||
const winner = await raceLiveSearch(
|
||||
() =>
|
||||
new Promise<SearchResults | null>(resolve => {
|
||||
setTimeout(() => resolve(localHits), 40);
|
||||
}),
|
||||
async () => networkHits,
|
||||
() => false,
|
||||
);
|
||||
expect(winner?.source).toBe('network');
|
||||
});
|
||||
|
||||
it('waits for network when local is empty', async () => {
|
||||
const winner = await raceLiveSearch(
|
||||
async () => empty,
|
||||
async () => networkHits,
|
||||
() => false,
|
||||
);
|
||||
expect(winner?.source).toBe('network');
|
||||
});
|
||||
|
||||
it('local wins when network is empty and local has hits', async () => {
|
||||
const winner = await raceLiveSearch(
|
||||
async () => localHits,
|
||||
async () => empty,
|
||||
() => false,
|
||||
);
|
||||
expect(winner?.source).toBe('local');
|
||||
});
|
||||
|
||||
it('waits for local when network is empty', async () => {
|
||||
const winner = await raceLiveSearch(
|
||||
() =>
|
||||
new Promise<SearchResults | null>(resolve => {
|
||||
setTimeout(() => resolve(localHits), 30);
|
||||
}),
|
||||
async () => empty,
|
||||
() => false,
|
||||
);
|
||||
expect(winner?.source).toBe('local');
|
||||
});
|
||||
|
||||
it('does not pick empty local before network returns hits', async () => {
|
||||
const winner = await raceLiveSearch(
|
||||
async () => empty,
|
||||
() =>
|
||||
new Promise<SearchResults | null>(resolve => {
|
||||
setTimeout(() => resolve(networkHits), 30);
|
||||
}),
|
||||
() => false,
|
||||
);
|
||||
expect(winner?.source).toBe('network');
|
||||
});
|
||||
|
||||
it('calls onSettled with both runner timings', async () => {
|
||||
let settled: LiveSearchRaceSettled | null = null;
|
||||
await raceLiveSearch(
|
||||
async () => localHits,
|
||||
async () => networkHits,
|
||||
() => false,
|
||||
meta => {
|
||||
settled = meta;
|
||||
},
|
||||
);
|
||||
await new Promise<void>(resolve => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
expect(settled).not.toBeNull();
|
||||
expect(settled!.localHits).toBe('1/0/0');
|
||||
expect(settled!.networkHits).toBe('1/0/0');
|
||||
expect(settled!.localMs).toBeGreaterThanOrEqual(0);
|
||||
expect(settled!.networkMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { raceSearchSources } from './searchRace';
|
||||
|
||||
type RacePayload = { id: string };
|
||||
|
||||
describe('raceSearchSources', () => {
|
||||
it('returns the first non-null result', async () => {
|
||||
const winner = await raceSearchSources<RacePayload>(
|
||||
[
|
||||
{
|
||||
source: 'local',
|
||||
run: () =>
|
||||
new Promise<RacePayload | null>(resolve => {
|
||||
setTimeout(() => resolve({ id: 'local' }), 30);
|
||||
}),
|
||||
},
|
||||
{
|
||||
source: 'network',
|
||||
run: () =>
|
||||
new Promise<RacePayload | null>(resolve => {
|
||||
setTimeout(() => resolve({ id: 'network' }), 5);
|
||||
}),
|
||||
},
|
||||
],
|
||||
() => false,
|
||||
);
|
||||
expect(winner?.source).toBe('network');
|
||||
expect(winner?.result).toEqual({ id: 'network' });
|
||||
});
|
||||
|
||||
it('waits for network when local returns null', async () => {
|
||||
const winner = await raceSearchSources<RacePayload>(
|
||||
[
|
||||
{ source: 'local', run: async () => null },
|
||||
{
|
||||
source: 'network',
|
||||
run: async () => ({ id: 'network' }),
|
||||
},
|
||||
],
|
||||
() => false,
|
||||
);
|
||||
expect(winner?.source).toBe('network');
|
||||
});
|
||||
|
||||
it('returns null when every runner returns null', async () => {
|
||||
await expect(
|
||||
raceSearchSources<RacePayload>(
|
||||
[
|
||||
{ source: 'local', run: async () => null },
|
||||
{ source: 'network', run: async () => null },
|
||||
],
|
||||
() => false,
|
||||
),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('rejects when all runners fail', async () => {
|
||||
await expect(
|
||||
raceSearchSources<RacePayload>(
|
||||
[
|
||||
{ source: 'local', run: async () => { throw new Error('local boom'); } },
|
||||
{ source: 'network', run: async () => { throw new Error('network boom'); } },
|
||||
],
|
||||
() => false,
|
||||
),
|
||||
).rejects.toThrow('local boom');
|
||||
});
|
||||
|
||||
it('succeeds when one runner fails and another returns data', async () => {
|
||||
const winner = await raceSearchSources<{ ok: boolean }>(
|
||||
[
|
||||
{ source: 'local', run: async () => { throw new Error('local boom'); } },
|
||||
{ source: 'network', run: async () => ({ ok: true }) },
|
||||
],
|
||||
() => false,
|
||||
);
|
||||
expect(winner?.source).toBe('network');
|
||||
});
|
||||
|
||||
it('does not resolve after isStale becomes true', async () => {
|
||||
let stale = false;
|
||||
const winnerPromise = raceSearchSources<RacePayload>(
|
||||
[
|
||||
{
|
||||
source: 'local',
|
||||
run: () =>
|
||||
new Promise<RacePayload | null>(resolve => {
|
||||
setTimeout(() => {
|
||||
stale = true;
|
||||
resolve({ id: 'late' });
|
||||
}, 10);
|
||||
}),
|
||||
},
|
||||
{ source: 'network', run: async () => null },
|
||||
],
|
||||
() => stale,
|
||||
);
|
||||
await expect(winnerPromise).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Parallel local vs network search — first successful backend wins.
|
||||
*/
|
||||
|
||||
import type { SearchResults } from '@/lib/api/subsonicTypes';
|
||||
|
||||
export type SearchRaceSource = 'local' | 'network';
|
||||
|
||||
export interface SearchRaceWinner<T> {
|
||||
source: SearchRaceSource;
|
||||
result: T;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface SearchRaceRunner<T> {
|
||||
source: SearchRaceSource;
|
||||
run: () => Promise<T | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run search backends in parallel. The first non-null result wins; one runner
|
||||
* failing does not reject until every runner has failed or returned null.
|
||||
*/
|
||||
export async function raceSearchSources<T>(
|
||||
runners: SearchRaceRunner<T>[],
|
||||
isStale: () => boolean,
|
||||
): Promise<SearchRaceWinner<T> | null> {
|
||||
if (runners.length === 0 || isStale()) return null;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let pending = runners.length;
|
||||
let settled = false;
|
||||
const errors: unknown[] = [];
|
||||
|
||||
const onRunnerDone = () => {
|
||||
pending -= 1;
|
||||
if (!settled && pending === 0) {
|
||||
if (errors.length > 0) reject(errors[0]);
|
||||
else resolve(null);
|
||||
}
|
||||
};
|
||||
|
||||
for (const { source, run } of runners) {
|
||||
const t0 = performance.now();
|
||||
void run()
|
||||
.then(result => {
|
||||
if (settled) return;
|
||||
if (isStale()) {
|
||||
onRunnerDone();
|
||||
return;
|
||||
}
|
||||
if (result != null) {
|
||||
settled = true;
|
||||
resolve({
|
||||
source,
|
||||
result,
|
||||
durationMs: Math.round(performance.now() - t0),
|
||||
});
|
||||
return;
|
||||
}
|
||||
onRunnerDone();
|
||||
})
|
||||
.catch(err => {
|
||||
if (settled) return;
|
||||
if (isStale()) {
|
||||
onRunnerDone();
|
||||
return;
|
||||
}
|
||||
errors.push(err);
|
||||
onRunnerDone();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function searchResultsHaveHits(results: SearchResults): boolean {
|
||||
return results.artists.length > 0 || results.albums.length > 0 || results.songs.length > 0;
|
||||
}
|
||||
|
||||
export interface LiveSearchRaceSettled {
|
||||
winner: SearchRaceSource;
|
||||
localMs: number;
|
||||
networkMs: number;
|
||||
localHits: string;
|
||||
networkHits: string;
|
||||
localResult: SearchResults | null;
|
||||
networkResult: SearchResults | null;
|
||||
}
|
||||
|
||||
function hitCounts(r: SearchResults): string {
|
||||
return `${r.artists.length}/${r.albums.length}/${r.songs.length}`;
|
||||
}
|
||||
|
||||
function emptySearchResults(): SearchResults {
|
||||
return { artists: [], albums: [], songs: [] };
|
||||
}
|
||||
|
||||
function resultOrEmpty(result: SearchResults | null): SearchResults {
|
||||
return result ?? emptySearchResults();
|
||||
}
|
||||
|
||||
/**
|
||||
* Live Search race: first backend with hits wins; empty waits for the other.
|
||||
* `onSettled` fires when both runners finish and includes both payloads for merge.
|
||||
*/
|
||||
export async function raceLiveSearch(
|
||||
localRun: () => Promise<SearchResults | null>,
|
||||
networkRun: () => Promise<SearchResults | null>,
|
||||
isStale: () => boolean,
|
||||
onSettled?: (meta: LiveSearchRaceSettled) => void,
|
||||
): Promise<SearchRaceWinner<SearchResults> | null> {
|
||||
if (isStale()) return null;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let resolvedWinner: SearchRaceSource | null = null;
|
||||
let localResult: SearchResults | null = null;
|
||||
let networkResult: SearchResults | null = null;
|
||||
let localDone = false;
|
||||
let networkDone = false;
|
||||
let localMs = 0;
|
||||
let networkMs = 0;
|
||||
let raceNotified = false;
|
||||
const errors: unknown[] = [];
|
||||
|
||||
const notifySettled = () => {
|
||||
if (raceNotified || !localDone || !networkDone || !onSettled) return;
|
||||
raceNotified = true;
|
||||
const local = resultOrEmpty(localResult);
|
||||
const network = resultOrEmpty(networkResult);
|
||||
const winner =
|
||||
resolvedWinner ??
|
||||
(searchResultsHaveHits(network)
|
||||
? 'network'
|
||||
: searchResultsHaveHits(local)
|
||||
? 'local'
|
||||
: networkResult
|
||||
? 'network'
|
||||
: 'local');
|
||||
onSettled({
|
||||
winner,
|
||||
localMs,
|
||||
networkMs,
|
||||
localHits: hitCounts(local),
|
||||
networkHits: hitCounts(network),
|
||||
localResult,
|
||||
networkResult,
|
||||
});
|
||||
};
|
||||
|
||||
const resolveWinner = (source: SearchRaceSource, result: SearchResults, durationMs: number) => {
|
||||
settled = true;
|
||||
resolvedWinner = source;
|
||||
resolve({ source, result, durationMs });
|
||||
};
|
||||
|
||||
const maybeFinish = () => {
|
||||
if (settled || isStale()) return;
|
||||
|
||||
if (localDone && localResult && searchResultsHaveHits(localResult)) {
|
||||
resolveWinner('local', localResult, localMs);
|
||||
if (networkDone) notifySettled();
|
||||
return;
|
||||
}
|
||||
if (networkDone && networkResult && searchResultsHaveHits(networkResult)) {
|
||||
resolveWinner('network', networkResult, networkMs);
|
||||
if (localDone) notifySettled();
|
||||
return;
|
||||
}
|
||||
if (!localDone || !networkDone) return;
|
||||
|
||||
settled = true;
|
||||
if (networkResult && searchResultsHaveHits(networkResult)) {
|
||||
resolvedWinner = 'network';
|
||||
resolve({ source: 'network', result: networkResult, durationMs: networkMs });
|
||||
} else if (localResult && searchResultsHaveHits(localResult)) {
|
||||
resolvedWinner = 'local';
|
||||
resolve({ source: 'local', result: localResult, durationMs: localMs });
|
||||
} else if (networkResult) {
|
||||
resolvedWinner = 'network';
|
||||
resolve({ source: 'network', result: networkResult, durationMs: networkMs });
|
||||
} else if (localResult) {
|
||||
resolvedWinner = 'local';
|
||||
resolve({ source: 'local', result: localResult, durationMs: localMs });
|
||||
} else if (errors.length > 0) {
|
||||
reject(errors[0]);
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
notifySettled();
|
||||
};
|
||||
|
||||
const localT0 = performance.now();
|
||||
void localRun()
|
||||
.then(result => {
|
||||
localMs = Math.round(performance.now() - localT0);
|
||||
localResult = result;
|
||||
localDone = true;
|
||||
maybeFinish();
|
||||
notifySettled();
|
||||
})
|
||||
.catch(err => {
|
||||
errors.push(err);
|
||||
localDone = true;
|
||||
maybeFinish();
|
||||
notifySettled();
|
||||
});
|
||||
|
||||
const networkT0 = performance.now();
|
||||
void networkRun()
|
||||
.then(result => {
|
||||
networkMs = Math.round(performance.now() - networkT0);
|
||||
networkResult = result;
|
||||
networkDone = true;
|
||||
maybeFinish();
|
||||
notifySettled();
|
||||
})
|
||||
.catch(err => {
|
||||
const name = err instanceof Error ? err.name : '';
|
||||
if (name === 'CanceledError' || name === 'AbortError') {
|
||||
networkDone = true;
|
||||
maybeFinish();
|
||||
notifySettled();
|
||||
return;
|
||||
}
|
||||
errors.push(err);
|
||||
networkDone = true;
|
||||
maybeFinish();
|
||||
notifySettled();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { libraryReconcileAlbumStars } from '@/lib/api/library';
|
||||
import { getStarred } from '@/lib/api/subsonicStarRating';
|
||||
import type { SubsonicAlbum } from '@/lib/api/subsonicTypes';
|
||||
import {
|
||||
invalidateStarredAlbumBrowseCache,
|
||||
setStarredAlbumBrowseCache,
|
||||
} from './albumBrowseStarredCache';
|
||||
|
||||
function parseAlbumStarredAtMs(album: SubsonicAlbum): number {
|
||||
if (!album.starred) return Date.now();
|
||||
const parsed = Date.parse(album.starred);
|
||||
return Number.isFinite(parsed) ? parsed : Date.now();
|
||||
}
|
||||
|
||||
function markServerStarredAlbums(albums: SubsonicAlbum[]): SubsonicAlbum[] {
|
||||
return albums.map(a => ({ ...a, starred: a.starred ?? 'true' }));
|
||||
}
|
||||
|
||||
/**
|
||||
* `getStarred2` → `album.starred_at` in the local index (UPDATE only).
|
||||
* Updates the in-memory favorites cache used for instant Albums browse.
|
||||
*/
|
||||
export async function refreshStarredAlbumIndexFromServer(
|
||||
serverId: string,
|
||||
indexEnabled: boolean,
|
||||
): Promise<SubsonicAlbum[]> {
|
||||
const { albums } = await getStarred();
|
||||
const mapped = markServerStarredAlbums(albums);
|
||||
if (indexEnabled) {
|
||||
await libraryReconcileAlbumStars({
|
||||
serverId,
|
||||
starredAlbums: mapped.map(a => ({
|
||||
id: a.id,
|
||||
starredAt: parseAlbumStarredAtMs(a),
|
||||
})),
|
||||
});
|
||||
}
|
||||
setStarredAlbumBrowseCache(serverId, mapped);
|
||||
return mapped;
|
||||
}
|
||||
|
||||
export function invalidateStarredAlbumBrowse(serverId: string | null | undefined): void {
|
||||
invalidateStarredAlbumBrowseCache(serverId);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { TFunction } from 'i18next';
|
||||
import {
|
||||
deriveMoodScores,
|
||||
formatQueueBpmTech,
|
||||
formatQueueMoodLabels,
|
||||
parseTrackEnrichmentFacts,
|
||||
resolveQueueBpm,
|
||||
resolveDisplayBpm,
|
||||
topMoodLabelIds,
|
||||
} from './trackEnrichment';
|
||||
import { topDistinctOximediaMoodTagIds, topOximediaMoodTagIds } from '@/config/moodGroups';
|
||||
|
||||
const t = ((key: string, opts?: Record<string, unknown>) => {
|
||||
if (key === 'queue.bpm') return `${opts?.bpm} BPM`;
|
||||
if (key === 'queue.moods.calm') return 'Calm';
|
||||
if (key === 'queue.moods.peaceful') return 'Peaceful';
|
||||
return key;
|
||||
}) as TFunction;
|
||||
|
||||
describe('parseTrackEnrichmentFacts', () => {
|
||||
it('does not surface oximedia mood labels in UI while detector is disabled', () => {
|
||||
const parsed = parseTrackEnrichmentFacts(
|
||||
[
|
||||
{
|
||||
serverId: 's1',
|
||||
trackId: 't1',
|
||||
factKind: 'moods',
|
||||
sourceKind: 'analysis',
|
||||
sourceId: 'oximedia-60s-center',
|
||||
valueText: '{"calm":0.6,"peaceful":0.4}',
|
||||
confidence: 0.9,
|
||||
fetchedAt: 1,
|
||||
},
|
||||
{
|
||||
serverId: 's1',
|
||||
trackId: 't1',
|
||||
factKind: 'valence',
|
||||
sourceKind: 'analysis',
|
||||
sourceId: 'oximedia-60s-center',
|
||||
valueReal: 0.55,
|
||||
confidence: 0.9,
|
||||
fetchedAt: 1,
|
||||
},
|
||||
{
|
||||
serverId: 's1',
|
||||
trackId: 't1',
|
||||
factKind: 'arousal',
|
||||
sourceKind: 'analysis',
|
||||
sourceId: 'oximedia-60s-center',
|
||||
valueReal: 0.42,
|
||||
confidence: 0.9,
|
||||
fetchedAt: 1,
|
||||
},
|
||||
],
|
||||
null,
|
||||
);
|
||||
expect(parsed.moodLabels).toEqual([]);
|
||||
});
|
||||
|
||||
it('reads analysis bpm fact with highest confidence', () => {
|
||||
const parsed = parseTrackEnrichmentFacts(
|
||||
[
|
||||
{
|
||||
serverId: 's1',
|
||||
trackId: 't1',
|
||||
factKind: 'bpm',
|
||||
sourceKind: 'analysis',
|
||||
sourceId: 'oximedia-60s-center',
|
||||
valueInt: 128,
|
||||
confidence: 0.9,
|
||||
fetchedAt: 1,
|
||||
},
|
||||
{
|
||||
serverId: 's1',
|
||||
trackId: 't1',
|
||||
factKind: 'bpm',
|
||||
sourceKind: 'analysis',
|
||||
sourceId: 'other',
|
||||
valueInt: 110,
|
||||
confidence: 0.5,
|
||||
fetchedAt: 1,
|
||||
},
|
||||
],
|
||||
120,
|
||||
);
|
||||
expect(parsed.measuredBpm).toBe(128);
|
||||
expect(parsed.serverBpm).toBe(120);
|
||||
expect(resolveQueueBpm(parsed)).toBe(128);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveQueueBpm', () => {
|
||||
it('prefers measured analysis bpm over tag', () => {
|
||||
expect(resolveQueueBpm({ serverBpm: 120, measuredBpm: 128, moodLabels: [] })).toBe(128);
|
||||
});
|
||||
|
||||
it('falls back to tag bpm when no analysis fact', () => {
|
||||
expect(resolveQueueBpm({ serverBpm: 120, measuredBpm: null, moodLabels: [] })).toBe(120);
|
||||
});
|
||||
|
||||
it('falls back to measured when tag bpm missing', () => {
|
||||
expect(resolveQueueBpm({ serverBpm: null, measuredBpm: 128, moodLabels: [] })).toBe(128);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDisplayBpm', () => {
|
||||
it('ignores zero tag bpm and uses measured', () => {
|
||||
expect(resolveDisplayBpm(0, 132)).toBe(132);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatters', () => {
|
||||
it('formats bpm for tech row', () => {
|
||||
expect(formatQueueBpmTech({ serverBpm: 120, measuredBpm: 128, moodLabels: [] }, t)).toBe('128 BPM');
|
||||
});
|
||||
|
||||
it('localizes mood labels without weights', () => {
|
||||
expect(formatQueueMoodLabels(['calm', 'peaceful'], t)).toBe('Calm · Peaceful');
|
||||
});
|
||||
});
|
||||
|
||||
describe('topMoodLabelIds', () => {
|
||||
it('returns at most two distinct cluster labels from valence/arousal', () => {
|
||||
const labels = topMoodLabelIds(0.4, 0.75);
|
||||
expect(labels.includes('happy') && labels.includes('excited')).toBe(false);
|
||||
expect(labels.length).toBeLessThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('distinct mood tag picking', () => {
|
||||
it('never keeps both happy and excited from raw scores', () => {
|
||||
expect(topDistinctOximediaMoodTagIds({ calm: 0.52, happy: 0.9, excited: 0.5 })).toEqual(['happy', 'calm']);
|
||||
});
|
||||
|
||||
it('sorts by score descending with id tie-break', () => {
|
||||
expect(topOximediaMoodTagIds({ calm: 0.2, happy: 0.9, excited: 0.5 })).toEqual([
|
||||
'happy',
|
||||
'excited',
|
||||
'calm',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveMoodScores', () => {
|
||||
it('delegates to soft valence/arousal scoring', () => {
|
||||
const scores = deriveMoodScores(0.55, 0.42);
|
||||
expect(topDistinctOximediaMoodTagIds(scores, 2).some(id => id === 'calm' || id === 'peaceful')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { TrackFactDto } from '@/lib/api/library';
|
||||
import {
|
||||
distinctOximediaMoodTagIds,
|
||||
topDistinctOximediaMoodTagIds,
|
||||
topDistinctOximediaMoodTagIdsFromValenceArousal,
|
||||
moodScoresFromValenceArousal,
|
||||
} from '@/config/moodGroups';
|
||||
|
||||
/** Oximedia mood labels in queue/Song Info — off until a reliable model ships. */
|
||||
export const OXIMEDIA_MOOD_UI_ENABLED = false;
|
||||
/** Mood group filter in Advanced Search — off while oximedia mood is disabled. */
|
||||
export const OXIMEDIA_MOOD_SEARCH_ENABLED = false;
|
||||
export const OXIMEDIA_ENRICHMENT_SOURCE_KIND = 'analysis';
|
||||
export const OXIMEDIA_ENRICHMENT_SOURCE_ID = 'oximedia-60s-center';
|
||||
|
||||
/** Oximedia mood label ids — see `src/config/moodGroups.ts` and Rust `mood_groups`. */
|
||||
export type { OximediaMoodTagId } from '@/config/moodGroups';
|
||||
|
||||
export interface ParsedTrackEnrichment {
|
||||
serverBpm: number | null;
|
||||
measuredBpm: number | null;
|
||||
moodLabels: string[];
|
||||
}
|
||||
|
||||
function isOximediaFact(f: TrackFactDto): boolean {
|
||||
return (
|
||||
f.sourceKind === OXIMEDIA_ENRICHMENT_SOURCE_KIND
|
||||
&& (f.sourceId === OXIMEDIA_ENRICHMENT_SOURCE_ID
|
||||
|| f.sourceId.startsWith(`${OXIMEDIA_ENRICHMENT_SOURCE_ID}:`))
|
||||
);
|
||||
}
|
||||
|
||||
function pickBestAnalysisBpmFact(facts: readonly TrackFactDto[]): TrackFactDto | undefined {
|
||||
let best: TrackFactDto | undefined;
|
||||
for (const f of facts) {
|
||||
if (f.factKind !== 'bpm') continue;
|
||||
if (f.sourceKind !== 'analysis') continue;
|
||||
if (f.valueInt == null || f.valueInt <= 0) continue;
|
||||
if (!best || (f.confidence ?? 0) > (best.confidence ?? 0)) best = f;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export function parseTrackEnrichmentFacts(
|
||||
facts: readonly TrackFactDto[],
|
||||
serverBpm: number | null | undefined,
|
||||
): ParsedTrackEnrichment {
|
||||
const oximedia = facts.filter(isOximediaFact);
|
||||
const measured = pickBestAnalysisBpmFact(facts);
|
||||
const moodsFact = oximedia.find(f => f.factKind === 'moods' && f.valueText);
|
||||
const legacyLabelsFact = oximedia.find(f => f.factKind === 'mood_labels' && f.valueText);
|
||||
const moodTagFacts = facts.filter(
|
||||
f => isOximediaFact(f) && f.factKind === 'mood_tag' && f.valueText,
|
||||
);
|
||||
const valence = oximedia.find(f => f.factKind === 'valence')?.valueReal ?? null;
|
||||
const arousal = oximedia.find(f => f.factKind === 'arousal')?.valueReal ?? null;
|
||||
|
||||
const hotBpm = serverBpm != null && serverBpm > 0 ? serverBpm : null;
|
||||
|
||||
const fromMoodsJson = topDistinctOximediaMoodTagIds(parseMoodsScoresJson(moodsFact?.valueText));
|
||||
const fromLegacy = parseMoodLabelsArray(legacyLabelsFact?.valueText);
|
||||
const fromMoodTags = distinctOximediaMoodTagIds(
|
||||
moodTagFacts.map(f => f.valueText!).filter(Boolean),
|
||||
);
|
||||
const fromValenceArousal =
|
||||
valence != null && arousal != null
|
||||
? topDistinctOximediaMoodTagIdsFromValenceArousal(valence, arousal)
|
||||
: [];
|
||||
const rawMoodLabels =
|
||||
(fromValenceArousal.length > 0 ? fromValenceArousal : null)
|
||||
?? (fromMoodTags.length > 0 ? fromMoodTags : null)
|
||||
?? (fromMoodsJson.length > 0 ? fromMoodsJson : null)
|
||||
?? (fromLegacy && fromLegacy.length > 0 ? fromLegacy : null)
|
||||
?? [];
|
||||
const moodLabels = OXIMEDIA_MOOD_UI_ENABLED ? rawMoodLabels : [];
|
||||
|
||||
return {
|
||||
serverBpm: hotBpm,
|
||||
measuredBpm: measured?.valueInt ?? null,
|
||||
moodLabels,
|
||||
};
|
||||
}
|
||||
|
||||
function parseMoodsScoresJson(raw: string | null | undefined): Record<string, number> | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (typeof parsed !== 'object' || parsed == null || Array.isArray(parsed)) return null;
|
||||
const out: Record<string, number> = {};
|
||||
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) out[key] = value;
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseMoodLabelsArray(raw: string | null | undefined): string[] | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
const labels = parsed.filter((v): v is string => typeof v === 'string' && v.length > 0);
|
||||
return labels.length > 0 ? labels.slice(0, 3) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Use `moodScoresFromValenceArousal` — oximedia quadrant copy. */
|
||||
export function deriveMoodScores(valence: number, arousal: number): Record<string, number> {
|
||||
return moodScoresFromValenceArousal(valence, arousal);
|
||||
}
|
||||
|
||||
/** @deprecated Use `topDistinctOximediaMoodTagIdsFromValenceArousal`. */
|
||||
export { topDistinctOximediaMoodTagIdsFromValenceArousal as topMoodLabelIds } from '@/config/moodGroups';
|
||||
|
||||
/** Analysis/measured BPM when present; otherwise file tag BPM. */
|
||||
export function resolveDisplayBpm(
|
||||
tagBpm: number | null | undefined,
|
||||
measuredBpm: number | null | undefined,
|
||||
): number | null {
|
||||
if (measuredBpm != null && measuredBpm > 0) return measuredBpm;
|
||||
if (tagBpm != null && tagBpm > 0) return tagBpm;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Analysis fact wins; tag BPM is shown until a fact is stored. */
|
||||
export function resolveQueueBpm(data: ParsedTrackEnrichment): number | null {
|
||||
return resolveDisplayBpm(data.serverBpm, data.measuredBpm);
|
||||
}
|
||||
|
||||
export function formatQueueBpmTech(data: ParsedTrackEnrichment, t: TFunction): string | null {
|
||||
const bpm = resolveQueueBpm(data);
|
||||
if (bpm == null) return null;
|
||||
return t('queue.bpm', { bpm });
|
||||
}
|
||||
|
||||
export function formatQueueMoodLabels(labels: readonly string[], t: TFunction): string | null {
|
||||
const names = labels
|
||||
.slice(0, 3)
|
||||
.map(id => {
|
||||
const key = `queue.moods.${id}`;
|
||||
const translated = t(key);
|
||||
return translated === key ? id : translated;
|
||||
});
|
||||
return names.length > 0 ? names.join(' · ') : null;
|
||||
}
|
||||
|
||||
function enrichmentHasMoodLabels(data: ParsedTrackEnrichment): boolean {
|
||||
return data.moodLabels.length > 0;
|
||||
}
|
||||
|
||||
function enrichmentHasBpm(data: ParsedTrackEnrichment): boolean {
|
||||
return (data.measuredBpm != null && data.measuredBpm > 0)
|
||||
|| (data.serverBpm != null && data.serverBpm > 0);
|
||||
}
|
||||
|
||||
export function enrichmentDisplayComplete(data: ParsedTrackEnrichment): boolean {
|
||||
return enrichmentHasMoodLabels(data) || enrichmentHasBpm(data);
|
||||
}
|
||||
Reference in New Issue
Block a user