diff --git a/src/web/backend/playlists.go b/src/web/backend/playlists.go index 8bd1e0f..bea5280 100644 --- a/src/web/backend/playlists.go +++ b/src/web/backend/playlists.go @@ -3,6 +3,7 @@ package backend import ( "bytes" "encoding/json" + "explo/src/discovery" "explo/src/models" "fmt" "image" @@ -95,6 +96,26 @@ type lbPlaylistResp struct { } `json:"playlist"` } +func fetchOnRepeatTracks(username string) ([][4]string, error) { + body, err := lbGet(fmt.Sprintf("%s/stats/user/%s/recordings?count=30&range=month", lbAPIBase, username)) + if err != nil { + return nil, fmt.Errorf("on-repeat stats fetch: %w", err) + } + var resp discovery.TopRecordings + if err := json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("on-repeat stats parse: %w", err) + } + out := make([][4]string, 0, len(resp.Payload.Recordings)) + for _, rec := range resp.Payload.Recordings { + var cover string + if rec.ReleaseMbid != "" { + cover = fmt.Sprintf("https://coverartarchive.org/release/%s/front-250", rec.ReleaseMbid) + } + out = append(out, [4]string{rec.TrackName, rec.ArtistName, rec.ReleaseName, cover}) + } + return out, nil +} + func fetchMostRecentLBPlaylist(username, playlistType string) ([][4]string, time.Time, error) { var offset int var bestDate time.Time @@ -287,7 +308,13 @@ func (s *Server) handlePrefetchCovers(w http.ResponseWriter, r *http.Request) { slog.Info("prefetch: cache already exists, skipping", "playlist", pt) continue } - tracks, _, err := fetchMostRecentLBPlaylist(body.User, pt) + var tracks [][4]string + var err error + if pt == "on-repeat" { + tracks, err = fetchOnRepeatTracks(body.User) + } else { + tracks, _, err = fetchMostRecentLBPlaylist(body.User, pt) + } if err != nil { slog.Warn("prefetch: failed to fetch LB playlist", "type", pt, "err", err) continue diff --git a/src/web/backend/server.go b/src/web/backend/server.go index 1495ec1..cd09b8c 100644 --- a/src/web/backend/server.go +++ b/src/web/backend/server.go @@ -115,7 +115,10 @@ func NewServer(cfg config.ServerConfig) *Server { func (s *Server) Start() error { s.initServerLog() s.startJobs() - s.PrefetchCovers() + coversDir := filepath.Join(s.cfg.WebDataDir, "cache", "covers") + if _, err := os.Stat(coversDir); os.IsNotExist(err) { + s.PrefetchCovers() + } slog.Info("Explo web UI started", "addr", s.server.Addr) return s.server.ListenAndServe() } @@ -333,7 +336,9 @@ func parseEnvText(text string) map[string]string { } // handleGetConfig returns resolved config as JSON: { values, sources }. -// Sources are "env" when set via os.Environ (takes precedence), "file" otherwise. +// File keys are checked first because cleanenv sets them as OS env vars on startup, +// so checking os.LookupEnv first would misclassify all file keys as "env". +// Only keys present in the OS environment but absent from the file are marked "env". func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) { data, err := os.ReadFile(s.cfg.WebEnvPath) var fileValues map[string]string @@ -346,12 +351,12 @@ func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) { values := make(map[string]string, len(allConfigKeys)) sources := make(map[string]string, len(allConfigKeys)) for _, key := range allConfigKeys { - if v, ok := os.LookupEnv(key); ok && v != "" { - values[key] = v - sources[key] = "env" - } else if v, ok := fileValues[key]; ok { + if v, ok := fileValues[key]; ok && v != "" { values[key] = v sources[key] = "file" + } else if v, ok := os.LookupEnv(key); ok && v != "" { + values[key] = v + sources[key] = "env" } } diff --git a/src/web/frontend/src/components/Settings.jsx b/src/web/frontend/src/components/Settings.jsx index 7860f27..8e42768 100644 --- a/src/web/frontend/src/components/Settings.jsx +++ b/src/web/frontend/src/components/Settings.jsx @@ -450,6 +450,7 @@ function ConfigSection({ onWizard }) { function LogsSection() { const [logFileEntries, setLogFileEntries] = useState([]) + const panelRef = useRef(null) const loadLog = () => { fetchLogs().then(text => { @@ -459,6 +460,10 @@ function LogsSection() { useEffect(() => { loadLog() }, []) + useEffect(() => { + if (panelRef.current) panelRef.current.scrollTop = panelRef.current.scrollHeight + }, [logFileEntries]) + return (
@@ -474,7 +479,7 @@ function LogsSection() { {logFileEntries.length === 0 ? (

No log output yet.

) : ( - + {logFileEntries.map((e, i) => )} )} diff --git a/src/web/frontend/src/components/ui/PlaylistCard.jsx b/src/web/frontend/src/components/ui/PlaylistCard.jsx index 7228492..66cab2b 100644 --- a/src/web/frontend/src/components/ui/PlaylistCard.jsx +++ b/src/web/frontend/src/components/ui/PlaylistCard.jsx @@ -3,18 +3,23 @@ import { motion, AnimatePresence } from 'motion/react' import { Toggle } from './Toggle' import { Button } from './common' import { fetchPlaylistTracks } from '../../lib/listenbrainz' +import { prefetchPlaylists } from '../../lib/api' // ── TrackRow ────────────────────────────────────────────────────────────────── -function TrackRow({ track }) { +function TrackRow({ track, index = 0 }) { const [imgFailed, setImgFailed] = useState(false) + const [imgLoaded, setImgLoaded] = useState(false) return ( -
+
{track.coverUrl && !imgFailed ? ( - setImgFailed(true)} - /> + <> + setImgLoaded(true)} + onError={() => setImgFailed(true)} + /> + + ) : ( )} @@ -88,24 +108,24 @@ function nextUpdateLabel(playlistType) { return `Next update ${nextMonday.toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' })}` } -export function TracklistDropdown({ playlist }) { +export function TracklistDropdown({ playlist, lbUser }) { const [tracks, setTracks] = useState([]) const [generatedAt, setGeneratedAt] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const [fetching, setFetching] = useState(false) - useEffect(() => { - if (!playlist) return + const loadTracks = (withRetry = false) => { let cancelled = false let retry = 0 let retryTimer = null setLoading(true) setError(null) const load = () => { - fetchPlaylistTracks(playlist, { force: retry > 0 }) + fetchPlaylistTracks(playlist, { force: retry > 0 || withRetry }) .then(({ tracks: t, generatedAt: g }) => { if (cancelled) return - if (t.length === 0 && retry < 8) { + if (t.length === 0 && withRetry && retry < 8) { retry += 1 retryTimer = setTimeout(load, 1500) return @@ -117,12 +137,23 @@ export function TracklistDropdown({ playlist }) { .catch(e => { if (!cancelled) { setError(e.message); setLoading(false) } }) } load() - return () => { - cancelled = true - if (retryTimer) clearTimeout(retryTimer) - } + return () => { cancelled = true; if (retryTimer) clearTimeout(retryTimer) } + } + + useEffect(() => { + if (!playlist) return + return loadTracks(false) }, [playlist]) + const handleFetch = () => { + if (!lbUser) return + setFetching(true) + prefetchPlaylists(lbUser, [playlist]) + .then(() => loadTracks(true)) + .catch(e => setError(e.message)) + .finally(() => setFetching(false)) + } + const genDate = generatedAt ? new Date(generatedAt) : null return ( @@ -142,16 +173,28 @@ export function TracklistDropdown({ playlist }) { {/* Track list */}
{loading ? ( -
Loading…
+
{fetching ? 'Fetching…' : 'Loading…'}
) : error ? (
{error}
) : tracks.length === 0 ? ( -
- No playlist found yet. {nextUpdateLabel(playlist)}. +
+ No playlist found yet. {nextUpdateLabel(playlist)}. + {lbUser && ( + + )}
) : ( - tracks.map(t => ( - + tracks.map((t, i) => ( + )) )}
diff --git a/src/web/frontend/src/components/ui/common.jsx b/src/web/frontend/src/components/ui/common.jsx index 3f1547f..d155601 100644 --- a/src/web/frontend/src/components/ui/common.jsx +++ b/src/web/frontend/src/components/ui/common.jsx @@ -48,8 +48,50 @@ export const Panel = forwardRef(({ children, className = '', ...props }, ref) => Panel.displayName = 'Panel' // A single structured log entry row (structured view, not raw). +// Keys that get special color treatment regardless of label +const VALUE_COLOR = { + 'track title': 'text-accent', + 'track': 'text-accent', + 'track artist': 'text-accent', + 'file': 'text-accent', + 'err': 'text-danger', + 'error': 'text-danger', +} + +// Human-readable labels for known keys +const KEY_LABELS = { + 'track title': 'Track', + 'track': 'Track', + 'track artist': 'Artist', + 'file': 'File', + 'err': 'Error', + 'error': 'Error', + 'playlist': 'Playlist', + 'playlists': 'Playlists', + 'type': 'Type', + 'user': 'User', + 'addr': 'Address', + 'count': 'Count', + 'covers': 'Covers', + 'source': 'Source', + 'service': 'Service', + 'system': 'System', + 'path': 'Path', + 'duration': 'Duration', + 'notify': 'Notify', + 'slskd': 'Slskd', + 'youtube': 'YouTube', + 'context': 'Context', + 'force_refresh': 'Force refresh', +} + +function formatValue(k, v) { + if (k === 'file') return v.replace(/.*[/\\]/, '') + if (k === 'addr' && v.startsWith(':')) return v.slice(1) + return v +} + export function LogRow({ entry }) { - const displayTrack = entry.track || (entry.file ? entry.file.replace(/.*[/\\]/, '') : '') return (
{entry.time} @@ -61,12 +103,16 @@ export function LogRow({ entry }) { )} {entry.msg} - {displayTrack && ( - - {displayTrack}{entry.artist && — {entry.artist}} - - )} - {entry.system && {entry.system}} + {Object.entries(entry.extras ?? {}).map(([k, v]) => { + const label = KEY_LABELS[k] ?? k.replace(/_/g, ' ').replace(/^\w/, c => c.toUpperCase()) + const color = VALUE_COLOR[k] ?? 'text-white' + return ( + + {label}: + {formatValue(k, v)} + + ) + })}
) } diff --git a/src/web/frontend/src/index.css b/src/web/frontend/src/index.css index 5efede0..cb25ad5 100644 --- a/src/web/frontend/src/index.css +++ b/src/web/frontend/src/index.css @@ -33,6 +33,16 @@ } .animate-fade-up { animation: fade-up 0.4s ease both; } +/* Track row stagger entrance */ +@keyframes track-in { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} +.track-row { + animation: track-in 0.18s ease-out both; + animation-delay: var(--delay, 0ms); +} + /* Syntax highlight classes used in the config editor */ .env-comment, .env-unset, .env-eq { color: var(--color-muted); } .env-key, .env-val { color: white; } diff --git a/src/web/frontend/src/lib/utils.js b/src/web/frontend/src/lib/utils.js index 87d4409..7d5a429 100644 --- a/src/web/frontend/src/lib/utils.js +++ b/src/web/frontend/src/lib/utils.js @@ -34,14 +34,14 @@ export function parseSlogLine(line) { try { time = new Date(kv.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }) } catch { time = kv.time } } + const structural = new Set(['time', 'level', 'msg']) + const extras = Object.fromEntries(Object.entries(kv).filter(([k]) => !structural.has(k))) + return { time, level: (kv.level || 'INFO').toUpperCase(), msg: kv.msg || line, - track: kv['track title'] || kv.track || '', - artist: kv['track artist'] || '', - file: kv.file || '', - system: kv.system || kv.service || '', + extras, } }