Files
yellowjacket/backend/library/library.go
yonluandClaude Opus 5 590a0d86dd
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m47s
CI / e2e (pull_request) Successful in 5m59s
perf(library): size the scan to the drive, and prefetch what it reads
Every parser in `backend/metadata` is header-only -- a few hundred
bytes and return -- so on a spinning disk a scan is not waiting on CPU
or on bytes, it is waiting on the head to arrive. Two things follow,
and the drive says which.

**How many reads should be in flight.** This was a flat 2 for anything
rotational, which is a pre-NCQ assumption: a modern SATA disk reports a
queue depth of 32 and reorders outstanding reads into the order its
head passes over them, and was being handed a quarter of what it can
use. It gets 4 now. A drive that reports 1 -- a USB bridge, a pre-2004
disk -- services one command at a time in the order given, where every
extra worker is one more seek competing for one head and the scan gets
*slower* the harder it is pushed; that keeps 2.

**And that the next seek should already be queued.** A prefetch stage
between the walk and the workers issues `POSIX_FADV_WILLNEED` over the
first 512 KB of each file -- enough for an ID3v2 tag carrying cover
art, or FLAC's STREAMINFO and PICTURE blocks. The buffered channel *is*
the lookahead: the goroutine runs 16 files ahead of the workers,
hinting as it goes, so the read a worker needs has been in flight for
sixteen files' worth of parsing by the time it asks. Rotational only;
an SSD gets the channel back unwrapped and pays nothing, since it has
no seek to hide and already has one worker per core.

`workersForProfile` is the policy on its own so it can be tested
against drives this machine does not have, and the scan logs the
device, its rotational flag and its queue depth, so the decision is
inspectable rather than inferred.

Also: `ScanConcurrency` has been a validated three-value config field
with exactly one caller, passing the constant `auto` -- so choosing
`ssd` or `hdd` by hand did nothing at all. It reads the config now.
The two modes overrule detection about the *disk* and not about its
queue, since a user who picks `hdd` on a queueing drive still wants
that drive's queue used.

What is not here is inode-ordered dispatch. It needs the streaming walk
restructured to buffer per directory, and with queueing the drive is
already reordering what the hints put in front of it; that wants a
measurement on real hardware before the complexity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MeQt5hgXg5YGoNZQ9ozG7L
2026-08-17 22:12:15 -04:00

2324 lines
62 KiB
Go

