diff --git a/backend/app.go b/backend/app.go
index 1f75ddf..4bec25a 100644
--- a/backend/app.go
+++ b/backend/app.go
@@ -100,6 +100,7 @@ func NewYellowJacketApp(
yjApp.FEBindings = []any{
yjApp.FrontendUtil,
+ yjApp.appConfig,
yjApp.library,
yjApp.playlist,
}
@@ -141,6 +142,10 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
yj.queue.SetPlayer(yj.player)
yj.queue.RestoreState()
+ // Give the library a reference to the queue so FullRescan can
+ // clear the queue and stop playback before wiping data.
+ yj.library.SetQueue(yj.queue)
+
// Register playback finished handler to drive queue auto-advance.
yj.player.SetPlaybackFinishedHandler(yj.queue.OnPlaybackFinished)
diff --git a/backend/config/config.go b/backend/config/config.go
index 3115d62..93cb8c8 100644
--- a/backend/config/config.go
+++ b/backend/config/config.go
@@ -11,7 +11,9 @@ import (
"path"
"github.com/BurntSushi/toml"
+ "github.com/wailsapp/wails/v2/pkg/runtime"
+ "yellowjacket/backend/events"
"yellowjacket/backend/library"
"yellowjacket/backend/system"
)
@@ -148,3 +150,49 @@ func (c *Config) applyDefaults() {
func (c *Config) SetContext(ctx context.Context) {
c.ctx = ctx
}
+
+// GetLibraryDirectory returns the currently configured library directory path.
+func (c *Config) GetLibraryDirectory() string {
+ if c.Library == nil {
+ return ""
+ }
+
+ return string(c.Library.DirectoryPath)
+}
+
+// SetLibraryDirectory validates and saves a new library directory,
+// then emits the LibraryConfigChanged event so listeners (e.g. the
+// Library scanner) can react.
+func (c *Config) SetLibraryDirectory(dir string) error {
+ newLibConf, err := library.NewConfig(dir)
+ if err != nil {
+ return fmt.Errorf(
+ "invalid library directory: %w", err,
+ )
+ }
+
+ c.Library = newLibConf
+
+ if err := c.Save(); err != nil {
+ return fmt.Errorf(
+ "could not save config after directory change: %w", err,
+ )
+ }
+
+ if c.ctx != nil {
+ runtime.EventsEmit(
+ c.ctx,
+ events.LibraryConfigChanged,
+ map[string]any{
+ "DirectoryPath": dir,
+ },
+ )
+ }
+
+ c.logger.Info(
+ "library directory updated",
+ "directory", dir,
+ )
+
+ return nil
+}
diff --git a/backend/database/sql/queries/artist_credit.sql b/backend/database/sql/queries/artist_credit.sql
index 73e6219..b659668 100644
--- a/backend/database/sql/queries/artist_credit.sql
+++ b/backend/database/sql/queries/artist_credit.sql
@@ -23,3 +23,6 @@ WHERE id = ?;
-- name: DeleteArtistCredit :exec
DELETE FROM artist_credit
WHERE id = ?;
+
+-- name: DeleteAllArtistCredits :exec
+DELETE FROM artist_credit;
diff --git a/backend/database/sql/queries/artist_credit_artists.sql b/backend/database/sql/queries/artist_credit_artists.sql
index 532f42a..617157e 100644
--- a/backend/database/sql/queries/artist_credit_artists.sql
+++ b/backend/database/sql/queries/artist_credit_artists.sql
@@ -15,3 +15,6 @@ WHERE id =?;
DELETE FROM artist_credit_artist
WHERE id =?;
+-- name: DeleteAllArtistCreditArtists :exec
+DELETE FROM artist_credit_artist;
+
diff --git a/backend/database/sql/queries/artists.sql b/backend/database/sql/queries/artists.sql
index 36b2933..23ea4e0 100644
--- a/backend/database/sql/queries/artists.sql
+++ b/backend/database/sql/queries/artists.sql
@@ -24,6 +24,9 @@ WHERE id = ?;
DELETE FROM artists
WHERE id = ?;
+-- name: DeleteAllArtists :exec
+DELETE FROM artists;
+
-- name: GetAllArtists :many
SELECT * FROM artists
ORDER BY name;
diff --git a/backend/database/sql/queries/audio_files.sql b/backend/database/sql/queries/audio_files.sql
index eea0aef..13ce89a 100644
--- a/backend/database/sql/queries/audio_files.sql
+++ b/backend/database/sql/queries/audio_files.sql
@@ -71,6 +71,9 @@ LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
WHERE af.file_path = ?
LIMIT 1;
+-- name: DeleteAllAudioFiles :exec
+DELETE FROM audio_files;
+
-- name: GetAudioFilesByReleaseGroup :many
SELECT
af.file_path,
diff --git a/backend/database/sql/queries/cover_art.sql b/backend/database/sql/queries/cover_art.sql
index 4006232..1838d22 100644
--- a/backend/database/sql/queries/cover_art.sql
+++ b/backend/database/sql/queries/cover_art.sql
@@ -26,3 +26,6 @@ WHERE id = ?;
-- name: DeleteCoverArt :exec
DELETE FROM cover_art
WHERE id = ?;
+
+-- name: DeleteAllCoverArt :exec
+DELETE FROM cover_art;
diff --git a/backend/database/sql/queries/playlists.sql b/backend/database/sql/queries/playlists.sql
index 6c094d8..870b51c 100644
--- a/backend/database/sql/queries/playlists.sql
+++ b/backend/database/sql/queries/playlists.sql
@@ -82,6 +82,9 @@ LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
ORDER BY pt.playlist_id, pt.position;
+-- name: DeleteAllPlaylistTracks :exec
+DELETE FROM playlist_tracks;
+
-- name: GetNextPlaylistTrackPosition :one
SELECT COALESCE(MAX(position), -1) + 1 AS next_position
FROM playlist_tracks WHERE playlist_id = ?;
diff --git a/backend/database/sql/queries/recordings.sql b/backend/database/sql/queries/recordings.sql
index 4b21e67..31bab1a 100644
--- a/backend/database/sql/queries/recordings.sql
+++ b/backend/database/sql/queries/recordings.sql
@@ -28,6 +28,9 @@ WHERE id = ?;
DELETE FROM recordings
WHERE id = ?;
+-- name: DeleteAllRecordings :exec
+DELETE FROM recordings;
+
-- name: GetAllRecordings :many
SELECT * FROM recordings
ORDER BY name;
diff --git a/backend/database/sql/queries/release_group_recordings.sql b/backend/database/sql/queries/release_group_recordings.sql
index 500caf9..6198bce 100644
--- a/backend/database/sql/queries/release_group_recordings.sql
+++ b/backend/database/sql/queries/release_group_recordings.sql
@@ -23,3 +23,6 @@ WHERE id = ?;
-- name: DeleteReleaseGroupRecordingByFK :exec
DELETE FROM release_group_recordings
WHERE release_group_id = ? AND recording_id = ?;
+
+-- name: DeleteAllReleaseGroupRecordings :exec
+DELETE FROM release_group_recordings;
diff --git a/backend/database/sql/queries/release_groups.sql b/backend/database/sql/queries/release_groups.sql
index a0ace65..3689553 100644
--- a/backend/database/sql/queries/release_groups.sql
+++ b/backend/database/sql/queries/release_groups.sql
@@ -38,6 +38,9 @@ WHERE id = ?;
DELETE FROM release_groups
WHERE id = ?;
+-- name: DeleteAllReleaseGroups :exec
+DELETE FROM release_groups;
+
-- name: GetAllReleaseGroups :many
SELECT * FROM release_groups
ORDER BY name;
diff --git a/backend/database/sql/sqlcgen/artist_credit.sql.go b/backend/database/sql/sqlcgen/artist_credit.sql.go
index b19c3a8..5d17bc0 100644
--- a/backend/database/sql/sqlcgen/artist_credit.sql.go
+++ b/backend/database/sql/sqlcgen/artist_credit.sql.go
@@ -21,6 +21,15 @@ func (q *Queries) CreateArtistCredit(ctx context.Context, text string) (ArtistCr
return i, err
}
+const deleteAllArtistCredits = `-- name: DeleteAllArtistCredits :exec
+DELETE FROM artist_credit
+`
+
+func (q *Queries) DeleteAllArtistCredits(ctx context.Context) error {
+ _, err := q.db.ExecContext(ctx, deleteAllArtistCredits)
+ return err
+}
+
const deleteArtistCredit = `-- name: DeleteArtistCredit :exec
DELETE FROM artist_credit
WHERE id = ?
diff --git a/backend/database/sql/sqlcgen/artist_credit_artists.sql.go b/backend/database/sql/sqlcgen/artist_credit_artists.sql.go
index 851418a..f762fbd 100644
--- a/backend/database/sql/sqlcgen/artist_credit_artists.sql.go
+++ b/backend/database/sql/sqlcgen/artist_credit_artists.sql.go
@@ -26,6 +26,15 @@ func (q *Queries) CreateArtistCreditArtist(ctx context.Context, arg CreateArtist
return i, err
}
+const deleteAllArtistCreditArtists = `-- name: DeleteAllArtistCreditArtists :exec
+DELETE FROM artist_credit_artist
+`
+
+func (q *Queries) DeleteAllArtistCreditArtists(ctx context.Context) error {
+ _, err := q.db.ExecContext(ctx, deleteAllArtistCreditArtists)
+ return err
+}
+
const deleteArtistCreditArtist = `-- name: DeleteArtistCreditArtist :exec
DELETE FROM artist_credit_artist
WHERE id =?
diff --git a/backend/database/sql/sqlcgen/artists.sql.go b/backend/database/sql/sqlcgen/artists.sql.go
index a74e46f..08cf13d 100644
--- a/backend/database/sql/sqlcgen/artists.sql.go
+++ b/backend/database/sql/sqlcgen/artists.sql.go
@@ -21,6 +21,15 @@ func (q *Queries) CreateArtist(ctx context.Context, name string) (Artist, error)
return i, err
}
+const deleteAllArtists = `-- name: DeleteAllArtists :exec
+DELETE FROM artists
+`
+
+func (q *Queries) DeleteAllArtists(ctx context.Context) error {
+ _, err := q.db.ExecContext(ctx, deleteAllArtists)
+ return err
+}
+
const deleteArtist = `-- name: DeleteArtist :exec
DELETE FROM artists
WHERE id = ?
diff --git a/backend/database/sql/sqlcgen/audio_files.sql.go b/backend/database/sql/sqlcgen/audio_files.sql.go
index 463b403..4a09103 100644
--- a/backend/database/sql/sqlcgen/audio_files.sql.go
+++ b/backend/database/sql/sqlcgen/audio_files.sql.go
@@ -51,6 +51,15 @@ func (q *Queries) CreateAudioFile(ctx context.Context, arg CreateAudioFileParams
return i, err
}
+const deleteAllAudioFiles = `-- name: DeleteAllAudioFiles :exec
+DELETE FROM audio_files
+`
+
+func (q *Queries) DeleteAllAudioFiles(ctx context.Context) error {
+ _, err := q.db.ExecContext(ctx, deleteAllAudioFiles)
+ return err
+}
+
const deleteAudioFile = `-- name: DeleteAudioFile :exec
DELETE FROM audio_files
WHERE id = ?
diff --git a/backend/database/sql/sqlcgen/cover_art.sql.go b/backend/database/sql/sqlcgen/cover_art.sql.go
index 13277ac..3184ad9 100644
--- a/backend/database/sql/sqlcgen/cover_art.sql.go
+++ b/backend/database/sql/sqlcgen/cover_art.sql.go
@@ -32,6 +32,15 @@ func (q *Queries) CreateCoverArt(ctx context.Context, arg CreateCoverArtParams)
return i, err
}
+const deleteAllCoverArt = `-- name: DeleteAllCoverArt :exec
+DELETE FROM cover_art
+`
+
+func (q *Queries) DeleteAllCoverArt(ctx context.Context) error {
+ _, err := q.db.ExecContext(ctx, deleteAllCoverArt)
+ return err
+}
+
const deleteCoverArt = `-- name: DeleteCoverArt :exec
DELETE FROM cover_art
WHERE id = ?
diff --git a/backend/database/sql/sqlcgen/playlists.sql.go b/backend/database/sql/sqlcgen/playlists.sql.go
index d1cc3db..83bcba5 100644
--- a/backend/database/sql/sqlcgen/playlists.sql.go
+++ b/backend/database/sql/sqlcgen/playlists.sql.go
@@ -58,6 +58,15 @@ func (q *Queries) CreatePlaylist(ctx context.Context, name string) (Playlist, er
return i, err
}
+const deleteAllPlaylistTracks = `-- name: DeleteAllPlaylistTracks :exec
+DELETE FROM playlist_tracks
+`
+
+func (q *Queries) DeleteAllPlaylistTracks(ctx context.Context) error {
+ _, err := q.db.ExecContext(ctx, deleteAllPlaylistTracks)
+ return err
+}
+
const deletePlaylist = `-- name: DeletePlaylist :exec
DELETE FROM playlists WHERE id = ?
`
diff --git a/backend/database/sql/sqlcgen/recordings.sql.go b/backend/database/sql/sqlcgen/recordings.sql.go
index cc41590..4519a8b 100644
--- a/backend/database/sql/sqlcgen/recordings.sql.go
+++ b/backend/database/sql/sqlcgen/recordings.sql.go
@@ -86,6 +86,15 @@ func (q *Queries) CreateRecordingFull(ctx context.Context, arg CreateRecordingFu
return i, err
}
+const deleteAllRecordings = `-- name: DeleteAllRecordings :exec
+DELETE FROM recordings
+`
+
+func (q *Queries) DeleteAllRecordings(ctx context.Context) error {
+ _, err := q.db.ExecContext(ctx, deleteAllRecordings)
+ return err
+}
+
const deleteRecording = `-- name: DeleteRecording :exec
DELETE FROM recordings
WHERE id = ?
diff --git a/backend/database/sql/sqlcgen/release_group_recordings.sql.go b/backend/database/sql/sqlcgen/release_group_recordings.sql.go
index 5fee597..22b9b48 100644
--- a/backend/database/sql/sqlcgen/release_group_recordings.sql.go
+++ b/backend/database/sql/sqlcgen/release_group_recordings.sql.go
@@ -41,6 +41,15 @@ func (q *Queries) CreateReleaseGroupRecording(ctx context.Context, arg CreateRel
return i, err
}
+const deleteAllReleaseGroupRecordings = `-- name: DeleteAllReleaseGroupRecordings :exec
+DELETE FROM release_group_recordings
+`
+
+func (q *Queries) DeleteAllReleaseGroupRecordings(ctx context.Context) error {
+ _, err := q.db.ExecContext(ctx, deleteAllReleaseGroupRecordings)
+ return err
+}
+
const deleteReleaseGroupRecording = `-- name: DeleteReleaseGroupRecording :exec
DELETE FROM release_group_recordings
WHERE id = ?
diff --git a/backend/database/sql/sqlcgen/release_groups.sql.go b/backend/database/sql/sqlcgen/release_groups.sql.go
index 6e8a236..2968323 100644
--- a/backend/database/sql/sqlcgen/release_groups.sql.go
+++ b/backend/database/sql/sqlcgen/release_groups.sql.go
@@ -68,6 +68,15 @@ func (q *Queries) CreateReleaseGroupFull(ctx context.Context, arg CreateReleaseG
return i, err
}
+const deleteAllReleaseGroups = `-- name: DeleteAllReleaseGroups :exec
+DELETE FROM release_groups
+`
+
+func (q *Queries) DeleteAllReleaseGroups(ctx context.Context) error {
+ _, err := q.db.ExecContext(ctx, deleteAllReleaseGroups)
+ return err
+}
+
const deleteReleaseGroup = `-- name: DeleteReleaseGroup :exec
DELETE FROM release_groups
WHERE id = ?
diff --git a/backend/events/events.go b/backend/events/events.go
index aeece66..4bd3d0f 100644
--- a/backend/events/events.go
+++ b/backend/events/events.go
@@ -56,5 +56,6 @@ const (
// Library events.
const (
+ LibraryScanStarted = "LibraryScanStarted"
LibraryScanComplete = "LibraryScanComplete"
)
diff --git a/backend/library/library.go b/backend/library/library.go
index 2acee18..15ff597 100644
--- a/backend/library/library.go
+++ b/backend/library/library.go
@@ -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 != "" {
diff --git a/backend/library/rescan.go b/backend/library/rescan.go
new file mode 100644
index 0000000..3111492
--- /dev/null
+++ b/backend/library/rescan.go
@@ -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
+}
diff --git a/backend/metadata/duration.go b/backend/metadata/duration.go
new file mode 100644
index 0000000..147d603
--- /dev/null
+++ b/backend/metadata/duration.go
@@ -0,0 +1,36 @@
+package metadata
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+)
+
+// getTrackDuration returns the duration of an audio file in
+// milliseconds. For MP3 files it uses a fast header-only parser
+// (Xing/VBRI/CBR); for other formats it falls back to a full
+// decode via beep which is already O(1) for FLAC, OGG, and WAV.
+//
+// The file position is undefined after this call.
+func getTrackDuration(f *os.File) (int64, error) {
+ ext := filepath.Ext(f.Name())
+
+ if ext == ".mp3" {
+ return getMP3Duration(f)
+ }
+
+ // FLAC, OGG, and WAV: beep's Decode() + Len() is already
+ // cheap (reads headers/metadata only, no full audio decode).
+ streamer, format, err := DecodeFile(f)
+ if err != nil {
+ return 0, fmt.Errorf("error decoding file: %w", err)
+ }
+
+ lengthMillis := int64(
+ float64(streamer.Len()*1000) /
+ float64(format.SampleRate),
+ )
+ _ = streamer.Close()
+
+ return lengthMillis, nil
+}
diff --git a/backend/metadata/metadata.go b/backend/metadata/metadata.go
index 35e7c55..568558b 100644
--- a/backend/metadata/metadata.go
+++ b/backend/metadata/metadata.go
@@ -2,6 +2,7 @@ package metadata
import (
"fmt"
+ "io"
"os"
)
@@ -50,3 +51,48 @@ func GetTrackLengthMillis(path string) (int64, error) {
return lengthMillis, nil
}
+
+// ExtractAllMetadata opens the file once and extracts both tags and duration.
+// This avoids the overhead of opening the file twice when both are needed.
+// If skipDuration is true, only tags are extracted and lengthMillis is 0.
+func ExtractAllMetadata(
+ path string,
+ skipDuration bool,
+) (*TrackMetadata, int64, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, 0, fmt.Errorf(
+ "could not open file: %w", err,
+ )
+ }
+
+ defer func() { _ = f.Close() }()
+
+ // Extract tags first (reads only headers, fast).
+ tags, err := ExtractTagsFromReader(f)
+ if err != nil {
+ return nil, 0, fmt.Errorf(
+ "could not extract tags from %s: %w", path, err,
+ )
+ }
+
+ if skipDuration {
+ return tags, 0, nil
+ }
+
+ // Seek back to the beginning for duration extraction.
+ if _, err := f.Seek(0, io.SeekStart); err != nil {
+ return tags, 0, fmt.Errorf(
+ "could not seek file for duration: %w", err,
+ )
+ }
+
+ lengthMillis, err := getTrackDuration(f)
+ if err != nil {
+ return tags, 0, fmt.Errorf(
+ "error getting duration for %s: %w", path, err,
+ )
+ }
+
+ return tags, lengthMillis, nil
+}
diff --git a/backend/metadata/mp3duration.go b/backend/metadata/mp3duration.go
new file mode 100644
index 0000000..541b879
--- /dev/null
+++ b/backend/metadata/mp3duration.go
@@ -0,0 +1,329 @@
+package metadata
+
+import (
+ "encoding/binary"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+)
+
+// errNoSyncWord is returned when no valid MP3 frame sync word
+// is found within the search window.
+var errNoSyncWord = errors.New("could not find MP3 sync word")
+
+// maxSyncSearchBytes limits how far we scan for the first sync word
+// after skipping any ID3v2 tag.
+const maxSyncSearchBytes = 64 * 1024
+
+// MPEG version constants.
+const (
+ mpegVersion1 = 3 // 0b11
+ mpegVersion2 = 2 // 0b10
+ mpegVersion2_5 = 0 // 0b00 (unofficial extension)
+)
+
+// bitrateTable maps [versionIndex][bitrateIndex] to kbps.
+// versionIndex 0 = MPEG1, 1 = MPEG2/2.5.
+// bitrateIndex 0 and 15 are invalid.
+//
+//nolint:mnd // lookup table values are from the MPEG spec.
+var bitrateTable = [2][16]int{
+ // MPEG1 Layer 3
+ {0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0},
+ // MPEG2/2.5 Layer 3
+ {0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0},
+}
+
+// sampleRateTable maps [versionIndex][sampleRateIndex] to Hz.
+// versionIndex: 0 = MPEG1, 1 = MPEG2, 2 = MPEG2.5.
+//
+//nolint:mnd // lookup table values are from the MPEG spec.
+var sampleRateTable = [3][4]int{
+ {44100, 48000, 32000, 0}, // MPEG1
+ {22050, 24000, 16000, 0}, // MPEG2
+ {11025, 12000, 8000, 0}, // MPEG2.5
+}
+
+// samplesPerFrame returns the number of PCM samples per MP3 frame
+// for the given MPEG version (Layer 3 only).
+//
+//nolint:mnd // constants from the MPEG spec.
+func samplesPerFrame(version int) int {
+ if version == mpegVersion1 {
+ return 1152
+ }
+
+ return 576 // MPEG2 / MPEG2.5
+}
+
+// getMP3Duration computes the duration of an MP3 file in
+// milliseconds by reading only the first frame's header and any
+// Xing/VBRI VBR header it contains. For CBR files (no VBR header)
+// it falls back to fileSize / bitrate.
+//
+// The file position is undefined after this call.
+func getMP3Duration(f *os.File) (int64, error) {
+ // 1. Skip a leading ID3v2 tag if present.
+ audioStart, err := skipID3v2(f)
+ if err != nil {
+ return 0, fmt.Errorf("skipping ID3v2: %w", err)
+ }
+
+ // 2. Find and parse the first MP3 frame header.
+ hdr, frameOffset, err := findFrameHeader(f, audioStart)
+ if err != nil {
+ return 0, err
+ }
+
+ // 3. Attempt to read a VBR header (Xing/Info or VBRI) from
+ // inside the first frame.
+ vbrFrames, found, err := readVBRHeader(f, hdr, frameOffset)
+ if err != nil {
+ return 0, err
+ }
+
+ if found && vbrFrames > 0 {
+ spf := samplesPerFrame(hdr.version)
+ durationMS := int64(vbrFrames) *
+ int64(spf) * 1000 / int64(hdr.sampleRate)
+
+ return durationMS, nil
+ }
+
+ // 4. CBR fallback: duration = audioBytes * 8 / bitrate.
+ fi, err := f.Stat()
+ if err != nil {
+ return 0, fmt.Errorf("stat file for CBR duration: %w", err)
+ }
+
+ audioBytes := fi.Size() - audioStart
+ durationMS := audioBytes * 8 * 1000 /
+ (int64(hdr.bitrateKbps) * 1000)
+
+ return durationMS, nil
+}
+
+// mpegFrameHeader holds the parsed fields of a 4-byte MPEG audio
+// frame header.
+type mpegFrameHeader struct {
+ version int // mpegVersion1, mpegVersion2, mpegVersion2_5
+ bitrateKbps int
+ sampleRate int
+ channelMode int // 0-3; 3 = mono
+ padding int // 0 or 1
+}
+
+// skipID3v2 checks for an ID3v2 tag at the start of f and returns
+// the byte offset where audio data begins.
+//
+//nolint:mnd // byte offsets from the ID3v2 spec.
+func skipID3v2(f *os.File) (int64, error) {
+ var buf [10]byte
+
+ if _, err := f.ReadAt(buf[:], 0); err != nil {
+ return 0, fmt.Errorf("reading ID3v2 header: %w", err)
+ }
+
+ if string(buf[:3]) != "ID3" {
+ return 0, nil // no ID3v2 tag
+ }
+
+ // Syncsafe integer: 4 bytes, each using 7 bits.
+ size := int64(buf[6])<<21 |
+ int64(buf[7])<<14 |
+ int64(buf[8])<<7 |
+ int64(buf[9])
+
+ return 10 + size, nil
+}
+
+// findFrameHeader scans from startOffset for the first valid MP3
+// sync word and returns the parsed header plus the file offset
+// where the frame begins.
+//
+//nolint:mnd,cyclop // bit manipulation from the MPEG spec.
+func findFrameHeader(
+ f *os.File,
+ startOffset int64,
+) (mpegFrameHeader, int64, error) {
+ if _, err := f.Seek(startOffset, io.SeekStart); err != nil {
+ return mpegFrameHeader{}, 0, fmt.Errorf(
+ "seeking to audio start: %w", err,
+ )
+ }
+
+ // Read a chunk large enough to contain the first frame.
+ buf := make([]byte, maxSyncSearchBytes)
+
+ n, err := io.ReadAtLeast(f, buf, 4)
+ if err != nil {
+ return mpegFrameHeader{}, 0, fmt.Errorf(
+ "reading audio data: %w", err,
+ )
+ }
+
+ buf = buf[:n]
+
+ for i := 0; i <= len(buf)-4; i++ {
+ // Sync word: 11 set bits (0xFF followed by 0xE0 mask).
+ if buf[i] != 0xFF || buf[i+1]&0xE0 != 0xE0 {
+ continue
+ }
+
+ hdr, ok := parseFrameHeader(buf[i : i+4])
+ if !ok {
+ continue
+ }
+
+ return hdr, startOffset + int64(i), nil
+ }
+
+ return mpegFrameHeader{}, 0, errNoSyncWord
+}
+
+// parseFrameHeader decodes a 4-byte MPEG audio frame header.
+// Returns false if the header contains invalid field combinations.
+//
+//nolint:mnd,cyclop // bit manipulation from the MPEG spec.
+func parseFrameHeader(b []byte) (mpegFrameHeader, bool) {
+ version := int((b[1] >> 3) & 0x03)
+ layer := int((b[1] >> 1) & 0x03)
+
+ // We only handle Layer 3.
+ if layer != 1 { // Layer encoding: 1 = Layer 3
+ return mpegFrameHeader{}, false
+ }
+
+ // Determine version index for the bitrate table.
+ var bitrateIdx int
+
+ switch version {
+ case mpegVersion1:
+ bitrateIdx = 0
+ case mpegVersion2, mpegVersion2_5:
+ bitrateIdx = 1
+ default:
+ return mpegFrameHeader{}, false // reserved
+ }
+
+ brIndex := int((b[2] >> 4) & 0x0F)
+ bitrate := bitrateTable[bitrateIdx][brIndex]
+
+ if bitrate == 0 {
+ return mpegFrameHeader{}, false
+ }
+
+ // Sample rate.
+ var srVersionIdx int
+
+ switch version {
+ case mpegVersion1:
+ srVersionIdx = 0
+ case mpegVersion2:
+ srVersionIdx = 1
+ case mpegVersion2_5:
+ srVersionIdx = 2
+ }
+
+ srIndex := int((b[2] >> 2) & 0x03)
+ sampleRate := sampleRateTable[srVersionIdx][srIndex]
+
+ if sampleRate == 0 {
+ return mpegFrameHeader{}, false
+ }
+
+ padding := int((b[2] >> 1) & 0x01)
+ channelMode := int((b[3] >> 6) & 0x03)
+
+ return mpegFrameHeader{
+ version: version,
+ bitrateKbps: bitrate,
+ sampleRate: sampleRate,
+ channelMode: channelMode,
+ padding: padding,
+ }, true
+}
+
+// readVBRHeader tries to read a Xing/Info or VBRI header from the
+// first frame at frameOffset. Returns the total frame count and
+// whether a VBR header was found.
+//
+//nolint:mnd // byte offsets from Xing/VBRI specs.
+func readVBRHeader(
+ f *os.File,
+ hdr mpegFrameHeader,
+ frameOffset int64,
+) (uint32, bool, error) {
+ // Xing/Info header offset depends on version and channel mode.
+ var sideInfoSize int
+
+ switch {
+ case hdr.version == mpegVersion1 && hdr.channelMode != 3:
+ sideInfoSize = 32
+ case hdr.version == mpegVersion1 && hdr.channelMode == 3:
+ sideInfoSize = 17
+ case hdr.channelMode != 3:
+ sideInfoSize = 17
+ default:
+ sideInfoSize = 9
+ }
+
+ // The Xing header sits right after the 4-byte frame header +
+ // side information.
+ xingOffset := frameOffset + 4 + int64(sideInfoSize)
+
+ // Read enough bytes for Xing header (magic + flags + frames).
+ var xingBuf [12]byte
+
+ if _, err := f.ReadAt(xingBuf[:], xingOffset); err != nil {
+ if errors.Is(err, io.EOF) {
+ return 0, false, nil
+ }
+
+ return 0, false, fmt.Errorf(
+ "reading Xing header: %w", err,
+ )
+ }
+
+ magic := string(xingBuf[:4])
+ if magic == "Xing" || magic == "Info" {
+ flags := binary.BigEndian.Uint32(xingBuf[4:8])
+
+ // Bit 0 of flags indicates the frames field is present.
+ if flags&0x01 != 0 {
+ frames := binary.BigEndian.Uint32(xingBuf[8:12])
+
+ return frames, true, nil
+ }
+
+ // Xing header present but no frame count — fall through
+ // to CBR fallback.
+ return 0, true, nil
+ }
+
+ // VBRI header is always at a fixed offset of 36 bytes from
+ // the frame start (regardless of version/channel mode).
+ vbriOffset := frameOffset + 36
+
+ var vbriBuf [26]byte
+
+ if _, err := f.ReadAt(vbriBuf[:], vbriOffset); err != nil {
+ if errors.Is(err, io.EOF) {
+ return 0, false, nil
+ }
+
+ return 0, false, fmt.Errorf(
+ "reading VBRI header: %w", err,
+ )
+ }
+
+ if string(vbriBuf[:4]) == "VBRI" {
+ // Total frames at offset 14 from VBRI magic.
+ frames := binary.BigEndian.Uint32(vbriBuf[14:18])
+
+ return frames, true, nil
+ }
+
+ return 0, false, nil
+}
diff --git a/backend/metadata/mp3duration_test.go b/backend/metadata/mp3duration_test.go
new file mode 100644
index 0000000..e3a9dc0
--- /dev/null
+++ b/backend/metadata/mp3duration_test.go
@@ -0,0 +1,117 @@
+package metadata
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// testMP3Files returns the paths to all .mp3 files in the test_data
+// directory. It skips the test if none are found.
+func testMP3Files(t *testing.T) []string {
+ t.Helper()
+
+ root := filepath.Join("..", "..", "test_data")
+
+ var files []string
+
+ err := filepath.Walk(root, func(
+ path string, info os.FileInfo, err error,
+ ) error {
+ if err != nil {
+ return err
+ }
+
+ if !info.IsDir() && filepath.Ext(path) == ".mp3" {
+ files = append(files, path)
+ }
+
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("walking test_data: %v", err)
+ }
+
+ if len(files) == 0 {
+ t.Skip("no .mp3 test fixtures found in test_data/")
+ }
+
+ return files
+}
+
+// TestGetMP3Duration_MatchesBeepDecode verifies that the fast
+// header-only parser produces a duration within 1 second of the
+// full decode via beep, for every test MP3 file.
+func TestGetMP3Duration_MatchesBeepDecode(t *testing.T) {
+ for _, path := range testMP3Files(t) {
+ t.Run(filepath.Base(path), func(t *testing.T) {
+ // Reference value: full beep decode.
+ refMS, err := GetTrackLengthMillis(path)
+ if err != nil {
+ t.Fatalf(
+ "beep decode failed: %v", err,
+ )
+ }
+
+ // Fast path.
+ f, err := os.Open(path)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+
+ defer func() { _ = f.Close() }()
+
+ fastMS, err := getMP3Duration(f)
+ if err != nil {
+ t.Fatalf(
+ "getMP3Duration failed: %v", err,
+ )
+ }
+
+ diffMS := refMS - fastMS
+ if diffMS < 0 {
+ diffMS = -diffMS
+ }
+
+ // Allow up to 1 second of difference to account
+ // for rounding and the slight inaccuracy of the
+ // CBR fallback for VBR-without-Xing files.
+ const toleranceMS = 1000
+
+ t.Logf(
+ "beep=%dms fast=%dms diff=%dms",
+ refMS, fastMS, diffMS,
+ )
+
+ if diffMS > toleranceMS {
+ t.Errorf(
+ "duration mismatch: beep=%dms fast=%dms "+
+ "(diff %dms exceeds %dms tolerance)",
+ refMS, fastMS, diffMS, toleranceMS,
+ )
+ }
+ })
+ }
+}
+
+// TestGetMP3Duration_BasicParsing exercises the parser on a single
+// file and verifies a positive duration is returned.
+func TestGetMP3Duration_BasicParsing(t *testing.T) {
+ files := testMP3Files(t)
+
+ f, err := os.Open(files[0])
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+
+ defer func() { _ = f.Close() }()
+
+ ms, err := getMP3Duration(f)
+ if err != nil {
+ t.Fatalf("getMP3Duration: %v", err)
+ }
+
+ if ms <= 0 {
+ t.Errorf("expected positive duration, got %d", ms)
+ }
+}
diff --git a/backend/queue/queue.go b/backend/queue/queue.go
index 3323d7f..d813be2 100644
--- a/backend/queue/queue.go
+++ b/backend/queue/queue.go
@@ -1185,6 +1185,29 @@ func (q *Queue) GetState() State {
}
}
+// Clear removes all tracks from the queue, stops playback, and
+// resets the queue state. It persists the cleared state and
+// notifies the frontend.
+func (q *Queue) Clear() {
+ q.mu.Lock()
+ defer q.mu.Unlock()
+
+ q.logger.Info("Clearing queue")
+
+ q.tracks = nil
+ q.currentIndex = -1
+ q.shuffleOrder = nil
+ q.sourcePlaylistID = 0
+
+ if q.player != nil {
+ q.player.UnloadTrack()
+ }
+
+ q.persistTracks()
+ q.persistState()
+ q.emitQueueChanged()
+}
+
// EmitCurrentState emits the current queue state to the frontend.
// This is called after the frontend DOM is ready.
func (q *Queue) EmitCurrentState() {
diff --git a/frontend/index.ts b/frontend/index.ts
index 23b4e78..440e04a 100644
--- a/frontend/index.ts
+++ b/frontend/index.ts
@@ -5,6 +5,7 @@ import '@components/now-playing/now-playing.ts';
import '@components/sidebar/app-sidebar.ts';
import '@components/queue-panel/queue-panel.ts';
import '@components/playlist-view/playlist-view.ts';
+import '@components/library-manager/library-manager.ts';
import '@awesome.me/webawesome/dist/styles/themes/default.css';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js';
@@ -36,6 +37,9 @@ document.addEventListener('navigate', (e: Event) => {
case 'playlists':
mainContent.innerHTML = '
Coming soon: ${view}
diff --git a/frontend/src/components/library-manager/library-manager.ts b/frontend/src/components/library-manager/library-manager.ts new file mode 100644 index 0000000..5430fa6 --- /dev/null +++ b/frontend/src/components/library-manager/library-manager.ts @@ -0,0 +1,329 @@ +import { LitElement, html, css } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; +import { EventsOn, EventsOff } from '@runtime/runtime'; +import { Scan, FullRescan } from '@go/library/Library'; +import { + GetLibraryDirectory, + SetLibraryDirectory, +} from '@go/config/Config'; +import { DirectoryPicker } from '@go/frontendutil/FrontendUtil'; +import { Events } from '../../events'; + +@customElement('library-manager') +export class LibraryManager extends LitElement { + @state() private libraryDirectory = ''; + @state() private selectedDirectory = ''; + @state() private scanning = false; + @state() private statusMessage = ''; + + static override styles = css` + :host { + display: block; + padding: 1.5em; + color: #e9ecef; + font-family: system-ui, -apple-system, sans-serif; + overflow-y: auto; + } + + h2 { + margin: 0 0 1em 0; + font-size: 1.4em; + font-weight: 600; + color: #f8f9fa; + } + + .section { + margin-bottom: 2em; + padding: 1.25em; + background: #2b3035; + border-radius: 8px; + } + + .section-title { + margin: 0 0 0.75em 0; + font-size: 1em; + font-weight: 600; + color: #dee2e6; + } + + .section-description { + margin: 0 0 1em 0; + font-size: 0.85em; + color: #868e96; + line-height: 1.4; + } + + .directory-row { + display: flex; + align-items: center; + gap: 0.75em; + margin-bottom: 1em; + } + + .directory-path { + flex: 1; + padding: 0.5em 0.75em; + background: #1a1d20; + border: 1px solid #495057; + border-radius: 4px; + color: #adb5bd; + font-size: 0.85em; + font-family: monospace; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-height: 1.2em; + } + + .directory-path.has-value { + color: #e9ecef; + } + + button { + padding: 0.5em 1.25em; + border: none; + border-radius: 4px; + font-size: 0.85em; + font-weight: 500; + cursor: pointer; + transition: background-color 0.15s ease; + white-space: nowrap; + } + + button:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .btn-primary { + background: #4263eb; + color: white; + } + + .btn-primary:hover:not(:disabled) { + background: #3b5bdb; + } + + .btn-success { + background: #2f9e44; + color: white; + } + + .btn-success:hover:not(:disabled) { + background: #2b8a3e; + } + + .btn-warning { + background: #e8590c; + color: white; + } + + .btn-warning:hover:not(:disabled) { + background: #d9480f; + } + + .btn-danger { + background: #e03131; + color: white; + } + + .btn-danger:hover:not(:disabled) { + background: #c92a2a; + } + + .scan-actions { + display: flex; + gap: 0.75em; + flex-wrap: wrap; + } + + .status-bar { + margin-top: 1.5em; + padding: 0.75em 1em; + background: #1a1d20; + border-radius: 4px; + font-size: 0.85em; + color: #868e96; + min-height: 1.2em; + } + + .status-bar.active { + color: #ffd43b; + } + `; + + override connectedCallback(): void { + super.connectedCallback(); + this.loadCurrentDirectory(); + + EventsOn( + Events.LibraryScanStarted, + this.handleScanStarted, + ); + EventsOn( + Events.LibraryScanComplete, + this.handleScanComplete, + ); + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + EventsOff(Events.LibraryScanStarted); + EventsOff(Events.LibraryScanComplete); + } + + private async loadCurrentDirectory(): Promise+ Library Directory +
++ Select the root directory containing your + music files. Changing this will + automatically trigger a scan. +
+Scan Actions
++ Soft scan finds new files, skips existing + ones, and removes orphaned entries. Full + rescan clears the entire database and + cover art cache, then re-imports + everything. +
+