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; 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 { export interface SubsonicSong {
id: string; id: string;
title: string; title: string;
@@ -84,6 +91,8 @@ export interface SubsonicSong {
/** Some OpenSubsonic responses attach parent ratings on child songs. */ /** Some OpenSubsonic responses attach parent ratings on child songs. */
albumUserRating?: number; albumUserRating?: number;
artistUserRating?: number; artistUserRating?: number;
artists?: SubsonicOpenArtistRef[];
albumArtists?: SubsonicOpenArtistRef[];
// Audio technical info // Audio technical info
bitRate?: number; bitRate?: number;
suffix?: string; suffix?: string;
@@ -260,6 +269,71 @@ export async function getAlbum(id: string): Promise<{ album: SubsonicAlbum; song
return { album, songs: 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[]> { export async function getArtists(): Promise<SubsonicArtist[]> {
const data = await api<{ artists: { index: Array<{ artist: SubsonicArtist[] }> } }>('getArtists.view', { const data = await api<{ artists: { index: Array<{ artist: SubsonicArtist[] }> } }>('getArtists.view', {
...libraryFilterParams(), ...libraryFilterParams(),
+4 -1
View File
@@ -54,6 +54,8 @@ interface AlbumTrackListProps {
currentTrack: Track | null; currentTrack: Track | null;
isPlaying: boolean; isPlaying: boolean;
ratings: Record<string, number>; ratings: Record<string, number>;
/** Merged after local `ratings` (e.g. skip→1★ optimistic updates). */
userRatingOverrides: Record<string, number>;
starredSongs: Set<string>; starredSongs: Set<string>;
onPlaySong: (song: SubsonicSong) => void; onPlaySong: (song: SubsonicSong) => void;
onRate: (songId: string, rating: number) => void; onRate: (songId: string, rating: number) => void;
@@ -67,6 +69,7 @@ export default function AlbumTrackList({
currentTrack, currentTrack,
isPlaying, isPlaying,
ratings, ratings,
userRatingOverrides,
starredSongs, starredSongs,
onPlaySong, onPlaySong,
onRate, onRate,
@@ -252,7 +255,7 @@ export default function AlbumTrackList({
return ( return (
<StarRating <StarRating
key="rating" key="rating"
value={ratings[song.id] ?? song.userRating ?? 0} value={ratings[song.id] ?? userRatingOverrides[song.id] ?? song.userRating ?? 0}
onChange={r => onRate(song.id, r)} onChange={r => onRate(song.id, r)}
/> />
); );
+25 -2
View File
@@ -8,8 +8,12 @@ import { useTranslation } from 'react-i18next';
import { playAlbum } from '../utils/playAlbum'; import { playAlbum } from '../utils/playAlbum';
import { useIsMobile } from '../hooks/useIsMobile'; import { useIsMobile } from '../hooks/useIsMobile';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter';
const INTERVAL_MS = 10000; const INTERVAL_MS = 10000;
const HERO_ALBUM_COUNT = 8;
/** Larger pool when mix rating filter is on so we can still fill the hero strip. */
const HERO_RANDOM_POOL = 32;
// Crossfading background — same layer pattern as FullscreenPlayer // Crossfading background — same layer pattern as FullscreenPlayer
function HeroBg({ url }: { url: string }) { function HeroBg({ url }: { url: string }) {
@@ -54,14 +58,33 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const navigate = useNavigate(); const navigate = useNavigate();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const mixMinRatingFilterEnabled = useAuthStore(s => s.mixMinRatingFilterEnabled);
const mixMinRatingAlbum = useAuthStore(s => s.mixMinRatingAlbum);
const mixMinRatingArtist = useAuthStore(s => s.mixMinRatingArtist);
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]); const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [activeIdx, setActiveIdx] = useState(0); const [activeIdx, setActiveIdx] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null); const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => { useEffect(() => {
if (albumsProp?.length) { setAlbums(albumsProp); return; } if (albumsProp?.length) { setAlbums(albumsProp); return; }
getRandomAlbums(8).then(a => { if (a.length) setAlbums(a); }).catch(() => {}); const cfg = { ...getMixMinRatingsConfigFromAuth(), minSong: 0 };
}, [albumsProp, musicLibraryFilterVersion]); const albumMix = cfg.enabled && (cfg.minAlbum > 0 || cfg.minArtist > 0);
const pool = albumMix ? HERO_RANDOM_POOL : HERO_ALBUM_COUNT;
getRandomAlbums(pool)
.then(async raw => {
const list = albumMix
? (await filterAlbumsByMixRatings(raw, cfg)).slice(0, HERO_ALBUM_COUNT)
: raw;
setAlbums(list);
})
.catch(() => {});
}, [
albumsProp,
musicLibraryFilterVersion,
mixMinRatingFilterEnabled,
mixMinRatingAlbum,
mixMinRatingArtist,
]);
// Start / restart auto-advance timer // Start / restart auto-advance timer
const startTimer = useCallback((len: number) => { const startTimer = useCallback((len: number) => {
+2 -1
View File
@@ -214,6 +214,7 @@ export default function QueuePanel() {
const queue = usePlayerStore(s => s.queue); const queue = usePlayerStore(s => s.queue);
const queueIndex = usePlayerStore(s => s.queueIndex); const queueIndex = usePlayerStore(s => s.queueIndex);
const currentTrack = usePlayerStore(s => s.currentTrack); const currentTrack = usePlayerStore(s => s.currentTrack);
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
const currentCoverFetchUrl = useMemo( const currentCoverFetchUrl = useMemo(
() => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', () => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '',
[currentTrack?.coverArt] [currentTrack?.coverArt]
@@ -447,7 +448,7 @@ export default function QueuePanel() {
{currentTrack.year && ( {currentTrack.year && (
<div className="queue-current-sub">{currentTrack.year}</div> <div className="queue-current-sub">{currentTrack.year}</div>
)} )}
{renderStars(currentTrack.userRating)} {renderStars(userRatingOverrides[currentTrack.id] ?? currentTrack.userRating)}
</div> </div>
</div> </div>
</div> </div>
+4 -6
View File
@@ -511,17 +511,15 @@ export const deTranslation = {
ratingsSectionTitle: 'Bewertungen', ratingsSectionTitle: 'Bewertungen',
ratingsSkipStarTitle: 'Überspringen für 1 Stern', ratingsSkipStarTitle: 'Überspringen für 1 Stern',
ratingsSkipStarDesc: ratingsSkipStarDesc:
'Nach N manuellen Überspringen desselben Titels: Server-Bewertung 1 Stern, sofern noch unbewertet.', 'Bei N Überspringen hintereinander: Titel auf 1 Stern setzen. Nur für zuvor unbewertete Titel.',
ratingsSkipStarThresholdLabel: 'Überspringer bis 1★', ratingsSkipStarThresholdLabel: 'Überspringer bis 1★',
ratingsSkipStarThresholdHint:
'Nur wenn Sie den Titel mit Weiter / Medientaste verlassen — nicht wenn er von selbst endet. Keine Änderung, wenn der Titel schon mindestens 1 Stern hat.',
ratingsMixFilterTitle: 'Filter nach Bewertung', ratingsMixFilterTitle: 'Filter nach Bewertung',
ratingsMixFilterDesc: ratingsMixFilterDesc:
'Inhalte mit niedriger Bewertung in Zufallsmixen und zufälligen Alben filtern. Gleichen Stern erneut klicken, um die Schwelle auszuschalten.', 'Inhalte mit niedriger Bewertung in {{mix}} und {{albums}} filtern. Erneut auf den gewählten Stern klicken, um die Schwelle zu deaktivieren.',
ratingsMixMinSong: 'Titel (Titelbewertung)', ratingsMixMinSong: 'Titel',
ratingsMixMinAlbum: 'Alben', ratingsMixMinAlbum: 'Alben',
ratingsMixMinArtist: 'Interpreten', ratingsMixMinArtist: 'Interpreten',
ratingsMixMinThresholdAria: 'Mindest-Sterne ({{label}})', ratingsMixMinThresholdAria: 'Mindest-Sterne: {{label}}',
backupTitle: 'Backup & Wiederherstellung', backupTitle: 'Backup & Wiederherstellung',
backupExport: 'Einstellungen exportieren', backupExport: 'Einstellungen exportieren',
backupExportDesc: 'Speichert alle Einstellungen, Serverprofile, Last.fm-Konfiguration, Theme, EQ und Tastenkürzel in eine .psybkp-Datei. Passwörter werden im Klartext gespeichert — Datei sicher aufbewahren.', backupExportDesc: 'Speichert alle Einstellungen, Serverprofile, Last.fm-Konfiguration, Theme, EQ und Tastenkürzel in eine .psybkp-Datei. Passwörter werden im Klartext gespeichert — Datei sicher aufbewahren.',
+4 -5
View File
@@ -496,16 +496,15 @@ export const enTranslation = {
ratingsSectionTitle: 'Ratings', ratingsSectionTitle: 'Ratings',
ratingsSkipStarTitle: 'Skip for 1 star', ratingsSkipStarTitle: 'Skip for 1 star',
ratingsSkipStarDesc: ratingsSkipStarDesc:
'After N manual skips of the same track, set server rating to 1 star if the track is still unrated.', 'After N skips in a row, set the track to 1★. Only for tracks not rated before.',
ratingsSkipStarThresholdLabel: 'Skips before 1★', ratingsSkipStarThresholdLabel: 'Skips before 1★',
ratingsSkipStarThresholdHint: 'Only counts when you leave the track with Next / media “next”, not when it ends on its own. Does nothing if the track is already rated 1 star or higher.',
ratingsMixFilterTitle: 'Filter by rating', ratingsMixFilterTitle: 'Filter by rating',
ratingsMixFilterDesc: ratingsMixFilterDesc:
'Filter low-rated items in random mixes and random albums. Click the same star again to turn off that columns threshold.', 'Filter low-rated items in {{mix}} and {{albums}}. Click the selected star again to turn off the threshold.',
ratingsMixMinSong: 'Songs (track rating)', ratingsMixMinSong: 'Songs',
ratingsMixMinAlbum: 'Albums', ratingsMixMinAlbum: 'Albums',
ratingsMixMinArtist: 'Artists', ratingsMixMinArtist: 'Artists',
ratingsMixMinThresholdAria: 'Minimum stars ({{label}})', ratingsMixMinThresholdAria: 'Minimum stars: {{label}}',
backupTitle: 'Backup & Restore', backupTitle: 'Backup & Restore',
backupExport: 'Export settings', backupExport: 'Export settings',
backupExportDesc: 'Saves all settings, server profiles, Last.fm config, theme, EQ and keybindings to a .psybkp file. Passwords are stored in plaintext — keep the file secure.', backupExportDesc: 'Saves all settings, server profiles, Last.fm config, theme, EQ and keybindings to a .psybkp file. Passwords are stored in plaintext — keep the file secure.',
+4 -6
View File
@@ -509,17 +509,15 @@ export const frTranslation = {
ratingsSectionTitle: 'Notes', ratingsSectionTitle: 'Notes',
ratingsSkipStarTitle: 'Passer pour 1 étoile', ratingsSkipStarTitle: 'Passer pour 1 étoile',
ratingsSkipStarDesc: ratingsSkipStarDesc:
'Après N sauts manuels du même morceau : note serveur 1 étoile si encore sans note.', 'Après N sauts daffilée : mettre le morceau à 1 étoile. Uniquement sil n’était pas encore noté.',
ratingsSkipStarThresholdLabel: 'Sauts avant 1★', ratingsSkipStarThresholdLabel: 'Sauts avant 1★',
ratingsSkipStarThresholdHint:
'Uniquement si vous quittez le morceau avec Suivant / touche média « suivant », pas en fin de lecture. Aucun effet si le morceau a déjà au moins 1 étoile.',
ratingsMixFilterTitle: 'Filtrage par note', ratingsMixFilterTitle: 'Filtrage par note',
ratingsMixFilterDesc: ratingsMixFilterDesc:
'Filtrer le contenu peu noté dans les mix aléatoires et les albums aléatoires. Cliquer de nouveau sur la même étoile désactive le seuil.', 'Filtrer le contenu peu noté dans {{mix}} et {{albums}}. Cliquer de nouveau sur l’étoile choisie désactive le seuil.',
ratingsMixMinSong: 'Morceaux (note du titre)', ratingsMixMinSong: 'Morceaux',
ratingsMixMinAlbum: 'Albums', ratingsMixMinAlbum: 'Albums',
ratingsMixMinArtist: 'Artistes', ratingsMixMinArtist: 'Artistes',
ratingsMixMinThresholdAria: 'Étoiles minimum ({{label}})', ratingsMixMinThresholdAria: 'Étoiles minimum : {{label}}',
backupTitle: 'Sauvegarde & Restauration', backupTitle: 'Sauvegarde & Restauration',
backupExport: 'Exporter les paramètres', backupExport: 'Exporter les paramètres',
backupExportDesc: 'Enregistre tous les paramètres, profils serveur, configuration Last.fm, thème, EQ et raccourcis dans un fichier .psybkp. Les mots de passe sont stockés en clair — conservez le fichier en sécurité.', backupExportDesc: 'Enregistre tous les paramètres, profils serveur, configuration Last.fm, thème, EQ et raccourcis dans un fichier .psybkp. Les mots de passe sont stockés en clair — conservez le fichier en sécurité.',
+4 -6
View File
@@ -492,17 +492,15 @@ export const nbTranslation = {
ratingsSectionTitle: 'Vurderinger', ratingsSectionTitle: 'Vurderinger',
ratingsSkipStarTitle: 'Hopp for 1 stjerne', ratingsSkipStarTitle: 'Hopp for 1 stjerne',
ratingsSkipStarDesc: ratingsSkipStarDesc:
'Etter N manuelle hopp på samme spor: servervurdering 1 stjerne hvis fortsatt uvurdert.', 'Etter N hopp på rad: sett sporet til 1 stjerne. Bare for spor som ikke var vurdert før.',
ratingsSkipStarThresholdLabel: 'Hopp før 1★', ratingsSkipStarThresholdLabel: 'Hopp før 1★',
ratingsSkipStarThresholdHint:
'Bare når du forlater sporet med Neste / medietast «neste», ikke når det slutter av seg selv. Ingenting skjer hvis sporet allerede har minst 1 stjerne.',
ratingsMixFilterTitle: 'Filtrering etter vurdering', ratingsMixFilterTitle: 'Filtrering etter vurdering',
ratingsMixFilterDesc: ratingsMixFilterDesc:
'Filtrer innhold med lav vurdering i tilfeldige mikser og tilfeldige album. Klikk samme stjerne igjen for å slå av terskelen.', 'Filtrer innhold med lav vurdering i {{mix}} og {{albums}}. Klikk på den valgte stjerna igjen for å slå av terskelen.',
ratingsMixMinSong: 'Spor (sporvurdering)', ratingsMixMinSong: 'Spor',
ratingsMixMinAlbum: 'Album', ratingsMixMinAlbum: 'Album',
ratingsMixMinArtist: 'Artister', ratingsMixMinArtist: 'Artister',
ratingsMixMinThresholdAria: 'Minimum stjerner ({{label}})', ratingsMixMinThresholdAria: 'Minimum stjerner: {{label}}',
backupTitle: 'Sikkerhetskopiering og gjenoppretting', backupTitle: 'Sikkerhetskopiering og gjenoppretting',
backupExport: 'Eksporter innstillinger', backupExport: 'Eksporter innstillinger',
backupExportDesc: 'Lagrer alle innstillinger, tjenerprofiler, Last.fm-konfigurasjon, tema, jevnstiller og tastebindinger til en .psybkp-fil. Passordet lagres i klartekst hold filen sikker.', backupExportDesc: 'Lagrer alle innstillinger, tjenerprofiler, Last.fm-konfigurasjon, tema, jevnstiller og tastebindinger til en .psybkp-fil. Passordet lagres i klartekst hold filen sikker.',
+4 -6
View File
@@ -509,17 +509,15 @@ export const nlTranslation = {
ratingsSectionTitle: 'Beoordelingen', ratingsSectionTitle: 'Beoordelingen',
ratingsSkipStarTitle: 'Overslaan voor 1 ster', ratingsSkipStarTitle: 'Overslaan voor 1 ster',
ratingsSkipStarDesc: ratingsSkipStarDesc:
'Na N handmatige overslagen van hetzelfde nummer: serverbeoordeling 1 ster als nog niet beoordeeld.', 'Na N overslagen op rij: nummer op 1 ster zetten. Alleen voor nummers die nog niet beoordeeld waren.',
ratingsSkipStarThresholdLabel: 'Overslagen voor 1★', ratingsSkipStarThresholdLabel: 'Overslagen voor 1★',
ratingsSkipStarThresholdHint:
'Alleen als je het nummer verlaat met Volgende / mediatoets «volgende», niet als het vanzelf stopt. Geen wijziging als het nummer al minstens 1 ster heeft.',
ratingsMixFilterTitle: 'Filter op beoordeling', ratingsMixFilterTitle: 'Filter op beoordeling',
ratingsMixFilterDesc: ratingsMixFilterDesc:
'Content met lage beoordeling filteren in willekeurige mixen en willekeurige albums. Klik dezelfde ster opnieuw om de drempel uit te zetten.', 'Content met lage beoordeling filteren in {{mix}} en {{albums}}. Klik opnieuw op de gekozen ster om de drempel uit te zetten.',
ratingsMixMinSong: 'Nummers (nummerbeoordeling)', ratingsMixMinSong: 'Nummers',
ratingsMixMinAlbum: 'Albums', ratingsMixMinAlbum: 'Albums',
ratingsMixMinArtist: 'Artiesten', ratingsMixMinArtist: 'Artiesten',
ratingsMixMinThresholdAria: 'Minimum sterren ({{label}})', ratingsMixMinThresholdAria: 'Minimum sterren: {{label}}',
backupTitle: 'Back-up & Herstel', backupTitle: 'Back-up & Herstel',
backupExport: 'Instellingen exporteren', backupExport: 'Instellingen exporteren',
backupExportDesc: 'Slaat alle instellingen, serverprofielen, Last.fm-configuratie, thema, EQ en sneltoetsen op in een .psybkp-bestand. Wachtwoorden worden opgeslagen als leesbare tekst — bewaar het bestand veilig.', backupExportDesc: 'Slaat alle instellingen, serverprofielen, Last.fm-configuratie, thema, EQ en sneltoetsen op in een .psybkp-bestand. Wachtwoorden worden opgeslagen als leesbare tekst — bewaar het bestand veilig.',
+5 -4
View File
@@ -460,6 +460,9 @@ export const ruTranslation = {
showTrayIconDesc: 'Показывать Psysonic в области уведомлений / строке меню.', showTrayIconDesc: 'Показывать Psysonic в области уведомлений / строке меню.',
minimizeToTray: 'Сворачивать в трей', minimizeToTray: 'Сворачивать в трей',
minimizeToTrayDesc: 'При закрытии окна не выходить из приложения, а оставаться в трее.', minimizeToTrayDesc: 'При закрытии окна не выходить из приложения, а оставаться в трее.',
useCustomTitlebar: 'Своя строка заголовка',
useCustomTitlebarDesc:
'Заменить системную строку заголовка встроенной, в стиле темы приложения. Отключите, чтобы использовать родную строку GNOME/GTK.',
discordRichPresence: 'Статус в Discord', discordRichPresence: 'Статус в Discord',
discordRichPresenceDesc: discordRichPresenceDesc:
'Показывать текущий трек в профиле и статусе Discord. Нужен запущенный клиент Discord.', 'Показывать текущий трек в профиле и статусе Discord. Нужен запущенный клиент Discord.',
@@ -514,13 +517,11 @@ export const ruTranslation = {
ratingsSectionTitle: 'Рейтинги', ratingsSectionTitle: 'Рейтинги',
ratingsSkipStarTitle: 'Скипнуть для 1 звезды', ratingsSkipStarTitle: 'Скипнуть для 1 звезды',
ratingsSkipStarDesc: ratingsSkipStarDesc:
'N ручных скипов трека — 1 звезда рейтинга, если трек ещё без оценки.', 'При N скипов подряд ставить 1★ треку. Только для не оцененных ранее.',
ratingsSkipStarThresholdLabel: 'Скипов', ratingsSkipStarThresholdLabel: 'Скипов',
ratingsSkipStarThresholdHint:
'Считаются только переходы «Далее» / медиаклавиша «следующий», не окончание трека само по себе. Не меняет оценку, если у трека уже не ниже 1★.',
ratingsMixFilterTitle: 'Фильтрация по рейтингу', ratingsMixFilterTitle: 'Фильтрация по рейтингу',
ratingsMixFilterDesc: ratingsMixFilterDesc:
'Фильтровать с низким рейтингом в Случайных миксах и Случайных альбомах. Повторный клик по выбранной звезде отключает порог.', 'Фильтровать с низким рейтингом в «{{mix}}» и «{{albums}}». Повторный клик по выбранной звезде отключает порог.',
ratingsMixMinSong: 'Песни', ratingsMixMinSong: 'Песни',
ratingsMixMinAlbum: 'Альбомы', ratingsMixMinAlbum: 'Альбомы',
ratingsMixMinArtist: 'Исполнители', ratingsMixMinArtist: 'Исполнители',
+4 -6
View File
@@ -489,17 +489,15 @@ export const zhTranslation = {
ratingsSectionTitle: '评分', ratingsSectionTitle: '评分',
ratingsSkipStarTitle: '跳过以评 1 星', ratingsSkipStarTitle: '跳过以评 1 星',
ratingsSkipStarDesc: ratingsSkipStarDesc:
'同一曲目手动跳过 N 次后,若尚未评分则在服务器上设为 1 星。', '连续跳过 N 次后将曲目设为 1 星。仅适用于此前未评分的曲目。',
ratingsSkipStarThresholdLabel: '跳过次数(至 1★)', ratingsSkipStarThresholdLabel: '跳过次数(至 1★)',
ratingsSkipStarThresholdHint:
'仅统计用“下一曲”或媒体键离开该曲的情况;自然播放结束不计入。曲目已有 1 星或以上时不改变。',
ratingsMixFilterTitle: '按评分筛选', ratingsMixFilterTitle: '按评分筛选',
ratingsMixFilterDesc: ratingsMixFilterDesc:
'在随机混合与随机专辑中筛选低评分内容。再次点击同一颗星可关闭该列阈值。', '在{{mix}}与{{albums}}中筛选低评分内容。再次点击所选星标可关闭阈值。',
ratingsMixMinSong: '歌曲(曲目评分)', ratingsMixMinSong: '歌曲',
ratingsMixMinAlbum: '专辑', ratingsMixMinAlbum: '专辑',
ratingsMixMinArtist: '艺人', ratingsMixMinArtist: '艺人',
ratingsMixMinThresholdAria: '最低星数{{label}}', ratingsMixMinThresholdAria: '最低星数{{label}}',
backupTitle: '备份与恢复', backupTitle: '备份与恢复',
backupExport: '导出设置', backupExport: '导出设置',
backupExportDesc: '将所有设置、服务器配置、Last.fm 配置、主题、均衡器和快捷键保存到 .psybkp 文件。密码以明文存储——请妥善保管该文件。', backupExportDesc: '将所有设置、服务器配置、Last.fm 配置、主题、均衡器和快捷键保存到 .psybkp 文件。密码以明文存储——请妥善保管该文件。',
+3
View File
@@ -34,6 +34,7 @@ export default function AlbumDetail() {
const openContextMenu = usePlayerStore(s => s.openContextMenu); const openContextMenu = usePlayerStore(s => s.openContextMenu);
const starredOverrides = usePlayerStore(s => s.starredOverrides); const starredOverrides = usePlayerStore(s => s.starredOverrides);
const setStarredOverride = usePlayerStore(s => s.setStarredOverride); const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
const currentTrack = usePlayerStore(s => s.currentTrack); const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying); const isPlaying = usePlayerStore(s => s.isPlaying);
@@ -137,6 +138,7 @@ const handleEnqueueAll = () => {
const handleRate = async (songId: string, rating: number) => { const handleRate = async (songId: string, rating: number) => {
setRatings(r => ({ ...r, [songId]: rating })); setRatings(r => ({ ...r, [songId]: rating }));
usePlayerStore.getState().setUserRatingOverride(songId, rating);
await setRating(songId, rating); await setRating(songId, rating);
}; };
@@ -331,6 +333,7 @@ const handleEnqueueAll = () => {
currentTrack={currentTrack} currentTrack={currentTrack}
isPlaying={isPlaying} isPlaying={isPlaying}
ratings={ratings} ratings={ratings}
userRatingOverrides={userRatingOverrides}
starredSongs={new Set([ starredSongs={new Set([
...[...starredSongs].filter(id => starredOverrides[id] !== false), ...[...starredSongs].filter(id => starredOverrides[id] !== false),
...Object.entries(starredOverrides).filter(([, v]) => v).map(([k]) => k), ...Object.entries(starredOverrides).filter(([, v]) => v).map(([k]) => k),
+56 -24
View File
@@ -7,10 +7,19 @@ import { NavLink, useNavigate } from 'react-router-dom';
import { ChevronRight } from 'lucide-react'; import { ChevronRight } from 'lucide-react';
import { useHomeStore } from '../store/homeStore'; import { useHomeStore } from '../store/homeStore';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter';
/** Match Random Albums overshoot when mix filter uses album/artist axes so hero + discover row can still fill. */
const HOME_RANDOM_FETCH = 100;
const HOME_HERO_COUNT = 8;
const HOME_DISCOVER_SLICE = 20;
export default function Home() { export default function Home() {
const homeSections = useHomeStore(s => s.sections); const homeSections = useHomeStore(s => s.sections);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const mixMinRatingFilterEnabled = useAuthStore(s => s.mixMinRatingFilterEnabled);
const mixMinRatingAlbum = useAuthStore(s => s.mixMinRatingAlbum);
const mixMinRatingArtist = useAuthStore(s => s.mixMinRatingArtist);
const isVisible = (id: string) => homeSections.find(s => s.id === id)?.visible ?? true; const isVisible = (id: string) => homeSections.find(s => s.id === id)?.visible ?? true;
const [starred, setStarred] = useState<SubsonicAlbum[]>([]); const [starred, setStarred] = useState<SubsonicAlbum[]>([]);
@@ -23,30 +32,50 @@ export default function Home() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
Promise.all([ let cancelled = false;
getAlbumList('starred', 12).catch(() => []), setLoading(true);
getAlbumList('newest', 12).catch(() => []), (async () => {
getAlbumList('random', 20).catch(() => []), try {
getAlbumList('frequent', 12).catch(() => []), const mixCfg = getMixMinRatingsConfigFromAuth();
getAlbumList('recent', 12).catch(() => []), const albumMix =
isVisible('discoverArtists') ? getArtists().catch(() => []) : Promise.resolve<SubsonicArtist[]>([]), mixCfg.enabled && (mixCfg.minAlbum > 0 || mixCfg.minArtist > 0);
]).then(([s, n, r, f, rp, artists]) => { const randomSize = albumMix ? HOME_RANDOM_FETCH : HOME_DISCOVER_SLICE;
setStarred(s); const [s, n, rRaw, f, rp, artists] = await Promise.all([
setRecent(n); getAlbumList('starred', 12).catch(() => []),
setHeroAlbums(r.slice(0, 8)); getAlbumList('newest', 12).catch(() => []),
setRandom(r.slice(8)); getAlbumList('random', randomSize).catch(() => []),
setMostPlayed(f); getAlbumList('frequent', 12).catch(() => []),
setRecentlyPlayed(rp); getAlbumList('recent', 12).catch(() => []),
// Pick 16 random artists via Fisher-Yates shuffle isVisible('discoverArtists') ? getArtists().catch(() => []) : Promise.resolve<SubsonicArtist[]>([]),
const shuffled = [...artists]; ]);
for (let i = shuffled.length - 1; i > 0; i--) { if (cancelled) return;
const j = Math.floor(Math.random() * (i + 1)); const r = await filterAlbumsByMixRatings(rRaw, mixCfg);
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; setStarred(s);
setRecent(n);
setHeroAlbums(r.slice(0, HOME_HERO_COUNT));
setRandom(r.slice(HOME_HERO_COUNT, HOME_DISCOVER_SLICE));
setMostPlayed(f);
setRecentlyPlayed(rp);
const shuffled = [...artists];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
setRandomArtists(shuffled.slice(0, 16));
} catch {
/* ignore */
} finally {
if (!cancelled) setLoading(false);
} }
setRandomArtists(shuffled.slice(0, 16)); })();
setLoading(false); return () => { cancelled = true; };
}).catch(() => setLoading(false)); }, [
}, [musicLibraryFilterVersion, homeSections]); musicLibraryFilterVersion,
homeSections,
mixMinRatingFilterEnabled,
mixMinRatingAlbum,
mixMinRatingArtist,
]);
const loadMore = async ( const loadMore = async (
type: 'starred' | 'newest' | 'random' | 'frequent' | 'recent', type: 'starred' | 'newest' | 'random' | 'frequent' | 'recent',
@@ -55,7 +84,10 @@ export default function Home() {
) => { ) => {
try { try {
const more = await getAlbumList(type, 12, currentList.length); const more = await getAlbumList(type, 12, currentList.length);
const newItems = more.filter(m => !currentList.find(c => c.id === m.id)); const mixCfg = getMixMinRatingsConfigFromAuth();
const batch =
type === 'random' ? await filterAlbumsByMixRatings(more, mixCfg) : more;
const newItems = batch.filter(m => !currentList.find(c => c.id === m.id));
if (newItems.length > 0) setter(prev => [...prev, ...newItems]); if (newItems.length > 0) setter(prev => [...prev, ...newItems]);
} catch (e) { } catch (e) {
console.error('Failed to load more', e); console.error('Failed to load more', e);
+2 -1
View File
@@ -211,6 +211,7 @@ export default function NowPlaying() {
const navigate = useNavigate(); const navigate = useNavigate();
const currentTrack = usePlayerStore(s => s.currentTrack); const currentTrack = usePlayerStore(s => s.currentTrack);
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
const isPlaying = usePlayerStore(s => s.isPlaying); const isPlaying = usePlayerStore(s => s.isPlaying);
const showLyrics = useLyricsStore(s => s.showLyrics); const showLyrics = useLyricsStore(s => s.showLyrics);
const activeTab = useLyricsStore(s => s.activeTab); const activeTab = useLyricsStore(s => s.activeTab);
@@ -292,7 +293,7 @@ export default function NowPlaying() {
{currentTrack.suffix && <span className="np-badge">{currentTrack.suffix.toUpperCase()}</span>} {currentTrack.suffix && <span className="np-badge">{currentTrack.suffix.toUpperCase()}</span>}
{currentTrack.bitRate && <span className="np-badge">{currentTrack.bitRate} kbps</span>} {currentTrack.bitRate && <span className="np-badge">{currentTrack.bitRate} kbps</span>}
{currentTrack.duration && <span className="np-badge">{formatTime(currentTrack.duration)}</span>} {currentTrack.duration && <span className="np-badge">{formatTime(currentTrack.duration)}</span>}
{renderStars(currentTrack.userRating)} {renderStars(userRatingOverrides[currentTrack.id] ?? currentTrack.userRating)}
<button onClick={toggleStar} className="np-star-btn" <button onClick={toggleStar} className="np-star-btn"
data-tooltip={starred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')} data-tooltip={starred ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
> >
+4 -2
View File
@@ -69,7 +69,7 @@ export default function PlaylistDetail() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { playTrack, enqueue, openContextMenu, currentTrack, isPlaying, starredOverrides, setStarredOverride } = usePlayerStore( const { playTrack, enqueue, openContextMenu, currentTrack, isPlaying, starredOverrides, setStarredOverride, userRatingOverrides } = usePlayerStore(
useShallow(s => ({ useShallow(s => ({
playTrack: s.playTrack, playTrack: s.playTrack,
enqueue: s.enqueue, enqueue: s.enqueue,
@@ -78,6 +78,7 @@ export default function PlaylistDetail() {
isPlaying: s.isPlaying, isPlaying: s.isPlaying,
starredOverrides: s.starredOverrides, starredOverrides: s.starredOverrides,
setStarredOverride: s.setStarredOverride, setStarredOverride: s.setStarredOverride,
userRatingOverrides: s.userRatingOverrides,
})) }))
); );
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist); const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
@@ -354,6 +355,7 @@ export default function PlaylistDetail() {
// ── Rating / Star ───────────────────────────────────────────── // ── Rating / Star ─────────────────────────────────────────────
const handleRate = (songId: string, rating: number) => { const handleRate = (songId: string, rating: number) => {
setRatings(prev => ({ ...prev, [songId]: rating })); setRatings(prev => ({ ...prev, [songId]: rating }));
usePlayerStore.getState().setUserRatingOverride(songId, rating);
setRating(songId, rating).catch(() => {}); setRating(songId, rating).catch(() => {});
}; };
@@ -844,7 +846,7 @@ export default function PlaylistDetail() {
</button> </button>
</div> </div>
); );
case 'rating': return <StarRating key="rating" value={ratings[song.id] ?? song.userRating ?? 0} onChange={r => handleRate(song.id, r)} />; case 'rating': return <StarRating key="rating" value={ratings[song.id] ?? userRatingOverrides[song.id] ?? song.userRating ?? 0} onChange={r => handleRate(song.id, r)} />;
case 'duration': return <div key="duration" className="track-duration">{formatDuration(song.duration ?? 0)}</div>; case 'duration': return <div key="duration" className="track-duration">{formatDuration(song.duration ?? 0)}</div>;
case 'format': return ( case 'format': return (
<div key="format" className="track-meta"> <div key="format" className="track-meta">
+22 -3
View File
@@ -5,8 +5,13 @@ import AlbumCard from '../components/AlbumCard';
import GenreFilterBar from '../components/GenreFilterBar'; import GenreFilterBar from '../components/GenreFilterBar';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter';
const ALBUM_COUNT = 30; const ALBUM_COUNT = 30;
/** Extra pool when mix rating filter is on so we can still fill the grid after filtering. */
const ALBUM_FETCH_OVERSHOOT = 100;
/** Cap genre-union size before rating prefetch (avoids hundreds of `getArtist` calls). */
const GENRE_UNION_PREFILTER_CAP = 250;
async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> { async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0))); const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0)));
@@ -17,12 +22,17 @@ async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
const j = Math.floor(Math.random() * (i + 1)); const j = Math.floor(Math.random() * (i + 1));
[union[i], union[j]] = [union[j], union[i]]; [union[i], union[j]] = [union[j], union[i]];
} }
return union.slice(0, ALBUM_COUNT); const pool = union.slice(0, GENRE_UNION_PREFILTER_CAP);
const filtered = await filterAlbumsByMixRatings(pool, getMixMinRatingsConfigFromAuth());
return filtered.slice(0, ALBUM_COUNT);
} }
export default function RandomAlbums() { export default function RandomAlbums() {
const { t } = useTranslation(); const { t } = useTranslation();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const mixMinRatingFilterEnabled = useAuthStore(s => s.mixMinRatingFilterEnabled);
const mixMinRatingAlbum = useAuthStore(s => s.mixMinRatingAlbum);
const mixMinRatingArtist = useAuthStore(s => s.mixMinRatingArtist);
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]); const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedGenres, setSelectedGenres] = useState<string[]>([]); const [selectedGenres, setSelectedGenres] = useState<string[]>([]);
@@ -34,9 +44,13 @@ export default function RandomAlbums() {
loadingRef.current = true; loadingRef.current = true;
setLoading(true); setLoading(true);
try { try {
const mixCfg = getMixMinRatingsConfigFromAuth();
const albumMixActive =
mixCfg.enabled && (mixCfg.minAlbum > 0 || mixCfg.minArtist > 0);
const randomSize = albumMixActive ? Math.max(ALBUM_COUNT * 3, ALBUM_FETCH_OVERSHOOT) : ALBUM_COUNT;
const data = genres.length > 0 const data = genres.length > 0
? await fetchByGenres(genres) ? await fetchByGenres(genres)
: await getAlbumList('random', ALBUM_COUNT); : (await filterAlbumsByMixRatings(await getAlbumList('random', randomSize), mixCfg)).slice(0, ALBUM_COUNT);
setAlbums(data); setAlbums(data);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@@ -44,7 +58,12 @@ export default function RandomAlbums() {
loadingRef.current = false; loadingRef.current = false;
setLoading(false); setLoading(false);
} }
}, [musicLibraryFilterVersion]); }, [
musicLibraryFilterVersion,
mixMinRatingFilterEnabled,
mixMinRatingAlbum,
mixMinRatingArtist,
]);
useEffect(() => { load(selectedGenres); }, [selectedGenres, load]); useEffect(() => { load(selectedGenres); }, [selectedGenres, load]);
+15 -23
View File
@@ -1,11 +1,15 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { getRandomSongs, getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic'; import { getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore'; import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { Play, RefreshCw, ChevronDown, ChevronUp, Heart } from 'lucide-react'; import { Play, RefreshCw, ChevronDown, ChevronUp, Heart } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useDragDrop } from '../contexts/DragDropContext'; import { useDragDrop } from '../contexts/DragDropContext';
import { passesMixMinRatings } from '../utils/mixRatingFilter'; import {
fetchRandomMixSongsUntilFull,
getMixMinRatingsConfigFromAuth,
passesMixMinRatings,
} from '../utils/mixRatingFilter';
const AUDIOBOOK_GENRES = [ const AUDIOBOOK_GENRES = [
'hörbuch', 'hoerbuch', 'hörspiel', 'hoerspiel', 'hörbuch', 'hoerbuch', 'hörspiel', 'hoerspiel',
@@ -76,19 +80,11 @@ export default function RandomMix() {
const fetchSongs = () => { const fetchSongs = () => {
setLoading(true); setLoading(true);
setSongs([]); setSongs([]);
getRandomSongs(50) fetchRandomMixSongsUntilFull(getMixMinRatingsConfigFromAuth())
.then(fetched => { .then(list => {
const cfg = useAuthStore.getState(); setSongs(list);
const mixCfg = {
enabled: cfg.mixMinRatingFilterEnabled,
minSong: cfg.mixMinRatingSong,
minAlbum: cfg.mixMinRatingAlbum,
minArtist: cfg.mixMinRatingArtist,
};
const filtered = fetched.filter(s => passesMixMinRatings(s, mixCfg));
setSongs(filtered);
const st = new Set<string>(); const st = new Set<string>();
filtered.forEach(s => { if (s.starred) st.add(s.id); }); list.forEach(s => { if (s.starred) st.add(s.id); });
setStarredSongs(st); setStarredSongs(st);
setLoading(false); setLoading(false);
}) })
@@ -161,15 +157,11 @@ export default function RandomMix() {
setGenreMixComplete(false); setGenreMixComplete(false);
setGenreMixSongs([]); setGenreMixSongs([]);
try { try {
const fetched = await getRandomSongs(50, genre, 45000); const list = await fetchRandomMixSongsUntilFull(getMixMinRatingsConfigFromAuth(), {
const cfg = useAuthStore.getState(); genre,
const mixCfg = { timeout: 45000,
enabled: cfg.mixMinRatingFilterEnabled, });
minSong: cfg.mixMinRatingSong, setGenreMixSongs(list);
minAlbum: cfg.mixMinRatingAlbum,
minArtist: cfg.mixMinRatingArtist,
};
setGenreMixSongs(fetched.filter(s => passesMixMinRatings(s, mixCfg)));
} catch {} } catch {}
setGenreMixLoading(false); setGenreMixLoading(false);
setGenreMixComplete(true); setGenreMixComplete(true);
+6 -6
View File
@@ -819,18 +819,18 @@ export default function Settings() {
</label> </label>
</div> </div>
</div> </div>
{auth.skipStarOnManualSkipsEnabled && (
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 10, lineHeight: 1.45 }}>
{t('settings.ratingsSkipStarThresholdHint')}
</p>
)}
<div className="settings-section-divider" /> <div className="settings-section-divider" />
<div className="settings-toggle-row"> <div className="settings-toggle-row">
<div> <div>
<div style={{ fontWeight: 500 }}>{t('settings.ratingsMixFilterTitle')}</div> <div style={{ fontWeight: 500 }}>{t('settings.ratingsMixFilterTitle')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.ratingsMixFilterDesc')}</div> <div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{t('settings.ratingsMixFilterDesc', {
mix: t('sidebar.randomMix'),
albums: t('sidebar.randomAlbums'),
})}
</div>
</div> </div>
<label className="toggle-switch" aria-label={t('settings.ratingsMixFilterTitle')}> <label className="toggle-switch" aria-label={t('settings.ratingsMixFilterTitle')}>
<input <input
+63 -4
View File
@@ -64,13 +64,23 @@ interface AuthState {
skipStarOnManualSkipsEnabled: boolean; skipStarOnManualSkipsEnabled: boolean;
/** Manual skips per track before applying rating 1 (when enabled). */ /** Manual skips per track before applying rating 1 (when enabled). */
skipStarManualSkipThreshold: number; skipStarManualSkipThreshold: number;
/**
* Manual Next-count per track for skip1. Key = `${serverId}\\u001f${trackId}`
* (empty serverId when none). Persisted; cleared when the track finishes naturally or when threshold is reached.
*/
skipStarManualSkipCountsByKey: Record<string, number>;
/** Increment skip count for current server + track; clears stored count when threshold reached. */
recordSkipStarManualAdvance: (trackId: string) => { crossedThreshold: boolean } | null;
/** Drop persisted skip count for this track on the active server (e.g. natural playback end). */
clearSkipStarManualCountForTrack: (trackId: string) => void;
/** Planned / active filter: random mixes (and later album flows) by min stars per axis. */ /** Random mixes, random albums, home hero: drop nonzero ratings at or below peraxis thresholds (0 = unrated, kept). */
mixMinRatingFilterEnabled: boolean; mixMinRatingFilterEnabled: boolean;
/** 0 = off; 13 = require at least that many stars on the song (UI capped at 3). */ /** 0 = ignore; 13 = cutoff (UI); exclude track rating r when 0 < r ≤ cutoff. */
mixMinRatingSong: number; mixMinRatingSong: number;
/** 03; uses `albumUserRating` on song payload when present (OpenSubsonic). */ /** 0 = ignore; album entity rating from payload or `getAlbum` when missing. */
mixMinRatingAlbum: number; mixMinRatingAlbum: number;
/** 0 = ignore; artist rating from payload / nested OpenSubsonic fields or `getArtist`. */
mixMinRatingArtist: number; mixMinRatingArtist: number;
/** Subsonic music folders for the active server (not persisted; refetched on login / server change). */ /** Subsonic music folders for the active server (not persisted; refetched on login / server change). */
@@ -170,6 +180,20 @@ function clampSkipStarThreshold(v: number): number {
return Math.max(1, Math.min(99, Math.round(v))); return Math.max(1, Math.min(99, Math.round(v)));
} }
function skipStarCountStorageKey(serverId: string | null | undefined, trackId: string): string {
return `${serverId ?? ''}\u001f${trackId}`;
}
function sanitizeSkipStarCounts(raw: unknown): Record<string, number> {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
const next: Record<string, number> = {};
for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
const n = Number(v);
if (Number.isFinite(n) && n > 0) next[k] = Math.min(Math.floor(n), 1_000_000);
}
return next;
}
export const useAuthStore = create<AuthState>()( export const useAuthStore = create<AuthState>()(
persist( persist(
(set, get) => ({ (set, get) => ({
@@ -211,6 +235,7 @@ export const useAuthStore = create<AuthState>()(
hotCacheDownloadDir: '', hotCacheDownloadDir: '',
skipStarOnManualSkipsEnabled: false, skipStarOnManualSkipsEnabled: false,
skipStarManualSkipThreshold: 3, skipStarManualSkipThreshold: 3,
skipStarManualSkipCountsByKey: {},
mixMinRatingFilterEnabled: false, mixMinRatingFilterEnabled: false,
mixMinRatingSong: 0, mixMinRatingSong: 0,
mixMinRatingAlbum: 0, mixMinRatingAlbum: 0,
@@ -305,8 +330,39 @@ export const useAuthStore = create<AuthState>()(
setHotCacheDebounceSec: (v) => set({ hotCacheDebounceSec: v }), setHotCacheDebounceSec: (v) => set({ hotCacheDebounceSec: v }),
setHotCacheDownloadDir: (v) => set({ hotCacheDownloadDir: v }), setHotCacheDownloadDir: (v) => set({ hotCacheDownloadDir: v }),
setSkipStarOnManualSkipsEnabled: (v) => set({ skipStarOnManualSkipsEnabled: v }), setSkipStarOnManualSkipsEnabled: (v) =>
set({
skipStarOnManualSkipsEnabled: v,
...(v ? {} : { skipStarManualSkipCountsByKey: {} }),
}),
setSkipStarManualSkipThreshold: (v) => set({ skipStarManualSkipThreshold: clampSkipStarThreshold(v) }), setSkipStarManualSkipThreshold: (v) => set({ skipStarManualSkipThreshold: clampSkipStarThreshold(v) }),
recordSkipStarManualAdvance: (trackId: string) => {
const s = get();
if (!s.skipStarOnManualSkipsEnabled || s.skipStarManualSkipThreshold < 1) return null;
const key = skipStarCountStorageKey(s.activeServerId, trackId);
const prev = s.skipStarManualSkipCountsByKey[key] ?? 0;
const threshold = s.skipStarManualSkipThreshold;
const next = prev + 1;
if (next >= threshold) {
const { [key]: _removed, ...rest } = s.skipStarManualSkipCountsByKey;
set({ skipStarManualSkipCountsByKey: rest });
return { crossedThreshold: true };
}
set({
skipStarManualSkipCountsByKey: { ...s.skipStarManualSkipCountsByKey, [key]: next },
});
return { crossedThreshold: false };
},
clearSkipStarManualCountForTrack: (trackId: string) => {
const s = get();
const key = skipStarCountStorageKey(s.activeServerId, trackId);
if (s.skipStarManualSkipCountsByKey[key] === undefined) return;
const { [key]: _removed, ...rest } = s.skipStarManualSkipCountsByKey;
set({ skipStarManualSkipCountsByKey: rest });
},
setMixMinRatingFilterEnabled: (v) => set({ mixMinRatingFilterEnabled: v }), setMixMinRatingFilterEnabled: (v) => set({ mixMinRatingFilterEnabled: v }),
setMixMinRatingSong: (v) => set({ mixMinRatingSong: clampMixFilterMinStars(v) }), setMixMinRatingSong: (v) => set({ mixMinRatingSong: clampMixFilterMinStars(v) }),
setMixMinRatingAlbum: (v) => set({ mixMinRatingAlbum: clampMixFilterMinStars(v) }), setMixMinRatingAlbum: (v) => set({ mixMinRatingAlbum: clampMixFilterMinStars(v) }),
@@ -367,6 +423,9 @@ export const useAuthStore = create<AuthState>()(
mixMinRatingSong: clampMixFilterMinStars(state.mixMinRatingSong as number), mixMinRatingSong: clampMixFilterMinStars(state.mixMinRatingSong as number),
mixMinRatingAlbum: clampMixFilterMinStars(state.mixMinRatingAlbum as number), mixMinRatingAlbum: clampMixFilterMinStars(state.mixMinRatingAlbum as number),
mixMinRatingArtist: clampMixFilterMinStars(state.mixMinRatingArtist as number), mixMinRatingArtist: clampMixFilterMinStars(state.mixMinRatingArtist as number),
skipStarManualSkipCountsByKey: sanitizeSkipStarCounts(
(state as { skipStarManualSkipCountsByKey?: unknown }).skipStarManualSkipCountsByKey,
),
}); });
}, },
} }
+43 -22
View File
@@ -76,6 +76,9 @@ interface PlayerState {
lastfmLovedCache: Record<string, boolean>; lastfmLovedCache: Record<string, boolean>;
starredOverrides: Record<string, boolean>; starredOverrides: Record<string, boolean>;
setStarredOverride: (id: string, starred: boolean) => void; setStarredOverride: (id: string, starred: boolean) => void;
/** Optimistic track ratings (e.g. skip→1★ while UI lists still have stale `song.userRating`). */
userRatingOverrides: Record<string, number>;
setUserRatingOverride: (id: string, rating: number) => void;
playRadio: (station: InternetRadioStation) => void; playRadio: (station: InternetRadioStation) => void;
playTrack: (track: Track, queue?: Track[], manual?: boolean) => void; playTrack: (track: Track, queue?: Track[], manual?: boolean) => void;
@@ -161,30 +164,33 @@ let seekTarget: number | null = null;
// to the Rust backend before it has finished the previous one. // to the Rust backend before it has finished the previous one.
let togglePlayLock = false; let togglePlayLock = false;
/** Manual skip counts per track id toward optional “skip → rating 1” (in-memory). */ /**
const manualSkipStarCounts = new Map<string, number>(); * Skip 1: counts in `authStore.skipStarManualSkipCountsByKey` (persisted).
* Only user-initiated `next()` increments. Natural track end (incl. gapless) clears the count;
* threshold reached clears count and sets 1 if still unrated.
*/
function applySkipStarOnManualNext(skippedTrack: Track | null, manual: boolean): void { function applySkipStarOnManualNext(skippedTrack: Track | null, manual: boolean): void {
if (!manual || !skippedTrack) return; if (!manual || !skippedTrack) return;
const { skipStarOnManualSkipsEnabled, skipStarManualSkipThreshold } = useAuthStore.getState();
if (!skipStarOnManualSkipsEnabled || skipStarManualSkipThreshold < 1) return;
const id = skippedTrack.id; const id = skippedTrack.id;
const n = (manualSkipStarCounts.get(id) ?? 0) + 1; const adv = useAuthStore.getState().recordSkipStarManualAdvance(id);
if (n >= skipStarManualSkipThreshold) { if (!adv?.crossedThreshold) return;
manualSkipStarCounts.delete(id); const live = usePlayerStore.getState();
const cur = skippedTrack.userRating ?? 0; const fromQueue = live.queue.find(t => t.id === id);
if (cur >= 1) return; const cur =
setRating(id, 1) live.userRatingOverrides[id] ??
.then(() => { fromQueue?.userRating ??
usePlayerStore.setState(s => ({ skippedTrack.userRating ??
queue: s.queue.map(t => (t.id === id ? { ...t, userRating: 1 } : t)), 0;
currentTrack: s.currentTrack?.id === id ? { ...s.currentTrack, userRating: 1 } : s.currentTrack, if (cur >= 1) return;
})); setRating(id, 1)
}) .then(() => {
.catch(() => {}); usePlayerStore.setState(s => ({
} else { queue: s.queue.map(t => (t.id === id ? { ...t, userRating: 1 } : t)),
manualSkipStarCounts.set(id, n); currentTrack: s.currentTrack?.id === id ? { ...s.currentTrack, userRating: 1 } : s.currentTrack,
} userRatingOverrides: { ...s.userRatingOverrides, [id]: 1 },
}));
})
.catch(() => {});
} }
// ── HTML5 Radio Player ──────────────────────────────────────────────────────── // ── HTML5 Radio Player ────────────────────────────────────────────────────────
@@ -361,7 +367,6 @@ function handleAudioEnded() {
} }
const { repeatMode, currentTrack, queue } = usePlayerStore.getState(); const { repeatMode, currentTrack, queue } = usePlayerStore.getState();
if (currentTrack?.id) manualSkipStarCounts.delete(currentTrack.id);
isAudioPaused = false; isAudioPaused = false;
usePlayerStore.setState({ isPlaying: false, progress: 0, currentTime: 0, buffered: 0 }); usePlayerStore.setState({ isPlaying: false, progress: 0, currentTime: 0, buffered: 0 });
setTimeout(() => { setTimeout(() => {
@@ -384,6 +389,9 @@ function handleAudioTrackSwitched(duration: number) {
isAudioPaused = false; isAudioPaused = false;
const store = usePlayerStore.getState(); const store = usePlayerStore.getState();
if (store.currentTrack?.id) {
useAuthStore.getState().clearSkipStarManualCountForTrack(store.currentTrack.id);
}
const { queue, queueIndex, repeatMode } = store; const { queue, queueIndex, repeatMode } = store;
const nextIdx = queueIndex + 1; const nextIdx = queueIndex + 1;
let nextTrack: Track | null = null; let nextTrack: Track | null = null;
@@ -603,6 +611,19 @@ export const usePlayerStore = create<PlayerState>()(
lastfmLovedCache: {}, lastfmLovedCache: {},
starredOverrides: {}, starredOverrides: {},
setStarredOverride: (id, starred) => set(s => ({ starredOverrides: { ...s.starredOverrides, [id]: starred } })), setStarredOverride: (id, starred) => set(s => ({ starredOverrides: { ...s.starredOverrides, [id]: starred } })),
userRatingOverrides: {},
setUserRatingOverride: (id, rating) =>
set(s => {
const nextOverrides = { ...s.userRatingOverrides };
if (rating === 0) delete nextOverrides[id];
else nextOverrides[id] = rating;
return {
userRatingOverrides: nextOverrides,
queue: s.queue.map(t => (t.id === id ? { ...t, userRating: rating } : t)),
currentTrack:
s.currentTrack?.id === id ? { ...s.currentTrack, userRating: rating } : s.currentTrack,
};
}),
isQueueVisible: true, isQueueVisible: true,
isFullscreenOpen: false, isFullscreenOpen: false,
repeatMode: 'off', repeatMode: 'off',
+209 -9
View File
@@ -1,4 +1,19 @@
import type { SubsonicSong } from '../api/subsonic'; import {
getRandomSongs,
prefetchAlbumUserRatings,
prefetchArtistUserRatings,
type SubsonicAlbum,
type SubsonicSong,
} from '../api/subsonic';
import { useAuthStore } from '../store/authStore';
/** Target list size for Random Mix after rating filter. */
export const RANDOM_MIX_TARGET_SIZE = 50;
const RANDOM_MIX_BATCH_SIZE = 50;
/** Upper bound on `getRandomSongs` calls (avoids infinite loop if the library is tiny or the filter is extreme). */
const RANDOM_MIX_MAX_BATCHES = 40;
/** Stop if several batches in a row bring no new track ids (server keeps repeating the same set). */
const RANDOM_MIX_MAX_DUP_STREAK = 6;
export interface MixMinRatingsConfig { export interface MixMinRatingsConfig {
enabled: boolean; enabled: boolean;
@@ -7,21 +22,206 @@ export interface MixMinRatingsConfig {
minArtist: number; minArtist: number;
} }
export function getMixMinRatingsConfigFromAuth(): MixMinRatingsConfig {
const s = useAuthStore.getState();
return {
enabled: s.mixMinRatingFilterEnabled,
minSong: s.mixMinRatingSong,
minAlbum: s.mixMinRatingAlbum,
minArtist: s.mixMinRatingArtist,
};
}
function numRating(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;
}
function ratingFromArtistRefs(
list: Array<{ id?: string; userRating?: unknown }> | undefined,
preferId?: string,
): number | undefined {
if (!list?.length) return undefined;
if (preferId) {
const m = list.find(a => a.id === preferId);
const r = numRating(m?.userRating);
if (r !== undefined) return r;
}
for (const a of list) {
const r = numRating(a.userRating);
if (r !== undefined) return r;
}
return undefined;
}
/** Song-level artist rating: explicit field, then OpenSubsonic `artists` / `albumArtists` on the child. */
function effectiveArtistRatingForFilter(song: SubsonicSong): number | undefined {
const d = numRating(song.artistUserRating);
if (d !== undefined) return d;
const fromArtists = ratingFromArtistRefs(song.artists, song.artistId);
if (fromArtists !== undefined) return fromArtists;
return ratingFromArtistRefs(song.albumArtists, song.artistId);
}
/** Song-level album (parent) rating when the server puts it on the child payload. */
function effectiveAlbumRatingOnSong(song: SubsonicSong): number | undefined {
return numRating(song.albumUserRating);
}
/** /**
* Random (and future album) flows: drop songs that are below per-axis thresholds when enabled. * Random mixes: when enabled, drop items with a **non-zero** rating that is **at or below** the
* Song axis uses `userRating` (missing = 0). Album/artist use optional OpenSubsonic-style * chosen threshold (inclusive). `0` / missing = unrated, never excluded.
* fields on the song payload; if a threshold is positive but the value is absent, the song is kept.
*/ */
export function passesMixMinRatings(song: SubsonicSong, c: MixMinRatingsConfig): boolean { export function passesMixMinRatings(song: SubsonicSong, c: MixMinRatingsConfig): boolean {
if (!c.enabled) return true; if (!c.enabled) return true;
if (c.minSong > 0 && (song.userRating ?? 0) < c.minSong) return false; if (c.minSong > 0) {
const r = numRating(song.userRating);
if (r !== undefined && r > 0 && r <= c.minSong) return false;
}
if (c.minAlbum > 0) { if (c.minAlbum > 0) {
const r = song.albumUserRating; const r = effectiveAlbumRatingOnSong(song);
if (r !== undefined && r < c.minAlbum) return false; if (r !== undefined && r > 0 && r <= c.minAlbum) return false;
} }
if (c.minArtist > 0) { if (c.minArtist > 0) {
const r = song.artistUserRating; const r = effectiveArtistRatingForFilter(song);
if (r !== undefined && r < c.minArtist) return false; if (r !== undefined && r > 0 && r <= c.minArtist) return false;
} }
return true; return true;
} }
export interface MixAlbumFilterExtra {
/** From `getArtist` when list payloads omit artist rating. */
artistUserRating?: number;
/** From `getAlbum` when list payloads omit album `userRating`. */
albumUserRating?: number;
}
/**
* Random album lists: album `userRating` when present; optional extra from entity fetches.
* Song axis is not on this payload. `0` / missing = unrated, keep.
*/
export function passesMixMinRatingsForAlbum(
album: SubsonicAlbum,
c: MixMinRatingsConfig,
extra?: MixAlbumFilterExtra,
): boolean {
if (!c.enabled) return true;
if (c.minAlbum > 0) {
const r = numRating(album.userRating ?? extra?.albumUserRating);
if (r !== undefined && r > 0 && r <= c.minAlbum) return false;
}
if (c.minArtist > 0) {
const r = numRating(extra?.artistUserRating);
if (r !== undefined && r > 0 && r <= c.minArtist) return false;
}
return true;
}
/**
* Fetches missing entity ratings (bounded concurrency) then filters. Used for random album grids / hero.
*/
export async function filterAlbumsByMixRatings(
albums: SubsonicAlbum[],
c: MixMinRatingsConfig,
): Promise<SubsonicAlbum[]> {
if (!c.enabled) return albums;
if (c.minAlbum <= 0 && c.minArtist <= 0) return albums;
const needArtist = c.minArtist > 0;
const needAlbum = c.minAlbum > 0;
let byArtist = new Map<string, number>();
let byAlbum = new Map<string, number>();
if (needArtist) {
const ids = [...new Set(albums.map(a => a.artistId).filter(Boolean))] as string[];
byArtist = await prefetchArtistUserRatings(ids);
}
if (needAlbum) {
const ids = [...new Set(albums.filter(a => a.userRating === undefined).map(a => a.id))];
if (ids.length) byAlbum = await prefetchAlbumUserRatings(ids);
}
return albums.filter(a =>
passesMixMinRatingsForAlbum(a, c, {
artistUserRating: a.artistId ? byArtist.get(a.artistId) : undefined,
albumUserRating: byAlbum.get(a.id),
}),
);
}
/**
* Merge `getArtist` / `getAlbum` ratings into songs before `passesMixMinRatings` when list payloads omit them.
*/
export async function enrichSongsForMixRatingFilter(
songs: SubsonicSong[],
c: MixMinRatingsConfig,
): Promise<SubsonicSong[]> {
if (!c.enabled || (c.minArtist <= 0 && c.minAlbum <= 0)) return songs;
const artistIds =
c.minArtist > 0
? [...new Set(songs.filter(s => s.artistUserRating === undefined && effectiveArtistRatingForFilter(s) === undefined && s.artistId).map(s => s.artistId!))]
: [];
const albumIds =
c.minAlbum > 0
? [...new Set(songs.filter(s => s.albumUserRating === undefined && s.albumId).map(s => s.albumId!))]
: [];
const [byArtist, byAlbum] = await Promise.all([
artistIds.length ? prefetchArtistUserRatings(artistIds) : Promise.resolve(new Map<string, number>()),
albumIds.length ? prefetchAlbumUserRatings(albumIds) : Promise.resolve(new Map<string, number>()),
]);
if (!byArtist.size && !byAlbum.size) return songs;
return songs.map(s => ({
...s,
...(s.artistUserRating === undefined &&
s.artistId &&
byArtist.has(s.artistId) && { artistUserRating: byArtist.get(s.artistId)! }),
...(s.albumUserRating === undefined &&
s.albumId &&
byAlbum.has(s.albumId) && { albumUserRating: byAlbum.get(s.albumId)! }),
}));
}
/**
* Loads random songs in batches until `RANDOM_MIX_TARGET_SIZE` pass `passesMixMinRatings` (after enrich),
* or limits are hit. When the mix rating filter is off, a single batch is used.
*/
export async function fetchRandomMixSongsUntilFull(
c: MixMinRatingsConfig,
opts?: { genre?: string; timeout?: number },
): Promise<SubsonicSong[]> {
const timeout = opts?.timeout ?? 15000;
const genre = opts?.genre;
if (!c.enabled) {
const raw = await getRandomSongs(RANDOM_MIX_BATCH_SIZE, genre, timeout);
return raw.slice(0, RANDOM_MIX_TARGET_SIZE);
}
const out: SubsonicSong[] = [];
const outIds = new Set<string>();
const seenFromApi = new Set<string>();
let dupStreak = 0;
for (let b = 0; b < RANDOM_MIX_MAX_BATCHES && out.length < RANDOM_MIX_TARGET_SIZE; b++) {
const raw = await getRandomSongs(RANDOM_MIX_BATCH_SIZE, genre, timeout);
if (!raw.length) break;
const novel = raw.filter(s => !seenFromApi.has(s.id));
for (const s of raw) seenFromApi.add(s.id);
if (!novel.length) {
if (++dupStreak >= RANDOM_MIX_MAX_DUP_STREAK) break;
continue;
}
dupStreak = 0;
const enriched = await enrichSongsForMixRatingFilter(novel, c);
for (const s of enriched) {
if (!passesMixMinRatings(s, c) || outIds.has(s.id)) continue;
outIds.add(s.id);
out.push(s);
if (out.length >= RANDOM_MIX_TARGET_SIZE) break;
}
}
return out;
}