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
This commit is contained in:
2026-03-18 13:02:30 -04:00
parent 3dba0e143c
commit f557ffd652
4 changed files with 209 additions and 8 deletions
+139 -7
View File
@@ -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
}
+57
View File
@@ -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 {
+5 -1
View File
@@ -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<string>,arg2:tagwriter.TagChanges):Promise<tagwriter.BatchResult>;
export function CancelBatchWrite():Promise<void>;
export function SetContext(arg1:context.Context):Promise<void>;
@@ -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);
}