// 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" "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 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, dbq.ListTracksByAlbumParams{AlbumID: 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) { 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", "public, 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) { track, apiErr := resolveByID(r, "id", dbq.New(h.pool).GetTrackByID, "track") if apiErr != nil { writeErr(w, apiErr) return } if !h.streamAuthOk(r, uuidToString(track.ID)) { writeErr(w, apierror.ErrUnauthorized) 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", "public, max-age=31536000, immutable") http.ServeContent(w, r, filepath.Base(track.FilePath), info.ModTime(), f) }