feat: implement scanner caching and add HTMX loading state to library page

This commit is contained in:
Rodrigo Verdiani 2026-07-07 10:10:56 -03:00
parent 8dcde67884
commit 0ef3783e04
7 changed files with 133 additions and 6 deletions

View File

@ -6,16 +6,22 @@ import (
"path/filepath"
"sort"
"strings"
"sync"
"coda/internal/config"
)
type Scanner struct {
musicPath string
mu sync.RWMutex
cache []Album
hasCached bool
}
func New(cfg *config.Config) *Scanner {
return &Scanner{musicPath: filepath.Clean(cfg.MusicPath)}
return &Scanner{
musicPath: filepath.Clean(cfg.MusicPath),
}
}
type Entry struct {
@ -142,9 +148,20 @@ func (s *Scanner) Scan() ([]Album, error) {
return albums[i].RelPath < albums[j].RelPath
})
s.mu.Lock()
s.cache = albums
s.hasCached = true
s.mu.Unlock()
return albums, nil
}
func (s *Scanner) GetCached() ([]Album, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.cache, s.hasCached
}
func (s *Scanner) Resolve(relPath string) (string, error) {
resolved := filepath.Join(s.musicPath, relPath)
resolved = filepath.Clean(resolved)

View File

@ -202,3 +202,26 @@ func TestBrowseNonExistent(t *testing.T) {
t.Error("expected error for nonexistent path")
}
}
func TestScanCache(t *testing.T) {
_, s := setupMusicDir(t)
albums, hasCache := s.GetCached()
if hasCache || len(albums) != 0 {
t.Fatal("expected no cache initially")
}
scanned, err := s.Scan()
if err != nil {
t.Fatalf("Scan: %v", err)
}
albums, hasCache = s.GetCached()
if !hasCache {
t.Fatal("expected cache to be populated after Scan")
}
if len(albums) != len(scanned) {
t.Errorf("cached count = %d, want %d", len(albums), len(scanned))
}
}

View File

@ -10,16 +10,28 @@ import (
"coda/internal/jobs"
"coda/internal/models"
"coda/internal/scanner"
)
type LibraryPageData struct {
Entries []scanner.Entry
CachedAlbums []scanner.Album
HasCache bool
}
func (h *Handler) Library(w http.ResponseWriter, r *http.Request) {
entries, err := h.scanner.Browse("")
if err != nil {
slog.Warn("library page browse failed, showing empty view", "error", err)
h.render.Render(w, "library.html", nil)
return
}
h.render.Render(w, "library.html", entries)
cachedAlbums, hasCache := h.scanner.GetCached()
data := LibraryPageData{
Entries: entries,
CachedAlbums: cachedAlbums,
HasCache: hasCache,
}
h.render.Render(w, "library.html", data)
}
func (h *Handler) LibraryBrowse(w http.ResponseWriter, r *http.Request) {

View File

@ -147,6 +147,60 @@ func TestLibraryPage(t *testing.T) {
}
}
func TestLibraryPageCacheRendering(t *testing.T) {
musicPath := t.TempDir()
albumDir := filepath.Join(musicPath, "Test Artist", "Test Album")
os.MkdirAll(albumDir, 0o755)
flacFile := filepath.Join(albumDir, "track.flac")
if err := os.WriteFile(flacFile, []byte("fake"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
dbPath := filepath.Join(t.TempDir(), "test.db")
st, err := store.New(dbPath)
if err != nil {
t.Fatalf("store.New: %v", err)
}
defer st.Close()
svc, err := config.NewService(context.Background(), &mockStore{data: map[string]string{
"MUSIC_PATH": musicPath,
}})
if err != nil {
t.Fatalf("config.NewService: %v", err)
}
q := queue.New(100)
jobSvc := jobs.New(st, q)
sc := scanner.New(&config.Config{MusicPath: musicPath})
if _, err := sc.Scan(); err != nil {
t.Fatalf("Scan: %v", err)
}
router := Router(Deps{
Cfg: svc,
Store: st,
Queue: q,
Scanner: sc,
Enqueuer: jobSvc,
})
rec := get(t, router, "/library")
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
}
body := rec.Body.String()
if !strings.Contains(body, "Test Album") {
t.Error("expected cached album 'Test Album' to be rendered on library page load")
}
if !strings.Contains(body, "Test Artist") {
t.Error("expected cached artist 'Test Artist' to be rendered on library page load")
}
}
func TestSettingsPage(t *testing.T) {
router := setupRouter(t)
rec := get(t, router, "/settings")

View File

View File

@ -4,3 +4,15 @@
@source not "../../tools";
@plugin "../../tools/daisyui.mjs";
.htmx-indicator {
display: none;
}
.htmx-request.htmx-indicator,
.htmx-request .htmx-indicator {
display: inline-block;
}
.htmx-request#scan-results {
opacity: 0.5;
pointer-events: none;
}

View File

@ -2,7 +2,12 @@
<div class="flex flex-col gap-4">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold">Library</h1>
<button class="btn btn-primary" hx-get="/api/library/scan" hx-target="#scan-results">
<button class="btn btn-primary animate-none"
hx-get="/api/library/scan"
hx-target="#scan-results"
hx-indicator="#scan-spinner, #scan-results"
hx-disabled-elt="this">
<span id="scan-spinner" class="loading loading-spinner loading-xs htmx-indicator mr-1"></span>
Scan for FLACs
</button>
</div>
@ -11,7 +16,11 @@
<div class="card-body">
<h2 class="card-title">Scan Results</h2>
<div id="scan-results" class="overflow-x-auto">
<p class="text-sm opacity-50">Click scan to find FLAC albums</p>
{{ if .HasCache }}
{{ template "scan_results.html" .CachedAlbums }}
{{ else }}
<p class="text-sm opacity-50">Click scan to find FLAC albums</p>
{{ end }}
</div>
</div>
</div>