From 43785de7ed990fa1de7d74b2dd3cf496a855a58a Mon Sep 17 00:00:00 2001
From: dammitjeff <44111923+dammitjeff@users.noreply.github.com>
Date: Wed, 13 May 2026 11:10:26 -0700
Subject: [PATCH 1/7] change handleGetConfig to prioritize file keys over env
vars
---
src/web/backend/server.go | 12 +++++++-----
1 file changed, 7 insertions(+), 5 deletions(-)
diff --git a/src/web/backend/server.go b/src/web/backend/server.go
index 1495ec1..f7fc711 100644
--- a/src/web/backend/server.go
+++ b/src/web/backend/server.go
@@ -333,7 +333,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 +348,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 {
values[key] = v
sources[key] = "file"
+ } else if v, ok := os.LookupEnv(key); ok && v != "" {
+ values[key] = v
+ sources[key] = "env"
}
}
From efc055afada9d4b4ce62cfcf736b1881527b996c Mon Sep 17 00:00:00 2001
From: dammitjeff <44111923+dammitjeff@users.noreply.github.com>
Date: Wed, 13 May 2026 12:22:05 -0700
Subject: [PATCH 2/7] Fix fetchOnRepeatTracks function, add manual Pull Tracks
button to prefetch manually
---
src/web/backend/playlists.go | 29 ++++++++++-
.../src/components/ui/PlaylistCard.jsx | 48 ++++++++++++++-----
2 files changed, 64 insertions(+), 13 deletions(-)
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/frontend/src/components/ui/PlaylistCard.jsx b/src/web/frontend/src/components/ui/PlaylistCard.jsx
index 7228492..6753c4f 100644
--- a/src/web/frontend/src/components/ui/PlaylistCard.jsx
+++ b/src/web/frontend/src/components/ui/PlaylistCard.jsx
@@ -3,6 +3,7 @@ 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 ──────────────────────────────────────────────────────────────────
@@ -88,24 +89,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 +118,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,12 +154,24 @@ 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 => (
From 75afe26c56e2df4d8688c334de3f070da4b32bb7 Mon Sep 17 00:00:00 2001
From: dammitjeff <44111923+dammitjeff@users.noreply.github.com>
Date: Wed, 13 May 2026 12:27:16 -0700
Subject: [PATCH 3/7] Added shimmer on loading album artworks in dropdown,
added staggger animation for easier viewing
---
.../src/components/ui/PlaylistCard.jsx | 50 +++++++++++++------
src/web/frontend/src/index.css | 10 ++++
2 files changed, 44 insertions(+), 16 deletions(-)
diff --git a/src/web/frontend/src/components/ui/PlaylistCard.jsx b/src/web/frontend/src/components/ui/PlaylistCard.jsx
index 6753c4f..bee787a 100644
--- a/src/web/frontend/src/components/ui/PlaylistCard.jsx
+++ b/src/web/frontend/src/components/ui/PlaylistCard.jsx
@@ -7,15 +7,19 @@ 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)}
+ />
+ {!imgLoaded && (
+
+ )}
+ >
) : (
♪
)}
@@ -174,8 +192,8 @@ export function TracklistDropdown({ playlist, lbUser }) {
)}
) : (
- tracks.map(t => (
-
+ tracks.map((t, i) => (
+
))
)}
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; }
From b7db488df16a26ce3c3875e5acc65962ead791a8 Mon Sep 17 00:00:00 2001
From: dammitjeff <44111923+dammitjeff@users.noreply.github.com>
Date: Wed, 13 May 2026 12:34:18 -0700
Subject: [PATCH 4/7] Fixed having to scroll in log tab everytime, standardized
log message renders
---
src/web/frontend/src/components/Settings.jsx | 7 ++-
src/web/frontend/src/components/ui/common.jsx | 60 ++++++++++++++++---
src/web/frontend/src/lib/utils.js | 8 +--
3 files changed, 63 insertions(+), 12 deletions(-)
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/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/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,
}
}
From 377a4be38f979d91b2067894e66e879d4008349d Mon Sep 17 00:00:00 2001
From: dammitjeff <44111923+dammitjeff@users.noreply.github.com>
Date: Wed, 13 May 2026 12:41:55 -0700
Subject: [PATCH 5/7] fixed cover prefetching only trigger when cache is empty
---
src/web/backend/server.go | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/src/web/backend/server.go b/src/web/backend/server.go
index f7fc711..4dd1beb 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()
}
From 9c2a360ec08980e71ef3a7bf91e6461cff5aa6d1 Mon Sep 17 00:00:00 2001
From: dammitjeff <44111923+dammitjeff@users.noreply.github.com>
Date: Wed, 13 May 2026 12:49:36 -0700
Subject: [PATCH 6/7] fix: ensure file values are not empty when handling
config
---
src/web/backend/server.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/web/backend/server.go b/src/web/backend/server.go
index 4dd1beb..cd09b8c 100644
--- a/src/web/backend/server.go
+++ b/src/web/backend/server.go
@@ -351,7 +351,7 @@ 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 := 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 != "" {
From 0a750ff4baff53cbd335b5f4176519f37bfa4164 Mon Sep 17 00:00:00 2001
From: dammitjeff <44111923+dammitjeff@users.noreply.github.com>
Date: Wed, 13 May 2026 13:01:13 -0700
Subject: [PATCH 7/7] added fade in to album artwork, made pull tracks button
more legible
---
.../src/components/ui/PlaylistCard.jsx | 29 ++++++++++---------
1 file changed, 15 insertions(+), 14 deletions(-)
diff --git a/src/web/frontend/src/components/ui/PlaylistCard.jsx b/src/web/frontend/src/components/ui/PlaylistCard.jsx
index bee787a..66cab2b 100644
--- a/src/web/frontend/src/components/ui/PlaylistCard.jsx
+++ b/src/web/frontend/src/components/ui/PlaylistCard.jsx
@@ -38,21 +38,22 @@ function TrackRow({ track, index = 0 }) {
src={track.coverUrl}
alt=""
loading="lazy"
- style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
+ style={{
+ width: '100%', height: '100%', objectFit: 'cover', display: 'block',
+ opacity: imgLoaded ? 1 : 0, transition: 'opacity 0.35s ease',
+ }}
onLoad={() => setImgLoaded(true)}
onError={() => setImgFailed(true)}
/>
- {!imgLoaded && (
-
- )}
+
>
) : (
♪
@@ -183,8 +184,8 @@ export function TracklistDropdown({ playlist, lbUser }) {
onClick={handleFetch}
disabled={fetching}
style={{
- fontSize: 11, padding: '3px 10px', borderRadius: 5, border: '1px solid #333',
- background: '#1f1f1f', color: '#aaa', cursor: 'pointer', flexShrink: 0,
+ fontSize: 11, padding: '3px 10px', borderRadius: 5, border: '1px solid #444',
+ background: '#1f1f1f', color: 'white', cursor: 'pointer', flexShrink: 0,
}}
>
Pull tracks