mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user