fix(library): drop out-of-range years instead of failing the album

Tag-supplied years were being passed straight to Postgres' date column
without validation. Files with corrupt or 5+ digit years (seen in the
wild on a couple of dozen albums) tripped SQLSTATE 22008 and the entire
album upsert failed, dropping every track on those albums from the
library.

Validate the year is within 1..9999 before constructing the date. If
it's outside that window, log a warning naming the album and year, and
insert the album with no release_date — a soft field shouldn't take
down the whole row.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 00:14:51 -04:00
parent dfcdf4d3ca
commit dcd19c0143
2 changed files with 48 additions and 5 deletions
+23 -5
View File
@@ -208,15 +208,33 @@ func (s *Scanner) resolveAlbum(ctx context.Context, q *dbq.Queries, artistID pgt
SortTitle: sortKey(title),
ArtistID: artistID,
}
if year > 0 {
params.ReleaseDate = pgtype.Date{
Time: time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC),
Valid: true,
}
if d, ok := releaseDateFromYear(year); ok {
params.ReleaseDate = d
} else if year != 0 {
// year=0 is the no-tag case; anything else getting rejected is a tag
// we couldn't trust (typo, OOB number, …). Log it so users can chase
// down the file but don't fail the album insert over a soft field.
s.logger.Warn("library scan: dropping invalid release year",
"year", year, "album", title)
}
return q.UpsertAlbum(ctx, params)
}
// releaseDateFromYear converts a tag-supplied year into a Postgres date,
// returning ok=false if the year is outside what we'll accept. We're
// deliberately strict (1..9999): Postgres' date type goes much wider, but
// 5-digit years from ID3 tags are always garbage in practice and trigger
// SQLSTATE 22008 (datetime field overflow) at insert time.
func releaseDateFromYear(year int) (pgtype.Date, bool) {
if year < 1 || year > 9999 {
return pgtype.Date{}, false
}
return pgtype.Date{
Time: time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC),
Valid: true,
}, true
}
// sortKey drops a leading "The " for sortable ordering. Non-English articles
// (Los, Die, Les) can be added if users ask — keeping the rule obvious for now.
func sortKey(s string) string {
+25
View File
@@ -16,6 +16,31 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
func TestReleaseDateFromYear(t *testing.T) {
cases := []struct {
in int
ok bool
label string
}{
{0, false, "missing tag"},
{-1, false, "negative"},
{1, true, "lower bound"},
{1999, true, "ordinary"},
{9999, true, "upper bound"},
{10000, false, "five digits"},
{99999999, false, "tag corruption"},
}
for _, c := range cases {
got, ok := releaseDateFromYear(c.in)
if ok != c.ok {
t.Errorf("%s: ok = %v, want %v", c.label, ok, c.ok)
}
if ok && (!got.Valid || got.Time.Year() != c.in) {
t.Errorf("%s: date = %+v, want year=%d", c.label, got, c.in)
}
}
}
// TestScanner_Integration exercises walk → tag-parse → upsert → incremental
// skip against a real Postgres. Gated on MINSTREL_TEST_DATABASE_URL.
func TestScanner_Integration(t *testing.T) {