test-go / test (push) Successful in 1m48s
test-web / test (push) Successful in 2m0s
android / Build + lint + test (push) Successful in 6m27s
release / Build signed APK (releases and dev) (push) Successful in 6m42s
test-go / integration (push) Successful in 4m55s
release / Build + push container image (push) Successful in 1m20s
release / Verify release artifacts (tag releases only) (push) Skipped
- Every request body is capped at 4 MiB and must arrive within 30s. The deadline is set per request and cleared at end of body rather than via http.Server.ReadTimeout, which would cancel audio streams and the SSE stream once the background read hit it. - IdleTimeout 120s closes idle keep-alive connections. Still no global WriteTimeout, for the same streaming reason. - Streams, album covers and playlist covers are Cache-Control: private, so a shared cache never keeps an authenticated response for others. - A cookie-authenticated write to /api must be application/json (415 otherwise). SameSite=Strict can't see a sibling app on the same registrable domain; forms and no-preflight fetches can't send JSON. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
203 lines
7.9 KiB
Go
203 lines
7.9 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"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
// resolveAlbumCoverPath delegates to coverart.ResolveAlbumPath; kept as a
|
|
// local alias so the call sites in this file read naturally.
|
|
func resolveAlbumCoverPath(ctx context.Context, q *dbq.Queries, album dbq.Album) string {
|
|
return coverart.ResolveAlbumPath(ctx, q, album)
|
|
}
|
|
|
|
// 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.
|
|
// This is the canonical table; both the browser stream endpoint and the
|
|
// UPnP cast token URL builder consult it. Unknown formats fall back to
|
|
// octet-stream so the browser downloads them rather than attempting to
|
|
// decode -- cast_token.go applies its own audio/mpeg fallback for Sonos.
|
|
//
|
|
// Aliases (mpeg/vorbis/wave) cover historical / alternate format spellings
|
|
// that have shown up in track rows. The trim+lowercase normalization makes
|
|
// the lookup permissive to whatever a scanner happened to write.
|
|
//
|
|
// Divergences from internal/subsonic/types.go's contentTypeForFormat are
|
|
// intentional: opus/vorbis→audio/ogg (library .opus / .ogg 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). Subsonic is a
|
|
// frozen client contract -- don't "fix" these to match it.
|
|
func audioContentType(format string) string {
|
|
switch strings.ToLower(strings.TrimSpace(format)) {
|
|
case "mp3", "mpeg":
|
|
return "audio/mpeg"
|
|
case "flac":
|
|
return "audio/flac"
|
|
case "ogg", "opus", "vorbis":
|
|
return "audio/ogg"
|
|
case "m4a", "mp4":
|
|
return "audio/mp4"
|
|
case "aac":
|
|
return "audio/aac"
|
|
case "wav", "wave":
|
|
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) {
|
|
q := dbq.New(h.pool)
|
|
album, apiErr := resolveByID(r, "id", q.GetAlbumByID, "album")
|
|
if apiErr != nil {
|
|
writeErr(w, apiErr)
|
|
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))
|
|
// Cover bytes change rarely (cover-source enrichment, manual rescan,
|
|
// rare MBID-driven re-fetch). One-day max-age + must-revalidate means
|
|
// clients skip the conditional GET for the bulk of a session, but
|
|
// stale art clears within 24h after a re-scan. ServeContent below
|
|
// still emits Last-Modified for the conditional path when needed.
|
|
w.Header().Set("Cache-Control", "private, max-age=86400, must-revalidate")
|
|
http.ServeContent(w, r, filepath.Base(path), info.ModTime(), f)
|
|
}
|
|
|
|
// streamAuthOk returns true if the request is authorized to fetch the
|
|
// stream — either via the standard session path (cookie OR bearer token
|
|
// resolved to a user-in-context by auth.OptionalUser, the middleware
|
|
// the route is wrapped with in api.go) OR via a valid short-lived HMAC
|
|
// token in the ?token=&exp= query (UPnP / Sonos path, new for the
|
|
// output-picker UPnP slice).
|
|
//
|
|
// The token path lets network speakers fetch the stream URL without
|
|
// carrying the user's session cookie — they cannot. See the design at
|
|
// docs/superpowers/specs/2026-06-03-android-output-picker-upnp-design.md.
|
|
//
|
|
// When h.streamSecret is nil (slice-1 default, until slice 2 wires the
|
|
// loader), the token path always rejects because the HMAC of an empty
|
|
// key won't match any token a client could mint. The session path keeps
|
|
// working.
|
|
func (h *handlers) streamAuthOk(r *http.Request, trackID string) bool {
|
|
if _, ok := auth.UserFromContext(r.Context()); ok {
|
|
return true
|
|
}
|
|
tok := r.URL.Query().Get("token")
|
|
expStr := r.URL.Query().Get("exp")
|
|
if tok == "" || expStr == "" {
|
|
return false
|
|
}
|
|
exp, err := strconv.ParseInt(expStr, 10, 64)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return VerifyStreamToken(h.streamSecret, trackID, exp, tok)
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// The route lives outside the authed.Group so this handler can accept
|
|
// EITHER a session-resolved user (attached by auth.OptionalUser, the
|
|
// permissive middleware the route is wrapped with) OR a signed token
|
|
// (UPnP slice). streamAuthOk gates both paths.
|
|
func (h *handlers) handleGetStream(w http.ResponseWriter, r *http.Request) {
|
|
// Auth check before the DB lookup: a 404 ahead of the auth check
|
|
// would let unauth callers probe which track IDs exist via the
|
|
// 404/401 response differential. streamAuthOk is keyed on the
|
|
// path's id directly (the HMAC token is signed over the same id
|
|
// string, so we don't need the resolved row yet).
|
|
rawID := chi.URLParam(r, "id")
|
|
if !h.streamAuthOk(r, rawID) {
|
|
writeErr(w, apierror.ErrUnauthorized)
|
|
return
|
|
}
|
|
track, apiErr := resolveByID(r, "id", dbq.New(h.pool).GetTrackByID, "track")
|
|
if apiErr != nil {
|
|
writeErr(w, apiErr)
|
|
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")
|
|
// Track bytes are immutable for a given track id (the scanner
|
|
// indexes by file_path; re-encoded files take new ids). One-year
|
|
// max-age + immutable lets the client cache (LockCachingAudioSource
|
|
// on the Flutter side, browser cache on web) skip even the
|
|
// conditional GET on repeat plays.
|
|
w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
|
|
http.ServeContent(w, r, filepath.Base(track.FilePath), info.ModTime(), f)
|
|
}
|