diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index aacae3e..a7d7b58 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -101,7 +101,10 @@ Plans: 2. Each field in the batch editor shows one of three states: "keep original" (mixed values, no change), "set to value" (apply this value to all selected tracks), or "clear field" (remove this value from all) — the user can see which fields differ across the selection and choose per-field what to do 3. For batch operations on 10+ tracks, a progress indicator shows how many tracks have been processed — the user is never left staring at a frozen UI wondering if the operation is working 4. User can set cover art for all selected tracks at once — the same image is embedded in every selected file -**Plans:** TBD +**Plans:** 2 plans +Plans: +- [ ] 18-01-PLAN.md — Backend batch write endpoint with progress events, cancellation, and partial failure +- [ ] 18-02-PLAN.md — Frontend batch mode in track-details with three-state editing, confirmation, progress UI, and view wiring ### Phase 19: OGG Vorbis Tag Writing **Goal:** Users can edit tags on OGG Vorbis files with the same experience as MP3 and FLAC — completing full format coverage @@ -134,7 +137,7 @@ Plans: | 15. Schema Migration & Write Safety | 2/2 | Complete | 2026-03-16 | - | | 16. Tag Writing & Database Sync | 3/3 | Complete | 2026-03-17 | - | | 17. Single Track Edit | 2/2 | Complete | 2026-03-18 | - | -| 18. Batch Edit | v1.2 | 0/? | Not started | - | +| 18. Batch Edit | v1.2 | 0/2 | Not started | - | | 19. OGG Vorbis Tag Writing | v1.2 | 0/? | Not started | - | --- diff --git a/.planning/phases/18-batch-edit/18-01-PLAN.md b/.planning/phases/18-batch-edit/18-01-PLAN.md new file mode 100644 index 0000000..cc6ed13 --- /dev/null +++ b/.planning/phases/18-batch-edit/18-01-PLAN.md @@ -0,0 +1,309 @@ +--- +phase: 18-batch-edit +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - backend/tagwriter/pipeline.go + - backend/events/events.go + - frontend/src/events.ts + - frontend/wailsjs/go/tagwriter/TagWriter.js + - frontend/wailsjs/go/tagwriter/TagWriter.d.ts +autonomous: true +requirements: [BATCH-01, BATCH-03] + +must_haves: + truths: + - "Backend can write the same tag changes to N tracks sequentially, emitting progress events after each track" + - "Frontend can call BatchWriteTrackTags with an array of file paths and a TagChanges map" + - "Progress events include current index, total count, current file path, and whether the batch was cancelled" + - "Partial failures do not abort the batch — failed tracks are collected and returned as a structured result" + - "The batch can be cancelled mid-flight via a cancel channel, and already-written tracks keep their changes" + artifacts: + - path: "backend/tagwriter/pipeline.go" + provides: "BatchWriteTrackTags method, BatchResult type, BatchWriteProgress event emission" + contains: "func (tw *TagWriter) BatchWriteTrackTags" + - path: "backend/events/events.go" + provides: "BatchWriteProgress event constant" + contains: "BatchWriteProgress" + - path: "frontend/src/events.ts" + provides: "Auto-generated BatchWriteProgress event constant" + contains: "BatchWriteProgress" + - path: "frontend/wailsjs/go/tagwriter/TagWriter.js" + provides: "Wails binding for BatchWriteTrackTags" + contains: "BatchWriteTrackTags" + - path: "frontend/wailsjs/go/tagwriter/TagWriter.d.ts" + provides: "TypeScript declaration for BatchWriteTrackTags" + contains: "BatchWriteTrackTags" + key_links: + - from: "backend/tagwriter/pipeline.go" + to: "backend/events/events.go" + via: "EventsEmit(tw.ctx, events.BatchWriteProgress, ...)" + pattern: "events\\.BatchWriteProgress" + - from: "frontend/wailsjs/go/tagwriter/TagWriter.js" + to: "backend/tagwriter/pipeline.go" + via: "Wails binding bridge" + pattern: "BatchWriteTrackTags" +--- + + +Add a backend BatchWriteTrackTags method that writes the same TagChanges to multiple tracks sequentially, emitting progress events after each track and collecting partial failures. + +Purpose: The batch edit UI needs a backend endpoint that processes N tracks, reports progress per-track, supports cancellation, and returns a structured result with success/failure counts — the existing WriteTrackTagsByPath only handles one track. + +Output: BatchWriteTrackTags Go method exposed via Wails, BatchWriteProgress event for live progress, BatchResult return type. + + + +@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md +@/home/caleb/.config/opencode/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/18-batch-edit/18-CONTEXT.md +@.planning/phases/17-single-track-edit/17-02-SUMMARY.md +@.planning/phases/16-tag-writing-database-sync/16-03-SUMMARY.md + +@backend/tagwriter/pipeline.go +@backend/tagwriter/tagwriter.go +@backend/events/events.go +@frontend/src/events.ts +@frontend/wailsjs/go/tagwriter/TagWriter.js +@frontend/wailsjs/go/tagwriter/TagWriter.d.ts + + + + +From backend/tagwriter/tagwriter.go: +```go +// TagChanges is a diff map of field name → new value. Only changed +// fields are present. +type TagChanges map[string]any + +// Field name constants. +const ( + FieldTitle = "title" + FieldArtist = "artist" + FieldAlbum = "album" + FieldAlbumArtist = "album_artist" + FieldGenre = "genre" + FieldYear = "year" + FieldTrackNumber = "track_number" + FieldDiscNumber = "disc_number" + FieldComposer = "composer" + FieldCoverArt = "cover_art" // []byte for set, nil for clear +) +``` + +From backend/tagwriter/pipeline.go: +```go +type TagWriter struct { + logger *slog.Logger + db *database.DB + ctx context.Context // Wails context for event emission + player PlayerStopper + library PipelineLocker +} + +func (tw *TagWriter) WriteTrackTags(trackID int64, changes TagChanges) error { ... } +func (tw *TagWriter) WriteTrackTagsByPath(filePath string, changes TagChanges) error { ... } +``` + +From backend/events/events.go: +```go +// Tag writing events. +const ( + TrackMetadataChanged = "TrackMetadataChanged" +) +``` + +Wails binding pattern (frontend/wailsjs/go/tagwriter/TagWriter.js): +```javascript +export function WriteTrackTagsByPath(arg1, arg2) { + return window['go']['tagwriter']['TagWriter']['WriteTrackTagsByPath'](arg1, arg2); +} +``` + +Wails TypeScript declaration pattern (frontend/wailsjs/go/tagwriter/TagWriter.d.ts): +```typescript +export function WriteTrackTagsByPath(arg1:string,arg2:tagwriter.TagChanges):Promise; +``` + + + + + + + Task 1: Add BatchWriteProgress event constant + backend/events/events.go, frontend/src/events.ts + +Add `BatchWriteProgress` to the "Tag writing events" const block in `backend/events/events.go`: + +```go +// Tag writing events. +const ( + TrackMetadataChanged = "TrackMetadataChanged" + BatchWriteProgress = "BatchWriteProgress" +) +``` + +Then regenerate the TypeScript events file: +```bash +cd backend/events && go generate ./... +``` + +This runs the existing `genevents` codegen tool that parses Go AST and outputs `frontend/src/events.ts`. Verify the generated file contains `BatchWriteProgress`. + + + grep -q "BatchWriteProgress" backend/events/events.go && grep -q "BatchWriteProgress" frontend/src/events.ts && echo "PASS" + + BatchWriteProgress event constant exists in both Go and TypeScript, auto-generated via existing codegen pipeline. + + + + Task 2: Add BatchWriteTrackTags method with progress, cancellation, and partial failure + backend/tagwriter/pipeline.go, frontend/wailsjs/go/tagwriter/TagWriter.js, frontend/wailsjs/go/tagwriter/TagWriter.d.ts + +In `backend/tagwriter/pipeline.go`, add: + +1. **BatchFailure struct** — holds per-track failure info: +```go +// BatchFailure records a single track that failed during a batch write. +type BatchFailure struct { + FilePath string `json:"filePath"` + Error string `json:"error"` +} +``` + +2. **BatchResult struct** — returned from the batch method: +```go +// 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"` +} +``` + +3. **cancelBatch field** on TagWriter — a `chan struct{}` that signals cancellation: +```go +cancelBatch chan struct{} +``` +Add `cancelBatch` to the TagWriter struct. Initialize to nil. The field is checked by BatchWriteTrackTags before each track. + +4. **CancelBatchWrite method** — callable from frontend: +```go +// 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) + } + } +} +``` + +5. **BatchWriteTrackTags method** — the core batch pipeline: +```go +// 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 { +``` + +Implementation details: +- Create `tw.cancelBatch = make(chan struct{})` at the start, defer setting it to nil. +- Loop over filePaths with index. Before each iteration, check if cancelBatch is closed via non-blocking select; if so, set result.Cancelled = true and break. +- Call `tw.WriteTrackTagsByPath(filePath, changes)` for each track. The existing method already handles pipeline lock, player safety, file write, DB sync, and TrackMetadataChanged event per track. + - **IMPORTANT:** The existing WriteTrackTags acquires and releases the pipeline lock per track. This is correct for batch — we do NOT want to hold the lock for the entire batch, because that would block scan for the entire duration. Per-track locking is fine. + - **IMPORTANT:** The existing WriteTrackTags emits TrackMetadataChanged per track. For batch, we want ONE invalidation at the end, not N. We need to suppress per-track events. Add a `suppressEvents bool` field to TagWriter that BatchWriteTrackTags sets to true during the loop, then emits a single TrackMetadataChanged after the loop completes. Modify the event emission in WriteTrackTags (step 7) to check `tw.suppressEvents`. +- After each track (success or failure), emit `BatchWriteProgress` event with payload: + ```go + map[string]any{ + "current": i + 1, + "total": len(filePaths), + "filePath": filePath, + "succeeded": result.Succeeded, + "failed": result.Failed, + } + ``` +- On error, append to result.Failures and increment result.Failed; on success, increment result.Succeeded. +- After the loop (or after cancel break), emit a single `TrackMetadataChanged` event (since per-track events were suppressed). This triggers one full library store invalidation. +- Return the BatchResult (not an error). The return type is the struct itself, so partial success is always communicated. Wails will serialize it as JSON. +- Log a summary at Info level: total, succeeded, failed, cancelled, duration. + +6. **Modify WriteTrackTags event emission** — Add a check for `tw.suppressEvents` before the event emission in step 7: +```go +// 7. Emit event (suppressed during batch writes). +if tw.ctx != nil && !tw.suppressEvents { +``` + +7. **Wails bindings** — Manually add to `frontend/wailsjs/go/tagwriter/TagWriter.js`: +```javascript +export function BatchWriteTrackTags(arg1, arg2) { + return window['go']['tagwriter']['TagWriter']['BatchWriteTrackTags'](arg1, arg2); +} + +export function CancelBatchWrite() { + return window['go']['tagwriter']['TagWriter']['CancelBatchWrite'](); +} +``` + +And to `frontend/wailsjs/go/tagwriter/TagWriter.d.ts`: +```typescript +export function BatchWriteTrackTags(arg1:Array,arg2:tagwriter.TagChanges):Promise; + +export function CancelBatchWrite():Promise; +``` + +Also add BatchResult and BatchFailure to the Wails models file `frontend/wailsjs/go/models.ts` in the `tagwriter` namespace (check if a tagwriter namespace already exists; if not, add it following the existing pattern). + +**Codebase conventions to follow:** +- godot: all doc comments end with a period. +- nlreturn: blank line before return statements. +- gci: imports grouped as stdlib, external, internal. +- 100-char line limit. +- Error wrapping with `%w`. +- `slog` structured logging with key-value pairs. + + + cd backend && go build ./tagwriter/... && echo "BUILD OK" && grep -q "BatchWriteTrackTags" ../frontend/wailsjs/go/tagwriter/TagWriter.js && grep -q "CancelBatchWrite" ../frontend/wailsjs/go/tagwriter/TagWriter.js && echo "BINDINGS OK" + + BatchWriteTrackTags method compiles, processes tracks sequentially with progress events and cancellation support, collects partial failures into BatchResult, suppresses per-track TrackMetadataChanged and emits one at the end. Wails bindings exist for BatchWriteTrackTags and CancelBatchWrite. + + + + + +1. `cd backend && go build ./...` — entire backend compiles +2. `cd backend && go vet ./tagwriter/...` — no vet warnings +3. `grep -c "BatchWriteProgress\|BatchWriteTrackTags\|CancelBatchWrite\|BatchResult\|BatchFailure" backend/tagwriter/pipeline.go` — confirms all new types/methods exist +4. `grep "BatchWriteProgress" frontend/src/events.ts` — event constant auto-generated +5. `grep "BatchWriteTrackTags\|CancelBatchWrite" frontend/wailsjs/go/tagwriter/TagWriter.d.ts` — TypeScript declarations exist + + + +- BatchWriteTrackTags Go method exists and compiles, accepting []string filePaths and TagChanges, returning BatchResult +- CancelBatchWrite Go method exists for mid-batch cancellation +- BatchWriteProgress event emitted per-track with current/total/filePath/succeeded/failed +- Per-track TrackMetadataChanged suppressed during batch; single event emitted after batch completes +- BatchResult struct contains total, succeeded, failed, cancelled, and failures array +- Wails bindings (JS + d.ts) manually created for both new methods +- BatchResult type added to Wails models + + + +After completion, create `.planning/phases/18-batch-edit/18-01-SUMMARY.md` + diff --git a/.planning/phases/18-batch-edit/18-02-PLAN.md b/.planning/phases/18-batch-edit/18-02-PLAN.md new file mode 100644 index 0000000..da9c9c2 --- /dev/null +++ b/.planning/phases/18-batch-edit/18-02-PLAN.md @@ -0,0 +1,569 @@ +--- +phase: 18-batch-edit +plan: 02 +type: execute +wave: 2 +depends_on: ["18-01"] +files_modified: + - frontend/src/components/track-details/track-details.ts + - frontend/src/components/track-list/track-list.ts + - frontend/src/components/cover-grid/cover-grid.ts + - frontend/src/components/queue-panel/queue-panel.ts + - frontend/src/components/playlist-details/playlist-details.ts +autonomous: false +requirements: [BATCH-01, BATCH-02, BATCH-03, BATCH-04] + +must_haves: + truths: + - "Selecting 2+ tracks and clicking Track Details opens a batch summary view showing 'N tracks selected' header" + - "Each field shows shared value (if identical across tracks) or 'Multiple values' placeholder (if different)" + - "In edit mode, typing into a field marks it dirty; only dirty fields are sent as TagChanges on save" + - "Clearing a field (empty string) after interaction is a distinct state from 'untouched' — it sends the clear to all tracks" + - "A confirmation dialog appears before save showing which fields will be set/cleared and the track count" + - "During batch save, a progress bar and 'N of M tracks' counter are visible inside the dialog" + - "The cancel button stops the batch after the current track; already-written tracks keep changes" + - "Partial failures show a summary with success count and per-failure details" + - "Cover art can be set or cleared for all selected tracks at once" + - "After batch save completes, dialog returns to read-only summary with refreshed data" + artifacts: + - path: "frontend/src/components/track-details/track-details.ts" + provides: "Batch mode: multi-track show(), summary view, three-state editing, confirmation, progress, cover art" + contains: "showBatch" + - path: "frontend/src/components/track-list/track-list.ts" + provides: "Updated context menu handler passing all selected filePaths to track-details" + contains: "showBatch" + - path: "frontend/src/components/cover-grid/cover-grid.ts" + provides: "Updated context menu handler passing all selected filePaths to track-details" + contains: "showBatch" + - path: "frontend/src/components/queue-panel/queue-panel.ts" + provides: "Updated context menu handler passing all selected filePaths to track-details" + contains: "showBatch" + - path: "frontend/src/components/playlist-details/playlist-details.ts" + provides: "Updated context menu handler passing all selected filePaths to track-details" + contains: "showBatch" + key_links: + - from: "frontend/src/components/track-details/track-details.ts" + to: "frontend/wailsjs/go/tagwriter/TagWriter.js" + via: "import { BatchWriteTrackTags, CancelBatchWrite }" + pattern: "BatchWriteTrackTags" + - from: "frontend/src/components/track-list/track-list.ts" + to: "frontend/src/components/track-details/track-details.ts" + via: "trackDetailsDialog.showBatch(tracks, coverArt)" + pattern: "showBatch" + - from: "frontend/src/components/track-details/track-details.ts" + to: "frontend/src/events.ts" + via: "EventsOn(Events.BatchWriteProgress, ...)" + pattern: "BatchWriteProgress" +--- + + +Adapt the track-details component for multi-track batch editing with three-state field model, progress UI, confirmation dialog, and batch cover art. Update all 4 view components to call the new batch API when multiple tracks are selected. + +Purpose: Users need to efficiently edit shared metadata across multiple tracks — the dialog must show merged field values, support implicit three-state editing (keep/set/clear), provide a confirmation guard, show live progress during writes, handle partial failures gracefully, and support batch cover art operations. + +Output: Fully functional batch edit mode in track-details component, all 4 views wired to use it. + + + +@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md +@/home/caleb/.config/opencode/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/18-batch-edit/18-CONTEXT.md +@.planning/phases/18-batch-edit/18-01-SUMMARY.md +@.planning/phases/17-single-track-edit/17-02-SUMMARY.md + +@frontend/src/components/track-details/track-details.ts +@frontend/src/components/track-list/track-list.ts +@frontend/src/components/cover-grid/cover-grid.ts +@frontend/src/components/queue-panel/queue-panel.ts +@frontend/src/components/playlist-details/playlist-details.ts +@frontend/src/events.ts +@frontend/wailsjs/go/tagwriter/TagWriter.js +@frontend/wailsjs/go/tagwriter/TagWriter.d.ts +@frontend/src/store/library-store.ts + + + + +From backend/tagwriter/pipeline.go (created by Plan 01): +```go +type BatchFailure struct { + FilePath string `json:"filePath"` + Error string `json:"error"` +} + +type BatchResult struct { + Total int `json:"total"` + Succeeded int `json:"succeeded"` + Failed int `json:"failed"` + Cancelled bool `json:"cancelled"` + Failures []BatchFailure `json:"failures"` +} + +func (tw *TagWriter) BatchWriteTrackTags(filePaths []string, changes TagChanges) BatchResult +func (tw *TagWriter) CancelBatchWrite() +``` + +Wails bindings (created by Plan 01): +```typescript +// TagWriter.d.ts +export function BatchWriteTrackTags(arg1:Array,arg2:tagwriter.TagChanges):Promise; +export function CancelBatchWrite():Promise; +``` + +BatchWriteProgress event payload shape: +```typescript +{ current: number, total: number, filePath: string, succeeded: number, failed: number } +``` + +From frontend/src/components/track-details/track-details.ts (existing): +```typescript +export interface CoverArtUrls { + coverArtPath: string; + coverArtSmall: string; + coverArtMedium: string; + coverArtLarge: string; +} + +interface MetadataField { + key: string; + label: string; + value: string; + editable: boolean; + type: 'text' | 'number'; +} + +export class TrackDetails extends LitElement { + show(track: library.Track, coverArt?: CoverArtUrls): void; + close(): void; + // State: editing, editValues, saving, errorMessage, pendingCoverArt, clearCoverArt + // Methods: saveEdit, buildChanges, selectCoverArt, removeCoverArt, getEditValue, onEditInput +} +``` + +From library.Track (Wails model): +```typescript +class Track { + TrackName: string; ArtistName: string; TrackLength: string; + FilePath: string; TrackNumber: number; DiscNumber: number; + Album: string; Genre: string[]; Year: number; + Composer: string; FileType: string; + // ... more fields +} +``` + +From each view's context menu handler (identical pattern in all 4): +```typescript +case 'track-details': + this.openTrackDetails(filePaths[0]!); + break; +``` + +Each view has: `this.selection.getSelectedKeysOrdered()` returning `string[]` of file paths. +Each view has: `resolveCoverArt(albumName: string): CoverArtUrls | null` +Each view has: `@query('track-details') private trackDetailsDialog: TrackDetails` +Each view has access to the tracks array for resolving file paths to Track objects. + +From frontend/src/events.ts + Wails runtime: +```typescript +import { Events } from '../events'; +import { EventsOn, EventsOff } from '@wailsjs/runtime/runtime'; +// Usage: EventsOn(Events.BatchWriteProgress, (data) => { ... }) +``` + + + + + + + Task 1: Add batch mode to track-details component + frontend/src/components/track-details/track-details.ts + +This is the core task. Extend the existing `track-details` component to handle multi-track batch editing. The component already has full single-track edit infrastructure — batch mode adapts it. + +**New state properties** (add to the existing @state() declarations): + +```typescript +@state() private batchMode = false; +@state() private batchTracks: library.Track[] = []; +@state() private batchFilePaths: string[] = []; +@state() private batchCoverArt: CoverArtUrls | null = null; // shared cover art, or null if mixed +@state() private batchCoverArtMixed = false; // true if tracks have different cover art +@state() private batchProgress: { current: number; total: number } | null = null; +@state() private batchResult: { succeeded: number; failed: number; cancelled: boolean; failures: Array<{ filePath: string; error: string }> } | null = null; +@state() private showConfirmation = false; +``` + +**New imports:** +```typescript +import { BatchWriteTrackTags, CancelBatchWrite } from '@go/tagwriter/TagWriter'; +import { EventsOn, EventsOff } from '@wailsjs/runtime/runtime'; +import { Events } from '../../events'; +``` + +**1. New public API — `showBatch()`:** + +```typescript +showBatch( + tracks: library.Track[], + coverArt: CoverArtUrls | null, + coverArtMixed: boolean, +): void { +``` + +- Sets `this.batchMode = true`, `this.batchTracks = tracks`, `this.batchFilePaths = tracks.map(t => t.FilePath)`. +- Sets `this.batchCoverArt = coverArt`, `this.batchCoverArtMixed = coverArtMixed`. +- Clears single-track state: `this.track = null`. +- Resets edit state: `editing = false`, `editValues = {}`, `errorMessage = ''`, `batchProgress = null`, `batchResult = null`, `showConfirmation = false`. +- Cleans up pending cover art. +- Opens dialog same as `show()`. + +**2. Merged field values for summary/edit:** + +Add a private method `getMergedFields()` that returns `MetadataField[]` with merged values: + +```typescript +private getMergedFields(): MetadataField[] { +``` + +For each of the 8 editable fields (title, artist, album, genre, year, trackNumber, discNumber, composer), extract the value from every track in `batchTracks`. If all values are identical → the field value is that shared value. If values differ → the field value is `''` (empty string) with a flag indicating mixed. + +Return MetadataField objects with the same structure as the single-track version. Add an optional `mixed` boolean to the MetadataField interface: +```typescript +interface MetadataField { + key: string; + label: string; + value: string; + editable: boolean; + type: 'text' | 'number'; + mixed?: boolean; // true if values differ across batch tracks +} +``` + +For the field value extraction, use the same mapping as in `renderDetailFields`: +- title → `t.TrackName` +- artist → `t.ArtistName` +- album → `t.Album` +- genre → `(t.Genre ?? []).join(', ')` +- year → `t.Year ? String(t.Year) : ''` +- composer → `t.Composer ?? ''` +- trackNumber → `t.TrackNumber ? String(t.TrackNumber) : ''` +- discNumber → `t.DiscNumber ? String(t.DiscNumber) : ''` + +**3. Render: summary/read-only view for batch mode:** + +Modify the `override render()` method. When `batchMode && !editing && !batchProgress && !batchResult`: +- Header: `${this.batchTracks.length} tracks selected` (instead of track title). +- Cover art section: if `batchCoverArtMixed` show a placeholder with text like "Multiple cover arts" and a count. If shared, show the actual cover art (same as single-track). +- For each merged field: if `mixed` show "N different values" in gray italic. If shared, show the actual value. +- Buttons: "Edit" and "Close" (same as single-track read-only). +- Do NOT show non-editable fields like file path, file type, bitrate, etc. (not meaningful for batch). + +**4. Render: edit mode for batch:** + +When `batchMode && editing && !batchProgress`: +- Header: `Editing ${this.batchTracks.length} tracks`. +- Cover art section with edit controls (same as single-track: click to pick, X to remove). If mixed, show placeholder; if shared, show art. pendingCoverArt and clearCoverArt work the same. +- For each merged field: render an input. Pre-populate with the shared value (if not mixed). If mixed, show empty input with `placeholder="Multiple values"` in gray italic style. +- Three-state field model is implicit via the existing `editValues` + `getEditValue` pattern: + - **Keep original:** user doesn't touch the field → key NOT in `editValues` → not sent in TagChanges. + - **Set value:** user types → key IN `editValues` with the typed value → sent in TagChanges. + - **Clear field:** user types then deletes everything → key IN `editValues` with `""` → sent in TagChanges (the value is empty string, which the backend writes as clearing the field). +- The existing `onEditInput` handler already adds the key to `editValues` on any input event, which is exactly the dirty-tracking mechanism needed. +- Buttons: "Cancel" and "Save" (same as single-track edit mode). + +**5. Confirmation dialog:** + +When user clicks "Save" in batch edit mode, set `this.showConfirmation = true` instead of saving immediately. Render a confirmation overlay within the dialog: + +```html +
+
+