package library
import (
"context"
"database/sql"
"errors"
"fmt"
"io/fs"
"log/slog"
"os"
"path/filepath"
goruntime "runtime"
"slices"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
"golang.org/x/sync/errgroup"
"yellowjacket/backend/autotag"
"yellowjacket/backend/database"
"yellowjacket/backend/database/sql/sqlcgen"
"yellowjacket/backend/events"
"yellowjacket/backend/jobs"
"yellowjacket/backend/metadata"
"yellowjacket/backend/system"
)
// scanBatchSize controls how many files are committed in a single
// database transaction during a scan. Larger batches amortize
// SQLite's fsync cost but increase the blast radius of a failed commit.
const scanBatchSize = 300
// entityCache holds recently resolved database rows so repeated
// upserts for the same artist/album/cover art within a scan can be
// served from memory instead of hitting the database.
// It is only accessed from the single DB-writer goroutine and
// therefore needs no synchronisation.
type entityCache struct {
artists map[string]sqlcgen.Artist
albums map[string]sqlcgen.Album
coverArt map[string]sqlcgen.CoverArt
genres map[string]sqlcgen.Genre
}
func newEntityCache() *entityCache {
return &entityCache{
artists: make(map[string]sqlcgen.Artist),
albums: make(map[string]sqlcgen.Album),
coverArt: make(map[string]sqlcgen.CoverArt),
genres: make(map[string]sqlcgen.Genre),
}
}
// RescanHooks holds optional callbacks that run before and after
// the library-clear-and-scan phase of a full rescan. The app
// layer sets these to coordinate cross-cutting concerns (e.g.
// clearing the queue, restoring playlists) without the library
// needing to know about those packages.
type RescanHooks struct {
// PreClear runs before library data is wiped
// (e.g. clear queue and stop playback).
PreClear func()
// PostScan runs after the scan completes
// (e.g. restore playlists from M3U8 files).
PostScan func()
}
// ScanHooks contains callbacks invoked after a library scan
// completes. The app layer wires these so the library package
// does not depend on the playlist package directly.
type ScanHooks struct {
// RepopulatePlaylists re-imports tracks for playlists that
// lost their playlist_tracks rows (e.g., from a pre-fix
// FullRescan). Runs before ResolvePhantoms.
RepopulatePlaylists func()
// ResolvePhantoms re-links phantom playlist tracks whose
// files now exist in the library after scanning.
ResolvePhantoms func()
// OnAllScansComplete runs after ALL queued scans finish
// (queue drained).
OnAllScansComplete func()
}
// Library manages scanning and querying the music collection.
type Library struct {
// mu protects ctx, conf, and rescanHooks from concurrent
// access during initialization.
mu sync.Mutex
ctx context.Context
logger *slog.Logger
conf *Config
db *database.DB
rescanHooks RescanHooks
// Scan control fields — protected by mu.
scanActive bool
scanCancel context.CancelFunc
scanPaused bool
scanPauseCh chan struct{}
// jobs is the background job registry. Nil in tests that do not
// exercise progress reporting; every use site must nil-check.
jobs *jobs.Registry
// Scan queue fields — protected by mu.
scanQueue []scanQueueEntry
currentScanLibraryID int64
currentScanLibraryName string
// removalHooks holds callbacks for cross-cutting concerns during
// library removal (e.g. stopping playback, compacting queue).
removalHooks RemovalHooks
// scanHooks holds callbacks for post-scan processing
// (e.g. resolving phantom playlist tracks).
scanHooks ScanHooks
// pipelineMu provides mutual exclusion between the scan
// pipeline and the tag write pipeline. Acquired at the
// start of each pipeline, released at the end.
pipelineMu sync.Mutex
}
// SetRescanHooks provides optional hooks for cross-cutting
// orchestration during FullRescan.
//
//wails:ignore // internal wiring, not part of the app's IPC surface.
func (l *Library) SetRescanHooks(h RescanHooks) {
l.mu.Lock()
defer l.mu.Unlock()
l.rescanHooks = h
}
// SetScanHooks provides optional hooks for cross-cutting
// orchestration after each library scan.
//
//wails:ignore // internal wiring, not part of the app's IPC surface.
func (l *Library) SetScanHooks(h ScanHooks) {
l.mu.Lock()
defer l.mu.Unlock()
l.scanHooks = h
}
// NewLibrary creates a new library with the given configuration.
// A nil config is permitted; scan paths come from the database
// rather than from the config's DirectoryPath.
func NewLibrary(
ctx context.Context,
logger *slog.Logger,
conf *Config,
db *database.DB,
) (*Library, error) {
if conf == nil {
conf = &Config{}
}
if err := conf.Validate(); err != nil {
return nil, fmt.Errorf("invalid library config %#v: %w", conf, err)
}
library := &Library{
ctx: ctx,
logger: logger,
conf: conf,
db: db,
}
return library, nil
}
// AcquirePipelineLock acquires the pipeline mutex for a tag write
// operation. The caller must call ReleasePipelineLock when done.
// If a scan is currently in progress, AcquirePipelineLock blocks
// until it completes (and vice versa).
//
//wails:ignore // internal wiring, not part of the app's IPC surface.
func (l *Library) AcquirePipelineLock() { l.pipelineMu.Lock() }
// ReleasePipelineLock releases the pipeline mutex after a tag write.
//
//wails:ignore // internal wiring, not part of the app's IPC surface.
func (l *Library) ReleasePipelineLock() { l.pipelineMu.Unlock() }
// ServiceStartup is v3's service lifecycle hook: it runs once the
// runtime exists, and ctx is cancelled when the app shuts down. It
// replaces v2's SetContext, which had to be called by hand from
// OnStartup and was exported, so it was also bound to the frontend.
func (l *Library) ServiceStartup(
ctx context.Context,
_ application.ServiceOptions,
) error {
l.mu.Lock()
l.ctx = ctx
l.mu.Unlock()
l.registerEventHandlers()
return nil
}
// emit publishes a Wails event under the library lock, which the
// background scan workers need because they outlive the context that
// started them. events.Emit tolerates a context with no Wails runtime;
// see its doc comment.
func (l *Library) emit(event string, data ...any) {
l.mu.Lock()
ctx := l.ctx
l.mu.Unlock()
events.Emit(ctx, event, data...)
}
// registerEventHandlers sets up Wails runtime event listeners.
// The legacy LibraryConfigChanged handler was removed — in the
// multi-library model, libraries are managed through the CRUD
// API (Phase 12) and scanned via ScanLibrary/ScanAllLibraries.
func (l *Library) registerEventHandlers() {
if l.ctx == nil {
l.logger.Error(
"Context is nil, cannot register event handlers",
)
}
}
// scanInternal performs the full scan pipeline for a single library.
// It is called from the scan queue coordinator (startScan) or the
// legacy Scan() wrapper. The caller is responsible for goroutine
// management; this method blocks until the scan completes.
func (l *Library) scanInternal(
libraryID int64,
libraryName string,
libraryPath string,
) *ScanMetrics {
// Acquire pipeline lock for scan/write mutual exclusion.
l.pipelineMu.Lock()
defer l.pipelineMu.Unlock()
metrics := newScanMetrics()
metrics.LibraryID = libraryID
metrics.LibraryName = libraryName
scanStart := time.Now()
// Register the background job before any work starts so the UI
// indicator appears immediately, even during the pre-walk count.
jobHandle := l.startScanJob(scanQueueEntry{
libraryID: libraryID,
libraryName: libraryName,
libraryPath: libraryPath,
})
// Stream non-fatal issues into the job log as they happen rather
// than dumping them all at completion — the point of the log pane
// is to answer "what is it doing right now".
metrics.onWarning = func(w ScanWarning) {
if jobHandle == nil {
return
}
jobHandle.LogDetail(jobs.LevelWarn, w.Phase+": "+w.Err, w.FilePath)
}
scanCtx, scanCancel := context.WithCancel(l.ctx)
defer scanCancel()
l.mu.Lock()
l.scanCancel = scanCancel
l.scanActive = true
l.scanPaused = false
l.scanPauseCh = nil
l.mu.Unlock()
defer func() {
l.mu.Lock()
l.scanCancel = nil
// If still paused, unpause so no dangling channel.
if l.scanPaused {
l.scanPaused = false
if l.scanPauseCh != nil {
close(l.scanPauseCh)
}
}
l.scanPauseCh = nil
l.mu.Unlock()
}()
// The configured mode, not a hardcoded "auto". `ScanConcurrency`
// has been a validated config field with three values and one
// caller passing a constant, so choosing `ssd` or `hdd` by hand
// did nothing at all.
diskProfile := system.ProfileForPath(libraryPath)
workerCount := resolveScanWorkerCount(
l.conf.ScanConcurrency,
libraryPath,
)
l.logger.Info(
"beginning library scan",
"libraryID", libraryID,
"libraryName", libraryName,
"libraryPath", libraryPath,
"workers", workerCount,
"mode", l.conf.ScanConcurrency,
"device", diskProfile.Device,
"rotational", diskProfile.Rotational,
"queueDepth", diskProfile.QueueDepth,
)
// Helper to build a ScanProgress with library identification.
queuedCount := func() int {
l.mu.Lock()
defer l.mu.Unlock()
return len(l.scanQueue)
}
mkProgress := func(
phase string,
total, processed, a, s, u int64,
) ScanProgress {
return ScanProgress{
Phase: phase,
Total: total,
Processed: processed,
Added: a,
Skipped: s,
Updated: u,
LibraryID: libraryID,
LibraryName: libraryName,
QueuedCount: queuedCount(),
}
}
// emitProgress publishes one progress update to both consumers: the
// legacy LibraryScanProgress event and the shared job registry.
// Routing everything through here keeps the two from drifting.
emitProgress := func(p ScanProgress) {
l.emit(events.LibraryScanProgress, p)
reportScanProgress(jobHandle, p)
}
l.emit(events.LibraryScanStarted, map[string]any{
"libraryId": libraryID,
"libraryName": libraryName,
})
basePath := libraryPath
// --- Pre-walk: count audio files for progress reporting ---
emitProgress(mkProgress("counting", 0, 0, 0, 0, 0))
// Paths the user has removed from the library. Loaded once per
// scan: the walk consults it per file, and the counts above and
// below must agree with it or the progress bar and the soft scan
// both describe a library that is not the one being built.
excluded := l.excludedPathSet(libraryID)
totalFiles := countAudioFiles(basePath, excluded)
l.logger.Debug(
"pre-walk file count complete",
"total", totalFiles,
)
// --- Phase 1: load existing files from DB (per-library) ---
loadStart := time.Now()
existingFiles, err := l.db.Queries.GetAudioFilesInLibrary(
l.ctx, libraryID,
)
if err != nil {
l.logger.Error(
"could not load existing audio files",
"libraryID", libraryID,
"err", err,
)
return metrics
}
existingPaths := &sync.Map{}
for _, f := range existingFiles {
existingPaths.Store(f.FilePath, f)
}
metrics.LoadExisting = time.Since(loadStart)
l.logger.Debug(
"loaded existing files from database",
"count", len(existingFiles),
"libraryID", libraryID,
"libraryPath", libraryPath,
)
workChan := make(chan scanWork, 100)
resultChan := make(chan importResult, 100)
dirDoneChan := make(chan dirClosed, 100)
var added, skipped, updated atomic.Int64
var scanErr error
var errMu sync.Mutex
// statBackfill collects staleness baselines for skipped files whose
// rows predate migration 47. Appended to only by the walk goroutine
// and read after workChan closes, which orders the writes before the
// flush.
var statBackfill []sqlcgen.UpdateAudioFileStatParams
// --- Phase 2: directory walk ---
walkStart := time.Now()
go func() {
defer func() {
metrics.WalkDuration = time.Since(walkStart)
close(workChan)
}()
// stack tracks the directories the walk currently has open, so
// that once one is fully enumerated (see isWithinDir) its total
// scanWork count can be reported to the DB writer as a single
// dirClosed event — see the dirClosed doc comment for why.
var stack []*openDir
closeDirsNotContaining := func(path string) {
for len(stack) > 0 && !isWithinDir(path, stack[len(stack)-1].relPath) {
top := stack[len(stack)-1]
stack = stack[:len(stack)-1]
select {
case dirDoneChan <- dirClosed{dir: top.absDir, expected: top.expected}:
case <-scanCtx.Done():
}
}
}
walkErr := fs.WalkDir(
os.DirFS(basePath),
".",
func(path string, d fs.DirEntry, err error) error {
if err != nil {
l.logger.Error(
"problem walking directory",
"path", path, "err", err,
)
return nil // continue walking
}
closeDirsNotContaining(path)
if d.IsDir() {
stack = append(stack, &openDir{
relPath: path,
absDir: filepath.Join(basePath, path),
})
return nil
}
absoluteFilePath := filepath.Join(
basePath, path,
)
fileExt := filepath.Ext(d.Name())
fileType, isSupportedAudioFile := metadata.GetSupportedFileType(fileExt)
if !isSupportedAudioFile {
return nil
}
// The user removed this path from the library. Leaving
// it out of existingPaths' LoadAndDelete as well is
// deliberate: if a row somehow exists for an excluded
// path, orphan cleanup below deletes it, which is the
// state the user asked for.
if isExcluded(excluded, absoluteFilePath) {
l.logger.Debug(
"skipping excluded path",
"path", absoluteFilePath,
)
return nil
}
// Stat the entry for the staleness comparison below.
// This happens before the file is read, so a file
// modified mid-scan records the pre-read mtime and is
// picked up again next scan — the safe direction.
var (
diskModTime int64
diskSize int64
)
if info, infoErr := d.Info(); infoErr == nil {
diskModTime = info.ModTime().Unix()
diskSize = info.Size()
} else {
l.logger.Debug(
"could not stat file, treating as unchanged",
"path", absoluteFilePath, "err", infoErr,
)
}
// Check if file already exists in database.
if existing, exists := existingPaths.LoadAndDelete(absoluteFilePath); exists {
audioFile := existing.(sqlcgen.AudioFile)
contentChanged := fileContentChanged(
audioFile, diskModTime, diskSize,
)
// A file with no title has never had its tags read
// (the row exists, the metadata pass did not run),
// which is the same "needs metadata" signal the
// recording_id == 0 test used to be.
if audioFile.Title == "" || contentChanged {
l.logger.Debug(
"file needs metadata update",
"path", absoluteFilePath,
"contentChanged", contentChanged,
)
select {
case workChan <- scanWork{
absolutePath: absoluteFilePath,
fileType: fileType,
existingFileID: audioFile.ID,
needsUpdate: true,
existingLength: audioFile.LengthMilliseconds,
contentChanged: contentChanged,
modTime: diskModTime,
}:
if len(stack) > 0 {
stack[len(stack)-1].expected++
}
case <-scanCtx.Done():
return scanCtx.Err()
}
return nil
}
l.logger.Debug(
"file already in library with metadata, skipping",
"path",
absoluteFilePath,
)
skipped.Add(1)
// Record the baseline for a row that lacks one so the
// next scan can detect edits. Collected here and
// flushed in one transaction after the walk rather
// than issuing an UPDATE per file.
if audioFile.ModifiedAt == 0 && diskModTime != 0 {
statBackfill = append(
statBackfill,
sqlcgen.UpdateAudioFileStatParams{
ModifiedAt: diskModTime,
FileSize: diskSize,
ID: audioFile.ID,
},
)
}
return nil
}
l.logger.Debug(
"queueing file for import",
"path", absoluteFilePath,
)
select {
case workChan <- scanWork{
absolutePath: absoluteFilePath,
fileType: fileType,
modTime: diskModTime,
}:
if len(stack) > 0 {
stack[len(stack)-1].expected++
}
case <-scanCtx.Done():
return scanCtx.Err()
}
return nil
},
)
if walkErr != nil {
metrics.addWarning(
"", "walk",
fmt.Errorf(
"problem walking library directory: %w",
walkErr,
),
)
}
// Close whatever's left on the stack, root included — the walk
// ended (normally or via cancellation) without another path
// ever coming along to trigger closeDirsNotContaining for
// these. isWithinDir treats "." (root) as containing every
// path, so closeDirsNotContaining itself can never pop it;
// unwind directly instead.
for len(stack) > 0 {
top := stack[len(stack)-1]
stack = stack[:len(stack)-1]
select {
case dirDoneChan <- dirClosed{dir: top.absDir, expected: top.expected}:
case <-scanCtx.Done():
}
}
close(dirDoneChan)
}()
// --- Thumbnail worker pool (async, decoupled from DB writer) ---
thumbChan := make(chan thumbnailWork, 100)
var thumbWg sync.WaitGroup
for range workerCount {
thumbWg.Add(1)
go func() {
defer thumbWg.Done()
for work := range thumbChan {
if err := l.generateSizedVariantsWithMetrics(
work.imgData,
work.dir,
work.hashStr,
work.metrics,
); err != nil {
l.logger.Warn(
"could not generate thumbnails",
"hash", work.hashStr,
"err", err,
)
metrics.addWarning(
"", "variant", err,
)
}
}
}()
}
// --- Progress ticker ---
// Periodically emits scan progress to the frontend. Stopped
// when the main scan phases (walk + extraction + DB writes)
// are complete, before orphan cleanup begins.
stopProgress := make(chan struct{})
go func() {
ticker := time.NewTicker(progressInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
a := added.Load()
s := skipped.Load()
u := updated.Load()
emitProgress(mkProgress(
"scanning", totalFiles,
a+s+u, a, s, u,
))
case <-stopProgress:
return
}
}
}()
// --- Phase 4: DB writer goroutine ---
var dbWg sync.WaitGroup
dbWg.Add(1)
go func() {
defer dbWg.Done()
cache := newEntityCache()
var (
batch []importResult
dbStarted bool
dbStartVal time.Time
)
flushBatch := func() {
if len(batch) == 0 {
return
}
batchStart := time.Now()
if batchErr := l.commitBatch(
batch, cache, metrics,
&added, &updated, &skipped,
thumbChan,
); batchErr != nil {
errMu.Lock()
scanErr = errors.Join(scanErr, batchErr)
errMu.Unlock()
}
metrics.BatchCommits += time.Since(batchStart)
batch = batch[:0]
}
// pending buffers extracted results by directory (keyed the
// same way GroupKey's caller derives it, filepath.Dir on the
// absolute path) until that directory's dirClosed event says
// no more are coming — see the dirClosed doc comment. Only
// then can ResolveDirectoryDiscNumbers see the whole
// directory's disc tags at once instead of each file
// guessing from its own tag alone.
pending := make(map[string][]importResult)
expected := make(map[string]int)
dirClosedSeen := make(map[string]bool)
resolveAndBatch := func(dir string) {
results := pending[dir]
delete(pending, dir)
delete(expected, dir)
delete(dirClosedSeen, dir)
if len(results) == 0 {
return
}
discs := make([]int, len(results))
for i, r := range results {
if r.tags != nil {
discs[i] = r.tags.DiscNumber
}
}
resolved := autotag.ResolveDirectoryDiscNumbers(discs)
for i := range results {
if results[i].tags != nil {
results[i].tags.DiscNumber = resolved[i]
}
// Thread library ID into each result for saveAudioFile.
results[i].libraryID = libraryID
batch = append(batch, results[i])
}
if len(batch) >= scanBatchSize {
flushBatch()
}
}
rc, dc := resultChan, dirDoneChan
for rc != nil || dc != nil {
select {
case result, ok := <-rc:
if !ok {
rc = nil
continue
}
if !dbStarted {
dbStartVal = time.Now()
dbStarted = true
}
dir := filepath.Dir(result.absolutePath)
pending[dir] = append(pending[dir], result)
if dirClosedSeen[dir] && len(pending[dir]) >= expected[dir] {
resolveAndBatch(dir)
}
case d, ok := <-dc:
if !ok {
dc = nil
continue
}
expected[d.dir] = d.expected
dirClosedSeen[d.dir] = true
if len(pending[d.dir]) >= d.expected {
resolveAndBatch(d.dir)
}
}
}
// Anything still buffered here belongs to a directory whose
// expected count was never reached — an extraction failure
// (see Phase 3: a failed file is warned-and-dropped, never
// reaching resultChan) or a dirClosed event lost to
// cancellation. Flush it anyway so no extracted file is
// silently dropped; it just resolves from whatever subset of
// the directory's disc tags actually arrived.
for dir := range pending {
resolveAndBatch(dir)
}
flushBatch()
if dbStarted {
metrics.DBWritesWallClock = time.Since(
dbStartVal,
)
}
}()
// --- Phase 3: worker pool ---
extractStart := time.Now()
g := new(errgroup.Group)
g.SetLimit(workerCount)
for work := range readaheadWork(scanCtx, workChan, diskProfile) {
g.Go(func() error {
if err := l.waitIfPaused(scanCtx); err != nil {
return err
}
result, err := l.extractAudioMetadata(
work, metrics,
)
if err != nil {
l.logger.Warn(
"failed to extract metadata",
"path", work.absolutePath,
"err", err,
)
metrics.addWarning(
work.absolutePath,
"extraction", err,
)
return nil
}
select {
case resultChan <- result:
case <-scanCtx.Done():
return scanCtx.Err()
}
return nil
})
}
_ = g.Wait()
metrics.ExtractionWallClock = time.Since(extractStart)
close(resultChan)
dbWg.Wait()
// Stop the progress ticker — main scan phases are done.
close(stopProgress)
// Emit a final "scanning" progress so the bar reaches 100%.
a := added.Load()
s := skipped.Load()
u := updated.Load()
emitProgress(mkProgress("scanning", totalFiles, a+s+u, a, s, u))
// Close thumbnail channel and wait for all thumbnail workers
// to finish. The DB writer has stopped sending work at this
// point so it is safe to close.
thumbStart := time.Now()
emitProgress(mkProgress("thumbnails", totalFiles, a+s+u, a, s, u))
close(thumbChan)
thumbWg.Wait()
metrics.ThumbnailWallClock = time.Since(thumbStart)
// Establish staleness baselines for unchanged files that lacked one.
// Safe to run even on a cancelled scan: every entry was individually
// confirmed against the file on disk during the walk.
l.flushStatBackfill(statBackfill)
// Skip orphan cleanup if the scan was cancelled — existingPaths
// still contains unvisited files that would be incorrectly deleted.
cancelled := scanCtx.Err() != nil
var removed atomic.Int64
if cancelled {
metrics.Cancelled = true
l.logger.Info("scan cancelled, skipping orphan cleanup")
} else {
// --- Phase 5: orphan cleanup ---
emitProgress(mkProgress("orphans", totalFiles, a+s+u, a, s, u))
orphanStart := time.Now()
existingPaths.Range(func(key, value any) bool {
path := key.(string)
audioFile := value.(sqlcgen.AudioFile)
l.logger.Debug(
"removing orphaned database entry",
"path", path, "id", audioFile.ID,
)
if err := l.db.Queries.DeleteAudioFile(
l.ctx, audioFile.ID,
); err != nil {
l.logger.Warn(
"failed to delete orphaned audio file",
"path", path,
"id", audioFile.ID,
"err", err,
)
metrics.addWarning(path, "orphan", err)
return true
}
// Keep the file's tagging group in sync: drop the group's
// track count and clear it out once empty, mirroring the
// bookkeeping maybeRebindTaggingGroup does for a group_key
// change. Without this, a folder whose files are removed
// and replaced leaves a stale tagging_items row behind —
// its track_count still counts the deleted files, and it
// never clears from the autotag queue.
if audioFile.GroupKey != "" {
if err := l.db.Queries.DecrementTaggingItemTrackCount(
l.ctx, audioFile.GroupKey,
); err != nil {
l.logger.Warn(
"failed to decrement tagging group for orphan",
"path", path,
"group_key", audioFile.GroupKey,
"err", err,
)
metrics.addWarning(path, "orphan", err)
} else if err := l.db.Queries.DeleteTaggingItemIfEmpty(
l.ctx, audioFile.GroupKey,
); err != nil {
l.logger.Warn(
"failed to clean up emptied tagging group for orphan",
"path", path,
"group_key", audioFile.GroupKey,
"err", err,
)
metrics.addWarning(path, "orphan", err)
}
}
// Remove from FTS5 search index.
if err := l.db.DeleteSearchIndex(
audioFile.ID,
); err != nil {
l.logger.Warn(
"failed to delete FTS entry for orphan",
"id", audioFile.ID,
"err", err,
)
metrics.addWarning(path, "orphan", err)
}
removed.Add(1)
return true
})
metrics.OrphanCleanup = time.Since(orphanStart)
// --- Phase 5b: orphaned metadata cleanup ---
// Deleting an audio_files row above doesn't cascade to the
// recording/release_group/artist_credit/artist rows it was the
// last owner of — clean those up too, so a swapped-out artist
// doesn't leave stale rows behind for the Explore index to
// keep pointing at.
l.pruneEmptyEntities()
}
// --- Phase 6: repopulate + resolve phantom playlist tracks ---
// Repopulate first: re-imports tracks for playlists that lost
// their rows (from a pre-fix FullRescan that deleted them).
if !cancelled && l.scanHooks.RepopulatePlaylists != nil {
l.scanHooks.RepopulatePlaylists()
}
// Then resolve: re-links phantom tracks to audio_files.
if !cancelled && l.scanHooks.ResolvePhantoms != nil {
l.scanHooks.ResolvePhantoms()
}
// --- Finalize ---
metrics.Added = added.Load()
metrics.Updated = updated.Load()
metrics.Skipped = skipped.Load()
metrics.Removed = removed.Load()
metrics.Total = time.Since(scanStart)
if scanErr != nil {
l.logger.Warn(
"scan completed with errors",
"err", scanErr,
)
}
l.logger.Info(
"library scan complete",
"libraryID", libraryID,
"libraryName", libraryName,
"added", metrics.Added,
"updated", metrics.Updated,
"removed", metrics.Removed,
"skipped", metrics.Skipped,
"warnings", len(metrics.Warnings),
"cancelled", cancelled,
"total", metrics.Total,
)
finishScanJob(jobHandle, metrics, cancelled)
if cancelled {
l.emit(events.LibraryScanCancelled, metrics)
} else {
l.emit(events.LibraryScanComplete, metrics)
}
return metrics
}
// progressInterval controls how often scan progress events are
// emitted to the frontend.
const progressInterval = 300 * time.Millisecond
// fileContentChanged reports whether a file on disk differs from what
// was imported, by comparing mtime and size against the recorded
// baseline. This is what catches another application retagging a file
// in place — without it the scan skips every path already in the
// database and the edit stays invisible until a full rescan.
//
// Two cases are deliberately treated as unchanged:
//
// - A recorded mtime of 0 means no baseline exists (the row predates
// migration 47). There is nothing to compare against, so reporting
// a change would re-import the entire library on first upgrade.
// - A disk mtime of 0 means the stat failed. Skipping is preferable
// to re-importing a file on no evidence.
//
// A writer that preserves mtime and lands on an identical file size
// defeats this check. That needs content hashing to catch, which costs
// a full read of every file — deliberately out of scope.
func fileContentChanged(
audioFile sqlcgen.AudioFile,
diskModTime, diskSize int64,
) bool {
if audioFile.ModifiedAt == 0 || diskModTime == 0 {
return false
}
return diskModTime != audioFile.ModifiedAt ||
diskSize != audioFile.FileSize
}
// flushStatBackfill writes mtime/size baselines for files the scan
// skipped but that had no baseline recorded. Failures are logged and
// not fatal — a missing baseline only means the file is re-checked on
// the next scan.
func (l *Library) flushStatBackfill(
entries []sqlcgen.UpdateAudioFileStatParams,
) {
if len(entries) == 0 {
return
}
tx, err := l.db.BeginTx()
if err != nil {
l.logger.Warn(
"could not begin stat backfill transaction",
"count", len(entries), "err", err,
)
return
}
defer func() { _ = tx.Rollback() }() // no-op after commit
txq := l.db.Queries.WithTx(tx)
for _, e := range entries {
if updErr := txq.UpdateAudioFileStat(l.ctx, e); updErr != nil {
l.logger.Warn(
"could not backfill file stat",
"audioFileID", e.ID, "err", updErr,
)
}
}
if err := tx.Commit(); err != nil {
l.logger.Warn(
"could not commit stat backfill",
"count", len(entries), "err", err,
)
return
}
l.logger.Info(
"recorded staleness baselines for existing files",
"count", len(entries),
)
}
// pruneEmptyEntities removes albums and artists left with nothing
// pointing at them.
//
// This used to be four sweeps in dependency order - recordings and
// their two link tables, then release groups with no recordings, then
// artist credits, then artists - because deleting an audio_files row
// cascaded to none of them. Two of those tables are gone and the third
// (file_genres) cascades, so what is left is the two tables that
// genuinely outlive a file: an album whose last track was removed, and
// an artist whose last album was.
//
// Best effort: logs and continues rather than failing the scan.
func (l *Library) pruneEmptyEntities() {
tx, err := l.db.BeginTx()
if err != nil {
l.logger.Warn("could not begin entity cleanup transaction", "err", err)
return
}
defer func() { _ = tx.Rollback() }() // no-op after commit
txq := l.db.Queries.WithTx(tx)
albumIDs, err := txq.GetEmptyAlbumIDs(l.ctx)
if err != nil {
l.logger.Warn("could not find empty albums", "err", err)
return
}
for _, id := range albumIDs {
if err := txq.DeleteAlbum(l.ctx, id); err != nil {
l.logger.Warn("could not delete empty album", "id", id, "err", err)
}
}
// Artists after albums: an artist is unreferenced only once the
// albums pointing at it are gone.
artistIDs, err := txq.GetUnreferencedArtistIDs(l.ctx)
if err != nil {
l.logger.Warn("could not find unreferenced artists", "err", err)
return
}
for _, id := range artistIDs {
if err := txq.DeleteArtist(l.ctx, id); err != nil {
l.logger.Warn("could not delete unreferenced artist", "id", id, "err", err)
}
}
genreIDs, err := txq.GetUnusedGenreIDs(l.ctx)
if err != nil {
l.logger.Warn("could not find unused genres", "err", err)
return
}
for _, id := range genreIDs {
if err := txq.DeleteGenre(l.ctx, id); err != nil {
l.logger.Warn("could not delete unused genre", "id", id, "err", err)
}
}
if err := tx.Commit(); err != nil {
l.logger.Warn("could not commit entity cleanup", "err", err)
return
}
if len(albumIDs) > 0 || len(artistIDs) > 0 || len(genreIDs) > 0 {
l.logger.Info("pruned empty library entities",
"albums", len(albumIDs),
"artists", len(artistIDs),
"genres", len(genreIDs),
)
}
}
// countAudioFiles performs a fast walk of the library directory,
// counting only files with supported audio extensions. No per-file
// I/O is performed — this reads only directory entries.
func countAudioFiles(basePath string, excluded map[string]struct{}) int64 {
var count int64
_ = fs.WalkDir(
os.DirFS(basePath), ".",
func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
ext := filepath.Ext(d.Name())
if _, ok := metadata.GetSupportedFileType(ext); !ok {
return nil
}
if isExcluded(excluded, filepath.Join(basePath, path)) {
return nil
}
count++
return nil
},
)
return count
}
// surveyAudioFiles walks the library directory and returns both the
// number of supported audio files and the newest mtime among them
// (Unix seconds). The soft scan compares both against the database:
// the count catches added and removed files, the mtime catches files
// another application edited in place.
//
// Unlike countAudioFiles this stats every entry, so it is the more
// expensive of the two walks. Only the startup soft scan uses it —
// the in-scan progress total does not need mtimes.
//
// Both walks take the library's excluded paths and skip them, because
// both answer "how many files would a scan import", not "how many
// files are there".
func surveyAudioFiles(
basePath string,
excluded map[string]struct{},
) (count, maxModTime int64) {
_ = fs.WalkDir(
os.DirFS(basePath), ".",
func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
ext := filepath.Ext(d.Name())
if _, ok := metadata.GetSupportedFileType(ext); !ok {
return nil
}
// An excluded path is not a file this scan would import,
// so it must not be counted: the soft scan compares this
// count against the database's, and a permanent
// disagreement queues a full scan on every launch.
if isExcluded(excluded, filepath.Join(basePath, path)) {
return nil
}
count++
info, infoErr := d.Info()
if infoErr != nil {
return nil
}
if mt := info.ModTime().Unix(); mt > maxModTime {
maxModTime = mt
}
return nil
},
)
return count, maxModTime
}
// How many extraction workers a spinning disk gets, and why it is two
// numbers rather than one.
//
// Extraction is not CPU work — every parser here reads headers and
// returns — so on a spinning disk the whole cost is seek latency, and
// the only question worth asking is how many reads should be in flight
// at once. That has two different right answers and the drive says
// which:
//
// - A drive with command queueing (NCQ: /sys/block/<dev>/device/
// queue_depth reports 31 or 32 on any SATA disk with it enabled)
// reorders outstanding reads into the order its head passes over
// them. Handing it several at once is most of why a parallel scan
// beats a serial one at all, and four is where the returns flatten:
// the drive needs a few requests to have anything to reorder, and
// past that it is queueing requests it was already going to
// service in that order.
// - A drive without it — queue_depth 1, which is what a USB bridge
// or a pre-2004 disk reports — services one command at a time in
// the order given. Every extra worker there is one more seek
// competing for one head, and the scan gets *slower* the harder it
// is pushed. Two is kept rather than one because the readahead
// hints (see readaheadWork) do the overlapping that concurrency
// was standing in for, and one worker cannot hide a stall.
//
// This used to be a flat 2 for anything rotational, which is a
// pre-NCQ assumption: it left a modern spinning disk with a quarter of
// the queue depth it can use.
const (
hddWorkerCountQueued = 4
hddWorkerCountSerial = 2
)
// Readahead tuning.
const (
// readaheadDepth is how many files ahead of the workers the
// prefetcher runs. It is the channel's buffer, so it is also the
// number of `WILLNEED` hints outstanding at once — comfortably more
// than a queueing drive's 32-command window is worth filling with
// one library, and small enough that a cancelled scan is not
// holding a long tail of queued reads.
readaheadDepth = 16
// readaheadBytes is how much of each file to pull in. Everything
// the scanner reads lives at the head: ID3v2 and FLAC's
// STREAMINFO/VORBIS_COMMENT/PICTURE blocks, and the first MPEG
// frame with its Xing header. 512 KB covers a tag carrying
// embedded cover art, which is the large case — and reading a
// little too much sequentially costs a spinning disk almost
// nothing next to the seek that got there.
readaheadBytes = 512 << 10
)
// readaheadWork forwards scan work while asking the kernel to fetch
// each file's header before a worker reaches it.
//
// The buffered channel *is* the lookahead: this goroutine runs ahead
// of the workers until the buffer fills, hinting every file as it goes,
// so by the time a worker takes an item the read it needs has been in
// flight for `readaheadDepth` files' worth of parsing. That is the
// only thing that helps a spinning disk here, because the per-file work
// is already header-only — every parser in `backend/metadata` reads a
// few hundred bytes and returns, so the scan is not waiting on CPU or
// on bytes, it is waiting on the head to arrive.
//
// It runs on rotational disks only. An SSD has no seek to hide and
// already has one worker per core; issuing hints there is pure syscall
// overhead against an OS readahead that is already ahead of us.
func readaheadWork(
ctx context.Context,
in <-chan scanWork,
profile system.DiskProfile,
) <-chan scanWork {
if !profile.Rotational {
return in
}
out := make(chan scanWork, readaheadDepth)
go func() {
defer close(out)
for work := range in {
hintReadahead(work.absolutePath, readaheadBytes)
select {
case out <- work:
case <-ctx.Done():
return
}
}
}()
return out
}
// resolveScanWorkerCount returns the number of concurrent
// extraction workers based on the configured concurrency mode
// and the storage type of the library directory.
func resolveScanWorkerCount(
mode ScanConcurrency,
libraryPath string,
) int {
return workersForProfile(
mode,
system.ProfileForPath(libraryPath),
goruntime.NumCPU(),
)
}
// workersForProfile is the policy on its own, so it can be tested
// against drives this machine does not have.
//
// `hdd` and `ssd` override what the device says rather than being a
// separate branch: the mode is the user overruling detection, and
// detection is right about the queue depth either way — a user who
// picks `hdd` on a queueing drive still wants that drive's queue used.
func workersForProfile(
mode ScanConcurrency,
profile system.DiskProfile,
cpus int,
) int {
spinning := profile.Rotational
switch mode {
case ScanConcurrencySSD:
spinning = false
case ScanConcurrencyHDD:
spinning = true
case ScanConcurrencyAuto:
}
if !spinning {
return cpus
}
workers := hddWorkerCountSerial
if profile.Queues() {
workers = hddWorkerCountQueued
}
return min(workers, cpus)
}
// scanWork represents a file to be processed by a worker.
type scanWork struct {
absolutePath string
fileType metadata.AudioFileExtension
existingFileID int64 // non-zero if this is an update
needsUpdate bool
existingLength int64 // existing length if updating
// contentChanged marks a file whose bytes differ from what was
// imported (mtime/size mismatch), as opposed to one merely missing
// its metadata link. The audio itself may have changed, so cached
// values like duration cannot be reused.
contentChanged bool
// modTime is the file's mtime (Unix seconds) observed during the
// walk, stored as the new staleness baseline.
modTime int64
}
// importResult holds metadata extracted by workers, ready for DB insertion.
type importResult struct {
absolutePath string
fileType metadata.AudioFileExtension
lengthMillis int64
tags *metadata.TrackMetadata
audioProps *metadata.AudioProperties
existingFileID int64 // non-zero if this is an update
needsUpdate bool
libraryID int64 // library this file belongs to
modTime int64 // mtime baseline to persist (Unix seconds)
}
// dirClosed reports that the walk has fully enumerated a directory's
// audio files and will never enqueue another scanWork for it — expected
// is exactly how many scanWork items were sent for it. The DB writer
// uses this to know when it has every file it's going to get for that
// directory, so it can resolve disc-number consensus across the whole
// directory (autotag.ResolveDirectoryDiscNumbers) instead of each file
// guessing in isolation.
type dirClosed struct {
dir string
expected int
}
// openDir is one frame of the walk goroutine's directory stack — see
// isWithinDir and its use in scanInternal's walk phase. relPath is
// the fs.WalkDir-relative path (slash-separated, root as "."), used
// only to detect when the walk has moved on to something outside this
// directory; absDir is the OS-native absolute path, which is what
// dirClosed reports and what the DB writer's importResult.absolutePath
// values key against via filepath.Dir.
type openDir struct {
relPath string
absDir string
expected int
}
// isWithinDir reports whether the fs.WalkDir-relative path is dir
// itself or something inside it. dir == "." (the library root) is
// always within, since fs.WalkDir's root path is "." and nothing on
// this stack can ever be outside the tree being walked.
func isWithinDir(path, dir string) bool {
if dir == "." {
return true
}
return path == dir || strings.HasPrefix(path, dir+"/")
}
// extractAudioMetadata reads and extracts metadata from an audio file.
// It opens the file once, extracting both tags and duration in a
// single pass, and records per-file timing in the shared metrics.
func (l *Library) extractAudioMetadata(
work scanWork,
metrics *ScanMetrics,
) (importResult, error) {
result := importResult{
absolutePath: work.absolutePath,
fileType: work.fileType,
existingFileID: work.existingFileID,
needsUpdate: work.needsUpdate,
modTime: work.modTime,
}
// Skip duration decode if we already have it from a previous import.
// A file whose bytes changed is decoded again — a re-encode or a
// replaced file can have a different duration than the one on record.
skipDuration := work.needsUpdate &&
work.existingLength > 0 &&
!work.contentChanged
tags, lengthMillis, audioProps, timing, err := metadata.ExtractAllMetadata(
work.absolutePath, skipDuration,
)
if timing != nil {
metrics.addExtraction(
string(work.fileType),
timing.TagExtraction,
timing.DurationExtraction,
)
}
if err != nil {
return result, fmt.Errorf(
"could not extract metadata for %s: %w",
work.absolutePath,
err,
)
}
// A degraded tag read is reported but never fatal — the track is
// imported either way, falling back to the filename if the tag
// yielded nothing.
if tags.TagReadWarning != nil {
l.logger.Warn(
"degraded tag read",
"path", work.absolutePath,
"err", tags.TagReadWarning,
)
metrics.addWarning(work.absolutePath, "tags", tags.TagReadWarning)
}
result.tags = tags
result.audioProps = audioProps
if skipDuration {
result.lengthMillis = work.existingLength
} else {
result.lengthMillis = lengthMillis
}
return result, nil
}
// commitBatch wraps a slice of import results in a single database
// transaction, creating all related records and audio file entries.
// Individual file failures are logged and accumulated but do not
// abort the entire batch. thumbChan dispatches thumbnail generation
// to the async worker pool.
func (l *Library) commitBatch(
batch []importResult,
cache *entityCache,
metrics *ScanMetrics,
added, updated, skipped *atomic.Int64,
thumbChan chan<- thumbnailWork,
) error {
tx, err := l.db.BeginTx()
if err != nil {
return fmt.Errorf("could not begin transaction: %w", err)
}
txq := l.db.Queries.WithTx(tx)
for i := range batch {
result := &batch[i]
var saveErr error
if result.needsUpdate {
saveErr = l.updateAudioFileMetadata(
txq, tx, cache, metrics, *result,
thumbChan,
)
if saveErr == nil {
updated.Add(1)
}
} else {
saveErr = l.saveAudioFile(
txq, tx, cache, metrics, *result,
thumbChan,
)
if saveErr == nil {
added.Add(1)
}
}
if saveErr != nil {
l.logger.Debug(
"failed to save audio file",
"path", result.absolutePath,
"err", saveErr,
)
metrics.addWarning(
result.absolutePath, "commit", saveErr,
)
// Count failed saves as skipped so the progress bar
// advances (e.g. UNIQUE constraint from pre-existing tracks).
skipped.Add(1)
}
}
if commitErr := tx.Commit(); commitErr != nil {
return fmt.Errorf(
"could not commit batch of %d files: %w",
len(batch), commitErr,
)
}
return nil
}
// saveAudioFile writes audio file metadata to the database (new files).
func (l *Library) saveAudioFile(
q *sqlcgen.Queries,
tx *sql.Tx,
cache *entityCache,
metrics *ScanMetrics,
result importResult,
thumbChan chan<- thumbnailWork,
) error {
l.logger.Debug(
"saving audio file to db",
"absolute-path", result.absolutePath,
"track-length-millis", result.lengthMillis,
"file-type", int64(
slices.Index(
metadata.SupportedFileExtensions,
result.fileType,
),
),
)
// Resolve the rows this file shares with others: its artist and
// its album. Everything else about it is a column on the file.
entities := l.resolveTagEntities(q, cache, metrics, result, thumbChan)
props := result.audioProps
if props == nil {
props = &metadata.AudioProperties{}
}
tags := result.tags
if tags == nil {
tags = &metadata.TrackMetadata{}
}
groupKey := autotag.GroupKey(
result.libraryID,
result.absolutePath,
tags.DiscNumber,
)
tagStatus := "untagged"
if tags.RecordingMBID != "" {
tagStatus = "user_confirmed"
}
artistCredit := tags.Artist
if artistCredit == "" {
artistCredit = "Unknown Artist"
}
title := l.getRecordingName(tags, result.absolutePath)
af, err := q.CreateAudioFile(
l.ctx, sqlcgen.CreateAudioFileParams{
FilePath: result.absolutePath,
LibraryID: result.libraryID,
FileTypeID: int64(
slices.Index(
metadata.SupportedFileExtensions,
result.fileType,
),
),
LengthMilliseconds: result.lengthMillis,
SampleRate: int64(props.SampleRate),
BitDepth: int64(props.BitDepth),
Channels: int64(props.Channels),
Bitrate: int64(props.Bitrate),
FileSize: props.FileSize,
Title: title,
ArtistCredit: artistCredit,
ArtistID: entities.artistID,
AlbumID: entities.albumID,
TrackNumber: toNullInt64(tags.TrackNumber),
DiscNumber: toNullInt64(tags.DiscNumber),
TotalTracks: toNullInt64(tags.TotalTracks),
Year: toNullInt64(tags.Year),
Composer: tags.Composer,
Comment: tags.Comment,
RecordingMbid: toNullString(tags.RecordingMBID),
Basename: filepath.Base(result.absolutePath),
GroupKey: groupKey,
ModifiedAt: result.modTime,
TagStatus: tagStatus,
})
if err != nil {
return fmt.Errorf(
"could not save audio file to db: %w", err,
)
}
l.linkFileGenres(q, cache, tags.Genre, af.ID)
if err := q.UpsertTaggingItemOnTrackAdd(
l.ctx, sqlcgen.UpsertTaggingItemOnTrackAddParams{
GroupKey: groupKey,
LibraryID: result.libraryID,
AlbumName: tags.Album,
AlbumArtist: tags.AlbumArtist,
DiscNumber: int64(tags.DiscNumber),
},
); err != nil {
l.logger.Warn(
"could not upsert tagging_items row",
"path", result.absolutePath,
"err", err,
)
metrics.addWarning(result.absolutePath, "commit", err)
}
// Index in FTS5 search_index.
// SAFETY: FTS5 virtual table, see search.go:InsertSearchIndex. All values parameterized.
if _, err := tx.ExecContext(
l.ctx,
`INSERT INTO search_index(rowid, file_path, title, artist, album)
VALUES (?, ?, ?, ?, ?)`,
af.ID, result.absolutePath, title, artistCredit, tags.Album,
); err != nil {
l.logger.Warn(
"could not index audio file in FTS",
"path", result.absolutePath,
"err", err,
)
metrics.addWarning(result.absolutePath, "commit", err)
}
l.logger.Debug(
"added audio file to library",
"path", result.absolutePath,
)
return nil
}
// updateAudioFileMetadata updates an existing audio file with extracted metadata.
func (l *Library) updateAudioFileMetadata(
q *sqlcgen.Queries,
tx *sql.Tx,
cache *entityCache,
metrics *ScanMetrics,
result importResult,
thumbChan chan<- thumbnailWork,
) error {
l.logger.Debug(
"updating audio file metadata",
"absolute-path", result.absolutePath,
"file-id", result.existingFileID,
)
tags := result.tags
if tags == nil {
tags = &metadata.TrackMetadata{}
}
// Resolve the file's artist and album from the tags as they are
// now. This used to create a *new* recording row and repoint the
// file at it, abandoning the old one - which is where 812 orphaned
// rows and every phantom "you own this" in a real library came
// from. The file's tags are its own columns, so a retag is an
// UPDATE and there is nothing left behind to strand.
entities := l.resolveTagEntities(q, cache, metrics, result, thumbChan)
props := result.audioProps
if props == nil {
props = &metadata.AudioProperties{}
}
artistCredit := tags.Artist
if artistCredit == "" {
artistCredit = "Unknown Artist"
}
if err := q.UpdateAudioFileTags(
l.ctx, sqlcgen.UpdateAudioFileTagsParams{
Title: l.getRecordingName(tags, result.absolutePath),
ArtistCredit: artistCredit,
ArtistID: entities.artistID,
AlbumID: entities.albumID,
TrackNumber: toNullInt64(tags.TrackNumber),
DiscNumber: toNullInt64(tags.DiscNumber),
TotalTracks: toNullInt64(tags.TotalTracks),
Year: toNullInt64(tags.Year),
Composer: tags.Composer,
Comment: tags.Comment,
RecordingMbid: toNullString(tags.RecordingMBID),
SampleRate: int64(props.SampleRate),
BitDepth: int64(props.BitDepth),
Channels: int64(props.Channels),
Bitrate: int64(props.Bitrate),
FileSize: props.FileSize,
LengthMilliseconds: result.lengthMillis,
ModifiedAt: result.modTime,
ID: result.existingFileID,
}); err != nil {
return fmt.Errorf(
"could not update audio file tags: %w", err,
)
}
// Genres are relinked wholesale: the tag is the whole truth about
// which genres a file carries, so a genre dropped from the tag has
// to be dropped from the link table too.
if err := q.DeleteFileGenres(l.ctx, result.existingFileID); err != nil {
l.logger.Warn("could not clear file genres",
"path", result.absolutePath, "err", err)
}
l.linkFileGenres(q, cache, tags.Genre, result.existingFileID)
// Re-index in FTS5 search_index.
// With contentless_delete=1 (migration 8), DeleteSearchIndex
// now works for individual row removal. For scan updates we
// still do delete + reinsert; Phase 16 will use the same
// pattern for inline tag edits.
// A file another tagger stamped with MBIDs since import is only
// discovered here — the insert path is what sets tag_status, so
// without this the file stays 'untagged' forever and its folder
// keeps asking to be tagged.
if tags.RecordingMBID != "" {
if err := q.PromoteAudioFileTagStatusIfUntagged(
l.ctx, result.existingFileID,
); err != nil {
l.logger.Warn(
"could not promote tag status after metadata update",
"path", result.absolutePath,
"err", err,
)
}
}
if err := l.maybeRebindTaggingGroup(q, result, tags); err != nil {
l.logger.Warn(
"could not rebind tagging group after metadata update",
"path", result.absolutePath,
"err", err,
)
metrics.addWarning(result.absolutePath, "commit", err)
}
title := l.getRecordingName(tags, result.absolutePath)
artistName := tags.Artist
if artistName == "" {
artistName = "Unknown Artist"
}
album := tags.Album
// SAFETY: FTS5 virtual table, see search.go:InsertSearchIndex. All values parameterized.
if _, err := tx.ExecContext(
l.ctx,
`INSERT INTO search_index(rowid, file_path, title, artist, album)
VALUES (?, ?, ?, ?, ?)`,
result.existingFileID,
result.absolutePath,
title,
artistName,
album,
); err != nil {
l.logger.Warn(
"could not index updated audio file in FTS",
"path", result.absolutePath,
"err", err,
)
metrics.addWarning(result.absolutePath, "commit", err)
}
l.logger.Debug(
"updated audio file metadata",
"path", result.absolutePath,
)
return nil
}
// trackEntities are the shared rows a file's tags resolve to: the
// artist and album it belongs to, and the cover art of that album.
//
// This replaced processMetadata, which created a `recordings` row per
// file plus an artist_credit, an artist_credit_artist link and a
// release_group_recordings link, then wrote MBIDs onto three of them
// with raw SQL. A file's tags are columns on the file now, so the only
// rows that still have to be *shared* are the two that genuinely are:
// the album several files belong to, and the artist several albums do.
type trackEntities struct {
artistID sql.NullInt64
albumID sql.NullInt64
}
// resolveTagEntities upserts the artist and album a file's tags name,
// and returns their ids for the file row.
func (l *Library) resolveTagEntities(
q *sqlcgen.Queries,
cache *entityCache,
metrics *ScanMetrics,
result importResult,
thumbChan chan<- thumbnailWork,
) trackEntities {
tags := result.tags
if tags == nil {
tags = &metadata.TrackMetadata{}
}
coverArtID := l.processCoverArt(q, cache, metrics, tags, thumbChan)
// The track artist. primaryArtist collapses a featured-artist
// credit to the artist the MBIDs actually identify, so "A feat. B"
// does not fork into its own artist row sharing A's MBID.
primaryName, primaryMBID := primaryArtist(tags)
artist := l.cachedUpsertArtist(q, cache, primaryName, primaryMBID)
entities := trackEntities{}
if artist.ID > 0 {
entities.artistID = sql.NullInt64{Int64: artist.ID, Valid: true}
}
if tags.Album == "" {
return entities
}
// The album artist, which is the track artist unless the tags say
// otherwise.
albumCredit := tags.AlbumArtist
albumArtistID := entities.artistID
if albumCredit == "" || albumCredit == tags.Artist {
albumCredit = tags.Artist
} else {
albumArtist := l.cachedUpsertArtist(q, cache, albumCredit, tags.AlbumArtistMBID)
if albumArtist.ID > 0 {
albumArtistID = sql.NullInt64{Int64: albumArtist.ID, Valid: true}
}
}
album := l.cachedUpsertAlbum(q, cache, albumParams{
name: tags.Album,
credit: albumCredit,
artistID: albumArtistID,
year: toNullInt64(tags.Year),
coverArtID: coverArtID,
})
if album.ID == 0 {
return entities
}
entities.albumID = sql.NullInt64{Int64: album.ID, Valid: true}
l.stampAlbumMBID(q, cache, album, tags)
return entities
}
// albumParams is what an album upsert needs from a file's tags.
type albumParams struct {
name string
credit string
artistID sql.NullInt64
year sql.NullInt64
coverArtID sql.NullInt64
}
// cachedUpsertArtist returns the artist row for a name, upserting it
// once per scan. The MBID is written on the way in rather than by a
// separate UPDATE afterwards.
func (l *Library) cachedUpsertArtist(
q *sqlcgen.Queries,
cache *entityCache,
name, mbid string,
) sqlcgen.Artist {
if name == "" {
name = "Unknown Artist"
}
if cached, ok := cache.artists[name]; ok {
if mbid != "" && !cached.Mbid.Valid {
if err := q.SetArtistMBID(l.ctx, sqlcgen.SetArtistMBIDParams{
Mbid: sql.NullString{String: mbid, Valid: true},
ID: cached.ID,
}); err == nil {
cached.Mbid = sql.NullString{String: mbid, Valid: true}
cache.artists[name] = cached
}
}
return cached
}
artist, err := q.UpsertArtist(l.ctx, sqlcgen.UpsertArtistParams{
Name: name,
Mbid: toNullString(mbid),
})
if err != nil {
l.logger.Warn("could not upsert artist", "artist", name, "err", err)
return sqlcgen.Artist{}
}
cache.artists[name] = artist
return artist
}
// cachedUpsertAlbum returns the album row for (name, credit), upserting
// it once per scan and filling in cover art the first time a file
// carries some.
func (l *Library) cachedUpsertAlbum(
q *sqlcgen.Queries,
cache *entityCache,
p albumParams,
) sqlcgen.Album {
// The key is the album's identity - name and credit - so two
// albums of the same name by different artists do not collide.
cacheKey := p.name + "\x00" + p.credit
if cached, ok := cache.albums[cacheKey]; ok {
if p.coverArtID.Valid && !cached.CoverArtID.Valid {
if err := q.SetAlbumCoverArt(l.ctx, sqlcgen.SetAlbumCoverArtParams{
CoverArtID: p.coverArtID,
ID: cached.ID,
}); err != nil {
l.logger.Warn("could not update album cover art", "err", err)
} else {
cached.CoverArtID = p.coverArtID
cache.albums[cacheKey] = cached
}
}
return cached
}
album, err := q.UpsertAlbum(l.ctx, sqlcgen.UpsertAlbumParams{
Name: p.name,
ArtistCredit: p.credit,
ArtistID: p.artistID,
Year: p.year,
CoverArtID: p.coverArtID,
})
if err != nil {
l.logger.Warn("could not upsert album", "album", p.name, "err", err)
return sqlcgen.Album{}
}
cache.albums[cacheKey] = album
return album
}
// stampAlbumMBID writes the album's MusicBrainz identity from the tags.
//
// Many taggers write MUSICBRAINZ_ALBUMID (a specific release) but not
// MUSICBRAINZ_RELEASEGROUPID (the abstract release group everything
// else is keyed by) - without the second branch, a genuinely MBID-
// tagged album shows as "library only" forever. A scan cannot afford a
// live MusicBrainz call to resolve release -> release group, so the
// release MBID is stashed for BackfillReleaseGroupMBIDs to resolve in
// the background.
func (l *Library) stampAlbumMBID(
q *sqlcgen.Queries,
cache *entityCache,
album sqlcgen.Album,
tags *metadata.TrackMetadata,
) {
if album.Mbid.Valid && album.Mbid.String != "" {
return
}
switch {
case tags.ReleaseGroupMBID != "":
if err := q.SetAlbumMBID(l.ctx, sqlcgen.SetAlbumMBIDParams{
Mbid: sql.NullString{String: tags.ReleaseGroupMBID, Valid: true},
ID: album.ID,
}); err != nil {
l.logger.Warn("could not set album mbid", "err", err)
return
}
album.Mbid = sql.NullString{String: tags.ReleaseGroupMBID, Valid: true}
cache.albums[album.Name+"\x00"+album.ArtistCredit] = album
case tags.ReleaseMBID != "" && !album.PendingReleaseMbid.Valid:
if err := q.SetAlbumPendingReleaseMBID(
l.ctx, sqlcgen.SetAlbumPendingReleaseMBIDParams{
PendingReleaseMbid: sql.NullString{String: tags.ReleaseMBID, Valid: true},
ID: album.ID,
},
); err != nil {
l.logger.Warn("could not set pending release mbid", "err", err)
}
}
}
// linkFileGenres parses the raw genre string and links the file to each
// genre it names.
func (l *Library) linkFileGenres(
q *sqlcgen.Queries,
cache *entityCache,
rawGenre string,
audioFileID int64,
) {
for _, name := range metadata.ParseGenres(rawGenre) {
genre, err := l.cachedUpsertGenre(q, cache, name)
if err != nil {
l.logger.Warn("could not upsert genre", "genre", name, "err", err)
continue
}
if err := q.LinkFileGenre(l.ctx, sqlcgen.LinkFileGenreParams{
AudioFileID: audioFileID,
GenreID: genre.ID,
}); err != nil {
l.logger.Warn("could not link file to genre",
"genre", name, "audioFileID", audioFileID, "err", err)
}
}
}
// maybeRebindTaggingGroup recomputes the group key from the freshly
// extracted metadata and, if it differs from the row's current
// group_key, migrates the track: decrement the old group's count
// (dropping it if emptied), upsert the new group, and write the new
// key onto the audio_files row. A no-op when the key is unchanged.
func (l *Library) maybeRebindTaggingGroup(
q *sqlcgen.Queries,
result importResult,
tags *metadata.TrackMetadata,
) error {
newKey := autotag.GroupKey(
result.libraryID,
result.absolutePath,
tags.DiscNumber,
)
oldKey, err := q.GetAudioFileGroupKey(l.ctx, result.existingFileID)
if err != nil {
return fmt.Errorf("read existing group_key: %w", err)
}
if oldKey == newKey {
return nil
}
if oldKey != "" {
if err := q.DecrementTaggingItemTrackCount(l.ctx, oldKey); err != nil {
return fmt.Errorf("decrement old group: %w", err)
}
if err := q.DeleteTaggingItemIfEmpty(l.ctx, oldKey); err != nil {
return fmt.Errorf("cleanup old group: %w", err)
}
}
if err := q.UpsertTaggingItemOnTrackAdd(
l.ctx, sqlcgen.UpsertTaggingItemOnTrackAddParams{
GroupKey: newKey,
LibraryID: result.libraryID,
AlbumName: tags.Album,
AlbumArtist: tags.AlbumArtist,
DiscNumber: int64(tags.DiscNumber),
},
); err != nil {
return fmt.Errorf("upsert new group: %w", err)
}
if err := q.SetAudioFileGroupKey(
l.ctx, sqlcgen.SetAudioFileGroupKeyParams{
GroupKey: newKey,
ID: result.existingFileID,
},
); err != nil {
return fmt.Errorf("write new group_key: %w", err)
}
return nil
}
// processCoverArt saves cover art to disk and upserts the DB record,
// using the cache to skip work for previously seen images. When
// thumbChan is non-nil, thumbnail generation is dispatched to the
// async worker pool.
func (l *Library) processCoverArt(
q *sqlcgen.Queries,
cache *entityCache,
metrics *ScanMetrics,
tags *metadata.TrackMetadata,
thumbChan chan<- thumbnailWork,
) sql.NullInt64 {
if tags.Picture == nil {
return sql.NullInt64{}
}
coverPath, err := l.saveCoverArt(
tags.Picture, metrics, thumbChan,
)
if err != nil {
l.logger.Warn("could not save cover art", "err", err)
return sql.NullInt64{}
}
if coverPath == "" {
return sql.NullInt64{}
}
// Check cache first.
if cached, ok := cache.coverArt[coverPath]; ok {
return sql.NullInt64{Int64: cached.ID, Valid: true}
}
ca, err := q.UpsertCoverArt(l.ctx, sqlcgen.UpsertCoverArtParams{
IsEmbedded: true,
FilePath: coverPath,
MimeType: tags.Picture.MIMEType,
})
if err != nil {
l.logger.Warn(
"could not create cover art record", "err", err,
)
return sql.NullInt64{}
}
cache.coverArt[coverPath] = ca
return sql.NullInt64{Int64: ca.ID, Valid: true}
}
// cachedUpsertGenre returns the genre for the given name, using
// the cache when possible.
func (l *Library) cachedUpsertGenre(
q *sqlcgen.Queries,
cache *entityCache,
name string,
) (sqlcgen.Genre, error) {
if cached, ok := cache.genres[name]; ok {
return cached, nil
}
genre, err := q.UpsertGenre(l.ctx, name)
if err != nil {
return sqlcgen.Genre{}, err
}
cache.genres[name] = genre
return genre, nil
}
// getRecordingName returns the track title, or falls back to the filename.
func (l *Library) getRecordingName(tags *metadata.TrackMetadata, filePath string) string {
if tags.Title != "" {
return tags.Title
}
// Fallback to filename without extension
base := filepath.Base(filePath)
return strings.TrimSuffix(base, filepath.Ext(base))
}
// toNullInt64 converts an int to sql.NullInt64, treating 0 as null.
func toNullInt64(v int) sql.NullInt64 {
if v == 0 {
return sql.NullInt64{}
}
return sql.NullInt64{Int64: int64(v), Valid: true}
}
// toNullString converts a string to sql.NullString, treating empty as null.
func toNullString(v string) sql.NullString {
if v == "" {
return sql.NullString{}
}
return sql.NullString{String: v, Valid: true}
}