diff --git a/internal/api/admin_coverage_test.go b/internal/api/admin_coverage_test.go index dd4e3c07..4fbd4e0c 100644 --- a/internal/api/admin_coverage_test.go +++ b/internal/api/admin_coverage_test.go @@ -69,15 +69,32 @@ func TestAdminLibraryCoverage_MixedRowsReturnCorrectBuckets(t *testing.T) { sourceNone := "none" sourceSidecar := "sidecar" sourceMbcaa := "mbcaa" + sourceEmbedded := "embedded" + sourceTheaudiodb := "theaudiodb" + sourceDeezer := "deezer" + sourceLastfm := "lastfm" mbid1 := "11111111-1111-1111-1111-111111111111" mbid2 := "22222222-2222-2222-2222-222222222222" mbid3 := "33333333-3333-3333-3333-333333333333" + mbid4 := "44444444-4444-4444-4444-444444444444" + mbid5 := "55555555-5555-5555-5555-555555555555" + mbid6 := "66666666-6666-6666-6666-666666666666" + mbid7 := "77777777-7777-7777-7777-777777777777" + // Cover every cover_art_source value the migrations allow so a future + // addition without updating the rollup query trips this test — + // regression guard for drift #557, the gap that hid #556 (deezer + + // lastfm omission introduced by migration 0020 but never wired into + // the rollup whitelist). rows := []seed{ - {title: "WithArtSidecar", source: &sourceSidecar, mbid: &mbid1}, // with_art - {title: "WithArtMbcaa", source: &sourceMbcaa, mbid: &mbid2}, // with_art - {title: "PendingHasMbid", source: nil, mbid: &mbid3}, // pending (eligible) - {title: "PendingNoMbid", source: nil, mbid: nil}, // pending + pending_no_mbid - {title: "Settled", source: &sourceNone, mbid: nil}, // settled (no mbid is fine here) + {title: "WithArtSidecar", source: &sourceSidecar, mbid: &mbid1}, // with_art + {title: "WithArtMbcaa", source: &sourceMbcaa, mbid: &mbid2}, // with_art + {title: "WithArtEmbedded", source: &sourceEmbedded, mbid: &mbid4}, // with_art + {title: "WithArtTheaudiodb", source: &sourceTheaudiodb, mbid: &mbid5}, // with_art + {title: "WithArtDeezer", source: &sourceDeezer, mbid: &mbid6}, // with_art + {title: "WithArtLastfm", source: &sourceLastfm, mbid: &mbid7}, // with_art + {title: "PendingHasMbid", source: nil, mbid: &mbid3}, // pending (eligible) + {title: "PendingNoMbid", source: nil, mbid: nil}, // pending + pending_no_mbid + {title: "Settled", source: &sourceNone, mbid: nil}, // settled (no mbid is fine here) } for _, s := range rows { if _, err := pool.Exec(context.Background(), ` @@ -100,11 +117,13 @@ func TestAdminLibraryCoverage_MixedRowsReturnCorrectBuckets(t *testing.T) { if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("decode: %v", err) } - if resp.Total != 5 { - t.Errorf("Total = %d, want 5", resp.Total) + if resp.Total != 9 { + t.Errorf("Total = %d, want 9", resp.Total) } - if resp.WithArt != 2 { - t.Errorf("WithArt = %d, want 2", resp.WithArt) + // Six with_art rows — one per valid cover_art_source value + // (sidecar, mbcaa, embedded, theaudiodb, deezer, lastfm). + if resp.WithArt != 6 { + t.Errorf("WithArt = %d, want 6", resp.WithArt) } if resp.Pending != 2 { t.Errorf("Pending = %d, want 2", resp.Pending) diff --git a/internal/db/dbq/albums.sql.go b/internal/db/dbq/albums.sql.go index ca990688..3ebea2a2 100644 --- a/internal/db/dbq/albums.sql.go +++ b/internal/db/dbq/albums.sql.go @@ -126,7 +126,8 @@ func (q *Queries) GetAlbumByID(ctx context.Context, id pgtype.UUID) (Album, erro const getAlbumCoverageRollup = `-- name: GetAlbumCoverageRollup :one SELECT COUNT(*) AS total, - COUNT(*) FILTER (WHERE cover_art_source IN ('sidecar','embedded','mbcaa','theaudiodb')) AS with_art, + COUNT(*) FILTER (WHERE cover_art_source IN + ('sidecar','embedded','mbcaa','theaudiodb','deezer','lastfm')) AS with_art, COUNT(*) FILTER (WHERE cover_art_source IS NULL) AS pending, COUNT(*) FILTER (WHERE cover_art_source = 'none') AS settled, COUNT(*) FILTER (WHERE cover_art_source IS NULL AND mbid IS NULL) AS pending_no_mbid @@ -151,10 +152,12 @@ type GetAlbumCoverageRollupRow struct { // Invariant: with_art + pending + settled = total. (pending_no_mbid is // not part of the sum — it's a subset of pending, surfaced separately.) // -// The IN list below ('sidecar','embedded','mbcaa','theaudiodb') must stay in sync -// with the cover_art_source CHECK constraint in migration -// 0016_album_cover_source.up.sql. If a new source value is added there -// without updating this query, with_art will silently undercount. +// The IN list below must stay in sync with the cover_art_source CHECK +// constraint — currently relaxed by migration 0020 to include 'deezer' +// and 'lastfm', and migration 0030 further relaxed it to any non-empty +// string. If a new source value is added without updating this query, +// with_art will silently undercount. Drift #556 caught the deezer + +// lastfm omission introduced by migration 0020. func (q *Queries) GetAlbumCoverageRollup(ctx context.Context) (GetAlbumCoverageRollupRow, error) { row := q.db.QueryRow(ctx, getAlbumCoverageRollup) var i GetAlbumCoverageRollupRow diff --git a/internal/db/queries/albums.sql b/internal/db/queries/albums.sql index 9811a083..4aaaadd0 100644 --- a/internal/db/queries/albums.sql +++ b/internal/db/queries/albums.sql @@ -156,13 +156,16 @@ SELECT a.id AS album_id, -- Invariant: with_art + pending + settled = total. (pending_no_mbid is -- not part of the sum — it's a subset of pending, surfaced separately.) -- --- The IN list below ('sidecar','embedded','mbcaa','theaudiodb') must stay in sync --- with the cover_art_source CHECK constraint in migration --- 0016_album_cover_source.up.sql. If a new source value is added there --- without updating this query, with_art will silently undercount. +-- The IN list below must stay in sync with the cover_art_source CHECK +-- constraint — currently relaxed by migration 0020 to include 'deezer' +-- and 'lastfm', and migration 0030 further relaxed it to any non-empty +-- string. If a new source value is added without updating this query, +-- with_art will silently undercount. Drift #556 caught the deezer + +-- lastfm omission introduced by migration 0020. SELECT COUNT(*) AS total, - COUNT(*) FILTER (WHERE cover_art_source IN ('sidecar','embedded','mbcaa','theaudiodb')) AS with_art, + COUNT(*) FILTER (WHERE cover_art_source IN + ('sidecar','embedded','mbcaa','theaudiodb','deezer','lastfm')) AS with_art, COUNT(*) FILTER (WHERE cover_art_source IS NULL) AS pending, COUNT(*) FILTER (WHERE cover_art_source = 'none') AS settled, COUNT(*) FILTER (WHERE cover_art_source IS NULL AND mbid IS NULL) AS pending_no_mbid diff --git a/internal/library/scanner.go b/internal/library/scanner.go index 5f47912a..c6b7c6e7 100644 --- a/internal/library/scanner.go +++ b/internal/library/scanner.go @@ -23,13 +23,20 @@ import ( syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync" ) -// audioExtensions is the v1 set. Duration extraction is not wired, so all of -// these get duration_ms=0 until an ffprobe / native-decoder pass lands. +// audioExtensions is the set the scanner indexes. Keep in sync with +// `internal/api/media.go` MIME detection — the stream handler must be +// able to serve every extension the scanner indexes, and there is no +// point in adding extensions to the stream handler that the scanner +// will silently skip. Drift #571 caught the divergence after .opus, +// .aac, and .wav were added to media.go but not here. var audioExtensions = map[string]bool{ ".mp3": true, ".m4a": true, ".flac": true, ".ogg": true, + ".opus": true, + ".aac": true, + ".wav": true, } type Stats struct { diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index 26796f06..d280e802 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -106,7 +106,13 @@ export type LikedIdsResponse = { }; export type EventRequest = - | { type: 'play_started'; track_id: string; client_id?: string } + // `source` is the system-playlist variant the play came from + // (for_you, discover, deep_cuts, …); empty/undefined for library + // / user-playlist / radio / Subsonic. Server stores it on + // play_events.source and uses it to advance the rotation. Drift + // #555: the type was missing `source` so callers had no typed slot + // for it and a "clean up extra properties" refactor could drop it. + | { type: 'play_started'; track_id: string; client_id?: string; source?: string } | { type: 'play_ended'; play_event_id: string; duration_played_ms: number } | { type: 'play_skipped'; play_event_id: string; position_ms: number }; diff --git a/web/src/lib/auth/publicRoutes.test.ts b/web/src/lib/auth/publicRoutes.test.ts index 1210eca8..71e875e3 100644 --- a/web/src/lib/auth/publicRoutes.test.ts +++ b/web/src/lib/auth/publicRoutes.test.ts @@ -30,4 +30,22 @@ describe('isPublicRoute', () => { expect(isPublicRoute('/login/extra')).toBe(false); expect(isPublicRoute('/loginx')).toBe(false); }); + + // Regression guard for drift #558: /forgot-password and the + // /reset-password/ deep links must be reachable without a + // session — the reset flow is entered by clicking an email link + // while signed out, so the auth gate redirecting to /login broke + // account recovery. + test('/forgot-password is public', () => { + expect(isPublicRoute('/forgot-password')).toBe(true); + }); + + test('/reset-password/ is public', () => { + expect(isPublicRoute('/reset-password/abc-123')).toBe(true); + expect(isPublicRoute('/reset-password/')).toBe(true); + }); + + test('/reset-password (no trailing slash) is NOT public — only the token sub-path', () => { + expect(isPublicRoute('/reset-password')).toBe(false); + }); }); diff --git a/web/src/lib/auth/publicRoutes.ts b/web/src/lib/auth/publicRoutes.ts index e79a9c31..aaecf98f 100644 --- a/web/src/lib/auth/publicRoutes.ts +++ b/web/src/lib/auth/publicRoutes.ts @@ -2,8 +2,15 @@ // when the visitor is unauthenticated. Bootstrap-admin self-registration // (#376) requires /register to be reachable without a session — without // /register here, the login page's "Register" link bounces back to /login. -const PUBLIC_ROUTES = new Set(['/login', '/register']); +// +// Drift #558: /forgot-password and /reset-password/ were missing +// from this set. The email-link reset flow is by definition entered +// without a session — a signed-out user clicking their reset link was +// being bounced to /login, breaking account recovery. +const PUBLIC_ROUTES = new Set(['/login', '/register', '/forgot-password']); +const PUBLIC_PREFIXES = ['/reset-password/']; export function isPublicRoute(pathname: string): boolean { - return PUBLIC_ROUTES.has(pathname); + if (PUBLIC_ROUTES.has(pathname)) return true; + return PUBLIC_PREFIXES.some((p) => pathname.startsWith(p)); } diff --git a/web/src/routes/admin/playback-errors/+page.svelte b/web/src/routes/admin/playback-errors/+page.svelte index 86fdf9a0..d43993f0 100644 --- a/web/src/routes/admin/playback-errors/+page.svelte +++ b/web/src/routes/admin/playback-errors/+page.svelte @@ -274,7 +274,7 @@ {/if}