feat(tracks): Highly Rated rail + per-card star display (#443)

* feat(tracks): Highly Rated rail + per-card star display

Adds a new SongRail above the Random Pick on the Tracks page that
surfaces the user's highly-rated tracks (sorted by rating DESC).
Auto-hides on non-Navidrome servers and when the library has no
rated tracks yet. Reuses the existing SongRail layout, with the
standard reroll button forcing a cache bypass.

Per-card stars: any SongCard whose `userRating > 0` now shows a
small five-star row (filled to the rating value) below the artist
line — visible everywhere SongCard is used, not only in the new
rail. Read-only display; rating is still done via the row's
context menu or the Now Playing star widget.

Cache layer in `ndListSongs`: opt-in `cacheMs` parameter (skipped
by VirtualSongList; used only by the Highly Rated rail with a 60 s
TTL). Cleared on `setRating` mutation so a freshly-rated track
shows up on the next page revisit, and on server switch alongside
the existing token cache. The reroll button explicitly invalidates
before refetching, so a manual refresh always hits the network.

* docs(changelog): add #443 Tracks Highly Rated rail entry

* chore(credits): add #443 to Psychotoxical contributions
This commit is contained in:
Frank Stellmacher
2026-05-03 18:54:07 +02:00
committed by GitHub
parent 98ff73d17a
commit 1799e90e04
15 changed files with 125 additions and 5 deletions
+11
View File
@@ -155,6 +155,17 @@ The boolean **Reduce animations** toggle in **Settings → Appearance** is now a
Existing users with `reducedAnimations: true` are migrated 1:1 to **Reduced** on first launch; everyone else lands on **Full**. The picker is in the same place as before. A contextual hint below the picker explains what the selected mode does.
### Tracks — Highly Rated Rail and Per-Card Star Display
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#443](https://github.com/Psychotoxical/psysonic/pull/443), prompted by Foxhunter-de in discussion [#442](https://github.com/Psychotoxical/psysonic/discussions/442)**
The Tracks page gets a new **Highly Rated** rail above Random Pick, surfacing your top-rated tracks (sorted by rating, descending). The rail auto-hides on non-Navidrome servers and on libraries with no rated tracks yet. The standard reroll button forces a fresh fetch.
Every song card across the app whose rating is greater than zero now shows a small five-star row below the artist line, filled to the rating value. Read-only display — rating is still done via the row's context menu or the Now Playing star widget.
Backed by an opt-in 60 s in-memory cache for `ndListSongs` (used only by the new rail; paginated browsing is unaffected). The cache is cleared automatically when you rate a track, switch server, or click the rail's reroll button.
## Fixed
- **Settings → Audio no longer blanks the app on macOS** *(Issue [#382](https://github.com/Psychotoxical/psysonic/issues/382), PR [#384](https://github.com/Psychotoxical/psysonic/pull/384), by [@Psychotoxical](https://github.com/Psychotoxical))*: Fixed a macOS-only crash where opening Settings → Audio could turn the whole app into a blank window. The Equalizer canvas now waits until it has valid layout dimensions before drawing, and redraws automatically once the section is visible.
+38 -3
View File
@@ -54,21 +54,45 @@ function mapNdSong(o: Record<string, unknown>): SubsonicSong {
};
}
export type NdSongSort = 'title' | 'artist' | 'album' | 'recently_added' | 'play_count';
export type NdSongSort = 'title' | 'artist' | 'album' | 'recently_added' | 'play_count' | 'rating';
/** Optional opt-in cache for `ndListSongs` — keyed by call signature + active server. */
type SongsCacheEntry = { data: SubsonicSong[]; expiresAt: number };
const songsCache = new Map<string, SongsCacheEntry>();
function songsCacheKey(
baseUrl: string, start: number, end: number, sort: string, order: string,
): string {
return `${baseUrl}|${start}-${end}|${sort}|${order}`;
}
/**
* Fetch a sorted, paginated slice of all songs via Navidrome's native REST API.
* Returns mapped SubsonicSong objects. Throws on auth failure or non-Navidrome.
*
* `cacheMs` (> 0) opts in to a per-call-signature in-memory cache. Skip for
* paginated browsing only useful for stable-list rails (e.g. Highly Rated)
* where a brief staleness window is acceptable in exchange for skipping the
* roundtrip on every page revisit.
*/
export async function ndListSongs(
start: number,
end: number,
sort: NdSongSort = 'title',
order: 'ASC' | 'DESC' = 'ASC',
cacheMs?: number,
): Promise<SubsonicSong[]> {
const baseUrl = useAuthStore.getState().getBaseUrl();
if (!baseUrl) throw new Error('No server configured');
const cacheKey = (cacheMs && cacheMs > 0)
? songsCacheKey(baseUrl, start, end, sort, order)
: null;
if (cacheKey) {
const hit = songsCache.get(cacheKey);
if (hit && hit.expiresAt > Date.now()) return hit.data;
}
const callOnce = async (token: string): Promise<unknown> =>
invoke<unknown>('nd_list_songs', { serverUrl: baseUrl, token, sort, order, start, end });
@@ -88,10 +112,21 @@ export async function ndListSongs(
}
if (!Array.isArray(raw)) return [];
return raw.map(s => mapNdSong(s as Record<string, unknown>));
const data = raw.map(s => mapNdSong(s as Record<string, unknown>));
if (cacheKey && cacheMs && cacheMs > 0) {
songsCache.set(cacheKey, { data, expiresAt: Date.now() + cacheMs });
}
return data;
}
/** Drop the cached token — call when the active server changes. */
/** Drop the cached token AND the songs cache — call when the active server changes. */
export function ndClearTokenCache(): void {
cachedToken = null;
songsCache.clear();
}
/** Drop the songs cache only (e.g. after a rating mutation). */
export function ndInvalidateSongsCache(): void {
songsCache.clear();
}
+5
View File
@@ -959,6 +959,11 @@ export async function searchSongsPaged(query: string, songCount: number, songOff
export async function setRating(id: string, rating: number): Promise<void> {
await api('setRating.view', { id, rating });
// Cached song lists keyed by rating (e.g. Tracks → Highly Rated rail) become
// stale immediately. Lazy-import to keep the module dep direction
// subsonic ← navidromeBrowse and avoid pulling Tauri internals into shared
// type-only consumers.
void import('./navidromeBrowse').then(m => m.ndInvalidateSongsCache()).catch(() => {});
}
/** How aggressively we assume `setRating` accepts album/artist ids (OpenSubsonic-style). */
+13 -1
View File
@@ -1,6 +1,6 @@
import React, { memo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Play, ListPlus } from 'lucide-react';
import { Play, ListPlus, Star } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { SubsonicSong, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore';
@@ -118,6 +118,18 @@ function SongCard({ song }: SongCardProps) {
onClick={handleArtistClick}
title={song.artist}
>{song.artist}</p>
{(song.userRating ?? 0) > 0 && (
<div className="song-card-rating" aria-label={`${song.userRating} stars`}>
{Array.from({ length: 5 }, (_, i) => (
<Star
key={i}
size={11}
fill={i < (song.userRating ?? 0) ? 'currentColor' : 'none'}
strokeWidth={1.5}
/>
))}
</div>
)}
</div>
</div>
);
+1
View File
@@ -388,6 +388,7 @@ export const deTranslation = {
playSong: 'Abspielen',
enqueueSong: 'Zur Warteschlange',
railRandom: 'Zufallsauswahl',
railHighlyRated: 'Hoch bewertet',
browseTitle: 'Alle Titel durchstöbern',
browseUnsupported: 'Dieser Server listet nicht die ganze Bibliothek auf einmal. Nutze die Suche oben, um bestimmte Titel zu finden.',
searchPlaceholder: 'Titel, Künstler oder Album suchen…',
+1
View File
@@ -390,6 +390,7 @@ export const enTranslation = {
playSong: 'Play',
enqueueSong: 'Add to queue',
railRandom: 'Random Pick',
railHighlyRated: 'Highly Rated',
browseTitle: 'Browse all tracks',
browseUnsupported: "This server doesn't list the whole library at once. Use the search above to find specific tracks.",
searchPlaceholder: 'Find a track by title, artist or album…',
+1
View File
@@ -390,6 +390,7 @@ export const esTranslation = {
playSong: 'Reproducir',
enqueueSong: 'Añadir a la cola',
railRandom: 'Selección aleatoria',
railHighlyRated: 'Mejor valoradas',
browseTitle: 'Explorar todas las canciones',
browseUnsupported: 'Este servidor no lista toda la biblioteca de una vez. Usa la búsqueda de arriba para encontrar canciones concretas.',
searchPlaceholder: 'Busca una canción por título, artista o álbum…',
+1
View File
@@ -388,6 +388,7 @@ export const frTranslation = {
playSong: 'Lire',
enqueueSong: 'Ajouter à la file',
railRandom: 'Sélection aléatoire',
railHighlyRated: 'Mieux notés',
browseTitle: 'Parcourir tous les titres',
browseUnsupported: 'Ce serveur ne liste pas toute la bibliothèque d\'un coup. Utilisez la recherche ci-dessus pour trouver des titres précis.',
searchPlaceholder: 'Chercher un titre par titre, artiste ou album…',
+1
View File
@@ -388,6 +388,7 @@ export const nbTranslation = {
playSong: 'Spill av',
enqueueSong: 'Legg til i kø',
railRandom: 'Tilfeldig valg',
railHighlyRated: 'Høyt vurdert',
browseTitle: 'Bla gjennom alle spor',
browseUnsupported: 'Denne tjeneren lister ikke hele biblioteket på én gang. Bruk søket ovenfor for å finne bestemte spor.',
searchPlaceholder: 'Finn et spor etter tittel, artist eller album…',
+1
View File
@@ -387,6 +387,7 @@ export const nlTranslation = {
playSong: 'Afspelen',
enqueueSong: 'Aan wachtrij toevoegen',
railRandom: 'Willekeurige selectie',
railHighlyRated: 'Hoog beoordeeld',
browseTitle: 'Alle nummers doorbladeren',
browseUnsupported: 'Deze server toont niet de hele bibliotheek in één keer. Gebruik de zoekbalk hierboven om specifieke nummers te vinden.',
searchPlaceholder: 'Zoek op titel, artiest of album…',
+1
View File
@@ -412,6 +412,7 @@ export const ruTranslation = {
playSong: 'Воспроизвести',
enqueueSong: 'В очередь',
railRandom: 'Случайная подборка',
railHighlyRated: 'Высоко оценённые',
browseTitle: 'Просмотреть все треки',
browseUnsupported: 'Этот сервер не возвращает всю библиотеку сразу. Воспользуйтесь поиском выше, чтобы найти конкретные треки.',
searchPlaceholder: 'Найти трек по названию, исполнителю или альбому…',
+1
View File
@@ -386,6 +386,7 @@ export const zhTranslation = {
playSong: '播放',
enqueueSong: '加入队列',
railRandom: '随机精选',
railHighlyRated: '高分推荐',
browseTitle: '浏览所有曲目',
browseUnsupported: '此服务器不支持一次性列出整个音乐库。使用上方的搜索来查找特定曲目。',
searchPlaceholder: '按标题、艺人或专辑搜索…',
+1
View File
@@ -350,6 +350,7 @@ const CONTRIBUTORS = [
'Windows: playback stutter under GPU load — MMCSS Pro Audio promotion + animation pause + reduce-animations toggle (PR #426)',
'Audio: frame-align gapless-off track-separation silence (fixes mono-channel playback after natural track end) (PR #439)',
'Settings: 3-state animation mode (Full / Reduced / Static) — replaces boolean reduce-animations toggle (PR #441)',
'Tracks: Highly Rated rail and per-card star display, with cache layer for ndListSongs (PR #443)',
],
},
] as const;
+41 -1
View File
@@ -14,8 +14,17 @@ import CachedImage from '../components/CachedImage';
import SongRail from '../components/SongRail';
import VirtualSongList from '../components/VirtualSongList';
import { playSongNow } from '../utils/playSong';
import { ndListSongs, ndInvalidateSongsCache } from '../api/navidromeBrowse';
const RANDOM_RAIL_SIZE = 18;
/** Over-fetch buffer so the client-side `userRating > 0` filter still leaves
* enough cards for the rail. Server-side rating filter on Navidrome's REST
* is finicky and not yet wired through revisit when verified. */
const RATED_RAIL_FETCH = 60;
const RATED_RAIL_DISPLAY = 30;
/** Stay-fresh window for the Highly Rated rail. Cleared on rating mutation, so
* the only staleness path is a reroll-button click after >60 s. */
const RATED_RAIL_CACHE_MS = 60_000;
export default function Tracks() {
const { t } = useTranslation();
@@ -29,6 +38,11 @@ export default function Tracks() {
const [random, setRandom] = useState<SubsonicSong[]>([]);
const [randomLoading, setRandomLoading] = useState(true);
const [rated, setRated] = useState<SubsonicSong[]>([]);
const [ratedLoading, setRatedLoading] = useState(true);
/** Hide the rail entirely on non-Navidrome servers (REST call throws) so we don't show an empty section. */
const [ratedSupported, setRatedSupported] = useState(true);
const rerollHero = useCallback(async () => {
setHeroLoading(true);
try {
@@ -49,11 +63,28 @@ export default function Tracks() {
}
}, []);
const reloadRated = useCallback(async () => {
setRatedLoading(true);
try {
const songs = await ndListSongs(0, RATED_RAIL_FETCH, 'rating', 'DESC', RATED_RAIL_CACHE_MS);
const filtered = songs.filter(s => (s.userRating ?? 0) > 0).slice(0, RATED_RAIL_DISPLAY);
setRated(filtered);
setRatedSupported(true);
} catch {
// Non-Navidrome server, or REST endpoint refused → silently hide the rail.
setRated([]);
setRatedSupported(false);
} finally {
setRatedLoading(false);
}
}, []);
useEffect(() => {
if (!activeServerId) return;
rerollHero();
rerollRandom();
}, [activeServerId, rerollHero, rerollRandom]);
reloadRated();
}, [activeServerId, rerollHero, rerollRandom, reloadRated]);
const heroCoverUrl = hero?.coverArt ? buildCoverArtUrl(hero.coverArt, 600) : '';
@@ -137,6 +168,15 @@ export default function Tracks() {
</section>
)}
{ratedSupported && (ratedLoading || rated.length > 0) && (
<SongRail
title={t('tracks.railHighlyRated')}
songs={rated}
loading={ratedLoading}
onReroll={() => { ndInvalidateSongsCache(); return reloadRated(); }}
/>
)}
<SongRail
title={t('tracks.railRandom')}
songs={railSongs}
+8
View File
@@ -294,6 +294,14 @@
text-overflow: ellipsis;
}
.song-card-rating {
display: flex;
gap: 2px;
color: var(--accent);
margin-top: 4px;
line-height: 1;
}
/* ─── Virtual Song List ───────────────────────────────────────── */
.virtual-song-list-section {