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.
|
// empty TagChanges map.
|
||||||
var errNoChanges = errors.New("tagwriter: no changes provided")
|
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
|
// PlayerStopper checks whether a file is currently playing and
|
||||||
// stops playback if needed. Defined as an interface to break the
|
// stops playback if needed. Defined as an interface to break the
|
||||||
// import cycle between tagwriter and player.
|
// import cycle between tagwriter and player.
|
||||||
@@ -38,11 +53,13 @@ type PipelineLocker interface {
|
|||||||
// TagWriter orchestrates the complete tag writing pipeline:
|
// TagWriter orchestrates the complete tag writing pipeline:
|
||||||
// file write → DB sync → event emission.
|
// file write → DB sync → event emission.
|
||||||
type TagWriter struct {
|
type TagWriter struct {
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
db *database.DB
|
db *database.DB
|
||||||
ctx context.Context // Wails context for event emission
|
ctx context.Context // Wails context for event emission
|
||||||
player PlayerStopper
|
player PlayerStopper
|
||||||
library PipelineLocker
|
library PipelineLocker
|
||||||
|
cancelBatch chan struct{} // Signals batch cancellation.
|
||||||
|
suppressEvents bool // Suppresses per-track events during batch.
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTagWriter creates a TagWriter with the given dependencies.
|
// 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)
|
return fmt.Errorf("sync database: %w", syncErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Emit event.
|
// 7. Emit event (suppressed during batch writes).
|
||||||
if tw.ctx != nil {
|
if tw.ctx != nil && !tw.suppressEvents {
|
||||||
wailsruntime.EventsEmit(tw.ctx, events.TrackMetadataChanged,
|
wailsruntime.EventsEmit(tw.ctx, events.TrackMetadataChanged,
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"trackId": trackID,
|
"trackId": trackID,
|
||||||
@@ -186,3 +203,118 @@ func (tw *TagWriter) WriteTrackTagsByPath(filePath string, changes TagChanges) e
|
|||||||
|
|
||||||
return tw.WriteTrackTags(audioFile.ID, changes)
|
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 namespace tracklist {
|
||||||
|
|
||||||
export class Column {
|
export class Column {
|
||||||
|
|||||||
+5
-1
@@ -1,7 +1,11 @@
|
|||||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||||
// This file is automatically generated. DO NOT EDIT
|
// This file is automatically generated. DO NOT EDIT
|
||||||
import {context} from '../models';
|
|
||||||
import {tagwriter} 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>;
|
export function SetContext(arg1:context.Context):Promise<void>;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||||
// This file is automatically generated. DO NOT EDIT
|
// 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) {
|
export function SetContext(arg1) {
|
||||||
return window['go']['tagwriter']['TagWriter']['SetContext'](arg1);
|
return window['go']['tagwriter']['TagWriter']['SetContext'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user