feat(ratings): mix cutoff filter for random flows and home, i18n

Apply the same per-axis star cutoff to random mix (multi-batch fetch until
full), random albums, Hero, and Home hero/discover rows. Prefetch artist and
album ratings via Subsonic when list payloads omit them. Exclude items when
0 < rating ≤ threshold; 0 or missing counts as unrated.

Settings: rating filter description interpolates sidebar menu labels; Russian
strings for custom title bar; short axis labels aligned across locales.
This commit is contained in:
Maxim Isaev
2026-04-08 04:57:26 +03:00
parent 8a1d942128
commit 1624880c2a
21 changed files with 557 additions and 137 deletions
+74
View File
@@ -68,6 +68,13 @@ export interface SubsonicAlbum {
userRating?: number;
}
/** OpenSubsonic `artists` / `albumArtists` entries on a child song (may include `userRating`). */
export interface SubsonicOpenArtistRef {
id?: string;
name?: string;
userRating?: number;
}
export interface SubsonicSong {
id: string;
title: string;
@@ -84,6 +91,8 @@ export interface SubsonicSong {
/** Some OpenSubsonic responses attach parent ratings on child songs. */
albumUserRating?: number;
artistUserRating?: number;
artists?: SubsonicOpenArtistRef[];
albumArtists?: SubsonicOpenArtistRef[];
// Audio technical info
bitRate?: number;
suffix?: string;
@@ -260,6 +269,71 @@ export async function getAlbum(id: string): Promise<{ album: SubsonicAlbum; song
return { album, songs: song ?? [] };
}
const MIX_RATING_PREFETCH_CONCURRENCY = 8;
function parseEntityUserRating(v: unknown): number | undefined {
if (v === null || v === undefined) return undefined;
const n = typeof v === 'number' ? v : Number(v);
if (!Number.isFinite(n)) return undefined;
return n;
}
/** Parallel `getArtist` calls to fill mix/album filters when list endpoints omit ratings. */
export async function prefetchArtistUserRatings(
ids: string[],
concurrency = MIX_RATING_PREFETCH_CONCURRENCY,
): Promise<Map<string, number>> {
const unique = [...new Set(ids.filter(Boolean))];
const out = new Map<string, number>();
if (!unique.length) return out;
let next = 0;
async function worker() {
for (;;) {
const i = next++;
if (i >= unique.length) return;
const id = unique[i];
try {
const { artist } = await getArtist(id);
const r = parseEntityUserRating(artist.userRating);
if (r !== undefined) out.set(id, r);
} catch {
/* ignore */
}
}
}
const nWorkers = Math.min(concurrency, unique.length);
await Promise.all(Array.from({ length: nWorkers }, () => worker()));
return out;
}
/** Parallel `getAlbum` calls when `albumList2` entries lack `userRating`. */
export async function prefetchAlbumUserRatings(
ids: string[],
concurrency = MIX_RATING_PREFETCH_CONCURRENCY,
): Promise<Map<string, number>> {
const unique = [...new Set(ids.filter(Boolean))];
const out = new Map<string, number>();
if (!unique.length) return out;
let next = 0;
async function worker() {
for (;;) {
const i = next++;
if (i >= unique.length) return;
const id = unique[i];
try {
const { album } = await getAlbum(id);
const r = parseEntityUserRating(album.userRating);
if (r !== undefined) out.set(id, r);
} catch {
/* ignore */
}
}
}
const nWorkers = Math.min(concurrency, unique.length);
await Promise.all(Array.from({ length: nWorkers }, () => worker()));
return out;
}
export async function getArtists(): Promise<SubsonicArtist[]> {
const data = await api<{ artists: { index: Array<{ artist: SubsonicArtist[] }> } }>('getArtists.view', {
...libraryFilterParams(),