Apply changes to ${this.batchTracks.length} tracks?

+
+ +
+
+ + +
+
+
+``` + +Build the summary from `editValues`: for each key in editValues, show `"Set {label} to '{value}'"` or `"Clear {label}"` if value is empty. If cover art is pending: `"Set cover art"`. If clearCoverArt: `"Remove cover art"`. + +Style the overlay: position absolute, full dialog coverage, semi-transparent backdrop, centered card. + +**6. Batch save flow (`confirmSave`):** + +When user confirms: +- Set `showConfirmation = false`. +- Set `batchProgress = { current: 0, total: batchFilePaths.length }`. +- Build TagChanges from `buildBatchChanges()` (new method, similar to `buildChanges` but for batch — only includes dirty fields, no diff against original since batch doesn't have a single original). +- Register a Wails event listener for `BatchWriteProgress`: + ```typescript + const cleanup = EventsOn(Events.BatchWriteProgress, (data: any) => { + this.batchProgress = { current: data.current, total: data.total }; + }); + ``` +- Call `await BatchWriteTrackTags(this.batchFilePaths, changes)`. +- After completion, call `EventsOff(Events.BatchWriteProgress)` (or use the cleanup function). +- Store result in `this.batchResult`. +- Set `batchProgress = null`. + +**New method `buildBatchChanges()`:** +```typescript +private buildBatchChanges(): Record { + const changes: Record = {}; + // Same fieldMap as buildChanges but WITHOUT diff against original — + // every key in editValues is a change. + const fieldMap = [ + { editKey: 'title', backendKey: 'title' }, + { editKey: 'artist', backendKey: 'artist' }, + { editKey: 'album', backendKey: 'album' }, + { editKey: 'genre', backendKey: 'genre' }, + { editKey: 'year', backendKey: 'year', transform: (v: string) => v ? parseInt(v, 10) : 0 }, + { editKey: 'composer', backendKey: 'composer' }, + { editKey: 'trackNumber', backendKey: 'track_number', transform: (v: string) => v ? parseInt(v, 10) : 0 }, + { editKey: 'discNumber', backendKey: 'disc_number', transform: (v: string) => v ? parseInt(v, 10) : 0 }, + ]; + for (const { editKey, backendKey, transform } of fieldMap) { + if (editKey in this.editValues) { + const val = this.editValues[editKey]!; + changes[backendKey] = transform ? transform(val) : val; + } + } + // Cover art + if (this.pendingCoverArt) { + changes['cover_art'] = Array.from(new Uint8Array(this.pendingCoverArt.data)); + } else if (this.clearCoverArt) { + changes['cover_art'] = null; + } + return changes; +} +``` + +**7. Progress UI:** + +When `batchProgress` is not null, render: +```html +
+
${batchProgress.current} of ${batchProgress.total} tracks
+
+
+
+ +
+``` + +The `cancelBatchWrite` handler calls `CancelBatchWrite()` (the Wails binding). + +Style the progress bar: full-width track with rounded corners, fill uses the app's accent color (`var(--yj-accent, #4a9eff)`), smooth width transition. + +**8. Results view:** + +When `batchResult` is not null, render: +- If `batchResult.cancelled`: "Batch cancelled — {succeeded} of {total} tracks updated" +- Else if `batchResult.failed === 0`: "All {succeeded} tracks updated successfully" +- Else: "{succeeded} tracks updated, {failed} failed" +- If failures exist, show an expandable list of failures (file name + error). +- "Close" button that resets to summary view with refreshed data. + +After displaying results and user clicks "Close": +- Reset `batchResult = null`, `batchProgress = null`. +- Re-fetch tracks from `libraryStore.getTracks()` and albums from `libraryStore.getAlbums()`. +- Re-resolve `batchTracks` from refreshed data (filter tracks by batchFilePaths). +- Re-resolve cover art (check if all tracks now share the same album art). +- Return to read-only summary view with updated data. + +**9. CSS additions:** + +Add styles for: +- `.batch-header` — larger text showing track count +- `.mixed-value` — gray italic placeholder text for "Multiple values" and "N different values" +- `.confirmation-overlay` — absolute positioned overlay with backdrop +- `.confirmation-content` — centered card with padding +- `.confirmation-summary` — list of changes +- `.batch-progress` — progress section layout +- `.progress-bar-track` — progress bar track (gray background, rounded) +- `.progress-bar-fill` — progress bar fill (accent color, transition: width 0.3s) +- `.batch-result` — results section +- `.failure-list` — expandable failure details + +**10. Close/cleanup behavior:** + +Override `close()` to also call `CancelBatchWrite()` if `batchProgress` is not null (closing during progress cancels the batch). Reset all batch state. + +**CRITICAL IMPLEMENTATION NOTES:** +- The single-track `show()` method remains unchanged — it sets `batchMode = false`. +- All existing single-track rendering and behavior continues to work when `batchMode === false`. +- The render method should branch on `batchMode` early to avoid complex conditional nesting. Consider helper methods like `renderBatchSummary()`, `renderBatchEdit()`, `renderBatchProgress()`, `renderBatchResult()`. +- Follow existing code style: arrow function handlers, @state() decorators, html tagged template literals, `override` keyword. +- Import type for type-only imports. +
+ + cd frontend && npx tsc --noEmit 2>&1 | head -30; echo "---"; grep -c "showBatch\|batchMode\|batchProgress\|buildBatchChanges\|confirmSave\|cancelBatchWrite\|renderBatchSummary\|BatchWriteTrackTags" src/components/track-details/track-details.ts + + +Track-details component supports batch mode with: +- showBatch() public API for multi-track entry +- Read-only summary showing merged field values with "Multiple values" for mixed fields +- Edit mode with implicit three-state field model (keep/set/clear via dirty tracking) +- Confirmation dialog listing all pending changes before save +- Progress bar with "N of M" counter during batch write, wired to BatchWriteProgress events +- Cancel button calling CancelBatchWrite +- Results summary showing success/failure counts with expandable failure details +- Batch cover art: pick, preview, or clear for all tracks +- Post-save data refresh returning to updated summary view + +
+ + + Task 2: Update all view context menu handlers for batch mode + + frontend/src/components/track-list/track-list.ts, + frontend/src/components/cover-grid/cover-grid.ts, + frontend/src/components/queue-panel/queue-panel.ts, + frontend/src/components/playlist-details/playlist-details.ts + + +Update the `'track-details'` case in `onContextMenuAction` for each of the 4 view components. Currently each does: +```typescript +case 'track-details': + this.openTrackDetails(filePaths[0]!); + break; +``` + +Change to: +```typescript +case 'track-details': + if (filePaths.length === 1) { + this.openTrackDetails(filePaths[0]!); + } else { + this.openBatchTrackDetails(filePaths); + } + break; +``` + +Add a new private method `openBatchTrackDetails(filePaths: string[])` to each view: + +**For track-list.ts:** +```typescript +private openBatchTrackDetails(filePaths: string[]) { + const tracks = filePaths + .map((fp) => this.tracks.find((t) => t.FilePath === fp)) + .filter((t): t is library.Track => t != null); + + if (tracks.length === 0) return; + + // Resolve cover art: check if all tracks share the same album. + const albumNames = new Set(tracks.map((t) => t.Album)); + let coverArt: CoverArtUrls | null = null; + let coverArtMixed = false; + + if (albumNames.size === 1) { + const albumName = [...albumNames][0]!; + coverArt = this.resolveCoverArt(albumName); + } else { + coverArtMixed = true; + } + + this.trackDetailsDialog?.showBatch(tracks, coverArt, coverArtMixed); +} +``` + +**For cover-grid.ts:** Same pattern but tracks come from `this.currentTracks` or `this.albumTracks` depending on current view state. Check how cover-grid stores its track list — it may use a different property name. The cover-grid has tracks available via album detail tracks. Look for where tracks are stored and use the same source. + +**For queue-panel.ts:** Queue panel uses indices, not file paths. The existing `openTrackDetails(index: number)` resolves queue tracks by index. For batch, the context menu handler has `indices = this.selection.getSelectedKeysOrdered()` (which are indices for queue). Map indices to queue tracks: +```typescript +private openBatchTrackDetails(indices: number[]) { + const queueTracks = queueStore.tracks; + const tracks = indices + .map((i) => queueTracks[i]) + .filter((t): t is QueueTrack => t != null); + + // QueueTrack has different shape than library.Track — need to resolve + // from library store. QueueTrack has filePath. + // ... resolve tracks from library store or adapt... +} +``` + +**IMPORTANT for queue-panel:** The queue panel's selection uses numeric indices, not file paths. The `onContextMenuAction` handler may already convert to file paths or indices. Check the actual code carefully. The queue-panel context menu handler likely already has access to `filePaths` or can derive them from queue tracks. Each QueueTrack has a `filePath` field. Resolve the library.Track objects from `libraryStore.getTracks()` (await) or use the queue tracks' metadata directly. The key insight: `showBatch` needs `library.Track[]` objects — queue panel must resolve them. + +**For playlist-details.ts:** Similar to track-list. Has its own tracks array. Use the same pattern. + +**Each view's `openBatchTrackDetails` method must:** +1. Resolve file paths to `library.Track[]` objects from the view's available track data +2. Determine cover art state: if all tracks share same album → resolve shared art. If different albums → coverArtMixed = true. +3. Call `this.trackDetailsDialog?.showBatch(tracks, coverArt, coverArtMixed)` + +The `resolveCoverArt(albumName)` method already exists on each view and can be reused. + + + cd frontend && npx tsc --noEmit 2>&1 | head -30; echo "---"; grep -c "openBatchTrackDetails\|showBatch" src/components/track-list/track-list.ts src/components/cover-grid/cover-grid.ts src/components/queue-panel/queue-panel.ts src/components/playlist-details/playlist-details.ts + + +All 4 view components (track-list, cover-grid, queue-panel, playlist-details) branch on selection count in the track-details context menu action: 1 track → existing openTrackDetails, 2+ tracks → new openBatchTrackDetails that resolves tracks, determines cover art state, and calls showBatch(). + + + + + Task 3: Verify complete batch edit flow + frontend/src/components/track-details/track-details.ts + +Human verification of the complete batch edit flow. Run `wails dev` and test: + +1. **Batch summary view:** In the track list, select 3+ tracks with different metadata. Right-click → Track Details. Verify header shows "N tracks selected", shared fields show value, mixed fields show "Multiple values" placeholder. + +2. **Three-state editing:** Click "Edit". Verify shared fields pre-populated, mixed fields have placeholder, typing marks fields dirty, untouched fields are not sent on save, clearing a field sends empty. + +3. **Confirmation dialog:** Click "Save". Verify confirmation overlay shows field changes and track count. + +4. **Progress:** Click "Apply" on 5+ tracks. Verify progress bar advances and counter updates. + +5. **Results:** After batch completes, verify success/failure summary. Close returns to updated summary. + +6. **Cover art:** Pick/remove in batch mode applies to all tracks. + +7. **Single-track unchanged:** Select 1 track → Track Details works as before. + + + cd frontend && npx tsc --noEmit && echo "TYPECHECK OK" + + All batch edit user flows verified: summary view, three-state editing, confirmation, progress, results, cover art, and single-track regression check passes. + + +
+ + +1. `cd frontend && npx tsc --noEmit` — TypeScript compiles with no errors +2. `cd backend && go build ./...` — backend still compiles (no regressions) +3. Select 2+ tracks → Track Details → shows batch summary (not single track) +4. Select 1 track → Track Details → shows single track (existing behavior unchanged) +5. Batch edit → save → all tracks updated with correct field values +6. Progress bar visible during batch save of 5+ tracks +7. Cover art batch set/clear works across all selected tracks + + + +- Batch mode activates when 2+ tracks are selected from any of the 4 views +- Summary view correctly shows shared vs mixed field values +- Three-state field model works: untouched fields preserved, typed fields set, cleared fields clear +- Confirmation dialog appears before batch save with change summary +- Progress bar and track counter visible during batch write +- Partial failures collected and displayed +- Cancel stops remaining tracks +- Cover art pick/clear applies to all selected tracks +- Single-track mode unchanged (no regression) +- All 4 views correctly dispatch to showBatch for multi-select + + + +After completion, create `.planning/phases/18-batch-edit/18-02-SUMMARY.md` +