Skip to content
29 changes: 28 additions & 1 deletion src/web/backend/playlists.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package backend
import (
"bytes"
"encoding/json"
"explo/src/discovery"
"explo/src/models"
"fmt"
"image"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
17 changes: 11 additions & 6 deletions src/web/backend/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down Expand Up @@ -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
Expand All @@ -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"
}
}

Expand Down
7 changes: 6 additions & 1 deletion src/web/frontend/src/components/Settings.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,7 @@ function ConfigSection({ onWizard }) {

function LogsSection() {
const [logFileEntries, setLogFileEntries] = useState([])
const panelRef = useRef(null)

const loadLog = () => {
fetchLogs().then(text => {
Expand All @@ -459,6 +460,10 @@ function LogsSection() {

useEffect(() => { loadLog() }, [])

useEffect(() => {
if (panelRef.current) panelRef.current.scrollTop = panelRef.current.scrollHeight
}, [logFileEntries])

return (
<div className="mt-6">
<div className="flex items-center justify-between mb-3.5">
Expand All @@ -474,7 +479,7 @@ function LogsSection() {
{logFileEntries.length === 0 ? (
<p className="text-[12px] text-muted py-1">No log output yet.</p>
) : (
<Panel className="h-[400px]">
<Panel ref={panelRef} className="h-[400px]">
{logFileEntries.map((e, i) => <LogRow key={i} entry={e} />)}
</Panel>
)}
Expand Down
99 changes: 71 additions & 28 deletions src/web/frontend/src/components/ui/PlaylistCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '0 2px', minHeight: 52,
borderBottom: '1px solid rgba(255,255,255,0.04)',
}}>
<div
className="track-row"
style={{
'--delay': `${index * 30}ms`,
display: 'flex', alignItems: 'center', gap: 12,
padding: '0 2px', minHeight: 52,
borderBottom: '1px solid rgba(255,255,255,0.04)',
}}>
<span style={{
width: 24, fontSize: 11, color: '#3a3a3a', textAlign: 'right',
flexShrink: 0, fontVariantNumeric: 'tabular-nums',
Expand All @@ -23,18 +28,33 @@ function TrackRow({ track }) {
</span>

<div style={{
width: 42, height: 42, borderRadius: 3, flexShrink: 0,
position: 'relative', width: 42, height: 42, borderRadius: 3, flexShrink: 0,
background: '#1e1e1e', overflow: 'hidden',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{track.coverUrl && !imgFailed ? (
<img
src={track.coverUrl}
alt=""
loading="lazy"
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
onError={() => setImgFailed(true)}
/>
<>
<img
src={track.coverUrl}
alt=""
loading="lazy"
style={{
width: '100%', height: '100%', objectFit: 'cover', display: 'block',
opacity: imgLoaded ? 1 : 0, transition: 'opacity 0.35s ease',
}}
onLoad={() => setImgLoaded(true)}
onError={() => setImgFailed(true)}
/>
<motion.div
animate={{ backgroundPosition: ['200% 0', '-200% 0'], opacity: imgLoaded ? 0 : 1 }}
transition={{ backgroundPosition: { duration: 1.2, repeat: Infinity, ease: 'linear' }, opacity: { duration: 0.35 } }}
style={{
position: 'absolute', inset: 0,
background: 'linear-gradient(90deg, #1e1e1e 25%, #2e2e2e 50%, #1e1e1e 75%)',
backgroundSize: '200% 100%',
}}
/>
</>
) : (
<span style={{ fontSize: 14, color: '#2e2e2e' }}>♪</span>
)}
Expand Down Expand Up @@ -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
Expand All @@ -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 (
Expand All @@ -142,16 +173,28 @@ export function TracklistDropdown({ playlist }) {
{/* Track list */}
<div className="no-scrollbar" style={{ maxHeight: 560, overflowY: 'auto', scrollbarWidth: 'none', msOverflowStyle: 'none' }}>
{loading ? (
<div style={{ padding: '16px 2px', fontSize: 12, color: '#4a4a4a' }}>Loading…</div>
<div style={{ padding: '16px 2px', fontSize: 12, color: '#4a4a4a' }}>{fetching ? 'Fetching…' : 'Loading…'}</div>
) : error ? (
<div style={{ padding: '16px 2px', fontSize: 12, color: '#c0392b' }}>{error}</div>
) : tracks.length === 0 ? (
<div style={{ padding: '16px 2px', fontSize: 12, color: '#4a4a4a' }}>
No playlist found yet. {nextUpdateLabel(playlist)}.
<div style={{ padding: '16px 2px', fontSize: 12, color: '#4a4a4a', display: 'flex', alignItems: 'center', gap: 10 }}>
<span>No playlist found yet. {nextUpdateLabel(playlist)}.</span>
{lbUser && (
<button
onClick={handleFetch}
disabled={fetching}
style={{
fontSize: 11, padding: '3px 10px', borderRadius: 5, border: '1px solid #444',
background: '#1f1f1f', color: 'white', cursor: 'pointer', flexShrink: 0,
}}
>
Pull tracks
</button>
)}
</div>
) : (
tracks.map(t => (
<TrackRow key={`${t.rank}-${t.title}-${t.artist}`} track={t} />
tracks.map((t, i) => (
<TrackRow key={`${t.rank}-${t.title}-${t.artist}`} track={t} index={i} />
))
)}
</div>
Expand Down
60 changes: 53 additions & 7 deletions src/web/frontend/src/components/ui/common.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="flex gap-2.5 items-baseline py-0.5 flex-wrap">
<span className="text-[11px] text-muted flex-shrink-0 tabular-nums">{entry.time}</span>
Expand All @@ -61,12 +103,16 @@ export function LogRow({ entry }) {
</span>
)}
<span className="text-[12px] text-white break-words">{entry.msg}</span>
{displayTrack && (
<span className="text-[12px] text-accent flex-shrink-0">
{displayTrack}{entry.artist && <span className="text-muted"> — {entry.artist}</span>}
</span>
)}
{entry.system && <span className="text-[11px] text-muted flex-shrink-0">{entry.system}</span>}
{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 (
<span key={k} className="text-[11px] flex-shrink-0">
<span className="text-muted">{label}: </span>
<span className={color}>{formatValue(k, v)}</span>
</span>
)
})}
</div>
)
}
10 changes: 10 additions & 0 deletions src/web/frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
8 changes: 4 additions & 4 deletions src/web/frontend/src/lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down
Loading