mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
ba73649360
Two reports rolled into one pass: 1. Rail occasionally rendered nothing on Home open. Cause: the rotated anchor sometimes had no Last.fm similar-artists or no library matches among the sampled set, and the old code gave up after one try and stored that dud anchor as the rotation cursor — so the next mount started from the same dud's neighbour and could fail again. 2. Recommendations felt repetitive. Cause: pool of 12 anchors walked round-robin made each anchor recur every 12 mounts, similar-artist sample of 6 from 12 had heavy overlap visit-to-visit, and per-artist single-album random pick meant artists with one library album always surfaced the same record. Anchor selection: random pick from pool with a per-server cooldown buffer (last 5 anchors excluded, capped at floor(pool/2) so small libraries don't soft-lock). Up to 4 anchors are tried in a shuffled candidates list before giving up; the localStorage cursor only advances on a successful anchor so duds don't poison future mounts. Picks variety: similar-artist fetch raised from 12 to 25 (same getArtistInfo call, larger response — Last.fm typically returns up to ~50). Per-server ring buffer of the last 30 shown album ids; per-similar -artist album choice prefers an album not in that buffer, falling back to any album when the artist's whole catalogue is stale so the slot is never lost. Pool cap raised 12 -> 20 to give the cooldown buffer room to breathe in libraries with varied listening history. Storage: legacy `psysonic_because_anchor:` single-id keys from the round-robin era are stripped on module load (one-shot localStorage sweep, the new `..._anchor_history:` prefix has a different colon position so no false matches). API budget unchanged in the hot path: 1 getArtistInfo + 6 getArtist per Home mount. Worst case (3 dud anchors, 4th succeeds) is 4 getArtistInfo + 6 getArtist.
344 lines
12 KiB
TypeScript
344 lines
12 KiB
TypeScript
import React, { memo, useEffect, useMemo, useState } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { Play, ListPlus, Music } from 'lucide-react';
|
||
import {
|
||
SubsonicAlbum,
|
||
buildCoverArtUrl,
|
||
coverArtCacheKey,
|
||
getAlbum,
|
||
getArtist,
|
||
getArtistInfo,
|
||
} from '../api/subsonic';
|
||
import CachedImage, { useCachedUrl } from './CachedImage';
|
||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||
import { useAuthStore } from '../store/authStore';
|
||
import { playAlbum } from '../utils/playAlbum';
|
||
|
||
const ANCHOR_HISTORY_KEY_PREFIX = 'psysonic_because_anchor_history:';
|
||
const PICKS_HISTORY_KEY_PREFIX = 'psysonic_because_picks:';
|
||
/** Legacy single-anchor key from the round-robin era. The history-key prefix
|
||
* is `..._anchor_history:` so the colon-suffixed legacy prefix below cannot
|
||
* match the new keys — safe to strip on module load. */
|
||
const LEGACY_ANCHOR_KEY_PREFIX = 'psysonic_because_anchor:';
|
||
|
||
(() => {
|
||
try {
|
||
const stale: string[] = [];
|
||
for (let i = 0; i < localStorage.length; i++) {
|
||
const k = localStorage.key(i);
|
||
if (k && k.startsWith(LEGACY_ANCHOR_KEY_PREFIX)) stale.push(k);
|
||
}
|
||
stale.forEach(k => { try { localStorage.removeItem(k); } catch { /* ignore */ } });
|
||
} catch { /* ignore */ }
|
||
})();
|
||
const TOP_ARTIST_POOL = 20;
|
||
const ANCHOR_MAX_TRIES = 4;
|
||
const ANCHOR_COOLDOWN = 5;
|
||
const SIMILAR_FETCH = 25;
|
||
const SIMILAR_PICK = 6;
|
||
const SHOW_COUNT = 3;
|
||
const PICKS_HISTORY_SIZE = 30;
|
||
const COVER_SIZE = 300;
|
||
|
||
function shuffle<T>(arr: T[]): T[] {
|
||
const out = arr.slice();
|
||
for (let i = out.length - 1; i > 0; i--) {
|
||
const j = Math.floor(Math.random() * (i + 1));
|
||
[out[i], out[j]] = [out[j], out[i]];
|
||
}
|
||
return out;
|
||
}
|
||
|
||
interface Anchor {
|
||
id: string;
|
||
name: string;
|
||
}
|
||
|
||
interface Props {
|
||
mostPlayed: SubsonicAlbum[];
|
||
recentlyPlayed?: SubsonicAlbum[];
|
||
starred?: SubsonicAlbum[];
|
||
disableArtwork?: boolean;
|
||
}
|
||
|
||
/** Round-robin merge of multiple album sources, dedup by artistId.
|
||
* Cycling sources (most-played, recently-played, starred) means the per-mount
|
||
* rotation cursor visits a different listening *mode* each visit instead of
|
||
* walking only down the top-played list. */
|
||
function buildAnchorPool(sources: SubsonicAlbum[][], limit: number): Anchor[] {
|
||
const seen = new Set<string>();
|
||
const out: Anchor[] = [];
|
||
const maxLen = sources.reduce((m, s) => Math.max(m, s.length), 0);
|
||
for (let i = 0; i < maxLen && out.length < limit; i++) {
|
||
for (const src of sources) {
|
||
if (out.length >= limit) break;
|
||
const a = src[i];
|
||
if (!a || !a.artistId || seen.has(a.artistId)) continue;
|
||
seen.add(a.artistId);
|
||
out.push({ id: a.artistId, name: a.artist });
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function formatAlbumDuration(seconds: number, t: (key: string, opts?: Record<string, unknown>) => string): string {
|
||
const totalMin = Math.max(0, Math.round(seconds / 60));
|
||
const hours = Math.floor(totalMin / 60);
|
||
const minutes = totalMin % 60;
|
||
if (hours > 0) return t('common.durationHoursMinutes', { hours, minutes });
|
||
return t('common.durationMinutesOnly', { minutes: totalMin });
|
||
}
|
||
|
||
/** Both rotation memories are **per-server** — server A and server B keep
|
||
* independent state, so switching servers doesn't snap the anchor cooldown
|
||
* or the recently-shown-album buffer onto the new server's content. */
|
||
function anchorHistoryKey(serverId: string | null): string | null {
|
||
return serverId ? `${ANCHOR_HISTORY_KEY_PREFIX}${serverId}` : null;
|
||
}
|
||
function picksHistoryKey(serverId: string | null): string | null {
|
||
return serverId ? `${PICKS_HISTORY_KEY_PREFIX}${serverId}` : null;
|
||
}
|
||
function readJsonArray(key: string | null): string[] {
|
||
if (!key) return [];
|
||
try {
|
||
const raw = localStorage.getItem(key);
|
||
if (!raw) return [];
|
||
const parsed = JSON.parse(raw);
|
||
return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === 'string') : [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
export default function BecauseYouLikeRail({
|
||
mostPlayed,
|
||
recentlyPlayed,
|
||
starred,
|
||
disableArtwork = false,
|
||
}: Props) {
|
||
const { t } = useTranslation();
|
||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||
const pool = useMemo(
|
||
() => buildAnchorPool([mostPlayed, recentlyPlayed ?? [], starred ?? []], TOP_ARTIST_POOL),
|
||
[mostPlayed, recentlyPlayed, starred],
|
||
);
|
||
const [anchor, setAnchor] = useState<Anchor | null>(null);
|
||
const [recs, setRecs] = useState<SubsonicAlbum[]>([]);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
if (pool.length === 0) {
|
||
setAnchor(null);
|
||
setRecs([]);
|
||
return;
|
||
}
|
||
|
||
const anchorHistKey = anchorHistoryKey(activeServerId);
|
||
const picksHistKey = picksHistoryKey(activeServerId);
|
||
const anchorHistory = readJsonArray(anchorHistKey);
|
||
const picksHistory = readJsonArray(picksHistKey);
|
||
|
||
/** Cooldown caps at half the pool size so a small library doesn't soft-lock
|
||
* itself out (a server with 4 anchor-eligible artists shouldn't be told
|
||
* "the last 5 are forbidden"). */
|
||
const cooldown = Math.min(ANCHOR_COOLDOWN, Math.max(0, Math.floor(pool.length / 2)));
|
||
const recentAnchors = new Set(anchorHistory.slice(-cooldown));
|
||
const eligibleRaw = pool.filter(a => !recentAnchors.has(a.id));
|
||
const eligible = eligibleRaw.length > 0 ? eligibleRaw : pool.slice();
|
||
const candidates = shuffle(eligible);
|
||
const recentPicks = new Set(picksHistory);
|
||
|
||
(async () => {
|
||
const tries = Math.min(ANCHOR_MAX_TRIES, candidates.length);
|
||
/** Random pick (with cooldown) replaces deterministic round-robin so the
|
||
* same anchor doesn't surface every pool.length mounts. The retry loop
|
||
* still walks forward through the shuffled `candidates` list when the
|
||
* current pick is a dud (no Last.fm similar artists, or no library
|
||
* matches). On success: append the chosen anchor + chosen album ids to
|
||
* their respective ring buffers so future mounts see different stuff. */
|
||
for (let i = 0; i < tries; i++) {
|
||
if (cancelled) return;
|
||
const candidate = candidates[i];
|
||
try {
|
||
const info = await getArtistInfo(candidate.id, { similarArtistCount: SIMILAR_FETCH });
|
||
if (cancelled) return;
|
||
const similar = (info.similarArtist ?? []).filter(s => s.id);
|
||
if (similar.length === 0) continue;
|
||
|
||
const sampled = shuffle(similar).slice(0, SIMILAR_PICK);
|
||
const results = await Promise.all(
|
||
sampled.map(s => getArtist(s.id).catch(() => null))
|
||
);
|
||
if (cancelled) return;
|
||
|
||
const picks: SubsonicAlbum[] = [];
|
||
for (const r of results) {
|
||
if (!r || r.albums.length === 0) continue;
|
||
/** Prefer an album not in the recently-shown buffer; fall back to
|
||
* *any* album when the artist's whole catalogue is in the buffer
|
||
* so the slot isn't lost. */
|
||
const fresh = r.albums.filter(a => !recentPicks.has(a.id));
|
||
const choice = fresh.length > 0 ? fresh : r.albums;
|
||
const album = choice[Math.floor(Math.random() * choice.length)];
|
||
picks.push(album);
|
||
if (picks.length >= SHOW_COUNT) break;
|
||
}
|
||
if (picks.length === 0) continue;
|
||
|
||
const newAnchorHistory = [...anchorHistory, candidate.id].slice(-ANCHOR_COOLDOWN);
|
||
const newPicksHistory = [...picksHistory, ...picks.map(p => p.id)].slice(-PICKS_HISTORY_SIZE);
|
||
try {
|
||
if (anchorHistKey) localStorage.setItem(anchorHistKey, JSON.stringify(newAnchorHistory));
|
||
if (picksHistKey) localStorage.setItem(picksHistKey, JSON.stringify(newPicksHistory));
|
||
} catch { /* ignore */ }
|
||
setAnchor(candidate);
|
||
setRecs(picks);
|
||
return;
|
||
} catch {
|
||
/* network / server error — try next anchor */
|
||
}
|
||
}
|
||
if (!cancelled) {
|
||
setAnchor(null);
|
||
setRecs([]);
|
||
}
|
||
})();
|
||
|
||
return () => { cancelled = true; };
|
||
}, [pool, activeServerId]);
|
||
|
||
if (!anchor || recs.length === 0) return null;
|
||
|
||
return (
|
||
<section className="album-row-section because-you-like-rail">
|
||
<div className="album-row-header">
|
||
<h2 className="section-title" style={{ marginBottom: 0 }}>
|
||
{t('home.becauseYouLikeFor', { artist: anchor.name })}
|
||
</h2>
|
||
</div>
|
||
<div className="because-card-grid">
|
||
{recs.map(album => (
|
||
<BecauseCard
|
||
key={album.id}
|
||
album={album}
|
||
anchor={anchor.name}
|
||
disableArtwork={disableArtwork}
|
||
/>
|
||
))}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
interface CardProps {
|
||
album: SubsonicAlbum;
|
||
anchor: string;
|
||
disableArtwork: boolean;
|
||
}
|
||
|
||
const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork }: CardProps) {
|
||
const { t } = useTranslation();
|
||
const navigate = useNavigate();
|
||
const enqueue = usePlayerStore(s => s.enqueue);
|
||
const coverUrl = useMemo(
|
||
() => (album.coverArt ? buildCoverArtUrl(album.coverArt, COVER_SIZE) : ''),
|
||
[album.coverArt],
|
||
);
|
||
const coverKey = useMemo(
|
||
() => (album.coverArt ? coverArtCacheKey(album.coverArt, COVER_SIZE) : ''),
|
||
[album.coverArt],
|
||
);
|
||
const bgResolved = useCachedUrl(coverUrl, coverKey);
|
||
|
||
const handleOpen = () => navigate(`/album/${album.id}`);
|
||
const handlePlay = (e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
playAlbum(album.id);
|
||
};
|
||
const handleEnqueue = async (e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
try {
|
||
const data = await getAlbum(album.id);
|
||
enqueue(data.songs.map(songToTrack));
|
||
} catch {
|
||
/* silent — toast would be too noisy for a hover action */
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div
|
||
role="button"
|
||
tabIndex={0}
|
||
className="because-card"
|
||
onClick={handleOpen}
|
||
onKeyDown={e => { if (e.key === 'Enter') handleOpen(); }}
|
||
aria-label={`${album.name} – ${album.artist}`}
|
||
>
|
||
{!disableArtwork && bgResolved && (
|
||
<div
|
||
className="because-card-bg"
|
||
style={{ backgroundImage: `url(${bgResolved})` }}
|
||
aria-hidden="true"
|
||
/>
|
||
)}
|
||
<div className="because-card-cover-wrap">
|
||
{!disableArtwork && coverUrl ? (
|
||
<CachedImage
|
||
src={coverUrl}
|
||
cacheKey={coverKey}
|
||
alt={album.name}
|
||
className="because-card-cover"
|
||
loading="lazy"
|
||
/>
|
||
) : (
|
||
<div className="because-card-cover because-card-cover-placeholder" aria-hidden="true">
|
||
<Music size={42} strokeWidth={1.5} />
|
||
</div>
|
||
)}
|
||
<div className="album-card-play-overlay">
|
||
<button
|
||
type="button"
|
||
className="album-card-details-btn"
|
||
onClick={handlePlay}
|
||
aria-label={t('hero.playAlbum')}
|
||
data-tooltip={t('hero.playAlbum')}
|
||
data-tooltip-pos="top"
|
||
>
|
||
<Play size={15} fill="currentColor" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="album-card-details-btn"
|
||
onClick={handleEnqueue}
|
||
aria-label={t('contextMenu.enqueueAlbum')}
|
||
data-tooltip={t('contextMenu.enqueueAlbum')}
|
||
data-tooltip-pos="top"
|
||
>
|
||
<ListPlus size={15} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="because-card-text">
|
||
<div className="because-card-top">
|
||
<div className="because-card-similar">
|
||
{t('home.similarTo', { artist: anchor })}
|
||
</div>
|
||
<div className="because-card-title">{album.name}</div>
|
||
<div className="because-card-artist">{album.artist}</div>
|
||
</div>
|
||
{album.releaseTypes && album.releaseTypes[0] ? (
|
||
<div className="because-card-pills">
|
||
<span className="because-card-pill because-card-pill-type">{album.releaseTypes[0]}</span>
|
||
</div>
|
||
) : null}
|
||
<div className="because-card-meta">
|
||
{album.year ? <span>{album.year}</span> : null}
|
||
{album.songCount ? <span>{t('home.becauseYouLikeTracks', { count: album.songCount })}</span> : null}
|
||
{album.duration ? <span>{formatAlbumDuration(album.duration, t)}</span> : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
});
|