Files
minstrel/internal/api/media.go
T

170 lines
5.8 KiB
Go

// 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"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// 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 coverart.FindSidecar(filepath.Dir(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.
//
// Divergences from internal/subsonic/types.go's contentTypeForFormat are
// intentional: opus→audio/ogg (library .opus files are Ogg-encapsulated, so
// this matches real library contents), aac→audio/aac (raw AAC is ADTS, not
// MP4, so audio/mp4 would mislead codec sniffers), and there is no "oga" case
// (we don't record that format). Don't "fix" these to match subsonic.
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"
}
// 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, apierror.BadRequest("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, &apierror.Error{Status: 404, Code: "not_found", Message: "album not found"})
return
}
h.logger.Error("api: get album for cover failed", "err", err)
writeErr(w, apierror.InternalMsg("server error", err))
return
}
path := resolveAlbumCoverPath(r.Context(), q, album)
if path == "" {
writeErr(w, &apierror.Error{Status: 404, Code: "not_found", Message: "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, &apierror.Error{Status: 404, Code: "not_found", Message: "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, apierror.InternalMsg("server error", err))
return
}
w.Header().Set("Content-Type", imageContentType(path))
http.ServeContent(w, r, filepath.Base(path), info.ModTime(), f)
}
// handleGetStream implements GET /api/tracks/{id}/stream. Opens the file on
// disk and delegates byte-serving to http.ServeContent, which handles Range,
// If-Modified-Since, and ETag based on the file's mod time.
func (h *handlers) handleGetStream(w http.ResponseWriter, r *http.Request) {
id, ok := parseUUID(chi.URLParam(r, "id"))
if !ok {
writeErr(w, apierror.BadRequest("bad_request", "invalid track id"))
return
}
q := dbq.New(h.pool)
track, err := q.GetTrackByID(r.Context(), id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeErr(w, &apierror.Error{Status: 404, Code: "not_found", Message: "track not found"})
return
}
h.logger.Error("api: get track for stream failed", "err", err)
writeErr(w, apierror.InternalMsg("server error", err))
return
}
f, err := os.Open(track.FilePath)
if err != nil {
// File vanished (scanner indexed it, filesystem lost it). Treat as
// 404 so clients can fall back to the next item in a queue.
writeErr(w, &apierror.Error{Status: 404, Code: "not_found", Message: "track file not found"})
return
}
defer func() { _ = f.Close() }()
info, err := f.Stat()
if err != nil {
h.logger.Error("api: stat track failed", "err", err)
writeErr(w, apierror.InternalMsg("server error", err))
return
}
w.Header().Set("Content-Type", audioContentType(track.FileFormat))
w.Header().Set("Accept-Ranges", "bytes")
http.ServeContent(w, r, filepath.Base(track.FilePath), info.ModTime(), f)
}