package subsonic import ( "context" "errors" "fmt" "log/slog" "net/http" "os" "path/filepath" "strconv" "strings" "time" "github.com/jackc/pgx/v5" "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. 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 events *playevents.Writer logger *slog.Logger } func newMediaHandlers(pool *pgxpool.Pool, events *playevents.Writer, logger *slog.Logger) *mediaHandlers { return &mediaHandlers{pool: pool, events: events, logger: logger} } // 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 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") 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 } 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")}) }