refactor(server): unify stream URL builders + MIME tables + cover-path helper
test-go / test (push) Successful in 28s
test-go / integration (push) Has been cancelled

Closes Scribe #614, #615, server half of #616 surfaced by the 2026-06-04 divergent-provider audit.

- streamURL helper now used everywhere /api/tracks/{id}/stream is built (was inline concat in playlists.go and cast_token.go); add streamURLWithExt for the .ext cast variant.

- audioContentType in media.go is the canonical file_format -> MIME lookup; mimeForFormat in cast_token.go is now a thin wrapper that overrides the unknown-format fallback to audio/mpeg (Sonos rejects octet-stream). Adds mpeg/vorbis/wave aliases. Subsonic's contentTypeForFormat stays frozen per docs.

- coverart.ResolveAlbumPath extracted; api and subsonic both delegate to it.
This commit is contained in:
2026-06-04 08:29:51 -04:00
parent edd198cdf5
commit 024493f2a7
6 changed files with 69 additions and 62 deletions
+10 -21
View File
@@ -33,28 +33,18 @@ type castTokenResponse struct {
Title string `json:"title"` Title string `json:"title"`
} }
// mimeForFormat maps the tracks.file_format column to an HTTP audio // mimeForFormat returns the audio MIME type for a cast (Sonos/UPnP) URL.
// MIME type. Sonos requires the protocolInfo MIME on DIDL-Lite to match // Wraps the canonical audioContentType lookup in media.go and overrides
// what the URL actually serves; "audio/*" wildcard is silently rejected. // the unknown-format fallback to audio/mpeg, because Sonos rejects
// Unknown formats fall back to audio/mpeg — most Sonos firmware probes // DIDL-Lite with protocolInfo=application/octet-stream (the browser
// the URL anyway and recovers from a small MIME mismatch. // fallback) -- most Sonos firmware probes the URL anyway and recovers
// from a small MIME mismatch.
func mimeForFormat(format string) string { func mimeForFormat(format string) string {
switch strings.ToLower(strings.TrimSpace(format)) { mime := audioContentType(format)
case "mp3", "mpeg": if mime == "application/octet-stream" {
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:
return "audio/mpeg" return "audio/mpeg"
} }
return mime
} }
// extForFormat maps the tracks.file_format column to a path-safe file // 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 // Include the file extension in the path so Sonos's URL probe sees a
// recognizable audio file. Without it, Sonos reports TrackDuration=0 // recognizable audio file. Without it, Sonos reports TrackDuration=0
// and seeks past 0s land "after the end" -> early track-skip. // and seeks past 0s land "after the end" -> early track-skip.
url := scheme + "://" + host + "/api/tracks/" + req.TrackID + url := scheme + "://" + host + streamURLWithExt(trackUUID, extForFormat(track.FileFormat)) +
"/stream." + extForFormat(track.FileFormat) +
"?token=" + token + "&exp=" + strconv.FormatInt(exp, 10) "?token=" + token + "&exp=" + strconv.FormatInt(exp, 10)
writeJSON(w, http.StatusOK, castTokenResponse{ writeJSON(w, http.StatusOK, castTokenResponse{
+9
View File
@@ -75,6 +75,15 @@ func streamURL(trackID pgtype.UUID) string {
return "/api/tracks/" + uuidToString(trackID) + "/stream" 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. // artistRefFrom projects a dbq.Artist into an ArtistRef without cover.
// albumCount must be pre-computed by the caller. Used by code paths that // 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, // don't have a representative-album lookup at hand (artist detail, search,
+21 -25
View File
@@ -22,46 +22,42 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
) )
// resolveAlbumCoverPath returns the filesystem path to the album's cover art. // resolveAlbumCoverPath delegates to coverart.ResolveAlbumPath; kept as a
// It prefers an explicit cover_art_path (set by the scanner in a future // local alias so the call sites in this file read naturally.
// milestone) and falls back to a sidecar next to the first track in the
// album's directory. "" means no art was found.
func resolveAlbumCoverPath(ctx context.Context, q *dbq.Queries, album dbq.Album) string { func resolveAlbumCoverPath(ctx context.Context, q *dbq.Queries, album dbq.Album) string {
if album.CoverArtPath != nil && *album.CoverArtPath != "" { return coverart.ResolveAlbumPath(ctx, q, album)
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))
} }
// audioContentType maps the short file_format recorded on tracks (mp3, flac, // 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. // 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 // This is the canonical table; both the browser stream endpoint and the
// rather than attempting to decode. // 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 // Divergences from internal/subsonic/types.go's contentTypeForFormat are
// intentional: opus→audio/ogg (library .opus files are Ogg-encapsulated, so // intentional: opus/vorbis→audio/ogg (library .opus / .ogg files are
// this matches real library contents), aac→audio/aac (raw AAC is ADTS, not // Ogg-encapsulated, so this matches real library contents), aac→audio/aac
// MP4, so audio/mp4 would mislead codec sniffers), and there is no "oga" case // (raw AAC is ADTS, not MP4, so audio/mp4 would mislead codec sniffers),
// (we don't record that format). Don't "fix" these to match subsonic. // 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 { func audioContentType(format string) string {
switch strings.ToLower(format) { switch strings.ToLower(strings.TrimSpace(format)) {
case "mp3": case "mp3", "mpeg":
return "audio/mpeg" return "audio/mpeg"
case "flac": case "flac":
return "audio/flac" return "audio/flac"
case "ogg", "opus": case "ogg", "opus", "vorbis":
return "audio/ogg" return "audio/ogg"
case "m4a": case "m4a", "mp4":
return "audio/mp4" return "audio/mp4"
case "aac": case "aac":
return "audio/aac" return "audio/aac"
case "wav": case "wav", "wave":
return "audio/wav" return "audio/wav"
} }
return "application/octet-stream" return "application/octet-stream"
+2 -2
View File
@@ -476,8 +476,8 @@ func playlistDetailToView(d *playlists.PlaylistDetail) playlistDetailView {
if t.TrackID != nil { if t.TrackID != nil {
s := uuidToString(*t.TrackID) s := uuidToString(*t.TrackID)
v.TrackID = &s v.TrackID = &s
streamURL := "/api/tracks/" + s + "/stream" url := streamURL(*t.TrackID)
v.StreamURL = &streamURL v.StreamURL = &url
} }
if t.AlbumID != nil { if t.AlbumID != nil {
s := uuidToString(*t.AlbumID) s := uuidToString(*t.AlbumID)
+24
View File
@@ -7,8 +7,11 @@
package coverart package coverart
import ( import (
"context"
"os" "os"
"path/filepath" "path/filepath"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
) )
// SidecarNames is the lookup order for cover art living next to audio files. // SidecarNames is the lookup order for cover art living next to audio files.
@@ -33,3 +36,24 @@ func FindSidecar(albumDir string) string {
} }
return "" 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))
}
+3 -14
View File
@@ -130,21 +130,10 @@ func (m *mediaHandlers) handleGetCoverArt(w http.ResponseWriter, r *http.Request
WriteFail(w, r, ErrDataNotFound, "Cover art not found") WriteFail(w, r, ErrDataNotFound, "Cover art not found")
} }
// resolveAlbumCoverPath returns the filesystem path to the album's cover art, // resolveAlbumCoverPath delegates to coverart.ResolveAlbumPath; kept as a
// preferring an explicit cover_art_path (set by the scanner in a future // local alias so the call sites in this file read naturally.
// 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 { func resolveAlbumCoverPath(ctx context.Context, q *dbq.Queries, album dbq.Album) string {
if album.CoverArtPath != nil && *album.CoverArtPath != "" { return coverart.ResolveAlbumPath(ctx, q, album)
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))
} }
func serveImage(w http.ResponseWriter, r *http.Request, path string) { func serveImage(w http.ResponseWriter, r *http.Request, path string) {