diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index 9df3a3e3..35bb5539 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -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> { + const unique = [...new Set(ids.filter(Boolean))]; + const out = new Map(); + 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> { + const unique = [...new Set(ids.filter(Boolean))]; + const out = new Map(); + 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 { const data = await api<{ artists: { index: Array<{ artist: SubsonicArtist[] }> } }>('getArtists.view', { ...libraryFilterParams(), diff --git a/src/components/AlbumTrackList.tsx b/src/components/AlbumTrackList.tsx index a6b1f8cc..268fc109 100644 --- a/src/components/AlbumTrackList.tsx +++ b/src/components/AlbumTrackList.tsx @@ -54,6 +54,8 @@ interface AlbumTrackListProps { currentTrack: Track | null; isPlaying: boolean; ratings: Record; + /** Merged after local `ratings` (e.g. skip→1★ optimistic updates). */ + userRatingOverrides: Record; starredSongs: Set; onPlaySong: (song: SubsonicSong) => void; onRate: (songId: string, rating: number) => void; @@ -67,6 +69,7 @@ export default function AlbumTrackList({ currentTrack, isPlaying, ratings, + userRatingOverrides, starredSongs, onPlaySong, onRate, @@ -252,7 +255,7 @@ export default function AlbumTrackList({ return ( onRate(song.id, r)} /> ); diff --git a/src/components/Hero.tsx b/src/components/Hero.tsx index 7d5ab9d1..521fd0aa 100644 --- a/src/components/Hero.tsx +++ b/src/components/Hero.tsx @@ -8,8 +8,12 @@ import { useTranslation } from 'react-i18next'; import { playAlbum } from '../utils/playAlbum'; import { useIsMobile } from '../hooks/useIsMobile'; import { useAuthStore } from '../store/authStore'; +import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter'; 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 function HeroBg({ url }: { url: string }) { @@ -54,14 +58,33 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) { const navigate = useNavigate(); const isMobile = useIsMobile(); 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([]); const [activeIdx, setActiveIdx] = useState(0); const timerRef = useRef | null>(null); useEffect(() => { if (albumsProp?.length) { setAlbums(albumsProp); return; } - getRandomAlbums(8).then(a => { if (a.length) setAlbums(a); }).catch(() => {}); - }, [albumsProp, musicLibraryFilterVersion]); + const cfg = { ...getMixMinRatingsConfigFromAuth(), minSong: 0 }; + 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 const startTimer = useCallback((len: number) => { diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx index 50d59410..90ef3849 100644 --- a/src/components/QueuePanel.tsx +++ b/src/components/QueuePanel.tsx @@ -214,6 +214,7 @@ export default function QueuePanel() { const queue = usePlayerStore(s => s.queue); const queueIndex = usePlayerStore(s => s.queueIndex); const currentTrack = usePlayerStore(s => s.currentTrack); + const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides); const currentCoverFetchUrl = useMemo( () => currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 128) : '', [currentTrack?.coverArt] @@ -447,7 +448,7 @@ export default function QueuePanel() { {currentTrack.year && (
{currentTrack.year}
)} - {renderStars(currentTrack.userRating)} + {renderStars(userRatingOverrides[currentTrack.id] ?? currentTrack.userRating)} diff --git a/src/locales/de.ts b/src/locales/de.ts index 301d086f..c0e48a3b 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -511,17 +511,15 @@ export const deTranslation = { ratingsSectionTitle: 'Bewertungen', ratingsSkipStarTitle: 'Überspringen für 1 Stern', 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★', - 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', ratingsMixFilterDesc: - 'Inhalte mit niedriger Bewertung in Zufallsmixen und zufälligen Alben filtern. Gleichen Stern erneut klicken, um die Schwelle auszuschalten.', - ratingsMixMinSong: 'Titel (Titelbewertung)', + 'Inhalte mit niedriger Bewertung in {{mix}} und {{albums}} filtern. Erneut auf den gewählten Stern klicken, um die Schwelle zu deaktivieren.', + ratingsMixMinSong: 'Titel', ratingsMixMinAlbum: 'Alben', ratingsMixMinArtist: 'Interpreten', - ratingsMixMinThresholdAria: 'Mindest-Sterne ({{label}})', + ratingsMixMinThresholdAria: 'Mindest-Sterne: {{label}}', backupTitle: 'Backup & Wiederherstellung', 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.', diff --git a/src/locales/en.ts b/src/locales/en.ts index 2636b519..721dec3f 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -496,16 +496,15 @@ export const enTranslation = { ratingsSectionTitle: 'Ratings', ratingsSkipStarTitle: 'Skip for 1 star', 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★', - 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', ratingsMixFilterDesc: - 'Filter low-rated items in random mixes and random albums. Click the same star again to turn off that column’s threshold.', - ratingsMixMinSong: 'Songs (track rating)', + 'Filter low-rated items in {{mix}} and {{albums}}. Click the selected star again to turn off the threshold.', + ratingsMixMinSong: 'Songs', ratingsMixMinAlbum: 'Albums', ratingsMixMinArtist: 'Artists', - ratingsMixMinThresholdAria: 'Minimum stars ({{label}})', + ratingsMixMinThresholdAria: 'Minimum stars: {{label}}', backupTitle: 'Backup & Restore', 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.', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index b625fcc9..75389410 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -509,17 +509,15 @@ export const frTranslation = { ratingsSectionTitle: 'Notes', ratingsSkipStarTitle: 'Passer pour 1 étoile', ratingsSkipStarDesc: - 'Après N sauts manuels du même morceau : note serveur 1 étoile si encore sans note.', + 'Après N sauts d’affilée : mettre le morceau à 1 étoile. Uniquement s’il n’était pas encore noté.', 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', 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.', - ratingsMixMinSong: 'Morceaux (note du titre)', + 'Filtrer le contenu peu noté dans {{mix}} et {{albums}}. Cliquer de nouveau sur l’étoile choisie désactive le seuil.', + ratingsMixMinSong: 'Morceaux', ratingsMixMinAlbum: 'Albums', ratingsMixMinArtist: 'Artistes', - ratingsMixMinThresholdAria: 'Étoiles minimum ({{label}})', + ratingsMixMinThresholdAria: 'Étoiles minimum : {{label}}', backupTitle: 'Sauvegarde & Restauration', 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é.', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 64cf36f5..4a4f0587 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -492,17 +492,15 @@ export const nbTranslation = { ratingsSectionTitle: 'Vurderinger', ratingsSkipStarTitle: 'Hopp for 1 stjerne', 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★', - 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', ratingsMixFilterDesc: - 'Filtrer innhold med lav vurdering i tilfeldige mikser og tilfeldige album. Klikk samme stjerne igjen for å slå av terskelen.', - ratingsMixMinSong: 'Spor (sporvurdering)', + 'Filtrer innhold med lav vurdering i {{mix}} og {{albums}}. Klikk på den valgte stjerna igjen for å slå av terskelen.', + ratingsMixMinSong: 'Spor', ratingsMixMinAlbum: 'Album', ratingsMixMinArtist: 'Artister', - ratingsMixMinThresholdAria: 'Minimum stjerner ({{label}})', + ratingsMixMinThresholdAria: 'Minimum stjerner: {{label}}', backupTitle: 'Sikkerhetskopiering og gjenoppretting', 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.', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 254efcda..cf59b459 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -509,17 +509,15 @@ export const nlTranslation = { ratingsSectionTitle: 'Beoordelingen', ratingsSkipStarTitle: 'Overslaan voor 1 ster', 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★', - 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', ratingsMixFilterDesc: - 'Content met lage beoordeling filteren in willekeurige mixen en willekeurige albums. Klik dezelfde ster opnieuw om de drempel uit te zetten.', - ratingsMixMinSong: 'Nummers (nummerbeoordeling)', + 'Content met lage beoordeling filteren in {{mix}} en {{albums}}. Klik opnieuw op de gekozen ster om de drempel uit te zetten.', + ratingsMixMinSong: 'Nummers', ratingsMixMinAlbum: 'Albums', ratingsMixMinArtist: 'Artiesten', - ratingsMixMinThresholdAria: 'Minimum sterren ({{label}})', + ratingsMixMinThresholdAria: 'Minimum sterren: {{label}}', backupTitle: 'Back-up & Herstel', 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.', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 7ba51955..cf9ec20c 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -460,6 +460,9 @@ export const ruTranslation = { showTrayIconDesc: 'Показывать Psysonic в области уведомлений / строке меню.', minimizeToTray: 'Сворачивать в трей', minimizeToTrayDesc: 'При закрытии окна не выходить из приложения, а оставаться в трее.', + useCustomTitlebar: 'Своя строка заголовка', + useCustomTitlebarDesc: + 'Заменить системную строку заголовка встроенной, в стиле темы приложения. Отключите, чтобы использовать родную строку GNOME/GTK.', discordRichPresence: 'Статус в Discord', discordRichPresenceDesc: 'Показывать текущий трек в профиле и статусе Discord. Нужен запущенный клиент Discord.', @@ -514,13 +517,11 @@ export const ruTranslation = { ratingsSectionTitle: 'Рейтинги', ratingsSkipStarTitle: 'Скипнуть для 1 звезды', ratingsSkipStarDesc: - 'N ручных скипов трека — 1 звезда рейтинга, если трек ещё без оценки.', + 'При N скипов подряд ставить 1★ треку. Только для не оцененных ранее.', ratingsSkipStarThresholdLabel: 'Скипов', - ratingsSkipStarThresholdHint: - 'Считаются только переходы «Далее» / медиаклавиша «следующий», не окончание трека само по себе. Не меняет оценку, если у трека уже не ниже 1★.', ratingsMixFilterTitle: 'Фильтрация по рейтингу', ratingsMixFilterDesc: - 'Фильтровать с низким рейтингом в Случайных миксах и Случайных альбомах. Повторный клик по выбранной звезде отключает порог.', + 'Фильтровать с низким рейтингом в «{{mix}}» и «{{albums}}». Повторный клик по выбранной звезде отключает порог.', ratingsMixMinSong: 'Песни', ratingsMixMinAlbum: 'Альбомы', ratingsMixMinArtist: 'Исполнители', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 6b91a4bb..35b6e3c8 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -489,17 +489,15 @@ export const zhTranslation = { ratingsSectionTitle: '评分', ratingsSkipStarTitle: '跳过以评 1 星', ratingsSkipStarDesc: - '同一曲目手动跳过 N 次后,若尚未评分则在服务器上设为 1 星。', + '连续跳过 N 次后将曲目设为 1 星。仅适用于此前未评分的曲目。', ratingsSkipStarThresholdLabel: '跳过次数(至 1★)', - ratingsSkipStarThresholdHint: - '仅统计用“下一曲”或媒体键离开该曲的情况;自然播放结束不计入。曲目已有 1 星或以上时不改变。', ratingsMixFilterTitle: '按评分筛选', ratingsMixFilterDesc: - '在随机混合与随机专辑中筛选低评分内容。再次点击同一颗星可关闭该列阈值。', - ratingsMixMinSong: '歌曲(曲目评分)', + '在{{mix}}与{{albums}}中筛选低评分内容。再次点击所选星标可关闭阈值。', + ratingsMixMinSong: '歌曲', ratingsMixMinAlbum: '专辑', ratingsMixMinArtist: '艺人', - ratingsMixMinThresholdAria: '最低星数({{label}})', + ratingsMixMinThresholdAria: '最低星数:{{label}}', backupTitle: '备份与恢复', backupExport: '导出设置', backupExportDesc: '将所有设置、服务器配置、Last.fm 配置、主题、均衡器和快捷键保存到 .psybkp 文件。密码以明文存储——请妥善保管该文件。', diff --git a/src/pages/AlbumDetail.tsx b/src/pages/AlbumDetail.tsx index 6477ad9f..f28355b7 100644 --- a/src/pages/AlbumDetail.tsx +++ b/src/pages/AlbumDetail.tsx @@ -34,6 +34,7 @@ export default function AlbumDetail() { const openContextMenu = usePlayerStore(s => s.openContextMenu); const starredOverrides = usePlayerStore(s => s.starredOverrides); const setStarredOverride = usePlayerStore(s => s.setStarredOverride); + const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides); const currentTrack = usePlayerStore(s => s.currentTrack); const isPlaying = usePlayerStore(s => s.isPlaying); @@ -137,6 +138,7 @@ const handleEnqueueAll = () => { const handleRate = async (songId: string, rating: number) => { setRatings(r => ({ ...r, [songId]: rating })); + usePlayerStore.getState().setUserRatingOverride(songId, rating); await setRating(songId, rating); }; @@ -331,6 +333,7 @@ const handleEnqueueAll = () => { currentTrack={currentTrack} isPlaying={isPlaying} ratings={ratings} + userRatingOverrides={userRatingOverrides} starredSongs={new Set([ ...[...starredSongs].filter(id => starredOverrides[id] !== false), ...Object.entries(starredOverrides).filter(([, v]) => v).map(([k]) => k), diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 8ba36968..383ee1e8 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -7,10 +7,19 @@ import { NavLink, useNavigate } from 'react-router-dom'; import { ChevronRight } from 'lucide-react'; import { useHomeStore } from '../store/homeStore'; 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() { const homeSections = useHomeStore(s => s.sections); 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 [starred, setStarred] = useState([]); @@ -23,30 +32,50 @@ export default function Home() { const [loading, setLoading] = useState(true); useEffect(() => { - Promise.all([ - getAlbumList('starred', 12).catch(() => []), - getAlbumList('newest', 12).catch(() => []), - getAlbumList('random', 20).catch(() => []), - getAlbumList('frequent', 12).catch(() => []), - getAlbumList('recent', 12).catch(() => []), - isVisible('discoverArtists') ? getArtists().catch(() => []) : Promise.resolve([]), - ]).then(([s, n, r, f, rp, artists]) => { - setStarred(s); - setRecent(n); - setHeroAlbums(r.slice(0, 8)); - setRandom(r.slice(8)); - setMostPlayed(f); - setRecentlyPlayed(rp); - // Pick 16 random artists via Fisher-Yates shuffle - 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]]; + let cancelled = false; + setLoading(true); + (async () => { + try { + const mixCfg = getMixMinRatingsConfigFromAuth(); + const albumMix = + mixCfg.enabled && (mixCfg.minAlbum > 0 || mixCfg.minArtist > 0); + const randomSize = albumMix ? HOME_RANDOM_FETCH : HOME_DISCOVER_SLICE; + const [s, n, rRaw, f, rp, artists] = await Promise.all([ + getAlbumList('starred', 12).catch(() => []), + getAlbumList('newest', 12).catch(() => []), + getAlbumList('random', randomSize).catch(() => []), + getAlbumList('frequent', 12).catch(() => []), + getAlbumList('recent', 12).catch(() => []), + isVisible('discoverArtists') ? getArtists().catch(() => []) : Promise.resolve([]), + ]); + if (cancelled) return; + const r = await filterAlbumsByMixRatings(rRaw, mixCfg); + 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); - }).catch(() => setLoading(false)); - }, [musicLibraryFilterVersion, homeSections]); + })(); + return () => { cancelled = true; }; + }, [ + musicLibraryFilterVersion, + homeSections, + mixMinRatingFilterEnabled, + mixMinRatingAlbum, + mixMinRatingArtist, + ]); const loadMore = async ( type: 'starred' | 'newest' | 'random' | 'frequent' | 'recent', @@ -55,7 +84,10 @@ export default function Home() { ) => { try { 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]); } catch (e) { console.error('Failed to load more', e); diff --git a/src/pages/NowPlaying.tsx b/src/pages/NowPlaying.tsx index 6d2defbe..cdfd6ca1 100644 --- a/src/pages/NowPlaying.tsx +++ b/src/pages/NowPlaying.tsx @@ -211,6 +211,7 @@ export default function NowPlaying() { const navigate = useNavigate(); const currentTrack = usePlayerStore(s => s.currentTrack); + const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides); const isPlaying = usePlayerStore(s => s.isPlaying); const showLyrics = useLyricsStore(s => s.showLyrics); const activeTab = useLyricsStore(s => s.activeTab); @@ -292,7 +293,7 @@ export default function NowPlaying() { {currentTrack.suffix && {currentTrack.suffix.toUpperCase()}} {currentTrack.bitRate && {currentTrack.bitRate} kbps} {currentTrack.duration && {formatTime(currentTrack.duration)}} - {renderStars(currentTrack.userRating)} + {renderStars(userRatingOverrides[currentTrack.id] ?? currentTrack.userRating)} ); - case 'rating': return handleRate(song.id, r)} />; + case 'rating': return handleRate(song.id, r)} />; case 'duration': return
{formatDuration(song.duration ?? 0)}
; case 'format': return (
diff --git a/src/pages/RandomAlbums.tsx b/src/pages/RandomAlbums.tsx index bb939c9f..e4d2f220 100644 --- a/src/pages/RandomAlbums.tsx +++ b/src/pages/RandomAlbums.tsx @@ -5,8 +5,13 @@ import AlbumCard from '../components/AlbumCard'; import GenreFilterBar from '../components/GenreFilterBar'; import { useTranslation } from 'react-i18next'; import { useAuthStore } from '../store/authStore'; +import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter'; 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 { const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0))); @@ -17,12 +22,17 @@ async function fetchByGenres(genres: string[]): Promise { const j = Math.floor(Math.random() * (i + 1)); [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() { const { t } = useTranslation(); 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([]); const [loading, setLoading] = useState(true); const [selectedGenres, setSelectedGenres] = useState([]); @@ -34,9 +44,13 @@ export default function RandomAlbums() { loadingRef.current = true; setLoading(true); 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 ? await fetchByGenres(genres) - : await getAlbumList('random', ALBUM_COUNT); + : (await filterAlbumsByMixRatings(await getAlbumList('random', randomSize), mixCfg)).slice(0, ALBUM_COUNT); setAlbums(data); } catch (e) { console.error(e); @@ -44,7 +58,12 @@ export default function RandomAlbums() { loadingRef.current = false; setLoading(false); } - }, [musicLibraryFilterVersion]); + }, [ + musicLibraryFilterVersion, + mixMinRatingFilterEnabled, + mixMinRatingAlbum, + mixMinRatingArtist, + ]); useEffect(() => { load(selectedGenres); }, [selectedGenres, load]); diff --git a/src/pages/RandomMix.tsx b/src/pages/RandomMix.tsx index 56e6be7d..09c35d04 100644 --- a/src/pages/RandomMix.tsx +++ b/src/pages/RandomMix.tsx @@ -1,11 +1,15 @@ 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 { useAuthStore } from '../store/authStore'; import { Play, RefreshCw, ChevronDown, ChevronUp, Heart } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useDragDrop } from '../contexts/DragDropContext'; -import { passesMixMinRatings } from '../utils/mixRatingFilter'; +import { + fetchRandomMixSongsUntilFull, + getMixMinRatingsConfigFromAuth, + passesMixMinRatings, +} from '../utils/mixRatingFilter'; const AUDIOBOOK_GENRES = [ 'hörbuch', 'hoerbuch', 'hörspiel', 'hoerspiel', @@ -76,19 +80,11 @@ export default function RandomMix() { const fetchSongs = () => { setLoading(true); setSongs([]); - getRandomSongs(50) - .then(fetched => { - const cfg = useAuthStore.getState(); - const mixCfg = { - enabled: cfg.mixMinRatingFilterEnabled, - minSong: cfg.mixMinRatingSong, - minAlbum: cfg.mixMinRatingAlbum, - minArtist: cfg.mixMinRatingArtist, - }; - const filtered = fetched.filter(s => passesMixMinRatings(s, mixCfg)); - setSongs(filtered); + fetchRandomMixSongsUntilFull(getMixMinRatingsConfigFromAuth()) + .then(list => { + setSongs(list); const st = new Set(); - filtered.forEach(s => { if (s.starred) st.add(s.id); }); + list.forEach(s => { if (s.starred) st.add(s.id); }); setStarredSongs(st); setLoading(false); }) @@ -161,15 +157,11 @@ export default function RandomMix() { setGenreMixComplete(false); setGenreMixSongs([]); try { - const fetched = await getRandomSongs(50, genre, 45000); - const cfg = useAuthStore.getState(); - const mixCfg = { - enabled: cfg.mixMinRatingFilterEnabled, - minSong: cfg.mixMinRatingSong, - minAlbum: cfg.mixMinRatingAlbum, - minArtist: cfg.mixMinRatingArtist, - }; - setGenreMixSongs(fetched.filter(s => passesMixMinRatings(s, mixCfg))); + const list = await fetchRandomMixSongsUntilFull(getMixMinRatingsConfigFromAuth(), { + genre, + timeout: 45000, + }); + setGenreMixSongs(list); } catch {} setGenreMixLoading(false); setGenreMixComplete(true); diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 56267442..75955615 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -819,18 +819,18 @@ export default function Settings() {
- {auth.skipStarOnManualSkipsEnabled && ( -

- {t('settings.ratingsSkipStarThresholdHint')} -

- )}
{t('settings.ratingsMixFilterTitle')}
-
{t('settings.ratingsMixFilterDesc')}
+
+ {t('settings.ratingsMixFilterDesc', { + mix: t('sidebar.randomMix'), + albums: t('sidebar.randomAlbums'), + })} +