Merge pull request 'feat(subsonic): stream/download/getCoverArt/scrobble (#296)' (#9) from dev into main
This commit was merged in pull request #9.
This commit is contained in:
@@ -0,0 +1,255 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package subsonic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFindSidecarCover(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
track := filepath.Join(dir, "01 - song.mp3")
|
||||||
|
if err := os.WriteFile(track, []byte("audio"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write track: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty dir first: no cover.
|
||||||
|
if got := findSidecarCover(track); got != "" {
|
||||||
|
t.Errorf("no sidecar → %q, want empty", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// folder.jpg lower priority than cover.jpg: add folder first, expect hit;
|
||||||
|
// then add cover.jpg, expect cover to win.
|
||||||
|
folder := filepath.Join(dir, "folder.jpg")
|
||||||
|
if err := os.WriteFile(folder, []byte("jpg"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := findSidecarCover(track); got != folder {
|
||||||
|
t.Errorf("folder.jpg fallback = %q, want %q", got, folder)
|
||||||
|
}
|
||||||
|
cover := filepath.Join(dir, "cover.jpg")
|
||||||
|
if err := os.WriteFile(cover, []byte("jpg"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := findSidecarCover(track); got != cover {
|
||||||
|
t.Errorf("cover.jpg priority = %q, want %q", got, cover)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImageContentType(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"cover.jpg": "image/jpeg",
|
||||||
|
"cover.JPEG": "image/jpeg",
|
||||||
|
"art.png": "image/png",
|
||||||
|
"thumb.gif": "image/gif",
|
||||||
|
"square.webp": "image/webp",
|
||||||
|
"unknown.tiff": "application/octet-stream",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := imageContentType(in); got != want {
|
||||||
|
t.Errorf("imageContentType(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNowPlayingMap(t *testing.T) {
|
||||||
|
m := newNowPlayingMap()
|
||||||
|
user := "user-1"
|
||||||
|
if _, ok := m.Get(user); ok {
|
||||||
|
t.Fatalf("unexpected entry on fresh map")
|
||||||
|
}
|
||||||
|
|
||||||
|
var trackID pgtype.UUID
|
||||||
|
_ = trackID.Scan("6ba7b810-9dad-11d1-80b4-00c04fd430c8")
|
||||||
|
m.Set(user, trackID)
|
||||||
|
|
||||||
|
entry, ok := m.Get(user)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("Set did not persist")
|
||||||
|
}
|
||||||
|
if uuidToID(entry.TrackID) != "6ba7b810-9dad-11d1-80b4-00c04fd430c8" {
|
||||||
|
t.Errorf("trackID round-trip broke: %q", uuidToID(entry.TrackID))
|
||||||
|
}
|
||||||
|
if entry.At.IsZero() {
|
||||||
|
t.Errorf("At timestamp not set")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
// either way.
|
// either way.
|
||||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, cfg Config) {
|
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, cfg Config) {
|
||||||
b := &browseHandlers{pool: pool}
|
b := &browseHandlers{pool: pool}
|
||||||
|
m := newMediaHandlers(pool)
|
||||||
r.Route("/rest", func(sub chi.Router) {
|
r.Route("/rest", func(sub chi.Router) {
|
||||||
sub.Use(Middleware(pool, cfg))
|
sub.Use(Middleware(pool, cfg))
|
||||||
register(sub, "/ping", handlePing)
|
register(sub, "/ping", handlePing)
|
||||||
@@ -31,6 +32,10 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, cfg Config) {
|
|||||||
register(sub, "/getAlbumList2", b.getAlbumList2)
|
register(sub, "/getAlbumList2", b.getAlbumList2)
|
||||||
register(sub, "/getSong", b.getSong)
|
register(sub, "/getSong", b.getSong)
|
||||||
register(sub, "/search3", b.search3)
|
register(sub, "/search3", b.search3)
|
||||||
|
register(sub, "/stream", m.handleStream)
|
||||||
|
register(sub, "/download", m.handleDownload)
|
||||||
|
register(sub, "/getCoverArt", m.handleGetCoverArt)
|
||||||
|
register(sub, "/scrobble", m.handleScrobble)
|
||||||
_ = logger
|
_ = logger
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -288,12 +288,11 @@ func songRef(t dbq.Track, albumTitle, artistName string) SongRef {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// coverArtID uses the album ID as the cover-art key since Minstrel's getCoverArt
|
// coverArtID returns the album UUID as the cover-art key. getCoverArt uses
|
||||||
// will resolve from albums.cover_art_path. Returns "" when no cover art exists.
|
// the album row to find art either in cover_art_path (when the scanner sets
|
||||||
|
// it) or via sidecar lookup in the album directory, so emitting the id
|
||||||
|
// unconditionally is safe — worst case getCoverArt returns a Subsonic 70.
|
||||||
func coverArtID(a dbq.Album) string {
|
func coverArtID(a dbq.Album) string {
|
||||||
if a.CoverArtPath == nil || *a.CoverArtPath == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return uuidToID(a.ID)
|
return uuidToID(a.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user