feat(api): scaffold media helpers for cover + stream endpoints
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user