Files
yellowjacket/.planning/phases/17-single-track-edit/17-01-PLAN.md
T

286 lines
12 KiB
Markdown

---
phase: 17-single-track-edit
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- backend/tagwriter/pipeline.go
- backend/frontendutil/frontendutil.go
- frontend/src/store/library-store.ts
- frontend/src/components/track-list/track-list.ts
- frontend/src/components/queue-panel/queue-panel.ts
- frontend/src/components/cover-grid/cover-grid.ts
- frontend/src/components/playlist-details/playlist-details.ts
autonomous: true
requirements:
- EDIT-01
- EDIT-04
must_haves:
truths:
- "WriteTrackTagsByPath accepts a file path string and TagChanges, resolves the track ID internally, and delegates to WriteTrackTags"
- "ImageFilePicker opens a native file dialog filtered to JPEG/PNG and returns the selected file path"
- "After a successful tag write, the library store invalidates all caches and re-fetches data so all views reflect the new metadata"
- "Right-clicking any single track in track-list, queue-panel, cover-grid, or playlist-details shows 'Track Details' in the context menu regardless of selection state"
artifacts:
- path: "backend/tagwriter/pipeline.go"
provides: "WriteTrackTagsByPath method on TagWriter"
contains: "func (tw *TagWriter) WriteTrackTagsByPath"
- path: "backend/frontendutil/frontendutil.go"
provides: "ImageFilePicker method for cover art selection"
contains: "func (fe *FrontendUtil) ImageFilePicker"
- path: "frontend/src/store/library-store.ts"
provides: "TrackMetadataChanged event handler calling invalidate()"
contains: "TrackMetadataChanged"
- path: "frontend/src/components/track-list/track-list.ts"
provides: "Track Details context menu item visible for any right-clicked track"
- path: "frontend/src/components/queue-panel/queue-panel.ts"
provides: "Track Details context menu item visible for any right-clicked track"
key_links:
- from: "frontend/src/store/library-store.ts"
to: "backend events"
via: "EventsOn(Events.TrackMetadataChanged)"
pattern: "EventsOn.*TrackMetadataChanged"
- from: "backend/tagwriter/pipeline.go"
to: "backend/database"
via: "GetAudioFileByPath query"
pattern: "GetAudioFileByPath"
---
<objective>
Wire the backend bridge methods and frontend plumbing needed for single-track tag editing.
Purpose: Phase 17 builds on the WriteTrackTags pipeline from Phase 16. The frontend identifies tracks by `FilePath` but WriteTrackTags requires `trackID int64`. This plan adds a path-based wrapper, a cover art file picker, the library store event handler that refreshes views after edits, and removes the selection-count gate on the "Track Details" context menu item.
Output: Backend methods ready for frontend consumption, library store reacts to tag write events, context menu accessible from any track context.
</objective>
<execution_context>
@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md
@/home/caleb/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/17-single-track-edit/17-CONTEXT.md
<interfaces>
<!-- Key types and contracts the executor needs. -->
From backend/tagwriter/pipeline.go:
```go
type TagWriter struct {
logger *slog.Logger
db *database.DB
ctx context.Context
player PlayerStopper
library PipelineLocker
}
func (tw *TagWriter) WriteTrackTags(trackID int64, changes TagChanges) error
```
From backend/tagwriter/tagwriter.go:
```go
type TagChanges map[string]any
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"
)
```
From backend/database/sql/sqlcgen/audio_files.sql.go:
```go
func (q *Queries) GetAudioFileByPath(ctx context.Context, filePath string) (AudioFile, error)
```
From backend/frontendutil/frontendutil.go:
```go
type FrontendUtil struct {
ctx context.Context
}
func (fe *FrontendUtil) DirectoryPicker() (string, error)
func (fe *FrontendUtil) PlaylistFilePicker() ([]string, error)
```
From frontend/src/store/library-store.ts:
```typescript
class LibraryStore {
private invalidate(): void { ... }
// Currently listens for: LibraryScanComplete, LibraryRemoved, LibraryAdded, LibraryRenamed
// Does NOT listen for TrackMetadataChanged
}
```
From frontend/src/events.ts:
```typescript
export const Events = {
TrackMetadataChanged: "TrackMetadataChanged",
// ...
} as const;
```
Context menu pattern (track-list.ts line ~1966):
```typescript
${this.selection.selectionCount === 1
? html`<wa-dropdown-item @click=${() => this.onContextMenuAction('track-details')}>
<wa-icon slot="icon" name="circle-info"></wa-icon>
Track Details
</wa-dropdown-item>` : nothing}
```
queue-panel.ts uses the same pattern at line ~1553.
cover-grid.ts and playlist-details.ts conditionally show Track Details only for single track context.
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add WriteTrackTagsByPath and ImageFilePicker backend methods</name>
<files>backend/tagwriter/pipeline.go, backend/frontendutil/frontendutil.go</files>
<action>
**In `backend/tagwriter/pipeline.go`**, add a new method `WriteTrackTagsByPath` directly below the existing `WriteTrackTags` method:
```go
// WriteTrackTagsByPath resolves a file path to its audio_file.id and
// delegates to WriteTrackTags. This is the frontend-facing entry
// point since the frontend identifies tracks by FilePath.
func (tw *TagWriter) WriteTrackTagsByPath(filePath string, changes TagChanges) error {
ctx := context.Background()
audioFile, err := tw.db.Queries.GetAudioFileByPath(ctx, filePath)
if err != nil {
return fmt.Errorf("resolve track by path %q: %w", filePath, err)
}
return tw.WriteTrackTags(audioFile.ID, changes)
}
```
This uses the existing `GetAudioFileByPath` sqlc query (already generated) to look up the `audio_files.id` from `file_path`, then delegates to the existing `WriteTrackTags` pipeline.
**In `backend/frontendutil/frontendutil.go`**, add a new method `ImageFilePicker` below `PlaylistFilePicker`:
```go
// ImageFilePicker opens a file selection dialog filtered to image
// files (JPEG, PNG). Returns the selected file path, or empty
// string if the user cancelled.
func (fe *FrontendUtil) ImageFilePicker() (string, error) {
file, err := runtime.OpenFileDialog(
fe.ctx,
runtime.OpenDialogOptions{
Title: "Select Cover Art",
Filters: []runtime.FileFilter{
{
DisplayName: "Image Files (*.jpg, *.jpeg, *.png)",
Pattern: "*.jpg;*.jpeg;*.png",
},
},
},
)
if err != nil {
return "", fmt.Errorf("could not open file dialog: %w", err)
}
return file, nil
}
```
After adding both methods, run `make generate` to regenerate Wails TypeScript bindings (this will create the `WriteTrackTagsByPath` and `ImageFilePicker` bindings in `frontend/wailsjs/go/`).
Ensure both methods follow codebase conventions: doc comments ending with periods, error wrapping with `%w`, `fmt.Errorf` context.
</action>
<verify>
`go build -tags webkit2_41 ./...` compiles without errors.
`make generate` succeeds and creates new TypeScript bindings.
`rg "WriteTrackTagsByPath" frontend/wailsjs/go/tagwriter/` shows the generated binding.
`rg "ImageFilePicker" frontend/wailsjs/go/frontendutil/` shows the generated binding.
</verify>
<done>
WriteTrackTagsByPath method exists on TagWriter, resolves filePath→trackID via GetAudioFileByPath, delegates to WriteTrackTags.
ImageFilePicker method exists on FrontendUtil, opens native file dialog filtered to JPEG/PNG, returns selected path.
Both have TypeScript bindings generated.
</done>
</task>
<task type="auto">
<name>Task 2: Add TrackMetadataChanged handler and fix context menu conditions</name>
<files>frontend/src/store/library-store.ts, frontend/src/components/track-list/track-list.ts, frontend/src/components/queue-panel/queue-panel.ts, frontend/src/components/cover-grid/cover-grid.ts, frontend/src/components/playlist-details/playlist-details.ts</files>
<action>
**In `frontend/src/store/library-store.ts`**, add a `TrackMetadataChanged` event listener in the constructor, after the existing `LibraryRenamed` listener:
```typescript
EventsOn(Events.TrackMetadataChanged, () => {
this.invalidate();
});
```
This causes a full cache invalidation + re-fetch of all library data (tracks, albums, artists, genres) whenever any track's tags are written. Full reload is acceptable per the CONTEXT.md decision: "Full reload is acceptable because editing is a low-frequency operation."
**In `frontend/src/components/track-list/track-list.ts`**, find the context menu rendering where "Track Details" is conditionally shown (around line 1966). Change the condition from `this.selection.selectionCount === 1` to always show the item. The item should appear when right-clicking any track. Per CONTEXT.md: "'Track Details' should appear in the context menu when right-clicking any track, regardless of selection state."
Replace:
```typescript
${this.selection.selectionCount === 1
? html`<wa-dropdown-item @click=${() => this.onContextMenuAction('track-details')}>
```
With:
```typescript
${html`<wa-dropdown-item @click=${() => this.onContextMenuAction('track-details')}>
```
Remove the corresponding `: nothing}` closing.
When `track-details` action is triggered with multiple selections, use the first selected track (or the right-clicked track). Check how `onContextMenuAction` resolves the target — it should use the context menu target row's `FilePath`, not require exactly 1 selection.
**In `frontend/src/components/queue-panel/queue-panel.ts`**, apply the same fix: remove the `selectionCount === 1` condition around the "Track Details" context menu item (around line 1553). The queue always has a specific right-click target (the clicked track row), so Track Details should always be available.
**In `frontend/src/components/cover-grid/cover-grid.ts`**, find the Track Details context menu item condition (it checks `contextMenuTarget.kind === 'track' && selectedTracks.size === 1`). Change to only check `contextMenuTarget.kind === 'track'` — the dialog opens for the right-clicked track regardless of multi-selection.
**In `frontend/src/components/playlist-details/playlist-details.ts`**, apply the same fix: remove the `selectionCount === 1` condition for the "Track Details" menu item.
For all 4 components: when Track Details is activated with multiple tracks selected, the `openTrackDetails` method should open details for the first selected track (or the context-menu-target track). Review each component's `openTrackDetails` to ensure it works with the right-clicked track, not the full selection.
</action>
<verify>
`pnpm run typecheck` in frontend/ passes.
`rg "TrackMetadataChanged" frontend/src/store/library-store.ts` shows the new event handler.
`rg "selectionCount === 1" frontend/src/components/track-list/track-list.ts frontend/src/components/queue-panel/queue-panel.ts` returns no matches (condition removed).
</verify>
<done>
TrackMetadataChanged event handler added to LibraryStore — after a tag write, all caches are invalidated and views refresh.
"Track Details" context menu item appears when right-clicking any track in all 4 views (track-list, queue-panel, cover-grid, playlist-details) regardless of how many tracks are selected.
</done>
</task>
</tasks>
<verification>
- `go build -tags webkit2_41 ./...` compiles
- `pnpm run typecheck` (in frontend/) passes
- `make generate` succeeds
- WriteTrackTagsByPath binding exists in `frontend/wailsjs/go/tagwriter/`
- ImageFilePicker binding exists in `frontend/wailsjs/go/frontendutil/`
- LibraryStore listens for TrackMetadataChanged
- "Track Details" context menu item no longer gated on single selection
</verification>
<success_criteria>
Backend methods ready for Plan 02 to call from the track-details dialog. Library store will automatically refresh all views when tag writes complete. Context menu shows "Track Details" for any right-clicked track.
</success_criteria>
<output>
After completion, create `.planning/phases/17-single-track-edit/17-01-SUMMARY.md`
</output>