From c799e5c1c92e88e6caf7efa87541bf2d0b92bd2f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 21 Apr 2026 22:30:25 -0400 Subject: [PATCH] feat(api): scaffold media helpers for cover + stream endpoints --- internal/api/library_fixtures_test.go | 35 ++++++ internal/api/media.go | 92 ++++++++++++++++ internal/api/media_test.go | 149 ++++++++++++++++++++++++++ 3 files changed, 276 insertions(+) create mode 100644 internal/api/media.go create mode 100644 internal/api/media_test.go diff --git a/internal/api/library_fixtures_test.go b/internal/api/library_fixtures_test.go index abb8833b..3cf13b61 100644 --- a/internal/api/library_fixtures_test.go +++ b/internal/api/library_fixtures_test.go @@ -2,6 +2,9 @@ package api import ( "context" + "os" + "path/filepath" + "strings" "testing" "time" @@ -85,3 +88,35 @@ func seedTrack(t *testing.T, pool *pgxpool.Pool, albumID, artistID pgtype.UUID, } return tr } + +// seedTrackWithFile creates a fresh temp directory, writes fileBody to +// /.<ext>, and inserts a Track row whose file_path points at it. +// Returns the inserted Track and the directory (callers drop sidecar covers +// into this dir when a test needs them). FileFormat is ext lowercased. +func seedTrackWithFile(t *testing.T, pool *pgxpool.Pool, albumID, artistID pgtype.UUID, title string, fileBody []byte, ext string) (dbq.Track, string) { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, title+"."+ext) + if err := os.WriteFile(path, fileBody, 0o644); err != nil { + t.Fatalf("WriteFile(%s): %v", path, err) + } + one := int32(1) + tr, err := dbq.New(pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: title, + AlbumID: albumID, + ArtistID: artistID, + TrackNumber: &one, + DiscNumber: nil, + DurationMs: int32(len(fileBody)), + FilePath: path, + FileSize: int64(len(fileBody)), + FileFormat: strings.ToLower(ext), + Bitrate: nil, + Mbid: nil, + Genre: nil, + }) + if err != nil { + t.Fatalf("UpsertTrack(%s): %v", title, err) + } + return tr, dir +} diff --git a/internal/api/media.go b/internal/api/media.go new file mode 100644 index 00000000..5f65f107 --- /dev/null +++ b/internal/api/media.go @@ -0,0 +1,92 @@ +// Package api — media endpoints serve raw bytes (cover art, audio streams). +// The helpers below duplicate shape with internal/subsonic/stream.go by +// design; the two packages are kept independent so subsonic can freeze as a +// compatibility surface while /api evolves. See project memory +// `project_subsonic_legacy.md` for the rationale. + +package api + +import ( + "context" + "os" + "path/filepath" + "strings" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// sidecarNames is the lookup order for cover art living next to audio files. +// Matches common library conventions: cover.* wins over folder.*; JPEG wins +// over PNG when both exist. +var sidecarNames = []string{ + "cover.jpg", "cover.jpeg", "cover.png", + "folder.jpg", "folder.jpeg", "folder.png", +} + +// findSidecarCover looks for a conventional cover image in the directory that +// contains trackPath. Returns "" if no sidecar exists. +func findSidecarCover(trackPath string) string { + dir := filepath.Dir(trackPath) + for _, name := range sidecarNames { + candidate := filepath.Join(dir, name) + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + return candidate + } + } + return "" +} + +// resolveAlbumCoverPath returns the filesystem path to the album's cover art. +// It prefers an explicit cover_art_path (set by the scanner in a future +// milestone) and falls back to a sidecar next to the first track in the +// album's directory. "" means no art was found. +func resolveAlbumCoverPath(ctx context.Context, q *dbq.Queries, album dbq.Album) string { + if album.CoverArtPath != nil && *album.CoverArtPath != "" { + if _, err := os.Stat(*album.CoverArtPath); err == nil { + return *album.CoverArtPath + } + } + tracks, err := q.ListTracksByAlbum(ctx, album.ID) + if err != nil || len(tracks) == 0 { + return "" + } + return findSidecarCover(tracks[0].FilePath) +} + +// audioContentType maps the short file_format recorded on tracks (mp3, flac, +// ogg, opus, m4a, aac, wav) to a MIME type for the Content-Type header. +// Unknown formats fall back to octet-stream so the browser downloads them +// rather than attempting to decode. +func audioContentType(format string) string { + switch strings.ToLower(format) { + case "mp3": + return "audio/mpeg" + case "flac": + return "audio/flac" + case "ogg", "opus": + return "audio/ogg" + case "m4a": + return "audio/mp4" + case "aac": + return "audio/aac" + case "wav": + return "audio/wav" + } + return "application/octet-stream" +} + +// imageContentType maps a file extension to a MIME type for cover art. +// Unknown extensions fall back to octet-stream. +func imageContentType(path string) string { + switch strings.ToLower(filepath.Ext(path)) { + case ".jpg", ".jpeg": + return "image/jpeg" + case ".png": + return "image/png" + case ".webp": + return "image/webp" + case ".gif": + return "image/gif" + } + return "application/octet-stream" +} diff --git a/internal/api/media_test.go b/internal/api/media_test.go new file mode 100644 index 00000000..50d28ccd --- /dev/null +++ b/internal/api/media_test.go @@ -0,0 +1,149 @@ +package api + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +func TestAudioContentType(t *testing.T) { + cases := map[string]string{ + "mp3": "audio/mpeg", + "MP3": "audio/mpeg", + "flac": "audio/flac", + "ogg": "audio/ogg", + "opus": "audio/ogg", + "m4a": "audio/mp4", + "aac": "audio/aac", + "wav": "audio/wav", + "unknown": "application/octet-stream", + "": "application/octet-stream", + } + for in, want := range cases { + if got := audioContentType(in); got != want { + t.Errorf("audioContentType(%q) = %q, want %q", in, got, want) + } + } +} + +func TestImageContentType(t *testing.T) { + cases := map[string]string{ + "/a/cover.jpg": "image/jpeg", + "/a/cover.JPG": "image/jpeg", + "/a/cover.jpeg": "image/jpeg", + "/a/cover.png": "image/png", + "/a/cover.webp": "image/webp", + "/a/cover.gif": "image/gif", + "/a/cover.bmp": "application/octet-stream", + "/a/cover": "application/octet-stream", + } + for in, want := range cases { + if got := imageContentType(in); got != want { + t.Errorf("imageContentType(%q) = %q, want %q", in, got, want) + } + } +} + +func TestFindSidecarCover_PrefersCoverOverFolder(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "cover.jpg"), []byte("c"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "folder.jpg"), []byte("f"), 0o644); err != nil { + t.Fatal(err) + } + got := findSidecarCover(filepath.Join(dir, "any-track.flac")) + if got != filepath.Join(dir, "cover.jpg") { + t.Errorf("got %q, want cover.jpg path", got) + } +} + +func TestFindSidecarCover_FallsBackToFolder(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "folder.png"), []byte("f"), 0o644); err != nil { + t.Fatal(err) + } + got := findSidecarCover(filepath.Join(dir, "t.flac")) + if got != filepath.Join(dir, "folder.png") { + t.Errorf("got %q, want folder.png path", got) + } +} + +func TestFindSidecarCover_NoneFound(t *testing.T) { + dir := t.TempDir() + if got := findSidecarCover(filepath.Join(dir, "t.flac")); got != "" { + t.Errorf("got %q, want empty string", got) + } +} + +func TestResolveAlbumCoverPath_ExplicitPresent(t *testing.T) { + _, 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, "explicit.jpg") + if err := os.WriteFile(explicit, []byte("e"), 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.Fatalf("UPDATE albums: %v", err) + } + got := resolveAlbumCoverPath(context.Background(), dbq.New(pool), fetchAlbum(t, pool, album.ID)) + if got != explicit { + t.Errorf("got %q, want %q", got, explicit) + } +} + +func TestResolveAlbumCoverPath_ExplicitMissingFallsThroughToSidecar(t *testing.T) { + _, 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") + sidecar := filepath.Join(dir, "cover.jpg") + if err := os.WriteFile(sidecar, []byte("s"), 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.Fatalf("UPDATE albums: %v", err) + } + + got := resolveAlbumCoverPath(context.Background(), dbq.New(pool), fetchAlbum(t, pool, album.ID)) + if got != sidecar { + t.Errorf("got %q, want %q", got, sidecar) + } +} + +func TestResolveAlbumCoverPath_NoArt(t *testing.T) { + _, 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) + got := resolveAlbumCoverPath(context.Background(), dbq.New(pool), album) + if got != "" { + t.Errorf("got %q, want empty string", got) + } +} + +// fetchAlbum reloads the album row so tests see updates made via raw SQL. +func fetchAlbum(t *testing.T, pool *pgxpool.Pool, id pgtype.UUID) dbq.Album { + t.Helper() + a, err := dbq.New(pool).GetAlbumByID(context.Background(), id) + if err != nil { + t.Fatalf("GetAlbumByID: %v", err) + } + return a +}