"Remove from library" deletes the audio_files row and records the path as excluded, so the next scan does not import it again. Without the exclusion the operation undoes itself on the next scan, which is worse than not having it at all; the file on disk is never touched, which is the promise the confirmation copy will make. The soft scan compares the number of audio files on disk against the number of rows, so both walks now skip excluded paths — otherwise the two counts disagree forever and every launch queues a full scan of the whole library. A full rescan clears the exclusions, which is the only way back for a path removed by mistake until there is a UI for it.
2192 lines
55 KiB
Go
2192 lines
55 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"
|
|
|
|
"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 entities so that
|
|
// 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 {
|
|
artistCredits map[string]sqlcgen.ArtistCredit
|
|
artists map[string]sqlcgen.Artist
|
|
releaseGroups map[string]sqlcgen.ReleaseGroup
|
|
coverArt map[string]sqlcgen.CoverArt
|
|
genres map[string]sqlcgen.Genre
|
|
// linkedCredits tracks artist-credit-artist links already created
|
|
// so we skip the duplicate INSERT. Key is "artistID:creditID".
|
|
linkedCredits map[string]struct{}
|
|
}
|
|
|
|
func newEntityCache() *entityCache {
|
|
return &entityCache{
|
|
artistCredits: make(map[string]sqlcgen.ArtistCredit),
|
|
artists: make(map[string]sqlcgen.Artist),
|
|
releaseGroups: make(map[string]sqlcgen.ReleaseGroup),
|
|
coverArt: make(map[string]sqlcgen.CoverArt),
|
|
genres: make(map[string]sqlcgen.Genre),
|
|
linkedCredits: make(map[string]struct{}),
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
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.
|
|
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).
|
|
func (l *Library) AcquirePipelineLock() { l.pipelineMu.Lock() }
|
|
|
|
// ReleasePipelineLock releases the pipeline mutex after a tag write.
|
|
func (l *Library) ReleasePipelineLock() { l.pipelineMu.Unlock() }
|
|
|
|
// SetContext sets the Wails runtime context and registers event handlers.
|
|
func (l *Library) SetContext(ctx context.Context) {
|
|
l.mu.Lock()
|
|
l.ctx = ctx
|
|
l.mu.Unlock()
|
|
|
|
l.registerEventHandlers()
|
|
}
|
|
|
|
// 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.GetAudioFilesByLibrary(
|
|
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)
|
|
|
|
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)
|
|
}()
|
|
|
|
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
|
|
}
|
|
|
|
if d.IsDir() {
|
|
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,
|
|
)
|
|
|
|
if audioFile.RecordingID == 0 || 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,
|
|
}:
|
|
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,
|
|
}:
|
|
case <-scanCtx.Done():
|
|
return scanCtx.Err()
|
|
}
|
|
|
|
return nil
|
|
},
|
|
)
|
|
if walkErr != nil {
|
|
metrics.addWarning(
|
|
"", "walk",
|
|
fmt.Errorf(
|
|
"problem walking library directory: %w",
|
|
walkErr,
|
|
),
|
|
)
|
|
}
|
|
}()
|
|
|
|
// --- 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]
|
|
}
|
|
|
|
for result := range resultChan {
|
|
// Thread library ID into each result for saveAudioFile.
|
|
result.libraryID = libraryID
|
|
|
|
if !dbStarted {
|
|
dbStartVal = time.Now()
|
|
dbStarted = true
|
|
}
|
|
|
|
batch = append(batch, result)
|
|
if len(batch) >= scanBatchSize {
|
|
flushBatch()
|
|
}
|
|
}
|
|
|
|
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.pruneOrphanedMetadata()
|
|
}
|
|
|
|
// --- 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()
|
|
}
|
|
|
|
// --- Phase 7: post-scan variant generation ---
|
|
if !cancelled {
|
|
variantStart := time.Now()
|
|
|
|
if err := l.generateMissingSizedVariants(); err != nil {
|
|
l.logger.Warn(
|
|
"could not generate missing sized variants",
|
|
"err", err,
|
|
)
|
|
|
|
metrics.addWarning("", "variant", err)
|
|
}
|
|
|
|
metrics.PostScanVariants = time.Since(variantStart)
|
|
}
|
|
|
|
// --- 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),
|
|
)
|
|
}
|
|
|
|
// pruneOrphanedMetadata removes recording/release_group/artist_credit/
|
|
// artist rows left behind once the audio_files rows that justified them
|
|
// are gone — deleting an audio_files row doesn't cascade to any of
|
|
// these. Runs in dependency order: recordings first (and their
|
|
// release_group_recordings/recording_genres rows), then release groups
|
|
// left with no recordings, then artist credits left with no
|
|
// recordings/release groups, then artists left with no credits. Best
|
|
// effort — logs and continues on error rather than failing the scan.
|
|
func (l *Library) pruneOrphanedMetadata() {
|
|
tx, err := l.db.BeginTx()
|
|
if err != nil {
|
|
l.logger.Warn("could not begin orphaned metadata cleanup transaction", "err", err)
|
|
|
|
return
|
|
}
|
|
|
|
defer func() { _ = tx.Rollback() }() // no-op after commit
|
|
|
|
txq := l.db.Queries.WithTx(tx)
|
|
|
|
recordingIDs, err := txq.GetOrphanedRecordingIDs(l.ctx)
|
|
if err != nil {
|
|
l.logger.Warn("could not find orphaned recordings", "err", err)
|
|
|
|
return
|
|
}
|
|
|
|
for _, id := range recordingIDs {
|
|
if err := txq.DeleteReleaseGroupRecordingsByRecording(l.ctx, id); err != nil {
|
|
l.logger.Warn(
|
|
"could not delete release group links for orphaned recording",
|
|
"id",
|
|
id,
|
|
"err",
|
|
err,
|
|
)
|
|
}
|
|
|
|
if err := txq.DeleteRecordingGenres(l.ctx, id); err != nil {
|
|
l.logger.Warn("could not delete genres for orphaned recording", "id", id, "err", err)
|
|
}
|
|
|
|
if err := txq.DeleteRecording(l.ctx, id); err != nil {
|
|
l.logger.Warn("could not delete orphaned recording", "id", id, "err", err)
|
|
}
|
|
}
|
|
|
|
releaseGroupIDs, err := txq.GetOrphanedReleaseGroupIDs(l.ctx)
|
|
if err != nil {
|
|
l.logger.Warn("could not find orphaned release groups", "err", err)
|
|
|
|
return
|
|
}
|
|
|
|
for _, id := range releaseGroupIDs {
|
|
if err := txq.DeleteReleaseGroup(l.ctx, id); err != nil {
|
|
l.logger.Warn("could not delete orphaned release group", "id", id, "err", err)
|
|
}
|
|
}
|
|
|
|
artistCreditIDs, err := txq.GetOrphanedArtistCreditIDs(l.ctx)
|
|
if err != nil {
|
|
l.logger.Warn("could not find orphaned artist credits", "err", err)
|
|
|
|
return
|
|
}
|
|
|
|
for _, id := range artistCreditIDs {
|
|
if err := txq.DeleteArtistCreditArtistByCredit(l.ctx, id); err != nil {
|
|
l.logger.Warn(
|
|
"could not delete artist links for orphaned artist credit",
|
|
"id",
|
|
id,
|
|
"err",
|
|
err,
|
|
)
|
|
}
|
|
|
|
if err := txq.DeleteArtistCredit(l.ctx, id); err != nil {
|
|
l.logger.Warn("could not delete orphaned artist credit", "id", id, "err", err)
|
|
}
|
|
}
|
|
|
|
artistIDs, err := txq.GetOrphanedArtistIDs(l.ctx)
|
|
if err != nil {
|
|
l.logger.Warn("could not find orphaned artists", "err", err)
|
|
|
|
return
|
|
}
|
|
|
|
for _, id := range artistIDs {
|
|
if err := txq.DeleteArtist(l.ctx, id); err != nil {
|
|
l.logger.Warn("could not delete orphaned artist", "id", id, "err", err)
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
l.logger.Warn("could not commit orphaned metadata cleanup", "err", err)
|
|
|
|
return
|
|
}
|
|
|
|
if len(recordingIDs) > 0 || len(releaseGroupIDs) > 0 || len(artistCreditIDs) > 0 ||
|
|
len(artistIDs) > 0 {
|
|
l.logger.Info(
|
|
"pruned orphaned library metadata",
|
|
"recordings", len(recordingIDs),
|
|
"releaseGroups", len(releaseGroupIDs),
|
|
"artistCredits", len(artistCreditIDs),
|
|
"artists", len(artistIDs),
|
|
)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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,
|
|
),
|
|
),
|
|
)
|
|
|
|
// Process metadata and create related records.
|
|
recordingID, err := l.processMetadata(
|
|
q, tx, cache, metrics, result, thumbChan,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("could not process metadata: %w", err)
|
|
}
|
|
|
|
props := result.audioProps
|
|
if props == nil {
|
|
props = &metadata.AudioProperties{}
|
|
}
|
|
|
|
tags := result.tags
|
|
if tags == nil {
|
|
tags = &metadata.TrackMetadata{}
|
|
}
|
|
|
|
basename := filepath.Base(result.absolutePath)
|
|
|
|
groupKey := autotag.GroupKey(
|
|
result.libraryID,
|
|
result.absolutePath,
|
|
tags.DiscNumber,
|
|
)
|
|
|
|
tagStatus := "untagged"
|
|
if tags.RecordingMBID != "" {
|
|
tagStatus = "user_confirmed"
|
|
}
|
|
|
|
af, err := q.CreateAudioFileWithGroupKey(
|
|
l.ctx, sqlcgen.CreateAudioFileWithGroupKeyParams{
|
|
FilePath: result.absolutePath,
|
|
LengthMilliseconds: result.lengthMillis,
|
|
FileTypeID: int64(
|
|
slices.Index(
|
|
metadata.SupportedFileExtensions,
|
|
result.fileType,
|
|
),
|
|
),
|
|
RecordingID: recordingID,
|
|
SampleRate: int64(props.SampleRate),
|
|
BitDepth: int64(props.BitDepth),
|
|
Channels: int64(props.Channels),
|
|
Bitrate: int64(props.Bitrate),
|
|
FileSize: props.FileSize,
|
|
Basename: basename,
|
|
LibraryID: result.libraryID,
|
|
GroupKey: groupKey,
|
|
TagStatus: tagStatus,
|
|
ModifiedAt: result.modTime,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf(
|
|
"could not save audio file to db: %w", err,
|
|
)
|
|
}
|
|
|
|
if err := q.UpsertTaggingItemOnTrackAdd(
|
|
l.ctx, sqlcgen.UpsertTaggingItemOnTrackAddParams{
|
|
GroupKey: groupKey,
|
|
LibraryID: result.libraryID,
|
|
AlbumName: tags.Album,
|
|
AlbumArtist: resolveAlbumArtistName(tags),
|
|
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.
|
|
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 (?, ?, ?, ?, ?)`,
|
|
af.ID, result.absolutePath, title, artistName, 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,
|
|
)
|
|
|
|
// Process metadata and create related records.
|
|
recordingID, err := l.processMetadata(
|
|
q, tx, cache, metrics, result, thumbChan,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("could not process metadata: %w", err)
|
|
}
|
|
|
|
props := result.audioProps
|
|
if props == nil {
|
|
props = &metadata.AudioProperties{}
|
|
}
|
|
|
|
if err := q.UpdateAudioFileRecording(
|
|
l.ctx, sqlcgen.UpdateAudioFileRecordingParams{
|
|
RecordingID: recordingID,
|
|
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 recording: %w", err,
|
|
)
|
|
}
|
|
|
|
// 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.
|
|
tags := result.tags
|
|
if tags == nil {
|
|
tags = &metadata.TrackMetadata{}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// processMetadata creates all related database records for metadata
|
|
// and returns the recording ID. It uses the provided queries object
|
|
// (which may be transaction-scoped) and the entity cache to avoid
|
|
// redundant upserts for repeated artist/album/cover-art values.
|
|
// When thumbChan is non-nil, thumbnail generation is dispatched
|
|
// asynchronously.
|
|
func (l *Library) processMetadata(
|
|
q *sqlcgen.Queries,
|
|
tx *sql.Tx,
|
|
cache *entityCache,
|
|
metrics *ScanMetrics,
|
|
result importResult,
|
|
thumbChan chan<- thumbnailWork,
|
|
) (int64, error) {
|
|
tags := result.tags
|
|
if tags == nil {
|
|
tags = &metadata.TrackMetadata{}
|
|
}
|
|
|
|
// 1. Handle cover art (if present).
|
|
coverArtID := l.processCoverArt(
|
|
q, cache, metrics, tags, thumbChan,
|
|
)
|
|
|
|
// 2. Get or create the artist credit for the track. The credit
|
|
// text is the full tagged string (e.g. "Lana Del Rey ft. Sean
|
|
// Lennon") and is kept only for display; the artist *entity* it
|
|
// links to is the primary artist, resolved cleanly by primaryArtist
|
|
// so featured-artist credits don't fork into their own bogus artist
|
|
// rows (all sharing the primary's single MBID).
|
|
creditText := tags.Artist
|
|
if creditText == "" {
|
|
creditText = "Unknown Artist"
|
|
}
|
|
|
|
artistCredit, err := l.cachedUpsertArtistCredit(
|
|
q, cache, creditText,
|
|
)
|
|
if err != nil {
|
|
return 0, fmt.Errorf(
|
|
"could not upsert artist credit: %w", err,
|
|
)
|
|
}
|
|
|
|
primaryName, primaryMBID := primaryArtist(tags)
|
|
|
|
l.cachedLinkArtist(q, cache, metrics, primaryName, artistCredit.ID)
|
|
|
|
// 3. Get or create artist credit for album artist.
|
|
albumArtistCreditID := l.resolveAlbumArtistCredit(
|
|
q, cache, metrics, tags, artistCredit.ID,
|
|
)
|
|
|
|
// 4. Get or create release group (album).
|
|
releaseGroupID := l.resolveReleaseGroup(
|
|
q, cache, tags, albumArtistCreditID, coverArtID,
|
|
)
|
|
|
|
// 5. Create recording.
|
|
recording, err := q.CreateRecordingFull(
|
|
l.ctx, sqlcgen.CreateRecordingFullParams{
|
|
Name: l.getRecordingName(
|
|
tags, result.absolutePath,
|
|
),
|
|
ArtistCreditID: artistCredit.ID,
|
|
TrackNumber: toNullInt64(tags.TrackNumber),
|
|
DiscNumber: toNullInt64(tags.DiscNumber),
|
|
Year: toNullInt64(tags.Year),
|
|
Genre: toNullString(tags.Genre),
|
|
Composer: toNullString(tags.Composer),
|
|
Lyrics: toNullString(tags.Lyrics),
|
|
Comment: toNullString(tags.Comment),
|
|
},
|
|
)
|
|
if err != nil {
|
|
return 0, fmt.Errorf(
|
|
"could not create recording: %w", err,
|
|
)
|
|
}
|
|
|
|
// 6. Link recording to genres.
|
|
l.linkRecordingGenres(q, cache, tags.Genre, recording.ID)
|
|
|
|
// 7. Link recording to release group.
|
|
if releaseGroupID.Valid {
|
|
_, err = q.CreateReleaseGroupRecording(
|
|
l.ctx,
|
|
sqlcgen.CreateReleaseGroupRecordingParams{
|
|
ReleaseGroupID: releaseGroupID.Int64,
|
|
RecordingID: recording.ID,
|
|
TrackNumber: toNullInt64(tags.TrackNumber),
|
|
DiscNumber: toNullInt64(tags.DiscNumber),
|
|
},
|
|
)
|
|
if err != nil {
|
|
l.logger.Warn(
|
|
"could not link recording to release group",
|
|
"err", err,
|
|
)
|
|
}
|
|
}
|
|
|
|
// 7. Update MusicBrainz IDs (if present in tags).
|
|
if releaseGroupID.Valid {
|
|
l.updateMBIDs(tx, cache, tags, primaryName, primaryMBID, releaseGroupID.Int64, recording.ID)
|
|
} else {
|
|
l.updateMBIDs(tx, cache, tags, primaryName, primaryMBID, 0, recording.ID)
|
|
}
|
|
|
|
return recording.ID, nil
|
|
}
|
|
|
|
// updateMBIDs writes MusicBrainz IDs from audio file tags to the
|
|
// corresponding database entities. Uses raw SQL since the sqlc
|
|
// queries predate the mbid columns. Skips silently if tags have
|
|
// no MBIDs.
|
|
func (l *Library) updateMBIDs(
|
|
tx *sql.Tx,
|
|
cache *entityCache,
|
|
tags *metadata.TrackMetadata,
|
|
artistName string,
|
|
artistMBID string,
|
|
releaseGroupID int64,
|
|
recordingID int64,
|
|
) {
|
|
// Artist MBID (the primary artist's, resolved by primaryArtist).
|
|
if artistMBID != "" {
|
|
if artist, ok := cache.artists[artistName]; ok {
|
|
_, _ = tx.ExecContext(l.ctx,
|
|
"UPDATE artists SET mbid = ? WHERE id = ? AND (mbid IS NULL OR mbid = '')",
|
|
artistMBID, artist.ID,
|
|
)
|
|
}
|
|
}
|
|
|
|
// Release group MBID.
|
|
if tags.ReleaseGroupMBID != "" && releaseGroupID > 0 {
|
|
_, _ = tx.ExecContext(l.ctx,
|
|
"UPDATE release_groups SET mbid = ? WHERE id = ? AND (mbid IS NULL OR mbid = '')",
|
|
tags.ReleaseGroupMBID, releaseGroupID,
|
|
)
|
|
}
|
|
|
|
// Recording MBID.
|
|
if tags.RecordingMBID != "" && recordingID > 0 {
|
|
_, _ = tx.ExecContext(l.ctx,
|
|
"UPDATE recordings SET mbid = ? WHERE id = ? AND (mbid IS NULL OR mbid = '')",
|
|
tags.RecordingMBID, recordingID,
|
|
)
|
|
}
|
|
}
|
|
|
|
// resolveAlbumArtistName returns the album-artist tag for tagging-
|
|
// group bookkeeping, falling back to the track artist when the
|
|
// album-artist field is empty.
|
|
func resolveAlbumArtistName(tags *metadata.TrackMetadata) string {
|
|
if tags.AlbumArtist != "" {
|
|
return tags.AlbumArtist
|
|
}
|
|
|
|
return tags.Artist
|
|
}
|
|
|
|
// 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: resolveAlbumArtistName(tags),
|
|
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}
|
|
}
|
|
|
|
// cachedUpsertArtistCredit returns the artist credit for the given
|
|
// name, using the cache when possible.
|
|
func (l *Library) cachedUpsertArtistCredit(
|
|
q *sqlcgen.Queries,
|
|
cache *entityCache,
|
|
name string,
|
|
) (sqlcgen.ArtistCredit, error) {
|
|
if cached, ok := cache.artistCredits[name]; ok {
|
|
return cached, nil
|
|
}
|
|
|
|
ac, err := q.UpsertArtistCredit(l.ctx, name)
|
|
if err != nil {
|
|
return sqlcgen.ArtistCredit{}, err
|
|
}
|
|
|
|
cache.artistCredits[name] = ac
|
|
|
|
return ac, nil
|
|
}
|
|
|
|
// cachedLinkArtist upserts the artist record and creates the
|
|
// artist-credit-artist link, skipping work already done.
|
|
// UNIQUE constraint violations are silently ignored (link already
|
|
// exists in the database). Other errors are recorded as scan warnings.
|
|
func (l *Library) cachedLinkArtist(
|
|
q *sqlcgen.Queries,
|
|
cache *entityCache,
|
|
metrics *ScanMetrics,
|
|
name string,
|
|
creditID int64,
|
|
) {
|
|
artist, ok := cache.artists[name]
|
|
if !ok {
|
|
var err error
|
|
|
|
artist, err = q.UpsertArtist(l.ctx, name)
|
|
if err != nil {
|
|
l.logger.Warn(
|
|
"could not upsert artist", "err", err,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
cache.artists[name] = artist
|
|
}
|
|
|
|
linkKey := fmt.Sprintf("%d:%d", artist.ID, creditID)
|
|
if _, done := cache.linkedCredits[linkKey]; done {
|
|
return
|
|
}
|
|
|
|
_, err := q.CreateArtistCreditArtist(
|
|
l.ctx,
|
|
sqlcgen.CreateArtistCreditArtistParams{
|
|
ArtistID: artist.ID,
|
|
CreditID: creditID,
|
|
},
|
|
)
|
|
if err != nil {
|
|
if !database.IsUniqueViolation(err) {
|
|
l.logger.Warn(
|
|
"could not link artist to credit",
|
|
"artist", name,
|
|
"creditID", creditID,
|
|
"err", err,
|
|
)
|
|
|
|
metrics.addWarning(
|
|
name, "commit",
|
|
fmt.Errorf(
|
|
"artist-credit link failed for %q: %w",
|
|
name, err,
|
|
),
|
|
)
|
|
}
|
|
|
|
// UNIQUE violation: link already exists in DB, not an error.
|
|
return
|
|
}
|
|
|
|
cache.linkedCredits[linkKey] = struct{}{}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// linkRecordingGenres parses the raw genre string, upserts each
|
|
// individual genre, and creates the recording-genre associations.
|
|
func (l *Library) linkRecordingGenres(
|
|
q *sqlcgen.Queries,
|
|
cache *entityCache,
|
|
rawGenre string,
|
|
recordingID int64,
|
|
) {
|
|
genres := metadata.ParseGenres(rawGenre)
|
|
|
|
for _, name := range genres {
|
|
genre, err := l.cachedUpsertGenre(q, cache, name)
|
|
if err != nil {
|
|
l.logger.Warn(
|
|
"could not upsert genre",
|
|
"genre", name,
|
|
"err", err,
|
|
)
|
|
|
|
continue
|
|
}
|
|
|
|
err = q.CreateRecordingGenre(
|
|
l.ctx,
|
|
sqlcgen.CreateRecordingGenreParams{
|
|
RecordingID: recordingID,
|
|
GenreID: genre.ID,
|
|
},
|
|
)
|
|
if err != nil {
|
|
l.logger.Warn(
|
|
"could not link recording to genre",
|
|
"genre", name,
|
|
"recordingID", recordingID,
|
|
"err", err,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// resolveAlbumArtistCredit returns the album artist credit ID.
|
|
// When the AlbumArtist tag is absent or matches the track artist,
|
|
// the track artist credit is reused.
|
|
func (l *Library) resolveAlbumArtistCredit(
|
|
q *sqlcgen.Queries,
|
|
cache *entityCache,
|
|
metrics *ScanMetrics,
|
|
tags *metadata.TrackMetadata,
|
|
trackArtistCreditID int64,
|
|
) sql.NullInt64 {
|
|
if tags.AlbumArtist == "" || tags.AlbumArtist == tags.Artist {
|
|
return sql.NullInt64{
|
|
Int64: trackArtistCreditID, Valid: true,
|
|
}
|
|
}
|
|
|
|
albumArtistCredit, err := l.cachedUpsertArtistCredit(
|
|
q, cache, tags.AlbumArtist,
|
|
)
|
|
if err != nil {
|
|
l.logger.Warn(
|
|
"could not upsert album artist credit", "err", err,
|
|
)
|
|
|
|
return sql.NullInt64{}
|
|
}
|
|
|
|
l.cachedLinkArtist(
|
|
q, cache, metrics, tags.AlbumArtist, albumArtistCredit.ID,
|
|
)
|
|
|
|
return sql.NullInt64{
|
|
Int64: albumArtistCredit.ID, Valid: true,
|
|
}
|
|
}
|
|
|
|
// resolveReleaseGroup returns the release group ID for the album,
|
|
// using the cache when possible.
|
|
func (l *Library) resolveReleaseGroup(
|
|
q *sqlcgen.Queries,
|
|
cache *entityCache,
|
|
tags *metadata.TrackMetadata,
|
|
albumArtistCreditID sql.NullInt64,
|
|
coverArtID sql.NullInt64,
|
|
) sql.NullInt64 {
|
|
if tags.Album == "" {
|
|
return sql.NullInt64{}
|
|
}
|
|
|
|
// Build composite cache key: "albumName\x00artistCreditID"
|
|
// (or "albumName\x00-1" if no artist). This prevents albums
|
|
// with the same name by different artists from colliding.
|
|
artistID := int64(-1)
|
|
if albumArtistCreditID.Valid {
|
|
artistID = albumArtistCreditID.Int64
|
|
}
|
|
|
|
cacheKey := fmt.Sprintf("%s\x00%d", tags.Album, artistID)
|
|
|
|
// Check cache first.
|
|
if cached, ok := cache.releaseGroups[cacheKey]; ok {
|
|
// If the cached release group lacks cover art and we now
|
|
// have it, update it.
|
|
if coverArtID.Valid && !cached.CoverArtID.Valid {
|
|
err := q.UpdateReleaseGroupCoverArt(
|
|
l.ctx,
|
|
sqlcgen.UpdateReleaseGroupCoverArtParams{
|
|
CoverArtID: coverArtID,
|
|
ID: cached.ID,
|
|
},
|
|
)
|
|
if err != nil {
|
|
l.logger.Warn(
|
|
"could not update release group cover art",
|
|
"err", err,
|
|
)
|
|
} else {
|
|
cached.CoverArtID = coverArtID
|
|
cache.releaseGroups[cacheKey] = cached
|
|
}
|
|
}
|
|
|
|
return sql.NullInt64{Int64: cached.ID, Valid: true}
|
|
}
|
|
|
|
rg, err := q.UpsertReleaseGroup(
|
|
l.ctx, sqlcgen.UpsertReleaseGroupParams{
|
|
Name: tags.Album,
|
|
AlbumArtistCreditID: albumArtistCreditID,
|
|
Year: toNullInt64(tags.Year),
|
|
},
|
|
)
|
|
if err != nil {
|
|
l.logger.Warn(
|
|
"could not upsert release group", "err", err,
|
|
)
|
|
|
|
return sql.NullInt64{}
|
|
}
|
|
|
|
// Update cover art if this album doesn't have one yet.
|
|
if coverArtID.Valid && !rg.CoverArtID.Valid {
|
|
err := q.UpdateReleaseGroupCoverArt(
|
|
l.ctx,
|
|
sqlcgen.UpdateReleaseGroupCoverArtParams{
|
|
CoverArtID: coverArtID,
|
|
ID: rg.ID,
|
|
},
|
|
)
|
|
if err != nil {
|
|
l.logger.Warn(
|
|
"could not update release group cover art",
|
|
"err", err,
|
|
)
|
|
} else {
|
|
rg.CoverArtID = coverArtID
|
|
}
|
|
}
|
|
|
|
cache.releaseGroups[cacheKey] = rg
|
|
|
|
return sql.NullInt64{Int64: rg.ID, Valid: true}
|
|
}
|
|
|
|
// 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}
|
|
}
|