Files
yellowjacket/backend/library/library.go
T
yonlu 176b32fb0d frontend fixes
album artist gets populated with artist as fallback (frontend also uses
this as display name fallback for albums). added scroll position
persistence when switching main views. also added frontend cache for
faster switching.
2026-02-16 14:38:09 -05:00

669 lines
17 KiB
Go

package library
import (
"context"
"database/sql"
"errors"
"fmt"
"io/fs"
"log/slog"
"os"
"path/filepath"
goruntime "runtime"
"slices"
"strings"
"sync"
"sync/atomic"
"github.com/wailsapp/wails/v2/pkg/runtime"
"golang.org/x/sync/errgroup"
"yellowjacket/backend/database"
"yellowjacket/backend/database/sql/sqlcgen"
"yellowjacket/backend/events"
"yellowjacket/backend/metadata"
)
var errLibraryDirNotConfigured = errors.New("library directory not configured")
// Library manages scanning and querying the music collection.
type Library struct {
ctx context.Context
logger *slog.Logger
conf *Config
db *database.DB
}
// NewLibrary creates a new library with the given configuration.
// A nil config is permitted; the library will be inert until a valid
// configuration is supplied via the LibraryConfigChanged event.
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
}
// SetContext sets the Wails runtime context and registers event handlers.
func (l *Library) SetContext(ctx context.Context) {
l.ctx = ctx
l.registerEventHandlers()
}
func (l *Library) registerEventHandlers() {
if l.ctx == nil {
l.logger.Error("Context is nil, cannot register event handlers")
return
}
runtime.EventsOn(l.ctx, events.LibraryConfigChanged, func(data ...any) {
l.logger.Info("Received LibraryConfigChanged event")
if len(data) == 0 {
l.logger.Error("LibraryConfigChanged event received with no data")
return
}
configMap, ok := data[0].(map[string]any)
if !ok {
l.logger.Error("LibraryConfigChanged event data is not a map", "data", data[0])
return
}
dir, ok := configMap["DirectoryPath"].(string)
if !ok {
l.logger.Error("DirectoryPath not found or not a string in config event")
return
}
updatedConfig := Config{DirectoryPath: Directory(dir)}
if err := l.handleConfigUpdate(updatedConfig); err != nil {
l.logger.Error("Failed to handle config update", "err", err)
}
})
}
// 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)
if len(l.conf.DirectoryPath) == 0 {
return errLibraryDirNotConfigured
}
// Load existing file paths from the database into a sync.Map for concurrent access.
// The map tracks path → audioFile; entries are removed as files are "seen" during the walk.
// Any entries remaining after the walk are orphans (files deleted from disk).
existingFiles, err := l.db.Queries.GetAllAudioFiles(l.ctx)
if err != nil {
return fmt.Errorf("could not load existing audio files: %w", err)
}
existingPaths := &sync.Map{}
for _, f := range existingFiles {
existingPaths.Store(f.FilePath, f)
}
l.logger.Debug(
"loaded existing files from database",
"count", len(existingFiles),
"library-directory", l.conf.DirectoryPath,
)
basePath := string(l.conf.DirectoryPath)
workChan := make(chan scanWork, 100)
resultChan := make(chan importResult, 100)
var added, skipped, updated atomic.Int64
var scanErr error
var errMu sync.Mutex
// Walker goroutine: traverse directory and send work items to workers
go func() {
defer 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
}
// Check if file already exists in database
if existing, exists := existingPaths.LoadAndDelete(absoluteFilePath); exists {
audioFile := existing.(sqlcgen.AudioFile)
// Check if this file needs metadata update (recording_id = 0)
if audioFile.RecordingID == 0 {
l.logger.Debug("file needs metadata update", "path", absoluteFilePath)
select {
case workChan <- scanWork{
absolutePath: absoluteFilePath,
fileType: fileType,
existingFileID: audioFile.ID,
needsUpdate: true,
existingLength: audioFile.LengthMilliseconds,
}:
case <-l.ctx.Done():
return l.ctx.Err()
}
return nil
}
l.logger.Debug(
"file already in library with metadata, skipping",
"path",
absoluteFilePath,
)
skipped.Add(1)
return nil
}
l.logger.Debug("queueing file for import", "path", absoluteFilePath)
// Send to workers for processing
select {
case workChan <- scanWork{absolutePath: absoluteFilePath, fileType: fileType}:
case <-l.ctx.Done():
return l.ctx.Err()
}
return nil
},
)
if walkErr != nil {
errMu.Lock()
scanErr = errors.Join(
scanErr,
fmt.Errorf("problem walking library directory: %w", walkErr),
)
errMu.Unlock()
}
}()
// DB writer goroutine: serialize all database writes to avoid SQLite contention
var dbWg sync.WaitGroup
dbWg.Add(1)
go func() {
defer dbWg.Done()
for result := range resultChan {
var saveErr error
if result.needsUpdate {
saveErr = l.updateAudioFileMetadata(result)
if saveErr == nil {
updated.Add(1)
}
} else {
saveErr = l.saveAudioFile(result)
if saveErr == nil {
added.Add(1)
}
}
if saveErr != nil {
l.logger.Warn(
"failed to save audio file",
"path",
result.absolutePath,
"err",
saveErr,
)
errMu.Lock()
scanErr = errors.Join(scanErr, saveErr)
errMu.Unlock()
}
}
}()
// Worker pool: extract metadata concurrently, send results to DB writer
g := new(errgroup.Group)
g.SetLimit(scanWorkerCount)
for work := range workChan {
g.Go(func() error {
result, err := l.extractAudioMetadata(work)
if err != nil {
l.logger.Warn("failed to extract metadata", "path", work.absolutePath, "err", err)
errMu.Lock()
scanErr = errors.Join(scanErr, err)
errMu.Unlock()
return nil // continue processing other files
}
// Send to DB writer
select {
case resultChan <- result:
case <-l.ctx.Done():
return l.ctx.Err()
}
return nil
})
}
_ = g.Wait() // Wait for all metadata extraction to complete
close(resultChan) // Signal DB writer to finish
dbWg.Wait() // Wait for all DB writes to complete
// Orphan cleanup: any entries remaining in existingPaths are files deleted from disk
var removed atomic.Int64
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,
)
return true
}
removed.Add(1)
return true
})
// Generate thumbnails for any cover art that doesn't have one yet.
if err := l.generateMissingThumbnails(); err != nil {
l.logger.Warn("could not generate missing thumbnails", "err", err)
}
l.logger.Info(
"library scan complete",
"added", added.Load(),
"updated", updated.Load(),
"removed", removed.Load(),
"skipped", skipped.Load(),
"library", l.conf.DirectoryPath,
)
runtime.EventsEmit(l.ctx, events.LibraryScanComplete)
return scanErr
}
// scanWorkerCount controls the number of concurrent file processors.
// TODO: make configurable via Config.
var scanWorkerCount = 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
}
// importResult holds metadata extracted by workers, ready for DB insertion.
type importResult struct {
absolutePath string
fileType metadata.AudioFileExtension
lengthMillis int64
tags *metadata.TrackMetadata
existingFileID int64 // non-zero if this is an update
needsUpdate bool
}
// extractAudioMetadata reads and extracts metadata from an audio file.
func (l *Library) extractAudioMetadata(work scanWork) (importResult, error) {
result := importResult{
absolutePath: work.absolutePath,
fileType: work.fileType,
existingFileID: work.existingFileID,
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,
)
}
result.lengthMillis = trackLengthMillis
}
// Extract tags
tags, err := metadata.ExtractTags(work.absolutePath)
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{}
}
result.tags = tags
return result, nil
}
// saveAudioFile writes audio file metadata to the database (new files).
func (l *Library) saveAudioFile(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)),
)
// Process metadata and create related records
recordingID, err := l.processMetadata(result)
if err != nil {
return fmt.Errorf("could not process metadata: %w", err)
}
if _, err := l.db.Queries.CreateAudioFile(
l.ctx, sqlcgen.CreateAudioFileParams{
FilePath: result.absolutePath,
LengthMilliseconds: result.lengthMillis,
FileTypeID: int64(
slices.Index(metadata.SupportedFileExtensions, result.fileType),
),
RecordingID: recordingID,
}); err != nil {
return fmt.Errorf("could not save audio file to db: %w", 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(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)
if err != nil {
return fmt.Errorf("could not process metadata: %w", err)
}
if err := l.db.Queries.UpdateAudioFileRecording(
l.ctx, sqlcgen.UpdateAudioFileRecordingParams{
RecordingID: recordingID,
ID: result.existingFileID,
}); err != nil {
return fmt.Errorf("could not update audio file recording: %w", 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.
func (l *Library) processMetadata(result importResult) (int64, error) {
tags := result.tags
if tags == nil {
tags = &metadata.TrackMetadata{}
}
// 1. Handle cover art (if present)
var coverArtID sql.NullInt64
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
artistName := tags.Artist
if artistName == "" {
artistName = "Unknown Artist"
}
artistCredit, err := l.db.Queries.UpsertArtistCredit(l.ctx, artistName)
if err != nil {
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,
})
}
// 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
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,
}
// 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),
})
if err != nil {
return 0, fmt.Errorf("could not create recording: %w", err)
}
// 6. Link recording to release group
if releaseGroupID.Valid {
_, err = l.db.Queries.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)
}
}
return recording.ID, nil
}
// getRecordingName returns the track title, or falls back to the filename.
func (l *Library) getRecordingName(tags *metadata.TrackMetadata, filePath string) string {
if tags.Title != "" {
return tags.Title
}
// Fallback to filename without extension
base := filepath.Base(filePath)
return strings.TrimSuffix(base, filepath.Ext(base))
}
// toNullInt64 converts an int to sql.NullInt64, treating 0 as null.
func toNullInt64(v int) sql.NullInt64 {
if v == 0 {
return sql.NullInt64{}
}
return sql.NullInt64{Int64: int64(v), Valid: true}
}
// toNullString converts a string to sql.NullString, treating empty as null.
func toNullString(v string) sql.NullString {
if v == "" {
return sql.NullString{}
}
return sql.NullString{String: v, Valid: true}
}
func (l *Library) handleConfigUpdate(updatedConfigValues Config) error {
l.logger.Info("handling config update", "updated", updatedConfigValues)
var updateErr error
if l.conf.DirectoryPath != updatedConfigValues.DirectoryPath {
l.logger.Info("new library, scanning")
l.conf.DirectoryPath = updatedConfigValues.DirectoryPath
if err := l.Scan(); err != nil {
updateErr = errors.Join(
updateErr,
fmt.Errorf("problem scanning library on config update: %w", err),
)
}
}
return updateErr
}