feat(api): scaffold media helpers for cover + stream endpoints

This commit is contained in:
2026-04-21 22:30:25 -04:00
parent 8da51a1d13
commit c799e5c1c9
3 changed files with 276 additions and 0 deletions
+35
View File
@@ -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
// <dir>/<title>.<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
}
+92
View File
@@ -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"
}
+149
View File
@@ -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
}