From fdf28efef00cdcba02d389307ce419dc17e7dd61 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sun, 19 Apr 2026 15:16:48 +0000 Subject: [PATCH] M1/#293: library scanner (walk + tag-parse + upsert + mtime incremental) --- internal/library/scanner.go | 227 ++++++++++++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 internal/library/scanner.go diff --git a/internal/library/scanner.go b/internal/library/scanner.go new file mode 100644 index 00000000..fc066b87 --- /dev/null +++ b/internal/library/scanner.go @@ -0,0 +1,227 @@ +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 year > 0 { + params.ReleaseDate = pgtype.Date{ + Time: time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC), + Valid: true, + } + } + return q.UpsertAlbum(ctx, params) +} + +// 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 +}