Files
psysonic/src/utils/library/advancedSearchLocal.ts
T
cucadmuh fab6ff19bf fix(home): Discover Songs covers for local-index tracks (#874)
* fix(home): pre-warm and prefetch Discover Songs row covers

The Discover Songs raблин. il came out of the cover pipeline merge with two
gaps that left its cards stuck on the placeholder disc icon on cold
caches.

- `warmHomeMainstageCovers` walked `heroAlbums` / `recent` / `random`
  through `ensureAlbumCoverMisses` + `predecodeWarmAlbums` but skipped
  `discoverSongs`, so songs that were peeked but missed on disk had to
  wait for lazy per-card ensure
- `Home.tsx`'s `coverPrefetchRegister` lumped `songRefs` into a
  `cappedRest` slice already saturated by 48 album refs + 16 artist
  refs at a 24-entry cap, so the song row's background prefetch was
  discarded entirely

Fix: ensure + decode-warm the Discover Songs cells alongside the album
rails, and register the song refs in their own bucket with a sane cap
and `middle` priority. Both follow the same shape as the working album
rails — no behavior change for surfaces that were already painting.

* fix(library): resolve track cover art from albumId for local index songs

Discover Songs uses runLocalRandomSongs; trackToSong only mapped coverArtId,
so rows with empty cover_art_id but a valid album_id showed the disc
placeholder. Mirror Rust COALESCE(cover_art_id, album_id) and Live Search's
coverArt ?? albumId in trackToSong, SongCard, and Home prefetch.

* docs(release): note Discover Songs cover fix in CHANGELOG and credits (PR #874)

* docs(release): credit PR #874 to Psychotoxical and cucadmuh jointly

* chore(credits): drop PR #874 from settingsCredits — minor fix

---------

Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com>
2026-05-27 08:49:06 +03:00

378 lines
13 KiB
TypeScript

/**
* Advanced Search against the local library index (spec §5.13 / F2).
*
* Maps the AdvancedSearch UI 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 '../../api/library';
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../../api/subsonicTypes';
import { search } from '../../api/subsonicSearch';
import { libraryScopeForServer } from '../../api/subsonicClient';
import { libraryIsReady } from './libraryReady';
import { logLibrarySearch, timed } from './libraryDevLog';
import { isLosslessSuffix } from './losslessFormats';
import { OXIMEDIA_MOOD_SEARCH_ENABLED } from './trackEnrichment';
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;
}
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,
};
}
/**
* 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 {
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;
}
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,
};
return { ...base, ...(raw as Partial<SubsonicAlbum>) };
}
export function artistToArtist(ar: LibraryArtistDto): SubsonicArtist {
const raw = isObject(ar.rawJson) ? ar.rawJson : {};
const base: SubsonicArtist = {
id: ar.id,
name: ar.name,
albumCount: ar.albumCount ?? undefined,
coverArt: ar.id,
};
return { ...base, ...(raw as Partial<SubsonicArtist>) };
}
/**
* Network search3 path for Advanced Search free-text (mirrors AdvancedSearch.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;
let 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);
}