Files
yellowjacket/backend/library/library.go
T
yonluandClaude Opus 5 e7748f1fd5
CI / check (push) Successful in 3m7s
CI / e2e (push) Canceled after 1m45s
feat(database): shape the library like files, and shrink the catalog
Plans 013 and 014, the album page that prompted them, and the smaller
fixes they turned up. Changelog, largest first.

## The local library is shaped like files, not like MusicBrainz

`audio_files` carries its own tags and points at `albums` and
`artists`; `file_genres` is the one real many-to-many. `recordings`,
`release_group_recordings`, `artist_credit`, `artist_credit_artist`,
`recording_genres`, `release_groups` and `release_to_rg` are gone from
the local side, and with them a six-way join in every read, a
`MIN(release_group_id)` subquery in eleven queries and a
first-credited-artist subquery in nine. Measured on a real 25,966-file
library, every many-to-many that model expressed was 1:1 in the data.

- Ownership is a file. `GetFilePathsByRecordingMBIDs`,
  `LibraryMBIDIndex.CheckMBIDs`, `collectLibraryEntities` and
  `pruneStaleLocalCrossReferences` all join `audio_files`, so the 812
  orphaned recordings, 216 release groups and 260 artists that library
  carried are now structurally impossible.
- One projection: every track query selects from the `track_metadata`
  view, one row type, one mapper. Nine hand-rolled copies had drifted
  far enough to report different years on different screens.
- `library_id = 0` means every library, so each list query exists once
  instead of scoped and unscoped with a branch at every call site.
- No migration chain. `sql/schemas/` is the one description of the
  shape; `sql/migrations/`, `applyMigrations` and `schema_migrations`
  are squashed away, along with the drift between them that had sqlc
  generating against a stale schema.
- `database.InsertTestTrack` is the one test seeder; twenty test files
  had been assembling the old FK chain each in its own order.

## The catalog stores its ids as bytes

`explore_index`'s three 36-char MBID columns and its entity-type text
are 16 raw bytes and a small integer. The table and its six indexes go
780 MB to 405 MB on a real 2,052,200-row catalog, which is why a fresh
install is ~0.6 GB rather than ~1.0 GB.

- `backend/explore/mbid.go` is the only place the encoding is known;
  everything above it speaks dashed strings.
- `CHECK(length(mbid) = 16)` makes a stringly write fail at the insert
  rather than silently returning no rows, since SQLite does not coerce
  between TEXT and BLOB.
- The importer asks the artifact what encoding it carries and converts
  on the way in, so the artifact already published keeps working and no
  format bump is needed.
- `indexRowColumns`/`scanIndexRow` replace four copies of a 22-column
  list, and `TestStoredEncodingRoundTrips` sweeps every read path.

## An album page that says how much of the album is yours

- One question, asked once: is there a file. `filePaths` is filled by a
  single batched lookup when the tracklist settles, and the badge, the
  Play count, the dimmed rows and every menu item read it — replacing
  four claims of decreasing confidence that could show a green tick on
  an album whose every action did nothing.
- Play, Play 7 of 12, or no play button at all.
- `total_tracks` on `explore_index` (~2 bytes over 400,677 release
  groups) and on `audio_files` from tags that have always carried it:
  a complete MBID-matched album now makes no catalog call at all, where
  it used to spend the most expensive request the app makes.
- A merged cluster shows the running order the most releases agree on,
  and the version list marks the release you own rather than standing a
  synthetic entry in for it.
- `AlbumReleasesFailed`: a slow fetch is no longer reported as a failed
  one by a 12-second timer.
- Rows not in the library are dimmed in place (with `aria-disabled`)
  instead of the owned ones wearing a green tick and a legend.

## Caches and cover art get ceilings

- Only the three tiers of a cover are stored; the full-resolution copy
  nothing rendered was 1,134 MB of a 1.4 GB covers directory.
- One artist portrait is downloaded and the rest are remembered as
  URLs — 4.1 GB of a 5.3 GB cache was candidates no code path reads.
- `browsedArtBudget` and `httpCacheBudget` bound what an age cannot:
  the same install held art for 5,770 artists in a 1,301-artist
  library.
- `OrphanedArtistImagesJob` joined a bare MBID onto a sharded
  directory, so it deleted the rows that were the only record of the
  files it left behind. `explore.ArtistImageDir` is that layout's one
  definition now.

## The autotag queue asks whether there is work

`tagging_items` was a row per album folder, not a queue, and no query
read the `tag_status` column that held the answer. The four queue
queries ask the files, which matters most where it is least visible:
`startPrefetch` was scoring every album in a tagged library against
MusicBrainz.

## Phantom playlist tracks resolve in place

An M3U8 imported before its files leaves phantom rows; they now match
by path and fall back to position, keep their place in the playlist
when resolved, and pair best-first so two phantoms cannot claim the
same file.

## Playing a track plays the list it is in

Double-click, and Play on a single row's menu, queue the list as
displayed with `startIndex` on that row — the album page and the track
list used to queue one track and discard the album around it. A
multi-row selection still plays exactly itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh
2026-08-16 13:58:15 -04:00

2198 lines
58 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()
}()
workerCount := resolveScanWorkerCount(
ScanConcurrencyAuto,
libraryPath,
)
l.logger.Info(
"beginning library scan",
"libraryID", libraryID,
"libraryName", libraryName,
"libraryPath", libraryPath,
"workers", workerCount,
)
// 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 workChan {
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
}
// hddWorkerCount is the maximum number of concurrent extraction
// workers when the library resides on a spinning disk.
const hddWorkerCount = 2
// 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 {
switch mode {
case ScanConcurrencySSD:
return goruntime.NumCPU()
case ScanConcurrencyHDD:
return min(hddWorkerCount, goruntime.NumCPU())
default: // auto
if system.IsRotationalDisk(libraryPath) {
return min(
hddWorkerCount, goruntime.NumCPU(),
)
}
return goruntime.NumCPU()
}
}
// 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}
}