From 945b773cbd64ab62f97332c67ff269f1b62a98f5 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 21 Apr 2026 22:37:53 -0400 Subject: [PATCH] feat(api): GET /api/albums/{id}/cover with sidecar fallback --- internal/api/library_test.go | 1 + internal/api/media.go | 51 +++++++++++++ internal/api/media_test.go | 140 +++++++++++++++++++++++++++++++++++ 3 files changed, 192 insertions(+) diff --git a/internal/api/library_test.go b/internal/api/library_test.go index 6a5e368d..961eae01 100644 --- a/internal/api/library_test.go +++ b/internal/api/library_test.go @@ -18,6 +18,7 @@ func newLibraryRouter(h *handlers) chi.Router { r.Get("/api/artists", h.handleListArtists) r.Get("/api/artists/{id}", h.handleGetArtist) r.Get("/api/albums/{id}", h.handleGetAlbum) + r.Get("/api/albums/{id}/cover", h.handleGetCover) r.Get("/api/tracks/{id}", h.handleGetTrack) r.Get("/api/search", h.handleSearch) return r diff --git a/internal/api/media.go b/internal/api/media.go index d27d46f5..a0045280 100644 --- a/internal/api/media.go +++ b/internal/api/media.go @@ -8,10 +8,15 @@ package api import ( "context" + "errors" + "net/http" "os" "path/filepath" "strings" + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) @@ -96,3 +101,49 @@ func imageContentType(path string) string { } return "application/octet-stream" } + +// handleGetCover implements GET /api/albums/{id}/cover. Resolves the cover +// path (explicit column, else sidecar next to first track), then delegates +// byte-serving to http.ServeContent so clients get Range / If-Modified-Since +// / ETag for free. +func (h *handlers) handleGetCover(w http.ResponseWriter, r *http.Request) { + id, ok := parseUUID(chi.URLParam(r, "id")) + if !ok { + writeErr(w, http.StatusBadRequest, "bad_request", "invalid album id") + return + } + q := dbq.New(h.pool) + album, err := q.GetAlbumByID(r.Context(), id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + writeErr(w, http.StatusNotFound, "not_found", "album not found") + return + } + h.logger.Error("api: get album for cover failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "server error") + return + } + path := resolveAlbumCoverPath(r.Context(), q, album) + if path == "" { + writeErr(w, http.StatusNotFound, "not_found", "cover not found") + return + } + f, err := os.Open(path) + if err != nil { + // resolveAlbumCoverPath saw the file a moment ago; if it vanished + // between stat and open, treat it the same as no-art — the user + // experience (404) matches, and 500 would misleadingly imply a server + // bug rather than a filesystem race. + writeErr(w, http.StatusNotFound, "not_found", "cover not found") + return + } + defer func() { _ = f.Close() }() + info, err := f.Stat() + if err != nil { + h.logger.Error("api: stat cover failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "server error") + return + } + w.Header().Set("Content-Type", imageContentType(path)) + http.ServeContent(w, r, filepath.Base(path), info.ModTime(), f) +} diff --git a/internal/api/media_test.go b/internal/api/media_test.go index 50d28ccd..39d286a5 100644 --- a/internal/api/media_test.go +++ b/internal/api/media_test.go @@ -1,7 +1,11 @@ package api import ( + "bytes" "context" + "encoding/json" + "net/http" + "net/http/httptest" "os" "path/filepath" "testing" @@ -147,3 +151,139 @@ func fetchAlbum(t *testing.T, pool *pgxpool.Pool, id pgtype.UUID) dbq.Album { } return a } + +func TestHandleGetCover_SidecarHappyPath(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + artist := seedArtist(t, pool, "A") + album := seedAlbum(t, pool, artist.ID, "X", 0) + + _, dir := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", []byte("audio"), "mp3") + coverBytes := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x01, 0x02, 0x03} // small JPEG-ish payload + if err := os.WriteFile(filepath.Join(dir, "cover.jpg"), coverBytes, 0o644); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/albums/"+uuidToString(album.ID)+"/cover", nil) + w := httptest.NewRecorder() + newLibraryRouter(h).ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) + } + if ct := w.Header().Get("Content-Type"); ct != "image/jpeg" { + t.Errorf("Content-Type = %q", ct) + } + if !bytes.Equal(w.Body.Bytes(), coverBytes) { + t.Errorf("body = %x, want %x", w.Body.Bytes(), coverBytes) + } +} + +func TestHandleGetCover_ExplicitPathHappyPath(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + artist := seedArtist(t, pool, "A") + album := seedAlbum(t, pool, artist.ID, "X", 0) + + dir := t.TempDir() + explicit := filepath.Join(dir, "art.png") + pngBytes := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A} // PNG magic + if err := os.WriteFile(explicit, pngBytes, 0o644); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(context.Background(), + `UPDATE albums SET cover_art_path = $1 WHERE id = $2`, explicit, album.ID); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/albums/"+uuidToString(album.ID)+"/cover", nil) + w := httptest.NewRecorder() + newLibraryRouter(h).ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d body = %s", w.Code, w.Body.String()) + } + if ct := w.Header().Get("Content-Type"); ct != "image/png" { + t.Errorf("Content-Type = %q", ct) + } + if !bytes.Equal(w.Body.Bytes(), pngBytes) { + t.Errorf("body mismatch") + } +} + +func TestHandleGetCover_ExplicitMissingFallsThroughToSidecar(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + artist := seedArtist(t, pool, "A") + album := seedAlbum(t, pool, artist.ID, "X", 0) + + _, dir := seedTrackWithFile(t, pool, album.ID, artist.ID, "t", []byte("audio"), "mp3") + sidecarBytes := []byte("sidecar") + if err := os.WriteFile(filepath.Join(dir, "cover.jpg"), sidecarBytes, 0o644); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(context.Background(), + `UPDATE albums SET cover_art_path = $1 WHERE id = $2`, + filepath.Join(dir, "does-not-exist.jpg"), album.ID); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/albums/"+uuidToString(album.ID)+"/cover", nil) + w := httptest.NewRecorder() + newLibraryRouter(h).ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + if !bytes.Equal(w.Body.Bytes(), sidecarBytes) { + t.Errorf("body = %q, want %q", w.Body.Bytes(), sidecarBytes) + } +} + +func TestHandleGetCover_NoArt(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + artist := seedArtist(t, pool, "A") + album := seedAlbum(t, pool, artist.ID, "X", 0) + seedTrack(t, pool, album.ID, artist.ID, "t", 1, 1000) // synthetic path, no sidecar + + req := httptest.NewRequest(http.MethodGet, "/api/albums/"+uuidToString(album.ID)+"/cover", nil) + w := httptest.NewRecorder() + newLibraryRouter(h).ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("status = %d", w.Code) + } + var body errorBody + _ = json.Unmarshal(w.Body.Bytes(), &body) + if body.Error.Code != "not_found" || body.Error.Message != "cover not found" { + t.Errorf("error body = %+v", body) + } +} + +func TestHandleGetCover_BadUUID(t *testing.T) { + h, _ := testHandlers(t) + req := httptest.NewRequest(http.MethodGet, "/api/albums/not-a-uuid/cover", nil) + w := httptest.NewRecorder() + newLibraryRouter(h).ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} + +func TestHandleGetCover_UnknownAlbum(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + req := httptest.NewRequest(http.MethodGet, + "/api/albums/00000000-0000-0000-0000-000000000001/cover", nil) + w := httptest.NewRecorder() + newLibraryRouter(h).ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("status = %d", w.Code) + } + var body errorBody + _ = json.Unmarshal(w.Body.Bytes(), &body) + if body.Error.Message != "album not found" { + t.Errorf("message = %q, want \"album not found\"", body.Error.Message) + } +}