diff --git a/internal/library/scanner.go b/internal/library/scanner.go index fc066b87..b4bb1b19 100644 --- a/internal/library/scanner.go +++ b/internal/library/scanner.go @@ -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 { diff --git a/internal/library/scanner_test.go b/internal/library/scanner_test.go index ce653775..a6632dcf 100644 --- a/internal/library/scanner_test.go +++ b/internal/library/scanner_test.go @@ -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) {