feat(api): GET /api/albums/{id}/cover with sidecar fallback
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user