# Architecture Patterns: Tag Editing Integration **Domain:** Audio metadata editing in existing music player **Researched:** 2026-03-16 **Confidence:** HIGH (based on full codebase analysis of existing architecture) ## Recommended Architecture 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. ### High-Level Data Flow ``` 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 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; ``` ### FTS5 Update Pattern 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 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. **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.) ) ``` ### Event Payloads ```go // TagsUpdated payload: type TagsUpdatedPayload struct { FilePaths []string `json:"filePaths"` // All affected file paths } // TagEditFailed payload: type TagEditFailedPayload struct { FilePath string `json:"filePath"` Error string `json:"error"` } ``` ### Frontend Event Handling 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) **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. ## Frontend Integration ### Existing `track-details` Component 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) 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()` ### 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