diff --git a/internal/api/cast_token.go b/internal/api/cast_token.go index 5f3f3046..0e18e869 100644 --- a/internal/api/cast_token.go +++ b/internal/api/cast_token.go @@ -33,28 +33,18 @@ type castTokenResponse struct { Title string `json:"title"` } -// mimeForFormat maps the tracks.file_format column to an HTTP audio -// MIME type. Sonos requires the protocolInfo MIME on DIDL-Lite to match -// what the URL actually serves; "audio/*" wildcard is silently rejected. -// Unknown formats fall back to audio/mpeg — most Sonos firmware probes -// the URL anyway and recovers from a small MIME mismatch. +// mimeForFormat returns the audio MIME type for a cast (Sonos/UPnP) URL. +// Wraps the canonical audioContentType lookup in media.go and overrides +// the unknown-format fallback to audio/mpeg, because Sonos rejects +// DIDL-Lite with protocolInfo=application/octet-stream (the browser +// fallback) -- most Sonos firmware probes the URL anyway and recovers +// from a small MIME mismatch. func mimeForFormat(format string) string { - switch strings.ToLower(strings.TrimSpace(format)) { - case "mp3", "mpeg": - return "audio/mpeg" - case "flac": - return "audio/flac" - case "aac", "m4a", "mp4": - return "audio/mp4" - case "ogg", "vorbis": - return "audio/ogg" - case "opus": - return "audio/opus" - case "wav", "wave": - return "audio/wav" - default: + mime := audioContentType(format) + if mime == "application/octet-stream" { return "audio/mpeg" } + return mime } // extForFormat maps the tracks.file_format column to a path-safe file @@ -144,8 +134,7 @@ func (h *handlers) handleCastStreamToken(w http.ResponseWriter, r *http.Request) // Include the file extension in the path so Sonos's URL probe sees a // recognizable audio file. Without it, Sonos reports TrackDuration=0 // and seeks past 0s land "after the end" -> early track-skip. - url := scheme + "://" + host + "/api/tracks/" + req.TrackID + - "/stream." + extForFormat(track.FileFormat) + + url := scheme + "://" + host + streamURLWithExt(trackUUID, extForFormat(track.FileFormat)) + "?token=" + token + "&exp=" + strconv.FormatInt(exp, 10) writeJSON(w, http.StatusOK, castTokenResponse{ diff --git a/internal/api/convert.go b/internal/api/convert.go index 87b58676..9560a679 100644 --- a/internal/api/convert.go +++ b/internal/api/convert.go @@ -75,6 +75,15 @@ func streamURL(trackID pgtype.UUID) string { return "/api/tracks/" + uuidToString(trackID) + "/stream" } +// streamURLWithExt returns the extension-bearing stream URL used by UPnP +// cast tokens. Sonos's URL probe gates duration detection on a recognizable +// audio file extension; the bare `/stream` shape reports TrackDuration=0 +// and breaks seek/auto-advance. The bare /stream route stays mounted as an +// alias for legacy / web / Subsonic clients. See task #610. +func streamURLWithExt(trackID pgtype.UUID, ext string) string { + return streamURL(trackID) + "." + ext +} + // artistRefFrom projects a dbq.Artist into an ArtistRef without cover. // albumCount must be pre-computed by the caller. Used by code paths that // don't have a representative-album lookup at hand (artist detail, search, diff --git a/internal/api/media.go b/internal/api/media.go index cca4afec..b2be8ed1 100644 --- a/internal/api/media.go +++ b/internal/api/media.go @@ -22,46 +22,42 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) -// resolveAlbumCoverPath returns the filesystem path to the album's cover art. -// It prefers an explicit cover_art_path (set by the scanner in a future -// milestone) and falls back to a sidecar next to the first track in the -// album's directory. "" means no art was found. +// resolveAlbumCoverPath delegates to coverart.ResolveAlbumPath; kept as a +// local alias so the call sites in this file read naturally. 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, dbq.ListTracksByAlbumParams{AlbumID: album.ID}) - if err != nil || len(tracks) == 0 { - return "" - } - return coverart.FindSidecar(filepath.Dir(tracks[0].FilePath)) + return coverart.ResolveAlbumPath(ctx, q, album) } // audioContentType maps the short file_format recorded on tracks (mp3, flac, // ogg, opus, m4a, aac, wav) to a MIME type for the Content-Type header. -// Unknown formats fall back to octet-stream so the browser downloads them -// rather than attempting to decode. +// This is the canonical table; both the browser stream endpoint and the +// UPnP cast token URL builder consult it. Unknown formats fall back to +// octet-stream so the browser downloads them rather than attempting to +// decode -- cast_token.go applies its own audio/mpeg fallback for Sonos. +// +// Aliases (mpeg/vorbis/wave) cover historical / alternate format spellings +// that have shown up in track rows. The trim+lowercase normalization makes +// the lookup permissive to whatever a scanner happened to write. // // Divergences from internal/subsonic/types.go's contentTypeForFormat are -// intentional: opus→audio/ogg (library .opus files are Ogg-encapsulated, so -// this matches real library contents), aac→audio/aac (raw AAC is ADTS, not -// MP4, so audio/mp4 would mislead codec sniffers), and there is no "oga" case -// (we don't record that format). Don't "fix" these to match subsonic. +// intentional: opus/vorbis→audio/ogg (library .opus / .ogg files are +// Ogg-encapsulated, so this matches real library contents), aac→audio/aac +// (raw AAC is ADTS, not MP4, so audio/mp4 would mislead codec sniffers), +// and there is no "oga" case (we don't record that format). Subsonic is a +// frozen client contract -- don't "fix" these to match it. func audioContentType(format string) string { - switch strings.ToLower(format) { - case "mp3": + switch strings.ToLower(strings.TrimSpace(format)) { + case "mp3", "mpeg": return "audio/mpeg" case "flac": return "audio/flac" - case "ogg", "opus": + case "ogg", "opus", "vorbis": return "audio/ogg" - case "m4a": + case "m4a", "mp4": return "audio/mp4" case "aac": return "audio/aac" - case "wav": + case "wav", "wave": return "audio/wav" } return "application/octet-stream" diff --git a/internal/api/playlists.go b/internal/api/playlists.go index e74291d3..6efba6ee 100644 --- a/internal/api/playlists.go +++ b/internal/api/playlists.go @@ -476,8 +476,8 @@ func playlistDetailToView(d *playlists.PlaylistDetail) playlistDetailView { if t.TrackID != nil { s := uuidToString(*t.TrackID) v.TrackID = &s - streamURL := "/api/tracks/" + s + "/stream" - v.StreamURL = &streamURL + url := streamURL(*t.TrackID) + v.StreamURL = &url } if t.AlbumID != nil { s := uuidToString(*t.AlbumID) diff --git a/internal/coverart/sidecar.go b/internal/coverart/sidecar.go index e5191c3a..84b8e67a 100644 --- a/internal/coverart/sidecar.go +++ b/internal/coverart/sidecar.go @@ -7,8 +7,11 @@ package coverart import ( + "context" "os" "path/filepath" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) // SidecarNames is the lookup order for cover art living next to audio files. @@ -33,3 +36,24 @@ func FindSidecar(albumDir string) string { } return "" } + +// ResolveAlbumPath returns the on-disk path to an album's cover image, +// preferring the explicit album.cover_art_path when set and the file +// exists, falling back to a sidecar (cover.jpg / folder.jpg) next to the +// first track in the album's directory. "" means no art was found. +// +// Shared by internal/api/media.go (browser endpoint) and +// internal/subsonic/stream.go (Subsonic endpoint); they were byte-identical +// duplicates before extraction. +func ResolveAlbumPath(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, dbq.ListTracksByAlbumParams{AlbumID: album.ID}) + if err != nil || len(tracks) == 0 { + return "" + } + return FindSidecar(filepath.Dir(tracks[0].FilePath)) +} diff --git a/internal/subsonic/stream.go b/internal/subsonic/stream.go index c63231b8..38b80078 100644 --- a/internal/subsonic/stream.go +++ b/internal/subsonic/stream.go @@ -130,21 +130,10 @@ func (m *mediaHandlers) handleGetCoverArt(w http.ResponseWriter, r *http.Request 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. +// resolveAlbumCoverPath delegates to coverart.ResolveAlbumPath; kept as a +// local alias so the call sites in this file read naturally. 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, dbq.ListTracksByAlbumParams{AlbumID: album.ID}) - if err != nil || len(tracks) == 0 { - return "" - } - return coverart.FindSidecar(filepath.Dir(tracks[0].FilePath)) + return coverart.ResolveAlbumPath(ctx, q, album) } func serveImage(w http.ResponseWriter, r *http.Request, path string) {