Files
bvandeusen 024493f2a7
test-go / test (push) Successful in 28s
test-go / integration (push) Has been cancelled
refactor(server): unify stream URL builders + MIME tables + cover-path helper
Closes Scribe #614, #615, server half of #616 surfaced by the 2026-06-04 divergent-provider audit.

- streamURL helper now used everywhere /api/tracks/{id}/stream is built (was inline concat in playlists.go and cast_token.go); add streamURLWithExt for the .ext cast variant.

- audioContentType in media.go is the canonical file_format -> MIME lookup; mimeForFormat in cast_token.go is now a thin wrapper that overrides the unknown-format fallback to audio/mpeg (Sonos rejects octet-stream). Adds mpeg/vorbis/wave aliases. Subsonic's contentTypeForFormat stays frozen per docs.

- coverart.ResolveAlbumPath extracted; api and subsonic both delegate to it.
2026-06-04 08:29:51 -04:00

223 lines
6.7 KiB
Go

package subsonic
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
)
// mediaHandlers serves the bytes-on-the-wire endpoints: stream, download,
// getCoverArt, scrobble. Scrobble routes through playevents.Writer so
// Subsonic clients feed the same play_events table as the native /api/events.
type mediaHandlers struct {
pool *pgxpool.Pool
events *playevents.Writer
logger *slog.Logger
}
func newMediaHandlers(pool *pgxpool.Pool, events *playevents.Writer, logger *slog.Logger) *mediaHandlers {
return &mediaHandlers{pool: pool, events: events, logger: logger}
}
// handleStream serves the raw track bytes. http.ServeContent gives us Range,
// If-Modified-Since, and ETag handling for free — the Subsonic spec requires
// Range support so clients can seek.
func (m *mediaHandlers) handleStream(w http.ResponseWriter, r *http.Request) {
m.serveTrack(w, r, false)
}
// handleDownload is stream with a filename and attachment disposition so
// browsers save rather than stream.
func (m *mediaHandlers) handleDownload(w http.ResponseWriter, r *http.Request) {
m.serveTrack(w, r, true)
}
func (m *mediaHandlers) serveTrack(w http.ResponseWriter, r *http.Request, attachment bool) {
idStr := r.URL.Query().Get("id")
if idStr == "" {
WriteFail(w, r, ErrMissingParameter, "Missing required parameter: id")
return
}
id, ok := parseUUID(idStr)
if !ok {
WriteFail(w, r, ErrDataNotFound, "Track not found")
return
}
q := dbq.New(m.pool)
track, err := q.GetTrackByID(r.Context(), id)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
WriteFail(w, r, ErrDataNotFound, "Track not found")
return
}
WriteFail(w, r, ErrGeneric, "Failed to load track")
return
}
f, err := os.Open(track.FilePath)
if err != nil {
// File vanished between scan and now — treat as not found so clients
// can recover gracefully rather than assume a 5xx network blip.
WriteFail(w, r, ErrDataNotFound, "Track file not found")
return
}
defer func() { _ = f.Close() }()
info, err := f.Stat()
if err != nil {
WriteFail(w, r, ErrGeneric, "Failed to stat track file")
return
}
w.Header().Set("Content-Type", contentTypeForFormat(track.FileFormat))
w.Header().Set("Accept-Ranges", "bytes")
if attachment {
w.Header().Set("Content-Disposition",
fmt.Sprintf(`attachment; filename=%q`, filepath.Base(track.FilePath)))
}
http.ServeContent(w, r, filepath.Base(track.FilePath), info.ModTime(), f)
}
// handleGetCoverArt resolves an id (album, track, or cover-art id — all of
// which we key off the album UUID) to an image file. Scanner does not yet
// populate albums.cover_art_path, so the common path is the sidecar lookup.
func (m *mediaHandlers) handleGetCoverArt(w http.ResponseWriter, r *http.Request) {
idStr := r.URL.Query().Get("id")
if idStr == "" {
WriteFail(w, r, ErrMissingParameter, "Missing required parameter: id")
return
}
id, ok := parseUUID(idStr)
if !ok {
WriteFail(w, r, ErrDataNotFound, "Cover art not found")
return
}
q := dbq.New(m.pool)
album, albumErr := q.GetAlbumByID(r.Context(), id)
if albumErr != nil {
// Fall back: maybe the id is a track. Resolve through its album_id.
track, terr := q.GetTrackByID(r.Context(), id)
if terr != nil {
WriteFail(w, r, ErrDataNotFound, "Cover art not found")
return
}
album, albumErr = q.GetAlbumByID(r.Context(), track.AlbumID)
if albumErr != nil {
WriteFail(w, r, ErrDataNotFound, "Cover art not found")
return
}
}
if path := resolveAlbumCoverPath(r.Context(), q, album); path != "" {
serveImage(w, r, path)
return
}
WriteFail(w, r, ErrDataNotFound, "Cover art not found")
}
// 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)
}
func serveImage(w http.ResponseWriter, r *http.Request, path string) {
f, err := os.Open(path)
if err != nil {
WriteFail(w, r, ErrDataNotFound, "Cover art not found")
return
}
defer func() { _ = f.Close() }()
info, err := f.Stat()
if err != nil {
WriteFail(w, r, ErrGeneric, "Failed to stat cover art")
return
}
w.Header().Set("Content-Type", imageContentType(path))
http.ServeContent(w, r, filepath.Base(path), info.ModTime(), f)
}
func imageContentType(path string) string {
switch strings.ToLower(filepath.Ext(path)) {
case ".jpg", ".jpeg":
return "image/jpeg"
case ".png":
return "image/png"
case ".gif":
return "image/gif"
case ".webp":
return "image/webp"
default:
return "application/octet-stream"
}
}
// handleScrobble translates Subsonic scrobble calls into native play_events.
//
// submission=false (now-playing): writes a play_started, leaves ended_at NULL.
// submission=true (completed): writes a synthetic completed play (start+end
// in one transaction). play_events WHERE ended_at IS NULL is the source of
// truth for "currently playing"; the prior in-memory nowPlayingMap is gone.
func (m *mediaHandlers) handleScrobble(w http.ResponseWriter, r *http.Request) {
params := r.URL.Query()
idStr := params.Get("id")
if idStr == "" {
WriteFail(w, r, ErrMissingParameter, "Missing required parameter: id")
return
}
trackID, ok := parseUUID(idStr)
if !ok {
WriteFail(w, r, ErrDataNotFound, "Track not found")
return
}
user, ok := UserFromContext(r.Context())
if !ok {
WriteFail(w, r, ErrGeneric, "Unauthenticated")
return
}
at := time.Now().UTC()
if t := params.Get("time"); t != "" {
// Subsonic spec uses ms-since-epoch; some clients send ISO 8601.
// Accept both for resilience.
if ms, err := strconv.ParseInt(t, 10, 64); err == nil {
at = time.UnixMilli(ms).UTC()
} else if parsed, err := time.Parse(time.RFC3339, t); err == nil {
at = parsed
}
}
clientID := params.Get("c")
// Subsonic default is submission=true (completed play).
submission := strings.ToLower(params.Get("submission"))
switch submission {
case "true", "":
if err := m.events.RecordSyntheticCompletedPlay(r.Context(), user.ID, trackID, clientID, at); err != nil {
WriteFail(w, r, ErrGeneric, "Could not record play")
return
}
case "false":
if _, err := m.events.RecordPlayStarted(r.Context(), user.ID, trackID, clientID, at); err != nil {
WriteFail(w, r, ErrGeneric, "Could not record now-playing")
return
}
default:
// Unknown submission values: ack and ignore.
}
Write(w, r, struct{ Envelope }{Envelope: NewEnvelope("ok")})
}