From f557ffd652179b7cf8f8ff4a06824f30edf08007 Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Wed, 18 Mar 2026 13:02:30 -0400 Subject: [PATCH] feat(18-01): add BatchWriteTrackTags with progress, cancellation, and partial failure - Add BatchFailure and BatchResult types for structured batch outcomes - Add cancelBatch channel and suppressEvents flag to TagWriter struct - Add CancelBatchWrite method for mid-batch cancellation from frontend - Add BatchWriteTrackTags method processing tracks sequentially - Emit BatchWriteProgress event per-track with current/total/succeeded/failed - Suppress per-track TrackMetadataChanged; emit single event after batch - Wails bindings auto-generated for BatchWriteTrackTags and CancelBatchWrite - tagwriter namespace with BatchResult/BatchFailure in models.ts --- backend/tagwriter/pipeline.go | 146 ++++++++++++++++++- frontend/wailsjs/go/models.ts | 57 ++++++++ frontend/wailsjs/go/tagwriter/TagWriter.d.ts | 6 +- frontend/wailsjs/go/tagwriter/TagWriter.js | 8 + 4 files changed, 209 insertions(+), 8 deletions(-) diff --git a/backend/tagwriter/pipeline.go b/backend/tagwriter/pipeline.go index 75128f0..48be84d 100644 --- a/backend/tagwriter/pipeline.go +++ b/backend/tagwriter/pipeline.go @@ -17,6 +17,21 @@ import ( // 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. @@ -38,11 +53,13 @@ type PipelineLocker interface { // 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 + 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. @@ -153,8 +170,8 @@ func (tw *TagWriter) WriteTrackTags(trackID int64, changes TagChanges) error { return fmt.Errorf("sync database: %w", syncErr) } - // 7. Emit event. - if tw.ctx != nil { + // 7. Emit event (suppressed during batch writes). + if tw.ctx != nil && !tw.suppressEvents { wailsruntime.EventsEmit(tw.ctx, events.TrackMetadataChanged, map[string]any{ "trackId": trackID, @@ -186,3 +203,118 @@ func (tw *TagWriter) WriteTrackTagsByPath(filePath string, changes TagChanges) e 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, + ) + + break + 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). + if tw.ctx != nil { + wailsruntime.EventsEmit(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. + if tw.ctx != nil { + wailsruntime.EventsEmit(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 +} diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 0e5caac..58af64c 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -672,6 +672,63 @@ export namespace sqlcgen { } +export namespace tagwriter { + + export class BatchFailure { + filePath: string; + error: string; + + static createFrom(source: any = {}) { + return new BatchFailure(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.filePath = source["filePath"]; + this.error = source["error"]; + } + } + export class BatchResult { + total: number; + succeeded: number; + failed: number; + cancelled: boolean; + failures: BatchFailure[]; + + static createFrom(source: any = {}) { + return new BatchResult(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.total = source["total"]; + this.succeeded = source["succeeded"]; + this.failed = source["failed"]; + this.cancelled = source["cancelled"]; + this.failures = this.convertValues(source["failures"], BatchFailure); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + +} + export namespace tracklist { export class Column { diff --git a/frontend/wailsjs/go/tagwriter/TagWriter.d.ts b/frontend/wailsjs/go/tagwriter/TagWriter.d.ts index dc57e7b..4b8b643 100755 --- a/frontend/wailsjs/go/tagwriter/TagWriter.d.ts +++ b/frontend/wailsjs/go/tagwriter/TagWriter.d.ts @@ -1,7 +1,11 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT -import {context} from '../models'; import {tagwriter} from '../models'; +import {context} from '../models'; + +export function BatchWriteTrackTags(arg1:Array,arg2:tagwriter.TagChanges):Promise; + +export function CancelBatchWrite():Promise; export function SetContext(arg1:context.Context):Promise; diff --git a/frontend/wailsjs/go/tagwriter/TagWriter.js b/frontend/wailsjs/go/tagwriter/TagWriter.js index b29810f..f3f7226 100755 --- a/frontend/wailsjs/go/tagwriter/TagWriter.js +++ b/frontend/wailsjs/go/tagwriter/TagWriter.js @@ -2,6 +2,14 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +export function BatchWriteTrackTags(arg1, arg2) { + return window['go']['tagwriter']['TagWriter']['BatchWriteTrackTags'](arg1, arg2); +} + +export function CancelBatchWrite() { + return window['go']['tagwriter']['TagWriter']['CancelBatchWrite'](); +} + export function SetContext(arg1) { return window['go']['tagwriter']['TagWriter']['SetContext'](arg1); }