feat(library): local lossless index, filters, and conserve dedicated page (#871)

* feat(library): local lossless index, filters, and conserve dedicated page

Add SQLite-backed lossless album browse and advanced-search filtering,
wire All Albums and artist/album lossless drill-down mode, and hide the
standalone /lossless-albums nav entry from sidebar visibility settings
(conserved route, default off).

* docs(release): note lossless local index in CHANGELOG and credits (PR #871)
This commit is contained in:
cucadmuh
2026-05-27 00:02:46 +03:00
committed by GitHub
parent 418b25914a
commit a8cfff0b62
65 changed files with 1615 additions and 138 deletions
@@ -1,5 +1,5 @@
import { ALL_NAV_ITEMS } from '../../config/navItems';
import type { SidebarItemConfig } from '../../store/sidebarStore';
import { CONSERVED_SIDEBAR_NAV_IDS, type SidebarItemConfig } from '../../store/sidebarStore';
export type SidebarNavSection = 'library' | 'system';
@@ -14,6 +14,7 @@ export function getLibraryItemsForReorder(
randomNavMode: 'hub' | 'separate',
): SidebarItemConfig[] {
return items.filter(cfg => {
if (CONSERVED_SIDEBAR_NAV_IDS.has(cfg.id)) return false;
if (!ALL_NAV_ITEMS[cfg.id] || ALL_NAV_ITEMS[cfg.id].section !== 'library') return false;
if (randomNavMode === 'hub' && (cfg.id === 'randomMix' || cfg.id === 'randomAlbums' || cfg.id === 'luckyMix')) return false;
if (randomNavMode === 'separate' && cfg.id === 'randomPicker') return false;
@@ -27,7 +28,7 @@ export function getSystemItemsForReorder(items: SidebarItemConfig[]): SidebarIte
/** Same entries as in Settings toggles — safe to hide via drag-out. */
export function isSidebarNavItemUserHideable(id: string): boolean {
return Boolean(ALL_NAV_ITEMS[id]);
return Boolean(ALL_NAV_ITEMS[id]) && !CONSERVED_SIDEBAR_NAV_IDS.has(id);
}
/**
@@ -12,6 +12,7 @@ const opts = (over: Partial<Parameters<typeof runLocalAdvancedSearch>[1]> = {})
bpmFrom: '',
bpmTo: '',
moodGroup: '',
losslessOnly: false,
resultType: 'all' as const,
...over,
});
@@ -61,6 +62,25 @@ describe('runLocalAdvancedSearch', () => {
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;
+12
View File
@@ -25,6 +25,7 @@ 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';
@@ -38,6 +39,7 @@ export interface LocalSearchOpts {
bpmFrom: string;
bpmTo: string;
moodGroup: string;
losslessOnly?: boolean;
resultType: AdvancedResultType;
}
@@ -89,6 +91,9 @@ function buildFilters(opts: LocalSearchOpts): LibraryFilterClause[] {
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;
}
@@ -107,6 +112,7 @@ function applyClientSongFilters(
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;
}
@@ -223,6 +229,12 @@ export async function runNetworkAdvancedTextSearch(
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,
+59 -6
View File
@@ -3,7 +3,7 @@
*/
import { search, searchSongsPaged } from '../../api/subsonicSearch';
import type { SearchResults, SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../../api/subsonicTypes';
import { libraryAdvancedSearch } from '../../api/library';
import { libraryAdvancedSearch, libraryGetArtistLosslessBrowse, libraryListLosslessAlbums } from '../../api/library';
import { libraryScopeForServer } from '../../api/subsonicClient';
import {
LIVE_SEARCH_DEBOUNCE_NETWORK_MS,
@@ -364,6 +364,52 @@ export async function runLocalRandomSongs(
}
}
/** 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).
@@ -397,6 +443,7 @@ export async function runLocalAlbumBrowsePage(
offset: number,
pageSize: number,
yearFilter?: { from: number; to: number },
losslessOnly?: boolean,
): Promise<SubsonicAlbum[] | null> {
if (!serverId || !(await libraryIsReady(serverId))) return null;
const filters: LibraryFilterClause[] = [];
@@ -408,6 +455,9 @@ export async function runLocalAlbumBrowsePage(
valueTo: yearFilter.to,
});
}
if (losslessOnly) {
filters.push({ field: 'lossless', op: 'is_true' });
}
try {
const resp = await libraryAdvancedSearch({
serverId,
@@ -436,22 +486,25 @@ export async function runLocalAlbumsByGenres(
genres: string[],
sort: AlbumBrowseSort,
limitPerGenre = GENRE_ALBUM_FETCH_LIMIT,
losslessOnly?: boolean,
): Promise<SubsonicAlbum[] | null> {
if (!serverId || !(await libraryIsReady(serverId)) || genres.length === 0) return null;
try {
const pages = await Promise.all(
genres.map(genre =>
libraryAdvancedSearch({
genres.map(genre => {
const filters: LibraryFilterClause[] = [{ field: 'genre', op: 'eq', value: genre }];
if (losslessOnly) filters.push({ field: 'lossless', op: 'is_true' });
return libraryAdvancedSearch({
serverId,
libraryScope: libraryScopeForServer(serverId) ?? undefined,
entityTypes: ['album'],
filters: [{ field: 'genre', op: 'eq', value: genre }],
filters,
sort: albumSortClauses(sort),
limit: limitPerGenre,
offset: 0,
skipTotals: true,
}),
),
});
}),
);
if (pages.some(p => p.source !== 'local')) return null;
const merged = dedupeById(pages.flatMap(p => p.albums.map(albumToAlbum)));
+9
View File
@@ -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());
}
+5
View File
@@ -0,0 +1,5 @@
export const LOSSLESS_MODE_QUERY = 'lossless=1';
export function isLosslessMode(searchParams: URLSearchParams): boolean {
return searchParams.get('lossless') === '1';
}