feat(subsonic): wire /rest/scrobble into playevents.Writer
submission=false → play_started (replaces in-memory nowPlayingMap). submission=true (default) → synthetic completed play. The nowPlayingMap struct + factory + the nowPlaying field on mediaHandlers are deleted; play_events WHERE ended_at IS NULL is now the source of truth for 'currently playing,' reachable from M3+ work. api.Mount now takes the shared *playevents.Writer instead of cfg so the same writer instance feeds both surfaces.
This commit is contained in:
+44
-48
@@ -7,27 +7,27 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"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"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||
)
|
||||
|
||||
// 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).
|
||||
// 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
|
||||
nowPlaying *nowPlayingMap
|
||||
pool *pgxpool.Pool
|
||||
events *playevents.Writer
|
||||
}
|
||||
|
||||
func newMediaHandlers(pool *pgxpool.Pool) *mediaHandlers {
|
||||
return &mediaHandlers{pool: pool, nowPlaying: newNowPlayingMap()}
|
||||
func newMediaHandlers(pool *pgxpool.Pool, events *playevents.Writer) *mediaHandlers {
|
||||
return &mediaHandlers{pool: pool, events: events}
|
||||
}
|
||||
|
||||
// handleStream serves the raw track bytes. http.ServeContent gives us Range,
|
||||
@@ -196,9 +196,12 @@ func imageContentType(path string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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")
|
||||
@@ -211,45 +214,38 @@ func (m *mediaHandlers) handleScrobble(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
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")})
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user