diff --git a/CHANGELOG.md b/CHANGELOG.md
index deb8c7c1..0597a402 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/src/api/navidromeBrowse.ts b/src/api/navidromeBrowse.ts
index 371a8e21..34a60a3e 100644
--- a/src/api/navidromeBrowse.ts
+++ b/src/api/navidromeBrowse.ts
@@ -54,21 +54,45 @@ function mapNdSong(o: Record): 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();
+
+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 {
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 =>
invoke('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));
+ const data = raw.map(s => mapNdSong(s as Record));
+
+ 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();
}
diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts
index 81cef56c..c843102c 100644
--- a/src/api/subsonic.ts
+++ b/src/api/subsonic.ts
@@ -959,6 +959,11 @@ export async function searchSongsPaged(query: string, songCount: number, songOff
export async function setRating(id: string, rating: number): Promise {
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). */
diff --git a/src/components/SongCard.tsx b/src/components/SongCard.tsx
index bfce2e4b..7b346f02 100644
--- a/src/components/SongCard.tsx
+++ b/src/components/SongCard.tsx
@@ -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}
+ {(song.userRating ?? 0) > 0 && (
+
+ {Array.from({ length: 5 }, (_, i) => (
+
+ ))}
+
+ )}
);
diff --git a/src/locales/de.ts b/src/locales/de.ts
index 94b1f9da..05cc4901 100644
--- a/src/locales/de.ts
+++ b/src/locales/de.ts
@@ -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…',
diff --git a/src/locales/en.ts b/src/locales/en.ts
index ddaf7bfd..01742c75 100644
--- a/src/locales/en.ts
+++ b/src/locales/en.ts
@@ -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…',
diff --git a/src/locales/es.ts b/src/locales/es.ts
index 126ff993..fad31d29 100644
--- a/src/locales/es.ts
+++ b/src/locales/es.ts
@@ -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…',
diff --git a/src/locales/fr.ts b/src/locales/fr.ts
index b4cbe0ac..c5dc98b8 100644
--- a/src/locales/fr.ts
+++ b/src/locales/fr.ts
@@ -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…',
diff --git a/src/locales/nb.ts b/src/locales/nb.ts
index 09752b6d..721af647 100644
--- a/src/locales/nb.ts
+++ b/src/locales/nb.ts
@@ -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…',
diff --git a/src/locales/nl.ts b/src/locales/nl.ts
index dc84996c..900e6bc5 100644
--- a/src/locales/nl.ts
+++ b/src/locales/nl.ts
@@ -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…',
diff --git a/src/locales/ru.ts b/src/locales/ru.ts
index d66afd11..e8c6d2b7 100644
--- a/src/locales/ru.ts
+++ b/src/locales/ru.ts
@@ -412,6 +412,7 @@ export const ruTranslation = {
playSong: 'Воспроизвести',
enqueueSong: 'В очередь',
railRandom: 'Случайная подборка',
+ railHighlyRated: 'Высоко оценённые',
browseTitle: 'Просмотреть все треки',
browseUnsupported: 'Этот сервер не возвращает всю библиотеку сразу. Воспользуйтесь поиском выше, чтобы найти конкретные треки.',
searchPlaceholder: 'Найти трек по названию, исполнителю или альбому…',
diff --git a/src/locales/zh.ts b/src/locales/zh.ts
index 1f0323f0..4f103ebd 100644
--- a/src/locales/zh.ts
+++ b/src/locales/zh.ts
@@ -386,6 +386,7 @@ export const zhTranslation = {
playSong: '播放',
enqueueSong: '加入队列',
railRandom: '随机精选',
+ railHighlyRated: '高分推荐',
browseTitle: '浏览所有曲目',
browseUnsupported: '此服务器不支持一次性列出整个音乐库。使用上方的搜索来查找特定曲目。',
searchPlaceholder: '按标题、艺人或专辑搜索…',
diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx
index e254e587..5780feaa 100644
--- a/src/pages/Settings.tsx
+++ b/src/pages/Settings.tsx
@@ -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;
diff --git a/src/pages/Tracks.tsx b/src/pages/Tracks.tsx
index b15ac44e..5da4fc83 100644
--- a/src/pages/Tracks.tsx
+++ b/src/pages/Tracks.tsx
@@ -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([]);
const [randomLoading, setRandomLoading] = useState(true);
+ const [rated, setRated] = useState([]);
+ 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() {
)}
+ {ratedSupported && (ratedLoading || rated.length > 0) && (
+ { ndInvalidateSongsCache(); return reloadRated(); }}
+ />
+ )}
+