dcd19c0143
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>
246 lines
6.2 KiB
Go
246 lines
6.2 KiB
Go
package library
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/dhowden/tag"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
// 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.
|
|
var audioExtensions = map[string]bool{
|
|
".mp3": true,
|
|
".m4a": true,
|
|
".flac": true,
|
|
".ogg": true,
|
|
}
|
|
|
|
type Stats struct {
|
|
Scanned int `json:"scanned"`
|
|
Added int `json:"added"`
|
|
Updated int `json:"updated"`
|
|
Skipped int `json:"skipped"`
|
|
Errored int `json:"errored"`
|
|
}
|
|
|
|
type Scanner struct {
|
|
pool *pgxpool.Pool
|
|
logger *slog.Logger
|
|
paths []string
|
|
}
|
|
|
|
func New(pool *pgxpool.Pool, logger *slog.Logger, paths []string) *Scanner {
|
|
return &Scanner{pool: pool, logger: logger, paths: paths}
|
|
}
|
|
|
|
// Scan walks every configured root and upserts any audio file whose mtime is
|
|
// newer than the existing row's updated_at. Walk errors and per-file errors
|
|
// are logged + counted; the scan keeps going.
|
|
func (s *Scanner) Scan(ctx context.Context) (Stats, error) {
|
|
var stats Stats
|
|
q := dbq.New(s.pool)
|
|
start := time.Now()
|
|
|
|
for _, root := range s.paths {
|
|
if err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
|
if ctx.Err() != nil {
|
|
return fs.SkipAll
|
|
}
|
|
if err != nil {
|
|
s.logger.Warn("library scan walk error", "path", path, "err", err)
|
|
stats.Errored++
|
|
return nil
|
|
}
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
if !audioExtensions[strings.ToLower(filepath.Ext(path))] {
|
|
return nil
|
|
}
|
|
if err := s.scanFile(ctx, q, path, &stats); err != nil {
|
|
s.logger.Warn("library scan file error", "path", path, "err", err)
|
|
stats.Errored++
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return stats, fmt.Errorf("library: walk %q: %w", root, err)
|
|
}
|
|
}
|
|
|
|
s.logger.Info("library scan complete",
|
|
"scanned", stats.Scanned,
|
|
"added", stats.Added,
|
|
"updated", stats.Updated,
|
|
"skipped", stats.Skipped,
|
|
"errored", stats.Errored,
|
|
"duration_ms", time.Since(start).Milliseconds(),
|
|
)
|
|
if err := ctx.Err(); err != nil {
|
|
return stats, err
|
|
}
|
|
return stats, nil
|
|
}
|
|
|
|
func (s *Scanner) scanFile(ctx context.Context, q *dbq.Queries, path string, stats *Stats) error {
|
|
stats.Scanned++
|
|
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return fmt.Errorf("stat: %w", err)
|
|
}
|
|
mtime := info.ModTime()
|
|
|
|
existing, err := q.GetTrackByPath(ctx, path)
|
|
knownTrack := err == nil
|
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
|
return fmt.Errorf("lookup: %w", err)
|
|
}
|
|
if knownTrack && !existing.UpdatedAt.Time.Before(mtime) {
|
|
stats.Skipped++
|
|
return nil
|
|
}
|
|
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return fmt.Errorf("open: %w", err)
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
|
|
meta, err := tag.ReadFrom(f)
|
|
if err != nil {
|
|
return fmt.Errorf("tag read: %w", err)
|
|
}
|
|
|
|
artistName := meta.Artist()
|
|
if artistName == "" {
|
|
artistName = "Unknown Artist"
|
|
}
|
|
albumTitle := meta.Album()
|
|
if albumTitle == "" {
|
|
albumTitle = "Unknown Album"
|
|
}
|
|
trackTitle := meta.Title()
|
|
if trackTitle == "" {
|
|
trackTitle = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
|
}
|
|
|
|
artist, err := s.resolveArtist(ctx, q, artistName)
|
|
if err != nil {
|
|
return fmt.Errorf("artist: %w", err)
|
|
}
|
|
album, err := s.resolveAlbum(ctx, q, artist.ID, albumTitle, meta.Year())
|
|
if err != nil {
|
|
return fmt.Errorf("album: %w", err)
|
|
}
|
|
|
|
trackNum, _ := meta.Track()
|
|
discNum, _ := meta.Disc()
|
|
params := dbq.UpsertTrackParams{
|
|
Title: trackTitle,
|
|
AlbumID: album.ID,
|
|
ArtistID: artist.ID,
|
|
DurationMs: 0,
|
|
FilePath: path,
|
|
FileSize: info.Size(),
|
|
FileFormat: strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), "."),
|
|
}
|
|
if trackNum > 0 {
|
|
v := int32(trackNum)
|
|
params.TrackNumber = &v
|
|
}
|
|
if discNum > 0 {
|
|
v := int32(discNum)
|
|
params.DiscNumber = &v
|
|
}
|
|
if g := meta.Genre(); g != "" {
|
|
params.Genre = &g
|
|
}
|
|
|
|
if _, err := q.UpsertTrack(ctx, params); err != nil {
|
|
return fmt.Errorf("upsert track: %w", err)
|
|
}
|
|
|
|
if knownTrack {
|
|
stats.Updated++
|
|
} else {
|
|
stats.Added++
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Scanner) resolveArtist(ctx context.Context, q *dbq.Queries, name string) (dbq.Artist, error) {
|
|
existing, err := q.GetArtistByName(ctx, name)
|
|
if err == nil {
|
|
return existing, nil
|
|
}
|
|
if !errors.Is(err, pgx.ErrNoRows) {
|
|
return dbq.Artist{}, err
|
|
}
|
|
return q.UpsertArtist(ctx, dbq.UpsertArtistParams{
|
|
Name: name,
|
|
SortName: sortKey(name),
|
|
})
|
|
}
|
|
|
|
func (s *Scanner) resolveAlbum(ctx context.Context, q *dbq.Queries, artistID pgtype.UUID, title string, year int) (dbq.Album, error) {
|
|
existing, err := q.GetAlbumByArtistAndTitle(ctx, dbq.GetAlbumByArtistAndTitleParams{ArtistID: artistID, Title: title})
|
|
if err == nil {
|
|
return existing, nil
|
|
}
|
|
if !errors.Is(err, pgx.ErrNoRows) {
|
|
return dbq.Album{}, err
|
|
}
|
|
params := dbq.UpsertAlbumParams{
|
|
Title: title,
|
|
SortTitle: sortKey(title),
|
|
ArtistID: artistID,
|
|
}
|
|
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 {
|
|
if len(s) >= 4 && strings.EqualFold(s[:4], "the ") {
|
|
return s[4:]
|
|
}
|
|
return s
|
|
}
|