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 }