mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
06da15caf3
* feat(albums): persist browse sort and genre filter for the session Keep Albums sort and genre selection in an in-memory Zustand store so navigating into album detail and back no longer resets browse context. Fixes #875 (partial). * feat(albums): restore browse filters only when returning from album detail Keep sort in the session store for the app lifetime. Stash genre, year, compilation, starred, and lossless filters when leaving Albums for an album page and restore them on POP (back). Clear the stash when opening Albums from elsewhere via sidebar navigation. * feat(albums): filter quick-clear chips; fix lossless A–Z sort Add inline × on active toolbar filters (genre, year, favorites, lossless, compilations) without opening the popover. Route lossless album browse through advanced search with album sort clauses on Albums and Lossless Albums; client-sort on the network fallback path. * fix(albums): apply year filter when only from or to is set Resolve open-ended year bounds with gte/lte on the local index and partial fromYear/toYear on Subsonic. Update the year filter chip label for single-bound ranges. * refactor(albums): combine browse filters in one query (genre + year + lossless) Replace mutually exclusive load/loadFiltered branches with fetchAlbumBrowsePage that ANDs server-side filters on the local index (genre OR union). Network fallback applies year bounds after genre fetch. Always show sort while a year filter is active. * fix(albums): load favorites filter server-side instead of scanning all albums Starred on Albums was client-only: each page was filtered locally and pendingClientFilterMatch kept paginating the full catalog. Query starred albums via the local index or getAlbumList(starred); apply overrides only for in-session star/unstar. * feat(library): local album/artist favorites via patch-on-use Mirror album- and artist-level stars into the library index (library_patch_album, library_patch_artist, migration 010). Albums and Artists favorites browse use entity starred_at only; normal album catalog stays track-derived so patch stubs do not hide the library. Keep album year on favorite cards via track COALESCE, patch metadata, and safer raw_json merge. * fix(library): reconcile album/artist stars from server, drop stubs Favorites browse uses getAlbumList/getStarred2 as source of truth. library_reconcile_*_stars clears local stars removed elsewhere; patch-on-use updates existing rows only (no stub INSERT). Reconcile on favorites load and after star/unstar in-app. * feat(albums): favorites reconcile, filter combos, and back-navigation fix Album browse keeps filter state when returning from album detail (POP stash read on mount, request-generation guard against stale loads). Favorites use getStarred2 as source of truth: reconcile album.starred_at in the local index (UPDATE only, no stub rows), with a small session cache for instant paint. Combine favorites with lossless or genre via restrictAlbumIds in advanced search. Remove album/artist patch-on-use and migration 010; artist favorites stay network-only. Track patch-on-use unchanged. * feat(albums): catalog year bounds and genre list narrowed by filters Year filter spinners use min/max years from the local track index (not 1900); "from" starts at oldest, "to" at newest, values clamp to catalog. When year, lossless, favorites, or compilation filters are active, the genre picker lists only genres present on matching albums (other filters applied, genre excluded). Adds library_get_catalog_year_bounds for the year UI. * feat(albums): debounce year filter and show genre album counts Debounce year range changes by 350ms before reloading browse. Genre picker lists album counts per genre (from getGenres or from albums matching other active filters) and sorts genres by count descending. * fix(albums): compilation filter detection and scan cap Recognize OpenSubsonic compilation flags (compilation, releaseTypes) so client-side comp filters work on local index rows. Cap background pagination at 500 albums when no matches are visible and show empty state instead of spinning through the whole catalog. * feat(albums): filter compilations via local library index Add `compilation` to advanced search (album entity): reads OpenSubsonic flags from album raw_json. Album browse passes compFilter into library_advanced_search when the index is ready; network-only path keeps client-side filtering with the existing scan cap. * fix(albums): apply compilation filter on track-grouped index browse Album browse uses track aggregation, so compilation clauses were skipped. Filter track raw_json (same SQL as album), merge album flags at sync, and always run the client-side compilation pass as a fallback. * refactor(albums): split browse modules and extract browse_support commands Move album browse fetch/filter logic into focused modules and useAlbumBrowseData; register reconcile/year-bounds Tauri commands from browse_support. Trim dead helpers and barrel exports; fix typecheck in compilation tests. * chore: note PR #876 in CHANGELOG and settings credits * fix(albums): show catalog min/max in partial year filter chip label When only from or to year is set, the active chip now reads e.g. 1990–2020 instead of 1990– or –2025, using indexed catalog bounds when available.
390 lines
13 KiB
TypeScript
390 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 { albumIsCompilation } from './albumCompilation';
|
|
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;
|
|
}
|
|
|
|
/** 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,
|
|
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);
|
|
}
|