diff --git a/.planning/research/ARCHITECTURE.md b/.planning/research/ARCHITECTURE.md index 9549116..30854d9 100644 --- a/.planning/research/ARCHITECTURE.md +++ b/.planning/research/ARCHITECTURE.md @@ -1,118 +1,469 @@ -# Architecture Research: Multi-Library Integration +# Architecture Patterns: Tag Editing Integration -**Researched:** 2026-03-08 -**Confidence:** HIGH +**Domain:** Audio metadata editing in existing music player +**Researched:** 2026-03-16 +**Confidence:** HIGH (based on full codebase analysis of existing architecture) -## Design Decision: Hybrid Model +## Recommended Architecture -- **`library_id` on `audio_files` only** — physical file binding -- **Artists, albums, recordings, genres stay global** — shared reference data -- **Unified presentation by default** — optional library filter -- **Cross-library playlists** — playlists reference audio_file_id -- **Phantom tracks on removal** — playlist entries preserved with metadata +Tag editing is a **cross-cutting operation** that touches files, database entities, the FTS5 search index, the cover art pipeline, and the frontend cache — all from a single user action. The architecture adds a new `backend/tageditor/` package that orchestrates the full write pipeline, keeping the existing `library`, `metadata`, and `database` packages focused on their current responsibilities. -## Database Changes +### High-Level Data Flow -### New Table: libraries +``` +UI: track-details "Save" click + → Wails binding: tageditor.EditTrack(filePath, changes) + → 1. Validate input + resolve audio_file by path + → 2. Write tags to temp file, rename over original (safe write) + → 3. Update DB entities in single transaction: + a. Upsert artist_credit + artist (if artist changed) + b. Upsert release_group (if album changed) + c. Update recording fields (title, year, track#, etc.) + d. Update genre links (delete old, insert new) + e. Update release_group_recordings link (if album changed) + f. Handle cover art (if image provided) + → 4. Update FTS5 search_index (re-insert with same rowid) + → 5. Emit TagsUpdated event with affected file paths + → Frontend: libraryStore receives event, patches cached tracks in-place + → All views re-render with updated metadata +``` + +### Component Boundaries + +| Component | Responsibility | Communicates With | +|-----------|---------------|-------------------| +| `backend/tageditor/` (NEW) | Orchestrates tag write pipeline: file write + DB update + FTS5 + events | `metadata/`, `database/`, `events/`, `coverart/`, Wails runtime | +| `backend/tageditor/writer.go` (NEW) | Format-specific tag writing (MP3/FLAC/OGG) via external libraries | File system, `bogem/id3v2`, `go-flac/go-flac` + `go-flac/flacvorbis` | +| `backend/metadata/tags.go` (EXISTING) | Tag reading via `dhowden/tag` — **no changes needed** | File system | +| `backend/library/library.go` (EXISTING) | Scan pipeline, entity upsert helpers — **reuse `processMetadata` pattern** | `database/`, `metadata/` | +| `backend/database/search.go` (EXISTING) | FTS5 index operations — **add `UpdateSearchIndex` method** | SQLite | +| `backend/events/events.go` (EXISTING) | Event constants — **add tag editing events** | Nothing | +| `frontend/src/components/track-details/` (EXISTING) | Edit UI — **wire Save to backend, add batch mode** | `tageditor` Wails binding | +| `frontend/src/store/library-store.ts` (EXISTING) | Track cache — **add event handler for in-place patch** | Wails events | + +## New Package: `backend/tageditor/` + +### Why a Separate Package + +The tag editing flow does NOT fit cleanly into the existing `library` package because: + +1. **Different lifecycle**: Scans are bulk, batch-oriented operations. Tag edits are individual, user-initiated, synchronous operations. +2. **Different entity update strategy**: Scans always CREATE new recordings. Tag edits must UPDATE existing recordings and handle shared entity reference changes. +3. **Different file I/O pattern**: Scans read files. Tag edits write files with safety guarantees (temp + rename). +4. **Wails binding boundary**: Tag editor needs its own binding registration for a clean API surface. + +However, the tag editor REUSES logic from existing packages: +- Entity upsert helpers from `library` (either extracted to shared code or duplicated with attribution) +- FTS5 operations from `database/search.go` +- Cover art pipeline from `library/coverart.go` and `coverart/` + +### Package Structure + +``` +backend/tageditor/ +├── tageditor.go # Service struct, EditTrack(), EditTracks(), SetCoverArt() +├── writer.go # Format-specific tag writing (MP3, FLAC, OGG) +└── writer_test.go # Tests for safe file write + tag round-trip +``` + +### Service API (Wails-Bound) + +```go +// Package tageditor provides audio file tag editing with safe +// file writes and inline database synchronization. +package tageditor + +// EditRequest describes changes to apply to a single track. +type EditRequest struct { + FilePath string `json:"filePath"` + Title *string `json:"title,omitempty"` + Artist *string `json:"artist,omitempty"` + Album *string `json:"album,omitempty"` + Genre *string `json:"genre,omitempty"` + Year *int `json:"year,omitempty"` + TrackNumber *int `json:"trackNumber,omitempty"` + DiscNumber *int `json:"discNumber,omitempty"` + Composer *string `json:"composer,omitempty"` + // CoverArt is set separately via SetCoverArt() +} + +// EditResult reports the outcome of a tag edit operation. +type EditResult struct { + FilePath string `json:"filePath"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + +// Service orchestrates tag editing operations. +type Service struct { + ctx context.Context + logger *slog.Logger + db *database.DB +} + +// EditTrack applies metadata changes to a single audio file. +func (s *Service) EditTrack(req EditRequest) EditResult + +// EditTracks applies shared field changes to multiple files (batch). +func (s *Service) EditTracks(reqs []EditRequest) []EditResult + +// SetCoverArt embeds an image file into one or more audio files. +func (s *Service) SetCoverArt(filePaths []string, imagePath string) []EditResult +``` + +Pointer fields (`*string`, `*int`) distinguish "not changed" (nil) from "set to empty/zero" (pointer to zero value). This is critical for batch editing where you only want to change shared fields. + +### Two-Phase Initialization + +Follows the existing `NewService()` + `SetContext()` pattern: + +```go +// In NewYellowJacketApp(): +yjApp.tagEditor = tageditor.NewService(logger, db) + +// In OnStartup(): +yj.tagEditor.SetContext(ctx) + +// In FEBindings: +yjApp.FEBindings = []any{ + // ... existing bindings ... + yjApp.tagEditor, +} +``` + +## File Writing Strategy + +### Write-to-Temp-Then-Rename (Corruption Safety) + +``` +1. Write modified tags to temporary file in same directory: + /music/track.mp3 → /music/.track.mp3.yjtmp +2. fsync the temp file +3. os.Rename temp file over original (atomic on same filesystem) +4. If any step fails, delete temp file and return error +``` + +Why same directory: `os.Rename` is atomic only within the same filesystem. Writing to a temp directory on a different mount would require a full copy. + +### Format-Specific Writers + +| Format | Library | Write Strategy | +|--------|---------|----------------| +| MP3 (ID3v2) | `github.com/bogem/id3v2/v2` (v2.1.4) | Open → parse existing → modify frames → Save() writes to same file. Use WriteTo() to write to temp file instead. | +| FLAC (Vorbis Comments) | `github.com/go-flac/go-flac/v2` + `github.com/go-flac/flacvorbis/v2` | ParseFile → find/create VorbisComment metablock → set fields → Save() to temp file | +| OGG (Vorbis Comments) | Custom or `dhowden/tag`-compatible approach | OGG Vorbis uses same comment format as FLAC. May need lower-level OGG page rewriting. **Needs deeper research at implementation time.** | + +**Confidence notes:** +- MP3 via `bogem/id3v2`: HIGH — mature library (359 stars, v2.1.4, 57 importers), well-documented read+write API, supports ID3v2.3 and v2.4, picture frames, UTF-8 encoding. +- FLAC via `go-flac/go-flac` + `go-flac/flacvorbis`: MEDIUM — smaller community (12 stars on flacvorbis), but clean API for metadata block manipulation. `flac.Save(filename)` writes back to disk. +- OGG Vorbis: LOW — no well-established pure-Go OGG tag writing library. May need to shell out to a tool or implement custom OGG page rewriting. **Consider deferring OGG write support to a follow-up if complexity is high.** + +### Cover Art Embedding + +For cover art, the writer embeds the image data directly into the audio file: + +- **MP3**: `id3v2.PictureFrame` with `PTFrontCover` type +- **FLAC**: `flac.MetaDataBlockPicture` (FLAC picture metadata block) + +After writing to the audio file, the cover art pipeline also: +1. Saves the image to the covers directory (hash-based filename) +2. Generates size variants (sm/md/lg) +3. Upserts the `cover_art` DB record +4. Updates `release_groups.cover_art_id` if needed + +## Database Update Strategy + +### The Shared Entity Problem + +The normalized schema means entities are shared across tracks: + +``` +artist_credit "The Beatles" ← referenced by 200 recordings +release_group "Abbey Road" ← referenced by 17 recordings +genre "Rock" ← referenced by 5000 recordings +``` + +When a user changes a track's artist from "The Beatles" to "The Beetles" (typo fix), we must NOT modify the existing `artist_credit` row — that would change the artist name for all 200 tracks. + +### Update Rules + +| Field Changed | DB Operation | +|---------------|-------------| +| Title | UPDATE `recordings.name` directly (recording is per-track) | +| Track Number | UPDATE `recordings.track_number` directly | +| Disc Number | UPDATE `recordings.disc_number` directly | +| Year | UPDATE `recordings.year` directly | +| Composer | UPDATE `recordings.composer` directly | +| Artist | Upsert new `artist_credit` + `artist`, UPDATE `recordings.artist_credit_id` to point to new credit. Old credit is NOT deleted (may be used by other recordings). | +| Album | Upsert new `release_group`, update `release_group_recordings` link. Old release group is NOT deleted. | +| Genre | Delete existing `recording_genres` links for this recording, upsert new genres, create new links. Old genres NOT deleted (shared). | +| Cover Art | Process through cover art pipeline, update `release_groups.cover_art_id` | + +### Orphan Cleanup Strategy + +After tag edits, orphaned entities (artist credits, release groups, genres with zero references) accumulate. Two options: + +**Option A: Lazy cleanup (RECOMMENDED)** +- Orphans are harmless — they don't appear in queries because all views JOIN through `audio_files → recordings → ...` +- Clean up during the next library rescan (existing orphan cleanup phase) +- Zero additional complexity in the tag edit path + +**Option B: Eager cleanup** +- After each edit, run reference-counting DELETE queries for affected entities +- Adds complexity and transaction time to every edit +- Only worthwhile if orphans cause visible problems (they don't) + +**Decision: Option A.** The existing rescan orphan cleanup handles this. Tag editing should be fast and simple. + +### Transaction Shape + +Single transaction per track edit: ```sql -CREATE TABLE IF NOT EXISTS libraries ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - path TEXT NOT NULL UNIQUE, - scan_concurrency TEXT NOT NULL DEFAULT 'auto', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - last_scanned_at DATETIME -); +BEGIN; +-- 1. Upsert artist_credit (if artist changed) +INSERT INTO artist_credit(text) VALUES(?) ON CONFLICT(text) DO UPDATE SET text=text RETURNING *; +INSERT INTO artists(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING *; +INSERT OR IGNORE INTO artist_credit_artist(artist_id, credit_id) VALUES(?, ?); + +-- 2. Upsert release_group (if album changed) +INSERT INTO release_groups(name, album_artist_credit_id) VALUES(?, ?) + ON CONFLICT(name, album_artist_credit_id) DO UPDATE SET name=name RETURNING *; + +-- 3. Update recording +UPDATE recordings SET name=?, artist_credit_id=?, track_number=?, disc_number=?, + year=?, genre=?, composer=? WHERE id=?; + +-- 4. Update genre links (if genre changed) +DELETE FROM recording_genres WHERE recording_id = ?; +INSERT INTO genres(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING *; +INSERT INTO recording_genres(recording_id, genre_id) VALUES(?, ?); + +-- 5. Update release_group_recordings (if album changed) +DELETE FROM release_group_recordings WHERE recording_id = ?; +INSERT INTO release_group_recordings(release_group_id, recording_id, track_number, disc_number) VALUES(?, ?, ?, ?); + +-- 6. FTS5 update (re-insert with same rowid) +INSERT INTO search_index(rowid, file_path, title, artist, album) VALUES(?, ?, ?, ?, ?); +COMMIT; ``` -### Migration 6: Multi-Library Support +### FTS5 Update Pattern -Order of operations: -1. Create `libraries` table -2. Read TOML `DirectoryPath`, insert as default library -3. `ALTER TABLE audio_files ADD COLUMN library_id INTEGER NOT NULL DEFAULT {defaultLibID}` -4. Create index on `audio_files(library_id)` -5. Rebuild `playlist_tracks` with SET NULL FK + phantom columns -6. Drop and recreate `track_metadata` VIEW with `library_id` -7. Set `PRAGMA user_version = 6` +The current `search_index` is contentless (`content=''`), which means: +- DELETE is not supported +- INSERT with an existing rowid adds a new entry; the old one becomes stale +- Stale entries are filtered out by the JOIN against `track_metadata` in search queries -### playlist_tracks Rebuild (for phantom support) +This works correctly for tag editing: re-INSERT with the same `audio_files.id` as rowid. The stale entry for the old metadata is harmless and filtered by the VIEW JOIN. -```sql -CREATE TABLE playlist_tracks_new ( - id INTEGER PRIMARY KEY, - playlist_id INTEGER NOT NULL, - audio_file_id INTEGER, -- NOW NULLABLE - position INTEGER NOT NULL, - phantom_file_path TEXT, - phantom_title TEXT, - phantom_artist TEXT, - phantom_album TEXT, - FOREIGN KEY(playlist_id) REFERENCES playlists(id) ON DELETE CASCADE, - FOREIGN KEY(audio_file_id) REFERENCES audio_files(id) ON DELETE SET NULL -); +**No FTS5 schema changes needed.** + +## Events + +### New Events + +```go +// Tag editing events. +const ( + TagsUpdated = "TagsUpdated" // Single or batch edit complete + TagEditFailed = "TagEditFailed" // Edit failed (file write error, etc.) +) ``` -Two-phase library removal: -1. Populate phantom metadata BEFORE deleting audio_files -2. Delete audio_files -> SET NULL triggers -> phantom columns preserve display info +### Event Payloads -### track_metadata VIEW (updated) +```go +// TagsUpdated payload: +type TagsUpdatedPayload struct { + FilePaths []string `json:"filePaths"` // All affected file paths +} -Add `af.library_id` to SELECT list. Same JOIN structure. Consumers get library_id for filtering. - -## Scan Pipeline Changes - -- `Scan()` -> `ScanLibrary(libraryID int64)` — accepts library ID, loads path from DB -- `ScanAllLibraries()` — sequential iteration, one at a time -- Orphan cleanup scoped to library being scanned -- Entity cache (artists, albums) remains per-scan and works correctly (shared entities) -- Progress events include library_id and library_name - -## Orphan Cleanup (Library Removal) - -Reference-counting bottom-up deletes in single transaction: -``` -audio_files (library_id = X) -> DELETE -recordings (no remaining audio_files) -> DELETE -release_group_recordings (orphaned) -> DELETE -recording_genres (orphaned) -> DELETE -release_groups (no remaining recordings) -> DELETE -artist_credit (no remaining references) -> DELETE -artist_credit_artist (orphaned) -> DELETE -artists (no remaining credits) -> DELETE -genres (no remaining recording links) -> DELETE -cover_art (no remaining release_groups) -> DELETE +// TagEditFailed payload: +type TagEditFailedPayload struct { + FilePath string `json:"filePath"` + Error string `json:"error"` +} ``` -## FTS5 Search Index +### Frontend Event Handling -Contentless FTS5 (`content=''`) works naturally: -- Search queries JOIN `search_index` on `track_metadata` (which now has `library_id`) -- Library-filtered search: add `AND tm.library_id = ?` to WHERE clause -- Stale entries after library removal filtered out by JOIN (same as current orphan behavior) -- Consider migrating to `contentless_delete=1` (SQLite 3.43.0+) for per-row DELETE support +When `TagsUpdated` fires: +1. `libraryStore` re-fetches all data (simplest approach for v1) +2. OR `libraryStore` patches affected tracks in-place from the payload (more complex but avoids full reload) -## Frontend Architecture +**Recommendation:** Start with full re-fetch on `TagsUpdated`. Optimize to incremental patch later if performance is an issue. The existing `LibraryScanComplete` handler already does a full re-fetch, so this is consistent. -- `libraryStore` gains: library list, active filter (null = all), persistence in localStorage -- Backend filtering (not frontend) — pass libraryID to backend queries -- `library-manager` component redesigned: library list view, add/remove/rename, per-library scan -- All browse views check active filter when fetching data -- New events: LibraryAdded, LibraryRemoved, LibraryRenamed -- Existing scan events gain library_id in payload +## Frontend Integration -## Config Migration +### Existing `track-details` Component -- TOML `[Library].DirectoryPath` read once during migration 6, inserted as default library -- Post-migration: library management through DB only -- `ScanConcurrency` moves per-library (DB column) with global default fallback -- `SetLibraryDirectory()` and `GetLibraryDirectory()` deprecated +The component already has: +- Edit mode toggle with input fields for all editable metadata +- `editValues` state tracking changes +- `saveEdit()` method (currently a no-op TODO) -## Build Order +Changes needed: +1. Wire `saveEdit()` to call `tageditor.EditTrack()` via Wails binding +2. Add loading/saving state for the save button +3. Add error display if the edit fails +4. Close dialog and emit refresh on success +5. Add cover art upload: file picker → `tageditor.SetCoverArt()` -1. Schema & Migration (foundation) -2. Backend scan pipeline (per-library scanning) -3. Backend API (CRUD, filtered queries, events) -4. Frontend (library manager, filter, store updates) +### Batch Editing (Multi-Select) + +The track list already has multi-select via `SelectionController`. Batch editing needs: + +1. New context menu item: "Edit Tags" (when multiple tracks selected) +2. A batch edit dialog variant of `track-details` that: + - Shows "Multiple Values" placeholder for fields that differ across selected tracks + - Only sends changed fields (using the `*string`/`*int` nil-means-no-change pattern) + - Calls `tageditor.EditTracks()` for all selected files + +### Store Updates + +`library-store.ts` needs: +```typescript +// In constructor, add event listener: +EventsOn(Events.TagsUpdated, () => { + // Re-fetch all data to reflect changes + this.eagerFetch(); +}); +``` + +This ensures all views (tracks, albums, artists, genres) reflect the updated metadata without manual cache invalidation. + +## Integration Points Summary + +| Existing Component | Change Type | What Changes | +|-------------------|-------------|-------------| +| `backend/app.go` | MODIFY | Add `tagEditor` field, wire in `NewYellowJacketApp`/`OnStartup`, add to `FEBindings` | +| `backend/events/events.go` | MODIFY | Add `TagsUpdated`, `TagEditFailed` constants | +| `frontend/src/events.ts` | MODIFY (auto-generated) | Mirror new event constants | +| `backend/database/search.go` | MINOR MODIFY | No changes needed — existing `InsertSearchIndex` works for re-insert | +| `backend/metadata/tags.go` | NO CHANGE | Read-only, continues to work as-is | +| `backend/library/library.go` | MINOR MODIFY | Extract `processMetadata` helpers to be reusable, or duplicate in tageditor with attribution | +| `backend/library/query.go` | NO CHANGE | Query methods work as-is | +| `frontend/src/components/track-details/` | MODIFY | Wire save to backend, add loading states, error handling | +| `frontend/src/store/library-store.ts` | MODIFY | Add `TagsUpdated` event listener for cache refresh | +| `go.mod` | MODIFY | Add `bogem/id3v2/v2`, `go-flac/go-flac/v2`, `go-flac/flacvorbis/v2` | + +## Patterns to Follow + +### Pattern 1: Pointer Fields for Optional Updates +**What:** Use `*string` and `*int` in `EditRequest` to distinguish "no change" from "set to empty/zero" +**When:** Any API that partially updates a record +**Example:** +```go +type EditRequest struct { + Title *string `json:"title,omitempty"` + Year *int `json:"year,omitempty"` +} + +// nil = don't change, non-nil = set to this value +if req.Title != nil { + recording.Name = *req.Title +} +``` + +### Pattern 2: Write-to-Temp-Then-Rename +**What:** Write to a temporary file in the same directory, then atomically rename +**When:** Any file modification that must not corrupt the original on failure +**Example:** +```go +tmpPath := filepath.Join(dir, "."+base+".yjtmp") +// Write to tmpPath... +if err := os.Rename(tmpPath, originalPath); err != nil { + os.Remove(tmpPath) + return err +} +``` + +### Pattern 3: Upsert-and-Relink for Shared Entities +**What:** Create new shared entity (artist/album/genre) and update the FK reference, rather than modifying the shared entity in place +**When:** Editing a field that maps to a shared/normalized entity +**Why:** Modifying a shared row would change data for all tracks referencing it + +## Anti-Patterns to Avoid + +### Anti-Pattern 1: Modifying Shared Entity Rows In-Place +**What:** `UPDATE artists SET name = ? WHERE id = ?` to change an artist name +**Why bad:** Changes the name for ALL tracks by that artist, not just the edited track +**Instead:** Upsert a new artist_credit, update the recording's FK to point to the new one + +### Anti-Pattern 2: Full Library Rescan After Tag Edit +**What:** Triggering a library scan to pick up tag changes +**Why bad:** Scans take seconds to minutes. Creates new recordings instead of updating existing ones. Terrible UX. +**Instead:** Inline DB update in the same transaction as the file write + +### Anti-Pattern 3: Frontend-Side Tag File Writing +**What:** Reading/writing audio files from TypeScript via File API +**Why bad:** Wails WebView doesn't have full filesystem access. Tag writing libraries are Go-native. +**Instead:** All file I/O happens in Go backend; frontend sends edit requests via Wails bindings + +### Anti-Pattern 4: Deleting and Recreating Recordings on Edit +**What:** DELETE the old recording, CREATE a new one with updated metadata +**Why bad:** Changes the recording ID, breaking all references (audio_files.recording_id, release_group_recordings, recording_genres, queue, playlists referencing file paths) +**Instead:** UPDATE the existing recording row in place + +## Scalability Considerations + +| Concern | Single Track Edit | Batch Edit (100 tracks) | Batch Edit (1000 tracks) | +|---------|-------------------|------------------------|--------------------------| +| File I/O | ~50ms (one file read+write) | ~5s (sequential, safe) | ~50s (consider progress bar) | +| DB Transaction | <10ms | <100ms (single transaction) | <500ms (batch in groups of 100) | +| FTS5 Update | <1ms | <10ms | <50ms | +| Frontend Refresh | Instant (single event) | Single event, full re-fetch | Single event, full re-fetch | +| Memory | Negligible | ~100MB if all cover arts loaded | Consider streaming cover art | + +For batch edits of >50 tracks, the UI should show a progress indicator. The backend should emit progress events similar to scan progress. + +## Build Order (Dependency-Aware) + +1. **Tag writing library integration** (`backend/tageditor/writer.go`) + - Add dependencies to `go.mod` + - Implement format-specific writers (MP3, FLAC) + - Write-to-temp-then-rename safety wrapper + - Unit tests with real audio files + +2. **DB update logic** (`backend/tageditor/tageditor.go`) + - Shared entity upsert (reuse or extract from library package) + - Recording UPDATE query (existing `UpdateRecordingFull` in sqlc) + - Genre re-linking + - Release group re-linking + - FTS5 re-index (existing `InsertSearchIndex`) + - Transaction wrapper + +3. **Events** (`backend/events/events.go`) + - Add `TagsUpdated`, `TagEditFailed` constants + - Run codegen to update `frontend/src/events.ts` + +4. **Service wiring** (`backend/app.go`) + - Create and bind `tageditor.Service` + - Two-phase init (NewService + SetContext) + +5. **Frontend: single track edit** (`frontend/src/components/track-details/`) + - Wire `saveEdit()` to `tageditor.EditTrack()` + - Loading/error states + - `library-store` event handler for refresh + +6. **Frontend: batch edit** (new or extended component) + - Multi-select context menu action + - Batch edit dialog + - `tageditor.EditTracks()` call + +7. **Cover art editing** (builds on phases 1-5) + - File picker for image selection + - `tageditor.SetCoverArt()` implementation + - Cover art pipeline integration (save to disk, generate variants, update DB) + +## Sources + +- Codebase analysis: `backend/library/library.go` (scan pipeline, entity upsert pattern) +- Codebase analysis: `backend/database/search.go` (FTS5 contentless behavior) +- Codebase analysis: `backend/metadata/tags.go` (read-only tag extraction via dhowden/tag) +- Codebase analysis: `frontend/src/components/track-details/track-details.ts` (existing edit UI stub) +- `bogem/id3v2/v2`: https://pkg.go.dev/github.com/bogem/id3v2/v2 (v2.1.4, MIT, 359 stars, 57 importers) +- `go-flac/go-flac`: https://github.com/go-flac/go-flac (FLAC metadata manipulation) +- `go-flac/flacvorbis`: https://github.com/go-flac/flacvorbis (Vorbis comment read/write for FLAC) +- SQLite FTS5 contentless tables: https://www.sqlite.org/fts5.html#contentless_tables diff --git a/.planning/research/FEATURES.md b/.planning/research/FEATURES.md index 68b5062..db4da48 100644 --- a/.planning/research/FEATURES.md +++ b/.planning/research/FEATURES.md @@ -1,73 +1,300 @@ -# Features Research: Multi-Library Support +# Feature Landscape: Tag Editing -**Researched:** 2026-03-08 -**Confidence:** HIGH +**Domain:** Metadata tag editing in desktop music players +**Researched:** 2026-03-16 +**Confidence:** HIGH (based on analysis of MusicBee, foobar2000, Kid3, Mp3tag, Picard patterns + Hydrogenaudio tag standards + existing YellowJacket codebase) -## How Mature Players Handle Multiple Libraries +## How Desktop Music Players Implement Tag Editing -| Player | Model | Libraries Separate? | Cross-Library Playlists? | -|--------|-------|--------------------|-----------------------| -| foobar2000 | Multiple folders, one merged library | No — all folders merge | N/A (one library) | -| MusicBee | Multiple folders per library, separate library databases | Yes (separate DBs) | No | -| Plex | Separate typed libraries, multiple folders each | Yes (fully isolated) | No | -| Jellyfin | Virtual collections with multiple paths | Yes | No | -| Navidrome | Named libraries with user access control | Yes (with multi-select merge) | Yes | -| Roon | Watched folders, one unified library | No — all merge | N/A (one library) | +### Reference Players Analyzed -### Desktop Player Pattern (foobar2000, Roon) +| Player | Single Edit | Batch Edit | Cover Art Edit | Auto-Tag | Tag Format Handling | +|--------|------------|------------|---------------|----------|-------------------| +| foobar2000 | Properties dialog | Multi-select → Properties (shared fields) | Embed/remove from Properties | Via plugins | ID3v2, Vorbis, APEv2; configurable write format | +| MusicBee | Inline + dialog | Multi-select → Edit panel (keep/clear/set) | Drag-drop + file picker + paste | Built-in | ID3v2.3/2.4, Vorbis; auto-convert on write | +| Kid3 | Side panel + dialog | Multi-select → panel applies to all | File picker + paste + drag | MusicBrainz/Discogs | ID3v1/v2, Vorbis, APEv2; shows raw frames | +| Mp3tag | List view + panel | Inherent (panel always applies to selection) | Drag-drop + file picker + clipboard | Tag Sources | All formats; extended tag view | +| Picard | Panel per file/album | Album-level batch via MusicBrainz match | Automatic via MusicBrainz + manual | Core feature | All formats; submission to MusicBrainz | -All folders contribute to one unified library. No folder-level filtering in default UI. User never thinks about "which folder." +### Common Patterns Across All Players -### Server Pattern (Plex, Jellyfin, Navidrome) +**Single-track editing:** +- Dialog/panel with labeled fields, plain text inputs +- Title, artist, album shown prominently (larger/bolder) +- Cover art displayed alongside fields (150-250px) +- Numeric fields (year, track #, disc #) use number inputs or constrained text +- Genre usually free-text (not dropdown — genre lists are opinionated and incomplete) +- Non-editable properties shown separately (bitrate, sample rate, file path, file size) +- Save button writes to file → updates database +- Cancel discards all changes -Separate libraries with access control. More suited to multi-user server apps. +**Batch editing (the critical UX challenge):** +- Select multiple tracks → open editor +- Fields show current value if identical across selection, blank/placeholder if mixed +- A "keep" / "don't change" / "mixed" indicator distinguishes "empty because cleared" from "empty because mixed" +- User types a value → it applies to ALL selected tracks on save +- Fields left unchanged preserve each track's individual value +- Common pattern: three-state per field — "keep original" (default), "set to value", "clear" +- Track number is special: batch edit typically excludes it (each track needs unique number) OR offers auto-number (sequential from N) -### YellowJacket Fit +**Cover art editing:** +- Display current embedded art (or "no cover" placeholder) +- Replace from file: file picker (JPEG, PNG) +- Remove embedded art (less common, but available in Kid3/Mp3tag) +- Cover art in batch edit: applies same image to all selected tracks (common for fixing an album) +- No crop/resize UI — users prepare images externally +- Players typically accept any size but recommend 500-1000px square -Desktop player = **merged by default**, with optional filter. Follows foobar2000/Roon pattern but adds Navidrome-style library selector for power users. +**File safety:** +- Write-to-temp-then-rename (atomic write) is universal best practice +- Some players (foobar2000) create backups before writing +- All players update their internal database after successful file write (no rescan) -## Feature Classification +### Universal Editable Fields (from Hydrogenaudio Tag Mapping + player analysis) -### Table Stakes (Must Have) +**Basic (ID3v1-level, universal compatibility):** +- Title, Artist, Album, Year, Genre, Track Number, Comment -| Feature | Complexity | Dependencies | -|---------|-----------|-------------| -| Add multiple watched folders | Low | Config to DB migration | -| Unified merged view (default) | Medium | All browse views, search aggregate | -| Per-folder independent scan | Medium | Scan pipeline scoping | -| Remove folder without data loss | Low | Phantom tracks for playlists | -| Folder status indicators | Low | Scan event system | -| Graceful offline handling | Medium | Guard orphan cleanup | -| Existing playlists unaffected | Low | File-path references already work | +**Standard (ID3v2/Vorbis, widely supported):** +- Album Artist, Composer, Disc Number, Track Total, Disc Total, Lyrics -### Differentiators (Nice to Have) +**Extended (advanced users, format-dependent):** +- BPM, Initial Key, Mood, Label, Catalog Number, ISRC, MusicBrainz IDs -| Feature | Complexity | Dependencies | -|---------|-----------|-------------| -| Filter/narrow by source folder | Medium | UI filter chip, query-level filtering | -| Named libraries | Low | DB stores name + path | -| Per-folder scan concurrency | Low | Extend existing ScanConcurrency per library | -| Folder health dashboard | Medium | Aggregate scan metrics | +## Table Stakes -### Anti-Features (Do NOT Build) +Features users expect. Missing = product feels incomplete. -| Anti-Feature | Why Avoid | -|--------------|-----------| -| Separate databases per library | Breaks unified browse, doubles query logic | -| User/access control per library | Desktop app is single-user | -| Auto-merge/deduplicate across folders | Complex, error-prone, unexpected | -| Library-specific settings/themes | Over-engineering | +| Feature | Why Expected | Complexity | Dependencies | Notes | +|---------|-------------|------------|--------------|-------| +| Single track tag editing (title, artist, album, genre, year, track#, disc#, composer) | Every player with tag editing supports these 8 fields minimum | Medium | Tag writing library, DB update queries, FTS5 reindex | Existing `track-details` dialog has edit mode UI scaffolded (save is no-op TODO) | +| Write tags to MP3 (ID3v2) | MP3 is the most common format; must-have | High | Need tag writing library (dhowden/tag is read-only) | Format-specific: must write ID3v2.3 or ID3v2.4 frames | +| Write tags to FLAC (Vorbis Comments) | FLAC is the standard lossless format | High | Same writing library | Vorbis comments in FLAC metadata block | +| Write tags to OGG (Vorbis Comments) | Already supported for reading | Medium | Same writing library | Same Vorbis comment format as FLAC | +| Write-to-temp-then-rename | File corruption on crash/power loss = unacceptable data loss | Low | `os.Rename` after writing to temp file | Universal best practice; Go stdlib handles this well | +| Inline DB + FTS5 update after tag write | Users expect immediate UI update; forcing rescan is unacceptable | Medium | UPDATE queries for recordings, artist_credit, release_groups, genres; FTS5 search_index rebuild for affected rows | Must update the `track_metadata` VIEW's source tables | +| Batch editing shared fields across multiple selected tracks | Every tag editor supports this; multi-select already exists in track list | High | Batch editor UI component, backend batch write endpoint, progress tracking | The hard UX problem: mixed-value indicators, three-state fields | +| Save confirmation / error feedback | User must know if write succeeded or failed | Low | Event emission, toast/notification UI | Especially important for read-only files or permission errors | +| Cover art set/replace from image file | Fundamental tag editing feature; cover art is visually prominent | Medium | File picker (already have `FrontendUtil.OpenFileDialog`), image embedding in tag write, cover art cache update | Must update both embedded tag and cover art cache + thumbnails | -## Library Removal Patterns +## Differentiators -All players that support library removal: -1. Show confirmation dialog -2. Remove tracks from DB (or mark as missing) -3. Handle playlist references (delete, mark phantom, or leave as-is) -4. Don't delete files from disk +Features that set the product apart. Not expected, but valued. -**YellowJacket approach:** Phantom tracks (preserving metadata) for playlists. Queue tracks cascade-deleted (ephemeral). Orphan cleanup for shared entities via reference counting. +| Feature | Value Proposition | Complexity | Dependencies | Notes | +|---------|------------------|------------|--------------|-------| +| Album artist field editing | Distinguishes VA compilations; power users expect it | Low | One additional field in edit form; already extracted by `dhowden/tag` | Not in PROJECT.md active list but low-hanging fruit | +| Lyrics field editing | Multi-line text editing for embedded lyrics | Low | Textarea in dialog; lyrics field already in `recordings` schema… wait, it's in `TrackMetadata` struct but not shown in track-details UI | Would need multiline input; niche but straightforward | +| Comment field editing | Standard tag field, some users store notes | Low | Already extracted, just needs UI input | Very low effort to include | +| Auto-number tracks in batch edit | Select album tracks → auto-assign sequential track numbers | Low | Frontend logic to generate sequential numbers, apply in batch write | Huge time-saver when retagging an album | +| Dirty indicator / unsaved changes warning | Prevent accidental dialog close with unsaved edits | Low | Track `editValues` diff vs original values | MusicBee and foobar2000 both do this | +| Undo last tag write (restore from backup) | Safety net for mistakes; builds user trust | Medium | Write original tag values to a backup store before overwriting | Most players don't do this — would be a genuine differentiator | +| Cover art remove (strip embedded art) | Some users want to remove bloated embedded art | Low | Write tags without picture data | Available in Kid3/Mp3tag but not most players | +| Cover art paste from clipboard | Quick workflow: copy image from browser → paste into editor | Medium | Clipboard API in WebView, image data extraction | MusicBee supports this; convenient for web-sourced art | +| Progress indicator for batch operations | Visual feedback during multi-file writes (batch of 20+ tracks) | Low | Progress event emission, progress bar in UI | Important when writing to many files (can take seconds per file for FLAC) | +| Total Tracks / Total Discs fields | Part of standard tag spec; power users tag these | Low | Two additional number fields; already in `TrackMetadata` struct | Mp3tag and Kid3 expose these; foobar2000 uses "X/Y" format | -## Offline Handling +## Anti-Features -Universal pattern: Don't delete tracks when source goes offline. Mark as unavailable. Auto-recover on next scan when source returns. Never auto-delete on temporary unavailability. +Features to explicitly NOT build. + +| Anti-Feature | Why Avoid | What to Do Instead | +|--------------|-----------|-------------------| +| Inline editing in track list columns | Extremely complex (virtual scrolling + inline inputs + focus management + multi-select conflicts); fragile UX | Use the existing modal dialog approach — click to open editor. This is what foobar2000 does. | +| MusicBrainz auto-tagging / lookup | Massive scope expansion (API integration, fuzzy matching, network dependency); separate milestone material | Defer to future "MusicBrainz browser" milestone already in PROJECT.md | +| Genre dropdown with predefined list | Genre lists are subjective, never complete, frustrate users who use custom genres | Free-text input with optional suggestions from existing genres in DB (future enhancement) | +| Tag format conversion (ID3v1→v2, strip APEv2) | Edge case tool feature; desktop tagger territory (Mp3tag) | Write the "correct" format for each file type; don't expose format internals to users | +| Raw tag frame editing | Power-user-only feature; complex UI for marginal value | Edit semantic fields (title, artist, etc.); abstract away ID3 frames vs Vorbis comments | +| Custom/arbitrary tag field editing | Requires extensible UI, arbitrary field names, format-specific storage concerns | Support the standard fields; users with custom tags use Mp3tag | +| Filename renaming from tags | Common in dedicated taggers (Mp3tag, Kid3) but orthogonal to tag editing; adds file system mutation risk | Out of scope; would need separate file operations system | +| ReplayGain scanning/writing | Separate audio analysis feature, not tag editing | Future milestone if ever; requires DSP analysis | +| Drag-and-drop cover art from external apps | Complex browser/WebView drag interop; unreliable across platforms | File picker is the reliable universal approach | +| Multi-value field editing (multiple artists/genres as separate entries) | ID3v2 and Vorbis support multiple values per field, but the UI complexity is enormous | Store as single string; genre already uses `||` separator internally | + +## Feature Dependencies + +``` +Single Track Edit ──→ Tag Writing Library (MP3/FLAC/OGG) + ──→ DB Update Queries (recordings, artist_credit, release_groups, genres) + ──→ FTS5 Reindex (search_index) + ──→ Event Emission (UI refresh) + +Batch Edit ────────→ Single Track Edit (batch = N × single with shared values) + ────────→ Mixed-value UI (three-state field indicators) + ────────→ Multi-select (already exists in track-list) + +Cover Art Edit ───→ Tag Writing Library (picture frame embedding) + ───→ Cover Art Cache Update (saveCoverArt + thumbnail generation) + ───→ File Picker Dialog (already exists: FrontendUtil.OpenFileDialog) + +Write Safety ─────→ Temp file + os.Rename (no dependencies on existing code) + +DB Update ────────→ Existing schema: recordings, artist_credit, artists, + release_groups, release_group_recordings, genres, genre_recordings, + cover_art, audio_files + ────→ FTS5 search_index rebuild for affected rows + ────→ track_metadata VIEW reflects changes automatically (it's a VIEW) +``` + +### Critical Dependency Chain +``` +Tag Writing Library → Single Track Edit → Batch Edit + → Cover Art Edit +``` + +The tag writing library choice gates everything. Until a library can write ID3v2 and Vorbis comments, no editing features can ship. + +### Dependency on Existing Architecture + +| Existing Feature | How Tag Editing Uses It | +|-----------------|----------------------| +| `track-details` component | Already has edit mode scaffolded with input fields, edit/save/cancel buttons, and `editValues` state. Save handler is a TODO stub. | +| Multi-select in track-list | Entry point for batch editing — selected file paths already accessible via `selection.getSelectedKeysOrdered()` | +| Context menu system | "Edit Tags" menu item for single or multi-select (currently shows "Track Details" for single) | +| `FrontendUtil.OpenFileDialog` | File picker for cover art image selection | +| `Library.saveCoverArt` + thumbnail pipeline | Reusable for cover art embedding — same hash-based cache, same thumbnail generation | +| Event system | New events needed: `TagsWritten`, `TagWriteProgress`, `TagWriteError` | +| `backend/metadata/tags.go` | `TrackMetadata` struct defines all writable fields; `ExtractTags` used for reading | + +## Batch Editing UX Patterns (Deep Dive) + +The batch editor is the highest-complexity feature. Here's how mature players handle it: + +### Three-State Field Model + +For each editable field in batch mode: +1. **Keep** (default): Shows "[Mixed]" or "[Various]" if values differ, shows the common value if all tracks share it. On save, each track retains its original value. +2. **Set**: User has typed a new value. On save, all selected tracks get this value. +3. **Clear**: User explicitly cleared the field. On save, all selected tracks have this field emptied. + +**Implementation approach:** +```typescript +type FieldState = 'keep' | 'set' | 'clear'; + +interface BatchField { + state: FieldState; + value: string; // The new value (only meaningful when state === 'set') + commonValue: string; // Value shared across all tracks (empty if mixed) + isMixed: boolean; // Whether tracks have different values +} +``` + +### Backend Batch Write Contract + +```go +// TagEdits contains the fields to write. nil = don't change, empty string = clear. +type TagEdits struct { + Title *string + Artist *string + Album *string + Genre *string + Year *int + TrackNumber *int + DiscNumber *int + Composer *string + CoverArt *CoverArtEdit // nil = keep, non-nil = set/remove +} + +type CoverArtEdit struct { + ImageData []byte // empty = remove cover art + MIMEType string +} +``` + +Using pointer fields: `nil` = keep original, non-nil = set to this value (empty string/zero = clear). This is the standard Go pattern for optional updates and maps directly to the three-state UI model. + +### Batch Write Ordering + +1. Validate all edits before writing any files (fail fast) +2. Write files sequentially (not concurrently — avoids disk thrashing and simplifies error handling) +3. For each file: read → modify → write-to-temp → rename +4. After ALL files written successfully: batch-update DB + FTS5 +5. Emit success event with count +6. On error: stop, report which file failed, files already written are committed (no rollback — file writes are atomic individually) + +## Cover Art Editing Workflow + +### Set/Replace Cover Art (Table Stakes) + +1. User clicks "Change Cover" in edit dialog +2. File picker opens (filter: `*.jpg, *.jpeg, *.png`) +3. User selects image file +4. Preview shown in dialog (replacing current art) +5. On save: + a. Read image bytes from selected file + b. Embed in audio file tag (APIC frame for ID3v2, METADATA_BLOCK_PICTURE for FLAC/OGG) + c. Save to cover art cache (via existing `saveCoverArt` pipeline → hash, dedupe, thumbnails) + d. Update `cover_art` table if hash changed + e. Update UI with new cover art URLs + +### Batch Cover Art (Same Image to All Selected Tracks) + +Common use case: fixing an album where some tracks have wrong/missing cover art. +1. In batch editor, cover art section shows "[Mixed]" or common art +2. User selects new image → applies to ALL selected tracks on save +3. This is the same flow as single-track, just repeated N times + +### What NOT to Build for Cover Art + +- No crop/resize — users use external tools (GIMP, Preview, etc.) +- No web search — would require API integration (future MusicBrainz milestone could add this) +- No multiple picture types (front, back, booklet) — only front cover. ID3v2 supports picture types but the complexity isn't worth it for v1. + +## Field Mapping: Tag Format → Database Schema + +Understanding how edited fields map through the system: + +| Edit Field | Tag (ID3v2) | Tag (Vorbis) | DB Table | DB Column | Notes | +|-----------|------------|-------------|----------|-----------|-------| +| Title | TIT2 | TITLE | `recordings` | `name` | | +| Artist | TPE1 | ARTIST | `artist_credit` → `artists` | `text` / `name` | May need to create new artist_credit + artist rows | +| Album | TALB | ALBUM | `release_groups` | `name` | May need to create new release_group row | +| Album Artist | TPE2 | ALBUMARTIST | (not currently stored separately) | — | Would need schema addition or use existing artist credit | +| Genre | TCON | GENRE | `genres` + `genre_recordings` | `name` | Multiple genres: split on `;` or `,` | +| Year | TYER/TDRC | DATE | `recordings` | `year` | | +| Track # | TRCK | TRACKNUMBER | `recordings` | `track_number` | | +| Disc # | TPOS | DISCNUMBER | `recordings` | `disc_number` | | +| Composer | TCOM | COMPOSER | `recordings` | `composer` | | +| Cover Art | APIC | METADATA_BLOCK_PICTURE | `cover_art` | `file_path` | Binary data; separate storage | +| Comment | COMM | COMMENT | `recordings` | `comment` | | +| Lyrics | USLT | LYRICS | `recordings` | `lyrics` | | + +### Schema Update Complexity + +Simple fields (title, year, track#, disc#, composer, comment, lyrics) → UPDATE `recordings` directly. + +Relational fields (artist, album, genre) → must handle entity lifecycle: +- **Artist change:** Look up or create new `artists` + `artist_credit` rows, update `recordings.artist_credit_id` +- **Album change:** Look up or create new `release_groups` row, update `release_group_recordings` link +- **Genre change:** Parse genre string, look up or create `genres` rows, update `genre_recordings` links + +This entity lookup logic already exists in `library.go`'s `processMetadata` / `saveAudioFile` pipeline — it should be extracted and reused. + +## MVP Recommendation + +**Prioritize (Phase 1 — Tag Editing Core):** +1. Tag writing library integration (MP3 + FLAC + OGG) +2. Single track editing (the 8 active fields from PROJECT.md) +3. Write-to-temp-then-rename safety +4. DB + FTS5 inline update +5. Cover art set/replace from file + +**Prioritize (Phase 2 — Batch Editing):** +6. Batch editing with three-state field model +7. Progress feedback for batch operations +8. Error handling and partial-success reporting + +**Defer:** +- Album artist editing (schema question, low priority) +- Lyrics/comment editing (easy to add later, niche) +- Auto-numbering tracks (convenience, not core) +- Undo/backup system (nice-to-have, not table stakes) +- Cover art paste from clipboard (WebView clipboard API complexity) + +## Sources + +- Hydrogenaudio Knowledgebase: Tag Mapping (https://wiki.hydrogenaud.io/index.php/Tag_Mapping) — HIGH confidence, authoritative tag format reference +- Hydrogenaudio Knowledgebase: foobar2000 Encouraged Tag Standards (https://wiki.hydrogenaud.io/index.php/Foobar2000:Encouraged_Tag_Standards) — HIGH confidence +- Hydrogenaudio Knowledgebase: Tag (metadata) (https://wiki.hydrogenaud.io/index.php/Tag) — HIGH confidence, basic/advanced/personalized field categorization +- YellowJacket codebase analysis: `track-details.ts`, `tags.go`, `coverart.go`, `library.go`, database schemas — PRIMARY source for dependency analysis +- MusicBee, foobar2000, Kid3, Mp3tag, Picard — feature set analysis from training data (MEDIUM confidence on specific UI details) diff --git a/.planning/research/PITFALLS.md b/.planning/research/PITFALLS.md index 597db06..3f8863f 100644 --- a/.planning/research/PITFALLS.md +++ b/.planning/research/PITFALLS.md @@ -1,100 +1,382 @@ -# Pitfalls Research: Multi-Library Support +# Domain Pitfalls: Tag Editing -**Researched:** 2026-03-08 -**Confidence:** HIGH +**Domain:** Adding tag editing to an existing music player with normalized DB +**Researched:** 2026-03-16 +**Confidence:** HIGH (based on codebase analysis + format specifications + SQLite FTS5 docs) ## Critical Pitfalls -### P1: SQLite ALTER TABLE ADD COLUMN with NOT NULL Requires DEFAULT +Mistakes that cause data loss, file corruption, or require architectural rework. -SQLite requires NOT NULL columns added via ALTER TABLE to have a default. Create `libraries` table and insert default library BEFORE adding `library_id` to `audio_files`. Use `DEFAULT {id}` where id is the auto-created library's ID. +### P1: FLAC Tag Writes Require Full File Rewrite -**Phase:** Schema & Migration +**What goes wrong:** FLAC stores Vorbis Comments in a METADATA_BLOCK after the STREAMINFO block. Unlike MP3 (which has padding in ID3v2 headers), FLAC metadata blocks are tightly packed with no padding by default. Changing a tag that increases the metadata size requires rewriting the entire file — moving every audio frame forward. A crash or power loss during this rewrite corrupts the file irrecoverably. -### P2: Table Rebuild for CASCADE to SET NULL Must Audit ALL Tables +**Why it happens:** FLAC spec doesn't mandate padding blocks. Most FLAC files in the wild have zero padding. Even if padding exists, adding cover art (which can be 100KB+) almost always exceeds it. -Both `playlist_tracks` AND `queue_tracks` have `ON DELETE CASCADE` on `audio_file_id`. Decision: playlist_tracks -> SET NULL (phantom support), queue_tracks -> keep CASCADE (queue is ephemeral). Must explicitly document this choice. +**Consequences:** Corrupted FLAC files that won't play. Audio data intact on disk but offset table is wrong, so decoders can't find frames. -**Phase:** Schema & Migration +**Prevention:** +1. **Write-to-temp-then-rename (mandatory for all formats).** Write modified file to a temp file in the same directory (same filesystem), then `os.Rename()` atomically. This is already listed in PROJECT.md as a target feature. +2. For FLAC specifically: read entire file → write new metadata blocks → copy audio frames → rename. There is no in-place shortcut. +3. Verify the written file can be opened and has correct duration before replacing the original. +4. Consider adding a PADDING metadata block after writing (e.g. 8KB) so small future edits can be done in-place. This is what tools like `metaflac` do. -### P3: FTS5 Contentless Table Cannot Delete Individual Rows +**Detection:** File size changes unexpectedly; beep decoder fails to open the file after write; duration changes after write (offset corruption). -After removing a library with 10K tracks, 10K stale FTS5 entries remain. Current JOIN filtering handles this, but FTS5 scoring is affected. Consider migrating to `contentless_delete=1` (SQLite 3.43.0+). Alternative: full rebuild after library removal. +**Phase:** File write layer (earliest phase) -**Phase:** Schema & Migration +--- -### P4: Orphan Cleanup Through Entity Graph Is Complex +### P2: Currently-Playing File Cannot Be Written On Windows (And Shouldn't On Any Platform) -Reference-counting deletes must handle shared entities. Two libraries with same artist — removing one must not delete the artist if the other still references it. Use `NOT IN (SELECT ...)` or `LEFT JOIN ... WHERE ... IS NULL` pattern. Single transaction required. +**What goes wrong:** The player holds an `os.File` handle on the currently playing track (`p.currentFile` in `player.go:461`). On Windows, the OS enforces mandatory file locking — `os.Rename()` will fail with "The process cannot access the file because it is being used by another process." On Linux, the rename succeeds but the player continues reading the old inode (now unlinked), which works until something closes and reopens the path. -**Phase:** Backend API / Library CRUD +**Why it happens:** The player opens files with `os.Open()` and holds them open for the duration of playback (streaming audio data). The beep library reads from this file handle continuously. -### P5: Existing User Migration Must Be Seamless +**Consequences:** On Windows: tag write fails silently or with confusing error. On Linux: tag write succeeds but the player sees stale data, and if the user seeks, the streamer may read garbage from the new file at old offsets. -First launch after update: migration 6 reads TOML DirectoryPath, creates library row, backfills audio_files.library_id. Test on real user database snapshot, not just fresh DB. +**Prevention:** +1. **Check if the target file is currently playing before writing.** Compare `player.currentTrackPath` against the edit target. +2. If the file IS playing: stop playback, close the file handle, perform the write, then reload and seek to the previous position. This creates a brief audio glitch but is the only safe approach. +3. For batch edits that include the current track: edit all other files first, handle the playing file last with the stop-write-reload dance. +4. Alternative (simpler): refuse to edit the currently playing file and show a user-facing message. Less ideal UX but avoids complexity. -**Phase:** Schema & Migration +**Detection:** `os.Rename()` returns error on Windows. On Linux, no error but playback becomes corrupted after seek. + +**Phase:** File write layer + player integration + +--- + +### P3: FTS5 Contentless Table Cannot UPDATE or DELETE Individual Rows + +**What goes wrong:** The current `search_index` is a contentless FTS5 table (`content=''`). The existing `DeleteSearchIndex()` method is literally a no-op (see `search.go:120-127`). After editing a track's title from "Love Song" to "Heart Song", searching for "Love Song" still returns the track because the old FTS5 entry cannot be removed. The stale entry points to a valid rowid, and the JOIN against `track_metadata` will return the row (now with different data), so the user sees a search result that doesn't match their query. + +**Why it happens:** Contentless FTS5 (`content=''`) stores only the index, not the original text. Without the original text, FTS5 can't compute what tokens to remove from the index. The current design relies on full rebuilds during rescan, which is fine for the read-only case but breaks for incremental edits. + +**Consequences:** Search returns false positives after tag edits. The more edits the user makes, the worse search quality gets — until the next full rescan rebuilds the index. + +**Prevention — Two Options:** + +**Option A: Migrate to `contentless_delete=1` (Recommended)** +SQLite 3.43.0+ supports `contentless_delete=1` which enables DELETE and INSERT OR REPLACE. This requires a schema migration (drop + recreate the FTS5 table). The `modernc.org/sqlite` driver bundles SQLite 3.45+, so this is available. After migration, tag edit can do: DELETE the old row, INSERT the new row. This is the `DELETE + INSERT` pattern already noted in the milestone context. + +**Option B: Rebuild the entire index after each edit session** +Call `RebuildSearchIndex()` after completing all tag writes. This is expensive (reads all tracks) but correct. Could be batched — rebuild once after a batch edit, not per-track. + +**Recommendation:** Option A. The migration is straightforward and makes individual updates O(1) instead of O(n). The existing `RebuildSearchIndex()` becomes the migration step. + +**Detection:** Search for old tag values — if they return results with the new values, the index is stale. + +**Phase:** Schema migration (do first, before any tag write code) + +--- + +### P4: Shared Entity Fan-Out — Editing Artist on One Track Affects Zero or Fifty Others + +**What goes wrong:** The normalized schema shares entities across tracks. An `artist_credit` row with text "The Beatles" may be referenced by 200 recordings via `recordings.artist_credit_id`. If the user edits the artist field on one track from "The Beatles" to "Beatles, The", the system must decide: (a) update the shared `artist_credit` row (changing all 200 tracks), (b) create a new `artist_credit` and repoint only this track's recording, or (c) something else. + +**Why it happens:** The MusicBrainz-inspired schema (`artists` → `artist_credit` → `recordings`) is designed for read-heavy workloads where entities are shared. Tag editing breaks this assumption by making per-track changes that may or may not be intended as global changes. + +**Consequences:** +- If you update the shared row: user edits one track, 199 other tracks silently change. Terrifying. +- If you create new rows: orphaned entities accumulate (old `artist_credit` row with only 199 refs, then 198, etc.). The artist browse view shows "The Beatles" AND "Beatles, The" as separate entries. +- If you try to be smart about it: complex merge/split logic that's hard to get right. + +**Prevention:** +1. **Tag editing always creates new entity rows for the edited track.** Create a new `recording`, new `artist_credit` (if changed), new `release_group_recordings` link, new `genre_recordings` links. Point the `audio_file.recording_id` at the new recording. This is the safest approach and matches what the scan pipeline already does (it always creates new recordings). +2. **Orphan cleanup after edit.** After repointing the audio_file, check if the old recording is still referenced by any audio_file. If not, delete it (and cascade to its genre links, release_group links). Same for artist_credit, artists, genres, release_groups. +3. **Never mutate shared entities in-place** during single-track or batch-within-same-album editing. The only exception is intentional "rename this artist across all tracks" which should be a separate, explicit feature (not part of v1.2). + +**Detection:** After editing one track's artist, check if other tracks in the same album now show the wrong artist. + +**Phase:** Database update layer (core architecture decision — must be settled before writing any DB update code) + +--- + +### P5: Race Condition — Scan Runs While Tags Are Being Written + +**What goes wrong:** User starts editing tags. While the edit is in progress (writing files, updating DB), a library scan starts (either from the scan queue, soft scan on launch, or user-initiated). The scan reads the file's tags (which may be half-written or already-written-but-DB-not-yet-updated), creates new entity rows, and overwrites the DB state that the tag editor just carefully set up. + +**Why it happens:** The scan pipeline (`scanInternal`) and tag editing are independent operations. The scan loads existing files from DB, walks the filesystem, extracts metadata, and writes to DB. If a file's on-disk tags differ from the DB (because the edit just wrote new tags), the scan treats it as needing an update and overwrites the recording. + +**Consequences:** Tag edits silently reverted. Or worse: the scan creates duplicate recordings (one from the edit, one from the scan) because the scan's entity cache doesn't know about the edit's newly-created entities. + +**Prevention:** +1. **Mutual exclusion between tag editing and scanning.** While tag writes are in progress, block scan start (or vice versa). The existing `l.mu` mutex protects scan state; extend it to cover "edit in progress" state. +2. **Simpler: Use the existing scan queue coordinator.** Tag edits happen on the main goroutine (via Wails binding). Scans run in background goroutines. Since SQLite has `SetMaxOpenConns(1)`, DB writes are already serialized. The risk is the scan re-reading the file AFTER the tag write but BEFORE the DB update. Solution: perform the file write and DB update atomically (in the same critical section), and have the scan skip files that were recently edited (timestamp check or "edited" flag). +3. **Best approach: Pause/cancel active scan during tag edit, resume after.** The existing `PauseScan()`/`ResumeScan()` mechanism can be leveraged. Pause the scan, do the edit (file write + DB update), resume the scan. + +**Detection:** Edit a tag, immediately trigger a scan, check if the edit survives. + +**Phase:** Tag write integration with scan pipeline + +--- + +### P6: Temp File Rename Fails Across Filesystem Boundaries + +**What goes wrong:** `os.Rename()` is atomic only when source and dest are on the same filesystem. If the temp file is created in `/tmp` (default `os.CreateTemp` behavior) but the music file is on `/mnt/music`, the rename becomes a copy+delete — no longer atomic, and if interrupted, you lose the file. + +**Why it happens:** Many developers use `os.CreateTemp("", ...)` which defaults to the system temp directory, which is often a different filesystem/partition from where music files live. + +**Consequences:** Non-atomic write. Power loss during copy = corrupted or missing file. + +**Prevention:** +1. **Create the temp file in the same directory as the target file.** Use `os.CreateTemp(filepath.Dir(targetPath), ".yj-edit-*")` to ensure same-filesystem rename. +2. Clean up temp files on startup (find files matching `.yj-edit-*` pattern in library directories — these are orphaned from crashed edits). +3. Use the temp file pattern: `