Files
minstrel/internal/subsonic/stream.go
T
bvandeusen 0760b48037 feat(subsonic): add stream/download/getCoverArt/scrobble (#296)
http.ServeContent handles Range/ETag/If-Modified-Since; download forces
attachment disposition. getCoverArt tries albums.cover_art_path, then
falls back to cover.{jpg,jpeg,png}/folder.{jpg,jpeg,png} next to the first
track's file. scrobble submission=false records a track into an in-memory
nowPlaying map keyed by user — M2 will read this and wire real events.
2026-04-19 19:26:39 +00:00

256 lines
7.4 KiB
Go

package subsonic
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// mediaHandlers serves the bytes-on-the-wire endpoints: stream, download,
// getCoverArt, scrobble. They share a pool and an in-memory now-playing map
// because M1 has no event ingestion yet (see M2).
type mediaHandlers struct {
pool *pgxpool.Pool
nowPlaying *nowPlayingMap
}
func newMediaHandlers(pool *pgxpool.Pool) *mediaHandlers {
return &mediaHandlers{pool: pool, nowPlaying: newNowPlayingMap()}
}
// 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 returns the filesystem path to the album's cover art,
// preferring an explicit cover_art_path (set by the scanner in a future
// milestone) and falling back to a sidecar image next to any track in the
// album 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 findSidecarCover(tracks[0].FilePath)
}
// sidecarNames are the conventional names for album art living next to audio
// files. Order matches how most library tools write them — cover.* wins over
// folder.*, and JPEG wins over PNG when both are present.
var sidecarNames = []string{
"cover.jpg", "cover.jpeg", "cover.png",
"folder.jpg", "folder.jpeg", "folder.png",
}
// findSidecarCover looks for a cover image in the directory containing the
// given track file. Returns "" if no sidecar exists.
func findSidecarCover(trackPath string) string {
dir := filepath.Dir(trackPath)
for _, name := range sidecarNames {
candidate := filepath.Join(dir, name)
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
return candidate
}
}
return ""
}
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 records a now-playing hint when submission=false. M1 has no
// event ingestion, so submission=true is a no-op that still returns ok — the
// real play/skip/seek/complete events land in M2.
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
}
// Per spec, submission defaults to true. Only false means "this is now
// playing, don't record a play event." Anything else is an M2 no-op.
submission := strings.ToLower(params.Get("submission"))
if submission == "false" {
if user, ok := UserFromContext(r.Context()); ok {
m.nowPlaying.Set(uuidToID(user.ID), trackID)
}
}
Write(w, r, struct{ Envelope }{Envelope: NewEnvelope("ok")})
}
// nowPlayingMap is a tiny in-memory store keyed by user id. M1 doesn't expose
// a getNowPlaying endpoint yet; this structure exists so scrobble has a place
// to write and M2 has something to read.
type nowPlayingMap struct {
mu sync.RWMutex
m map[string]nowPlayingEntry
}
type nowPlayingEntry struct {
TrackID pgtype.UUID
At time.Time
}
func newNowPlayingMap() *nowPlayingMap {
return &nowPlayingMap{m: make(map[string]nowPlayingEntry)}
}
func (n *nowPlayingMap) Set(userID string, trackID pgtype.UUID) {
n.mu.Lock()
defer n.mu.Unlock()
n.m[userID] = nowPlayingEntry{TrackID: trackID, At: time.Now()}
}
func (n *nowPlayingMap) Get(userID string) (nowPlayingEntry, bool) {
n.mu.RLock()
defer n.mu.RUnlock()
e, ok := n.m[userID]
return e, ok
}