dev → main: collage center-crop, server DRY, CI durability-off #80
@@ -116,6 +116,27 @@ jobs:
|
|||||||
# Wait for Postgres to accept TCP (no health-check dependency).
|
# Wait for Postgres to accept TCP (no health-check dependency).
|
||||||
for i in $(seq 1 60); do (echo > "/dev/tcp/${PG_IP}/5432") 2>/dev/null && break; sleep 2; done
|
for i in $(seq 1 60); do (echo > "/dev/tcp/${PG_IP}/5432") 2>/dev/null && break; sleep 2; done
|
||||||
|
|
||||||
|
# Relax durability on the throwaway CI Postgres. Our test pattern
|
||||||
|
# is dbtest.ResetDB → TRUNCATE … RESTART IDENTITY CASCADE before
|
||||||
|
# every test, and the per-TRUNCATE commit fsync is the dominant
|
||||||
|
# cost of the integration suite. The CI DB is rebuilt every run so
|
||||||
|
# fsync / full_page_writes / synchronous_commit buy nothing. Apply
|
||||||
|
# via docker exec because:
|
||||||
|
# - The act_runner `services:` block can't override the container
|
||||||
|
# command, so `postgres -c fsync=off` at boot isn't an option.
|
||||||
|
# - ALTER SYSTEM cannot run inside a transaction; psql -c
|
||||||
|
# auto-commits each statement, which is what we need.
|
||||||
|
# - fsync / full_page_writes are sighup GUCs and
|
||||||
|
# synchronous_commit is user-context, so pg_reload_conf() picks
|
||||||
|
# all three up with no restart.
|
||||||
|
# Non-fatal: a perms surprise degrades to "slower", never red CI.
|
||||||
|
docker exec "$PG_ID" psql -U minstrel -d minstrel_test \
|
||||||
|
-c "ALTER SYSTEM SET fsync = off" \
|
||||||
|
-c "ALTER SYSTEM SET synchronous_commit = off" \
|
||||||
|
-c "ALTER SYSTEM SET full_page_writes = off" \
|
||||||
|
-c "SELECT pg_reload_conf()" \
|
||||||
|
|| echo "WARN: durability relax failed; continuing"
|
||||||
|
|
||||||
# Apply embedded migrations to the fresh test DB, then run the
|
# Apply embedded migrations to the fresh test DB, then run the
|
||||||
# full suite (no -short → integration tests execute). -p 1:
|
# full suite (no -short → integration tests execute). -p 1:
|
||||||
# every integration package TRUNCATEs the one shared test DB;
|
# every integration package TRUNCATEs the one shared test DB;
|
||||||
|
|||||||
+10
-21
@@ -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{
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -176,16 +176,43 @@ func fallbackGlyph() image.Image {
|
|||||||
return img
|
return img
|
||||||
}
|
}
|
||||||
|
|
||||||
// drawScaled copies src into dst.Rect, scaling with simple nearest-neighbor.
|
// drawScaled copies src into dst.Rect using a center-cropped "cover" fit
|
||||||
// stdlib lacks high-quality scaling; nearest-neighbor is fine for a
|
// (the same model as BoxFit.cover / object-fit: cover in the clients).
|
||||||
// 600x600 output where each cell is 300x300 — most album covers are
|
// Non-square sources are scaled so the *smaller* destination dimension is
|
||||||
// already 300-1500 pixels and the visual loss is minor.
|
// fully filled and the larger axis is center-cropped, preserving aspect
|
||||||
|
// ratio. Without this, banner-shaped or LP-shaped album art stretches in
|
||||||
|
// the cell -- the album-coherent system playlists (new_for_you,
|
||||||
|
// first_listens) make the stretching disproportionately visible because
|
||||||
|
// fewer unique covers contribute, so each warped cell is a quarter of
|
||||||
|
// the collage rather than diluted.
|
||||||
|
//
|
||||||
|
// Scaling itself stays nearest-neighbor -- stdlib lacks high-quality
|
||||||
|
// scaling and dependency cost is unjustified for this 600x600 output.
|
||||||
func drawScaled(dst draw.Image, r image.Rectangle, src image.Image) {
|
func drawScaled(dst draw.Image, r image.Rectangle, src image.Image) {
|
||||||
srcBounds := src.Bounds()
|
srcBounds := src.Bounds()
|
||||||
|
srcW := srcBounds.Dx()
|
||||||
|
srcH := srcBounds.Dy()
|
||||||
|
if srcW <= 0 || srcH <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dstW := r.Dx()
|
||||||
|
dstH := r.Dy()
|
||||||
|
// Cover-fit: the side of src that maps to dst at the larger scale
|
||||||
|
// fully fills its axis; the other axis is center-cropped.
|
||||||
|
scaleX := float64(dstW) / float64(srcW)
|
||||||
|
scaleY := float64(dstH) / float64(srcH)
|
||||||
|
scale := scaleX
|
||||||
|
if scaleY > scale {
|
||||||
|
scale = scaleY
|
||||||
|
}
|
||||||
|
cropW := float64(dstW) / scale
|
||||||
|
cropH := float64(dstH) / scale
|
||||||
|
cropOffX := float64(srcBounds.Min.X) + (float64(srcW)-cropW)/2
|
||||||
|
cropOffY := float64(srcBounds.Min.Y) + (float64(srcH)-cropH)/2
|
||||||
for y := r.Min.Y; y < r.Max.Y; y++ {
|
for y := r.Min.Y; y < r.Max.Y; y++ {
|
||||||
|
sy := int(cropOffY + float64(y-r.Min.Y)*cropH/float64(dstH))
|
||||||
for x := r.Min.X; x < r.Max.X; x++ {
|
for x := r.Min.X; x < r.Max.X; x++ {
|
||||||
sx := srcBounds.Min.X + (x-r.Min.X)*srcBounds.Dx()/r.Dx()
|
sx := int(cropOffX + float64(x-r.Min.X)*cropW/float64(dstW))
|
||||||
sy := srcBounds.Min.Y + (y-r.Min.Y)*srcBounds.Dy()/r.Dy()
|
|
||||||
dst.Set(x, y, src.At(sx, sy))
|
dst.Set(x, y, src.At(sx, sy))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user