package tagwriter import ( "context" "errors" "fmt" "log/slog" "time" "github.com/wailsapp/wails/v3/pkg/application" "yellowjacket/backend/database" "yellowjacket/backend/events" ) // errNoChanges is returned when WriteTrackTags is called with an // empty TagChanges map. var errNoChanges = errors.New("tagwriter: no changes provided") // BatchFailure records a single track that failed during a batch write. type BatchFailure struct { FilePath string `json:"filePath"` Error string `json:"error"` } // BatchResult summarises the outcome of a batch tag write. type BatchResult struct { Total int `json:"total"` Succeeded int `json:"succeeded"` Failed int `json:"failed"` Cancelled bool `json:"cancelled"` Failures []BatchFailure `json:"failures"` } // PlayerStopper checks whether a file is currently playing and // stops playback if needed. Defined as an interface to break the // import cycle between tagwriter and player. type PlayerStopper interface { // CurrentFilePath returns the file path of the currently- // loaded track, or empty string if nothing is loaded. CurrentFilePath() string // StopAndRelease stops playback and releases the file handle. StopAndRelease() } // PipelineLocker abstracts the library's pipeline mutex for // scan/write mutual exclusion. type PipelineLocker interface { AcquirePipelineLock() ReleasePipelineLock() } // TagWriter orchestrates the complete tag writing pipeline: // file write → DB sync → event emission. type TagWriter struct { logger *slog.Logger db *database.DB ctx context.Context // Wails context for event emission player PlayerStopper library PipelineLocker cancelBatch chan struct{} // Signals batch cancellation. suppressEvents bool // Suppresses per-track events during batch. } // NewTagWriter creates a TagWriter with the given dependencies. // Call SetContext after the Wails runtime is available. func NewTagWriter( logger *slog.Logger, db *database.DB, player PlayerStopper, library PipelineLocker, ) *TagWriter { return &TagWriter{ logger: logger.WithGroup("tagwriter"), db: db, player: player, library: library, } } // ServiceStartup is v3's service lifecycle hook: it runs once the // runtime exists, and ctx is cancelled when the app shuts down. It // replaces v2's SetContext, which had to be called by hand from // OnStartup and was exported, so it was also bound to the frontend. func (tw *TagWriter) ServiceStartup( ctx context.Context, _ application.ServiceOptions, ) error { tw.ctx = ctx return nil } // WriteTrackTags is the single entry point for writing metadata to // a track's audio file and synchronising all changes to the // database. It accepts a track ID (audio_file.id) and a diff map // of changed fields. // // The pipeline: // 1. Look up track from DB. // 2. Detect audio format. // 3. Acquire pipeline lock (mutual exclusion with scan). // 4. Stop player if this file is currently playing. // 5. Write tags to file (format-specific writer). // 6. Sync database (entity relink, FTS5, orphan cleanup). // 7. Emit TrackMetadataChanged event. func (tw *TagWriter) WriteTrackTags(trackID int64, changes TagChanges) error { start := time.Now() ctx := context.Background() if len(changes) == 0 { return errNoChanges } // 1. Look up track. audioFile, err := tw.db.Queries.GetAudioFile(ctx, trackID) if err != nil { return fmt.Errorf("get audio file %d: %w", trackID, err) } // 2. Detect format. format, err := DetectFormat(audioFile.FilePath) if err != nil { return fmt.Errorf("detect format: %w", err) } // 3. Acquire pipeline lock. tw.library.AcquirePipelineLock() defer tw.library.ReleasePipelineLock() // 4. Player safety check. if tw.player != nil && tw.player.CurrentFilePath() == audioFile.FilePath { tw.logger.Info("stopping playback for tag write", "path", audioFile.FilePath) tw.player.StopAndRelease() } // 5. Write file tags. switch format { case FormatMP3: err = writeMp3Tags(tw.logger, audioFile.FilePath, changes) case FormatFLAC: err = writeFlacTags(tw.logger, audioFile.FilePath, changes) case FormatWAV: err = writeWavTags(tw.logger, audioFile.FilePath, changes) case FormatOGG: err = writeOggTags(tw.logger, audioFile.FilePath, changes) default: err = fmt.Errorf("%w: %s", errUnsupportedFormat, format) } if err != nil { return fmt.Errorf("write file tags: %w", err) } // 6. Sync database. if syncErr := syncDatabase(ctx, tw.logger, tw.db, dbSyncParams{ audioFileID: audioFile.ID, filePath: audioFile.FilePath, changes: changes, oldFile: audioFile, }); syncErr != nil { tw.logger.Error("db sync failed after successful file write", "err", syncErr, "trackID", trackID, "path", audioFile.FilePath, ) return fmt.Errorf("sync database: %w", syncErr) } // 7. Emit event (suppressed during batch writes). if !tw.suppressEvents { events.Emit(tw.ctx, events.TrackMetadataChanged, map[string]any{ "trackId": trackID, "filePath": audioFile.FilePath, }, ) } tw.logger.Info("tag write complete", "trackID", trackID, "path", audioFile.FilePath, "duration", time.Since(start), "changedFields", len(changes), ) return nil } // WriteUntrackedFileTags writes tags to a file that is not in the // library, skipping every step of the full pipeline that assumes it is: // no audio_file lookup, no database sync, no event. // // This exists for the download import path, which tags files while they // are still in the staging directory. Tagging before the move is what // makes the import atomic from the library's point of view — the // scanner only ever sees a finished, correctly tagged file, instead of // ingesting a mislabelled one and being corrected afterwards. // // Callers are responsible for ensuring the file is not in a library // path; using this on a tracked file would leave the database stale. func (tw *TagWriter) WriteUntrackedFileTags( filePath string, changes TagChanges, ) error { if len(changes) == 0 { return errNoChanges } return WriteFileTags(tw.logger, filePath, changes) } // WriteFileTags writes tags straight to an audio file, with no // database, player or lock involvement. It is the format-dispatch // half of WriteUntrackedFileTags, exported so tooling that has no // app to construct — the fixture generator in cmd/gentestdata — can // tag files with the same writers the app uses, rather than growing a // second tagger that is free to drift from this one. // // Callers owning a *TagWriter should use WriteUntrackedFileTags. func WriteFileTags( logger *slog.Logger, filePath string, changes TagChanges, ) error { format, err := DetectFormat(filePath) if err != nil { return fmt.Errorf("detect format: %w", err) } switch format { case FormatMP3: err = writeMp3Tags(logger, filePath, changes) case FormatFLAC: err = writeFlacTags(logger, filePath, changes) case FormatWAV: err = writeWavTags(logger, filePath, changes) case FormatOGG: err = writeOggTags(logger, filePath, changes) default: err = fmt.Errorf("%w: %s", errUnsupportedFormat, format) } if err != nil { return fmt.Errorf("write file tags: %w", err) } return nil } // WriteTrackTagsByPath resolves a file path to its audio_file.id and // delegates to WriteTrackTags. This is the frontend-facing entry // point since the frontend identifies tracks by FilePath. func (tw *TagWriter) WriteTrackTagsByPath(filePath string, changes TagChanges) error { ctx := context.Background() audioFile, err := tw.db.Queries.GetAudioFileByPath(ctx, filePath) if err != nil { return fmt.Errorf("resolve track by path %q: %w", filePath, err) } return tw.WriteTrackTags(audioFile.ID, changes) } // CancelBatchWrite signals the in-progress batch write to stop after // the current track completes. func (tw *TagWriter) CancelBatchWrite() { ch := tw.cancelBatch if ch != nil { select { case <-ch: // Already closed. default: close(ch) } } } // BatchWriteTrackTags applies the same TagChanges to every file in // filePaths. It processes tracks sequentially, emits a // BatchWriteProgress event after each track, and continues past // individual failures. Returns a BatchResult summarising outcomes. func (tw *TagWriter) BatchWriteTrackTags( filePaths []string, changes TagChanges, ) BatchResult { start := time.Now() total := len(filePaths) result := BatchResult{ Total: total, Failures: []BatchFailure{}, } if total == 0 || len(changes) == 0 { return result } // Set up cancellation channel. tw.cancelBatch = make(chan struct{}) defer func() { tw.cancelBatch = nil }() // Suppress per-track TrackMetadataChanged events — we emit one // at the end instead. tw.suppressEvents = true defer func() { tw.suppressEvents = false }() for i, filePath := range filePaths { // Check for cancellation before each track. select { case <-tw.cancelBatch: result.Cancelled = true tw.logger.Info("batch write cancelled", "at", i, "total", total, ) default: } if result.Cancelled { break } err := tw.WriteTrackTagsByPath(filePath, changes) if err != nil { result.Failed++ result.Failures = append(result.Failures, BatchFailure{ FilePath: filePath, Error: err.Error(), }) tw.logger.Warn("batch track failed", "path", filePath, "err", err, "index", i+1, "total", total, ) } else { result.Succeeded++ } // Emit progress after each track (success or failure). events.Emit(tw.ctx, events.BatchWriteProgress, map[string]any{ "current": i + 1, "total": total, "filePath": filePath, "succeeded": result.Succeeded, "failed": result.Failed, }, ) } // Emit a single TrackMetadataChanged after the batch completes // so the library store invalidates once rather than per-track. events.Emit(tw.ctx, events.TrackMetadataChanged, map[string]any{ "batch": true, "total": result.Succeeded, }, ) tw.logger.Info("batch write complete", "total", total, "succeeded", result.Succeeded, "failed", result.Failed, "cancelled", result.Cancelled, "duration", time.Since(start), ) return result }