improved library scan speeds, added library manager component

-libraries tab now opens a library manager
-choose library directory, manually soft scan and rescan
-batched db queues
-mp3 header-based duration extraction
This commit is contained in:
2026-02-19 10:17:09 -05:00
parent 8c013d4179
commit 81793975b4
36 changed files with 2350 additions and 179 deletions
+456 -179
View File
@@ -26,12 +26,55 @@ import (
var errLibraryDirNotConfigured = errors.New("library directory not configured")
// 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 = 50
// 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
// 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),
linkedCredits: make(map[string]struct{}),
}
}
// queueClearer is a narrow interface for clearing the playback queue.
type queueClearer interface {
Clear()
}
// Library manages scanning and querying the music collection.
type Library struct {
ctx context.Context
logger *slog.Logger
conf *Config
db *database.DB
queue queueClearer
}
// SetQueue provides the library with a reference to the queue so
// that destructive operations like FullRescan can clear the queue
// and stop playback before wiping data.
func (l *Library) SetQueue(q queueClearer) {
l.queue = q
}
// NewLibrary creates a new library with the given configuration.
@@ -107,7 +150,11 @@ func (l *Library) registerEventHandlers() {
// Scan syncs the library by adding new files and removing deleted ones.
// Files that exist but have incomplete metadata (recording_id = 0) will be updated.
func (l *Library) Scan() error {
l.logger.Info("beginning library scan", "workers", scanWorkerCount)
l.logger.Info(
"beginning library scan", "workers", scanWorkerCount,
)
runtime.EventsEmit(l.ctx, events.LibraryScanStarted)
if len(l.conf.DirectoryPath) == 0 {
return errLibraryDirNotConfigured
@@ -224,7 +271,9 @@ func (l *Library) Scan() error {
}
}()
// DB writer goroutine: serialize all database writes to avoid SQLite contention
// DB writer goroutine: serialize all database writes to avoid SQLite
// contention. Results are committed in batches to amortize the cost
// of SQLite's fsync-per-commit.
var dbWg sync.WaitGroup
dbWg.Add(1)
@@ -232,35 +281,35 @@ func (l *Library) Scan() error {
go func() {
defer dbWg.Done()
for result := range resultChan {
var saveErr error
cache := newEntityCache()
if result.needsUpdate {
saveErr = l.updateAudioFileMetadata(result)
if saveErr == nil {
updated.Add(1)
}
} else {
saveErr = l.saveAudioFile(result)
if saveErr == nil {
added.Add(1)
}
var batch []importResult
flushBatch := func() {
if len(batch) == 0 {
return
}
if saveErr != nil {
l.logger.Warn(
"failed to save audio file",
"path",
result.absolutePath,
"err",
saveErr,
)
if batchErr := l.commitBatch(
batch, cache, &added, &updated,
); batchErr != nil {
errMu.Lock()
scanErr = errors.Join(scanErr, saveErr)
scanErr = errors.Join(scanErr, batchErr)
errMu.Unlock()
}
batch = batch[:0]
}
for result := range resultChan {
batch = append(batch, result)
if len(batch) >= scanBatchSize {
flushBatch()
}
}
// Flush any remaining results.
flushBatch()
}()
// Worker pool: extract metadata concurrently, send results to DB writer
@@ -368,6 +417,7 @@ type importResult struct {
}
// extractAudioMetadata reads and extracts metadata from an audio file.
// It opens the file once, extracting both tags and duration in a single pass.
func (l *Library) extractAudioMetadata(work scanWork) (importResult, error) {
result := importResult{
absolutePath: work.absolutePath,
@@ -376,236 +426,241 @@ func (l *Library) extractAudioMetadata(work scanWork) (importResult, error) {
needsUpdate: work.needsUpdate,
}
// Get duration (skip if updating and we already have it)
if work.needsUpdate && work.existingLength > 0 {
result.lengthMillis = work.existingLength
} else {
trackLengthMillis, err := metadata.GetTrackLengthMillis(work.absolutePath)
if err != nil {
return result, fmt.Errorf(
"could not get track length for %s: %w",
work.absolutePath,
err,
)
}
// Skip duration decode if we already have it from a previous import.
skipDuration := work.needsUpdate && work.existingLength > 0
result.lengthMillis = trackLengthMillis
}
// Extract tags
tags, err := metadata.ExtractTags(work.absolutePath)
tags, lengthMillis, err := metadata.ExtractAllMetadata(
work.absolutePath, skipDuration,
)
if err != nil {
l.logger.Warn("could not extract tags", "path", work.absolutePath, "err", err)
// Continue with empty tags - not a fatal error
tags = &metadata.TrackMetadata{}
return result, fmt.Errorf(
"could not extract metadata for %s: %w",
work.absolutePath,
err,
)
}
result.tags = tags
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.
func (l *Library) commitBatch(
batch []importResult,
cache *entityCache,
added, updated *atomic.Int64,
) error {
tx, err := l.db.BeginTx()
if err != nil {
return fmt.Errorf("could not begin transaction: %w", err)
}
txq := l.db.Queries.WithTx(tx)
var batchErr error
for i := range batch {
result := &batch[i]
var saveErr error
if result.needsUpdate {
saveErr = l.updateAudioFileMetadata(txq, cache, *result)
if saveErr == nil {
updated.Add(1)
}
} else {
saveErr = l.saveAudioFile(txq, cache, *result)
if saveErr == nil {
added.Add(1)
}
}
if saveErr != nil {
l.logger.Warn(
"failed to save audio file",
"path", result.absolutePath,
"err", saveErr,
)
batchErr = errors.Join(batchErr, saveErr)
}
}
if commitErr := tx.Commit(); commitErr != nil {
return fmt.Errorf(
"could not commit batch of %d files: %w",
len(batch), commitErr,
)
}
return batchErr
}
// saveAudioFile writes audio file metadata to the database (new files).
func (l *Library) saveAudioFile(result importResult) error {
func (l *Library) saveAudioFile(
q *sqlcgen.Queries,
cache *entityCache,
result importResult,
) 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)),
"file-type", int64(
slices.Index(
metadata.SupportedFileExtensions,
result.fileType,
),
),
)
// Process metadata and create related records
recordingID, err := l.processMetadata(result)
// Process metadata and create related records.
recordingID, err := l.processMetadata(q, cache, result)
if err != nil {
return fmt.Errorf("could not process metadata: %w", err)
}
if _, err := l.db.Queries.CreateAudioFile(
if _, err := q.CreateAudioFile(
l.ctx, sqlcgen.CreateAudioFileParams{
FilePath: result.absolutePath,
LengthMilliseconds: result.lengthMillis,
FileTypeID: int64(
slices.Index(metadata.SupportedFileExtensions, result.fileType),
slices.Index(
metadata.SupportedFileExtensions,
result.fileType,
),
),
RecordingID: recordingID,
}); err != nil {
return fmt.Errorf("could not save audio file to db: %w", err)
return fmt.Errorf(
"could not save audio file to db: %w", err,
)
}
l.logger.Debug("added audio file to library", "path", result.absolutePath)
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(result importResult) error {
func (l *Library) updateAudioFileMetadata(
q *sqlcgen.Queries,
cache *entityCache,
result importResult,
) 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(result)
// Process metadata and create related records.
recordingID, err := l.processMetadata(q, cache, result)
if err != nil {
return fmt.Errorf("could not process metadata: %w", err)
}
if err := l.db.Queries.UpdateAudioFileRecording(
if err := q.UpdateAudioFileRecording(
l.ctx, sqlcgen.UpdateAudioFileRecordingParams{
RecordingID: recordingID,
ID: result.existingFileID,
}); err != nil {
return fmt.Errorf("could not update audio file recording: %w", err)
return fmt.Errorf(
"could not update audio file recording: %w", err,
)
}
l.logger.Debug("updated audio file metadata", "path", result.absolutePath)
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.
func (l *Library) processMetadata(result importResult) (int64, error) {
// 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.
func (l *Library) processMetadata(
q *sqlcgen.Queries,
cache *entityCache,
result importResult,
) (int64, error) {
tags := result.tags
if tags == nil {
tags = &metadata.TrackMetadata{}
}
// 1. Handle cover art (if present)
var coverArtID sql.NullInt64
// 1. Handle cover art (if present).
coverArtID := l.processCoverArt(q, cache, tags)
if tags.Picture != nil {
coverPath, err := l.saveCoverArt(tags.Picture)
if err != nil {
l.logger.Warn("could not save cover art", "err", err)
} else if coverPath != "" {
// Use upsert to avoid duplicates
ca, err := l.db.Queries.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)
} else {
coverArtID = sql.NullInt64{Int64: ca.ID, Valid: true}
}
}
}
// 2. Get or create artist credit for track artist
// 2. Get or create artist credit for track artist.
artistName := tags.Artist
if artistName == "" {
artistName = "Unknown Artist"
}
artistCredit, err := l.db.Queries.UpsertArtistCredit(l.ctx, artistName)
artistCredit, err := l.cachedUpsertArtistCredit(
q, cache, artistName,
)
if err != nil {
return 0, fmt.Errorf("could not upsert artist credit: %w", err)
return 0, fmt.Errorf(
"could not upsert artist credit: %w", err,
)
}
// Also create the artist record and link (best effort)
artist, err := l.db.Queries.UpsertArtist(l.ctx, artistName)
if err != nil {
l.logger.Warn("could not upsert artist", "err", err)
} else {
// Link artist to credit (ignore error if already linked)
_, _ = l.db.Queries.CreateArtistCreditArtist(l.ctx, sqlcgen.CreateArtistCreditArtistParams{
ArtistID: artist.ID,
CreditID: artistCredit.ID,
})
}
l.cachedLinkArtist(q, cache, artistName, artistCredit.ID)
// 3. Get or create artist credit for album artist.
// Always assign an album artist credit so the cover grid displays an
// artist name. When the AlbumArtist tag is absent or identical to the
// track Artist, reuse the track artist credit instead of leaving it NULL.
var albumArtistCreditID sql.NullInt64
albumArtistCreditID := l.resolveAlbumArtistCredit(
q, cache, tags, artistCredit.ID,
)
if tags.AlbumArtist != "" && tags.AlbumArtist != tags.Artist {
albumArtistCredit, err := l.db.Queries.UpsertArtistCredit(
l.ctx, tags.AlbumArtist,
)
if err != nil {
l.logger.Warn("could not upsert album artist credit", "err", err)
} else {
albumArtistCreditID = sql.NullInt64{
Int64: albumArtistCredit.ID, Valid: true,
}
// 4. Get or create release group (album).
releaseGroupID := l.resolveReleaseGroup(
q, cache, tags, albumArtistCreditID, coverArtID,
)
// Also create the artist record and link.
albumArtist, err := l.db.Queries.UpsertArtist(
l.ctx, tags.AlbumArtist,
)
if err != nil {
l.logger.Warn("could not upsert album artist", "err", err)
} else {
_, _ = l.db.Queries.CreateArtistCreditArtist(
l.ctx,
sqlcgen.CreateArtistCreditArtistParams{
ArtistID: albumArtist.ID,
CreditID: albumArtistCredit.ID,
},
)
}
}
} else {
// AlbumArtist is empty or matches the track artist — reuse the
// track artist credit so the release group always has an artist.
albumArtistCreditID = sql.NullInt64{
Int64: artistCredit.ID, Valid: true,
}
}
// 4. Get or create release group (album)
var releaseGroupID sql.NullInt64
if tags.Album != "" {
rg, err := l.db.Queries.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)
} else {
releaseGroupID = sql.NullInt64{Int64: rg.ID, Valid: true}
// Update cover art if this album doesn't have one yet
if coverArtID.Valid && !rg.CoverArtID.Valid {
err := l.db.Queries.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)
}
}
}
}
// 5. Create recording
recording, err := l.db.Queries.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),
})
// 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)
return 0, fmt.Errorf(
"could not create recording: %w", err,
)
}
// 6. Link recording to release group
// 6. Link recording to release group.
if releaseGroupID.Valid {
_, err = l.db.Queries.CreateReleaseGroupRecording(
_, err = q.CreateReleaseGroupRecording(
l.ctx,
sqlcgen.CreateReleaseGroupRecordingParams{
ReleaseGroupID: releaseGroupID.Int64,
@@ -615,13 +670,235 @@ func (l *Library) processMetadata(result importResult) (int64, error) {
},
)
if err != nil {
l.logger.Warn("could not link recording to release group", "err", err)
l.logger.Warn(
"could not link recording to release group",
"err", err,
)
}
}
return recording.ID, nil
}
// processCoverArt saves cover art to disk and upserts the DB record,
// using the cache to skip work for previously seen images.
func (l *Library) processCoverArt(
q *sqlcgen.Queries,
cache *entityCache,
tags *metadata.TrackMetadata,
) sql.NullInt64 {
if tags.Picture == nil {
return sql.NullInt64{}
}
coverPath, err := l.saveCoverArt(tags.Picture)
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.
func (l *Library) cachedLinkArtist(
q *sqlcgen.Queries,
cache *entityCache,
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
}
_, _ = q.CreateArtistCreditArtist(
l.ctx,
sqlcgen.CreateArtistCreditArtistParams{
ArtistID: artist.ID,
CreditID: creditID,
},
)
cache.linkedCredits[linkKey] = struct{}{}
}
// 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,
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, 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{}
}
// Check cache first.
if cached, ok := cache.releaseGroups[tags.Album]; 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[tags.Album] = 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[tags.Album] = 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 != "" {
+185
View File
@@ -0,0 +1,185 @@
package library
import (
"fmt"
"os"
"path/filepath"
"github.com/wailsapp/wails/v2/pkg/runtime"
"yellowjacket/backend/events"
"yellowjacket/backend/system"
)
// FullRescan clears the queue and player, wipes all library data
// (database records and cover art files), and performs a fresh
// scan from scratch.
func (l *Library) FullRescan() error {
l.logger.Info("beginning full library rescan")
runtime.EventsEmit(l.ctx, events.LibraryScanStarted)
// Stop playback and clear the queue before wiping data so
// the player is not referencing now-deleted tracks.
if l.queue != nil {
l.queue.Clear()
}
if err := l.clearLibraryData(); err != nil {
return fmt.Errorf("could not clear library data: %w", err)
}
return l.Scan()
}
// clearLibraryData removes all library-related records from the
// database and deletes all cover art files from disk. The deletes
// are executed in FK-safe order within a single transaction.
func (l *Library) clearLibraryData() error {
l.logger.Info("clearing all library data")
if err := l.clearLibraryTables(); err != nil {
return err
}
if err := l.clearCoverArtFiles(); err != nil {
return err
}
l.logger.Info("library data cleared successfully")
return nil
}
// clearLibraryTables deletes all library-related rows in FK-safe
// order within a single transaction.
func (l *Library) clearLibraryTables() error {
tx, err := l.db.BeginTx()
if err != nil {
return fmt.Errorf("could not begin transaction: %w", err)
}
defer func() {
_ = tx.Rollback()
}()
txq := l.db.Queries.WithTx(tx)
// Phase 1: leaf tables (nothing references these).
if err := txq.ClearQueueTracks(l.ctx); err != nil {
return fmt.Errorf("could not clear queue tracks: %w", err)
}
if err := txq.DeleteAllPlaylistTracks(l.ctx); err != nil {
return fmt.Errorf(
"could not clear playlist tracks: %w", err,
)
}
if err := txq.DeleteAllReleaseGroupRecordings(l.ctx); err != nil {
return fmt.Errorf(
"could not clear release group recordings: %w", err,
)
}
if err := txq.DeleteAllArtistCreditArtists(l.ctx); err != nil {
return fmt.Errorf(
"could not clear artist credit artists: %w", err,
)
}
// Phase 2: mid-level tables.
if err := txq.DeleteAllAudioFiles(l.ctx); err != nil {
return fmt.Errorf(
"could not clear audio files: %w", err,
)
}
if err := txq.DeleteAllReleaseGroups(l.ctx); err != nil {
return fmt.Errorf(
"could not clear release groups: %w", err,
)
}
if err := txq.DeleteAllRecordings(l.ctx); err != nil {
return fmt.Errorf(
"could not clear recordings: %w", err,
)
}
// Phase 3: root tables.
if err := txq.DeleteAllCoverArt(l.ctx); err != nil {
return fmt.Errorf(
"could not clear cover art: %w", err,
)
}
if err := txq.DeleteAllArtistCredits(l.ctx); err != nil {
return fmt.Errorf(
"could not clear artist credits: %w", err,
)
}
if err := txq.DeleteAllArtists(l.ctx); err != nil {
return fmt.Errorf(
"could not clear artists: %w", err,
)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf(
"could not commit library clear transaction: %w", err,
)
}
l.logger.Info("all library tables cleared")
return nil
}
// clearCoverArtFiles removes all files from the covers directory.
func (l *Library) clearCoverArtFiles() error {
dataDir, err := system.GetUserDataDirPath()
if err != nil {
return fmt.Errorf(
"could not get user data directory: %w", err,
)
}
coverDir := filepath.Join(dataDir, "covers")
entries, err := os.ReadDir(coverDir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf(
"could not read covers directory: %w", err,
)
}
var removed int
for _, entry := range entries {
if entry.IsDir() {
continue
}
path := filepath.Join(coverDir, entry.Name())
if err := os.Remove(path); err != nil {
l.logger.Warn(
"could not remove cover art file",
"path", path, "err", err,
)
continue
}
removed++
}
l.logger.Info("cover art files removed", "count", removed)
return nil
}