1ddde12959
Two unrelated wins as a single batch.
Flutter — lazy source building in setQueueFromTracks:
Today: Future.wait builds all N AudioSource objects (drift queries +
LockCaching ctor) before the player can call setAudioSources →
play(). Measured at 83ms for a 25-track playlist, on top of the
~285ms initial-source preload.
New flow: build only the initial source, hand it to setAudioSources
([initial], initialIndex: 0) so play() can start, then background-
fill the rest. Forward direction (skipNext targets) added via
addAudioSource. Backward direction (skipPrev) inserted at index 0..
initialIndex-1 with _suppressIndexUpdates true so the unavoidable
currentIndex shifts don't push the wrong MediaItem onto the stream.
Saves the up-front source-build wait — tap-to-audio for long queues
should drop by ~80-100ms even on cache hits.
Server — Cache-Control on the three byte-serving endpoints:
- /api/albums/{id}/cover: max-age=86400, must-revalidate. Covers
change rarely (re-scan, MBID enrichment); a day of cache is safe
and skips conditional GETs for the bulk of a session.
- /api/playlists/{id}/cover: max-age=300, must-revalidate. Collages
recompute when contents change; short enough for edits to feel
fresh, long enough to skip repeat fetches during a session.
- /api/tracks/{id}/stream: max-age=31536000, immutable. Track bytes
are immutable for a given id (scanner re-indexes by file_path; new
files get new ids). LockCachingAudioSource on the Flutter side
already disk-caches, but proper headers let it skip even the
conditional 304 on repeat plays.
157 lines
5.8 KiB
Go
157 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"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"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, 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)
|
|
}
|
|
|
|
// 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) {
|
|
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", "public, max-age=31536000, immutable")
|
|
http.ServeContent(w, r, filepath.Base(track.FilePath), info.ModTime(), f)
|
|
}
|