M1/#293: library scanner (walk + tag-parse + upsert + mtime incremental)
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user