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 +} diff --git a/internal/subsonic/stream_test.go b/internal/subsonic/stream_test.go new file mode 100644 index 00000000..67fddf24 --- /dev/null +++ b/internal/subsonic/stream_test.go @@ -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") + } +} diff --git a/internal/subsonic/subsonic.go b/internal/subsonic/subsonic.go index da1ec68b..e72b80fc 100644 --- a/internal/subsonic/subsonic.go +++ b/internal/subsonic/subsonic.go @@ -19,6 +19,7 @@ import ( // either way. func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, cfg Config) { b := &browseHandlers{pool: pool} + m := newMediaHandlers(pool) r.Route("/rest", func(sub chi.Router) { sub.Use(Middleware(pool, cfg)) 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, "/getSong", b.getSong) 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 }) } diff --git a/internal/subsonic/types.go b/internal/subsonic/types.go index 56dbf63c..df0d1c18 100644 --- a/internal/subsonic/types.go +++ b/internal/subsonic/types.go @@ -288,12 +288,11 @@ func songRef(t dbq.Track, albumTitle, artistName string) SongRef { return s } -// coverArtID uses the album ID as the cover-art key since Minstrel's getCoverArt -// will resolve from albums.cover_art_path. Returns "" when no cover art exists. +// coverArtID returns the album UUID as the cover-art key. getCoverArt uses +// 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 { - if a.CoverArtPath == nil || *a.CoverArtPath == "" { - return "" - } return uuidToID(a.ID) }