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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user