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
+//
/., 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
+}