mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
feat(home): Lossless Albums rail + dedicated page + sidebar nav (#506)
* wip(home): Lossless rail + dedicated /lossless-albums page Rail under Home > mostPlayed and a dedicated infinite-scroll page that list albums whose tracks are tagged in lossless containers. Walks Navidrome's native /api/song?_sort=bit_depth&_order=DESC, dedupes by albumId on the way down, stops when the song stream crosses into lossy (bitDepth==0) or the server runs out of rows. _filters has no operators on quality columns, so a sort + walk is the only path; equality on sample_rate / bit_depth probes returned empty (verified). The page paginates through the song-cursor with an in-flight ref so overlapping IntersectionObserver fires don't double-add albums, plus a cancelled flag so React StrictMode's double-mount doesn't apply two parallel result sets in dev. Suffix allowlist excludes ambiguous wrappers — m4a/m4b can be ALAC *or* AAC and Navidrome's response carries an empty codec field, so we can't tell them apart; same story for wma. Allowlist is flac, wav, aiff/aif, dsf/dff, ape, wv, shn, tta — containers that are only lossless. ALAC-in-m4a setups will miss out, acceptable trade-off without a reliable codec field. Status: WIP. Settings home-customizer label and en.ts strings landed, no other locales yet, no quality badge on AlbumCards across the rest of the app, no CHANGELOG. Branch was 'feat/home-hires-rail' during the earlier hi-res-only iteration before the lossless broadening. * wip(home): Lossless page header parity + streaming + sidebar nav Page header now mirrors All Albums: selection mode with three action buttons (Enqueue Selected, Add Offline, Download ZIPs) wired to the same handlers Albums.tsx uses, selection-counter title swap, and the perfFlags.disableMainstageStickyHeader respect path. Filters were intentionally skipped — the rail is sorted by bit_depth, mixing client- side filters with server-driven pagination would produce gaps. Loading feels noticeably faster: ndListLosslessAlbumsPage takes an optional onProgress callback that fires once per internal fetch with the entries discovered in that fetch, so the page can stream new albums into state instead of waiting on the whole loadMore. Page-side budget dropped from 5×200 to 2×100 songs per loadMore (~1 MB worst case vs 5 MB before), since the rail's catch-em-all pass is wrong for infinite-scroll UX. Subtitle under the page title primes the user that this is slower than other album pages because Psysonic walks the song catalog by quality (Navidrome ignores _fields, so per-song responses ship with lyrics + tags + participants whether we want them or not). Sidebar nav entry registered under 'losslessAlbums' with a Gem icon, defaults to visible:false (matches composers / folderBrowser / deviceSync — niche browsing modes). Existing users get the entry appended at the end of their persisted sidebar list automatically via the onRehydrateStorage merge that sidebarStore already runs for new DEFAULT_SIDEBAR_ITEMS. i18n: full coverage across all 8 locales for sidebar.losslessAlbums, home.losslessAlbums, losslessAlbums.empty, losslessAlbums.unsupported and the new losslessAlbums.slowFetchHint subtitle. ru/zh are machine-translation quality, flagged for a polish pass. * feat(home): default Lossless sidebar entry to visible Flips the DEFAULT_SIDEBAR_ITEMS entry for `losslessAlbums` from false to true. Existing installs keep whatever the user has in persisted storage; fresh installs see the entry in the sidebar from the start. Earlier wip commit defaulted it off (matched composers / folderBrowser / deviceSync as a niche browse mode), but the rail + page do show something useful for any library with at least one FLAC/WAV/etc. album, so off-by-default just hid the feature. * docs: changelog entry for PR #506 Logs the Lossless Albums rail + page + sidebar entry in v1.46.0 "## Added". * docs(settings): contributor entry for PR #506 Adds the Lossless Albums rail/page bullet to Psychotoxical's contributions list.
This commit is contained in:
committed by
GitHub
parent
a41e3a624a
commit
bd56177e2c
+145
-1
@@ -64,7 +64,7 @@ function mapNdSong(o: Record<string, unknown>): SubsonicSong {
|
||||
};
|
||||
}
|
||||
|
||||
export type NdSongSort = 'title' | 'artist' | 'album' | 'recently_added' | 'play_count' | 'rating';
|
||||
export type NdSongSort = 'title' | 'artist' | 'album' | 'recently_added' | 'play_count' | 'rating' | 'sample_rate' | 'bit_depth';
|
||||
|
||||
/** Optional opt-in cache for `ndListSongs` — keyed by call signature + active server. */
|
||||
type SongsCacheEntry = { data: SubsonicSong[]; expiresAt: number };
|
||||
@@ -258,6 +258,150 @@ export async function ndListAlbumsByArtistRole(
|
||||
return raw.map(a => mapNdAlbum(a as Record<string, unknown>));
|
||||
}
|
||||
|
||||
export interface NdLosslessAlbumEntry {
|
||||
album: SubsonicAlbum;
|
||||
sampleRate: number;
|
||||
bitDepth: number;
|
||||
}
|
||||
|
||||
export interface NdLosslessPageRequest {
|
||||
/** Resume the song-cursor from a previous call. Default 0 (start fresh). */
|
||||
startSongOffset?: number;
|
||||
songsPerPage?: number;
|
||||
maxPagesPerCall?: number;
|
||||
/** Stop once this many *new* unique album ids are collected this call. */
|
||||
targetNewAlbums: number;
|
||||
/** Mutated as the call walks; keep one Set across calls so repeated invocations
|
||||
* return only albums you haven't seen yet. */
|
||||
seenAlbumIds?: Set<string>;
|
||||
/** Fires once per internal fetch with the entries discovered in that fetch.
|
||||
* Lets a paginated UI render albums progressively while the rest of the
|
||||
* call is still running — the song endpoint returns ~1 MB per 200-song
|
||||
* fetch, so a single `loadMore` that internally pages 5× otherwise stalls
|
||||
* the spinner for several seconds before any album appears. */
|
||||
onProgress?: (entries: NdLosslessAlbumEntry[]) => void;
|
||||
}
|
||||
|
||||
export interface NdLosslessPage {
|
||||
entries: NdLosslessAlbumEntry[];
|
||||
/** True when the song stream entered lossy territory or the server ran
|
||||
* out of rows — caller should stop paginating. */
|
||||
done: boolean;
|
||||
/** Pass back as `startSongOffset` on the next call to continue the walk. */
|
||||
nextSongOffset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a page of lossless albums. Walks the native API's `_sort=bit_depth`
|
||||
* song stream (descending) so all 24/32-bit tracks come first, then 16-bit,
|
||||
* then lossy formats which report `bit_depth: 0`. Dedupes to unique album
|
||||
* ids on the way down and stops as soon as the stream crosses into lossy
|
||||
* territory. `_filters` has no operators usable on quality columns so a
|
||||
* sort + walk is the only path.
|
||||
*
|
||||
* Pages through the song stream internally up to `maxPagesPerCall` so albums
|
||||
* with many tracks (compilations, big lossless box sets) don't soak up a
|
||||
* single fetch window and starve the rest. Stops the internal pagination
|
||||
* once `targetNewAlbums` unique ids are collected this call, the song stream
|
||||
* crosses into lossy, the server returns a short page, or the per-call cap
|
||||
* is hit.
|
||||
*
|
||||
* Stateful pagination (the dedicated Lossless page) reuses the returned
|
||||
* `nextSongOffset` and a long-lived `seenAlbumIds` Set on subsequent calls.
|
||||
* Single-shot pagination (the Home rail) just calls once and ignores the
|
||||
* resume hooks. Returns empty page on Subsonic-only servers — caller treats
|
||||
* that as a silent capability miss.
|
||||
*/
|
||||
/** File-extension allowlist of containers that are *only* lossless. Skips
|
||||
* ambiguous wrappers (m4a/m4b — could be ALAC or AAC, codec field is often
|
||||
* empty in Navidrome responses; wma — could be WMA Lossless or WMA Standard)
|
||||
* because they require a codec check we can't reliably perform. */
|
||||
const LOSSLESS_SUFFIXES = new Set(['flac', 'wav', 'wave', 'aiff', 'aif', 'dsf', 'dff', 'ape', 'wv', 'shn', 'tta']);
|
||||
|
||||
export async function ndListLosslessAlbumsPage(req: NdLosslessPageRequest): Promise<NdLosslessPage> {
|
||||
const PAGE_SIZE = req.songsPerPage ?? 200;
|
||||
const MAX_PAGES = req.maxPagesPerCall ?? 5;
|
||||
const targetAlbums = req.targetNewAlbums;
|
||||
const seen = req.seenAlbumIds ?? new Set<string>();
|
||||
let songOffset = req.startSongOffset ?? 0;
|
||||
|
||||
const baseUrl = useAuthStore.getState().getBaseUrl();
|
||||
if (!baseUrl) throw new Error('No server configured');
|
||||
|
||||
const fetchPage = async (start: number, end: number): Promise<unknown[]> => {
|
||||
const callOnce = async (token: string): Promise<unknown> =>
|
||||
invoke<unknown>('nd_list_songs', {
|
||||
serverUrl: baseUrl,
|
||||
token,
|
||||
sort: 'bit_depth',
|
||||
order: 'DESC',
|
||||
start,
|
||||
end,
|
||||
});
|
||||
|
||||
let token = await getToken();
|
||||
try {
|
||||
const raw = await callOnce(token);
|
||||
return Array.isArray(raw) ? raw : [];
|
||||
} catch (err) {
|
||||
const msg = String(err);
|
||||
if (msg.includes('401') || msg.includes('403')) {
|
||||
token = await getToken(true);
|
||||
const raw = await callOnce(token);
|
||||
return Array.isArray(raw) ? raw : [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const entries: NdLosslessAlbumEntry[] = [];
|
||||
let done = false;
|
||||
|
||||
for (let p = 0; p < MAX_PAGES; p++) {
|
||||
const songs = await fetchPage(songOffset, songOffset + PAGE_SIZE);
|
||||
if (songs.length === 0) { done = true; break; }
|
||||
|
||||
let belowThreshold = false;
|
||||
const pageEntries: NdLosslessAlbumEntry[] = [];
|
||||
for (const item of songs) {
|
||||
if (typeof item !== 'object' || item === null) continue;
|
||||
const o = item as Record<string, unknown>;
|
||||
const bitDepth = asNumber(o.bitDepth) ?? 0;
|
||||
if (bitDepth <= 0) { belowThreshold = true; break; }
|
||||
const suffix = (typeof o.suffix === 'string' ? o.suffix : '').toLowerCase();
|
||||
if (!LOSSLESS_SUFFIXES.has(suffix)) continue;
|
||||
const albumId = asString(o.albumId);
|
||||
if (!albumId || seen.has(albumId)) continue;
|
||||
seen.add(albumId);
|
||||
|
||||
const album: SubsonicAlbum = {
|
||||
id: albumId,
|
||||
name: asString(o.album),
|
||||
artist: asString(o.albumArtist) || asString(o.artist),
|
||||
artistId: asString(o.albumArtistId) || asString(o.artistId),
|
||||
coverArt: asString(o.coverArtId) || albumId,
|
||||
songCount: 0,
|
||||
duration: 0,
|
||||
year: asNumber(o.year),
|
||||
genre: typeof o.genre === 'string' ? o.genre : undefined,
|
||||
};
|
||||
pageEntries.push({ album, bitDepth, sampleRate: asNumber(o.sampleRate) ?? 0 });
|
||||
}
|
||||
|
||||
if (pageEntries.length > 0) {
|
||||
entries.push(...pageEntries);
|
||||
req.onProgress?.(pageEntries);
|
||||
}
|
||||
|
||||
songOffset += songs.length;
|
||||
if (belowThreshold) { done = true; break; }
|
||||
if (songs.length < PAGE_SIZE) { done = true; break; }
|
||||
if (entries.length >= targetAlbums) break;
|
||||
}
|
||||
|
||||
return { entries, done, nextSongOffset: songOffset };
|
||||
}
|
||||
|
||||
/** Drop the cached token AND the songs cache — call when the active server changes. */
|
||||
export function ndClearTokenCache(): void {
|
||||
cachedToken = null;
|
||||
|
||||
Reference in New Issue
Block a user