diff --git a/internal/subsonic/stream.go b/internal/subsonic/stream.go new file mode 100644 index 00000000..f15f6861 --- /dev/null +++ b/internal/subsonic/stream.go @@ -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 +}