Files
yellowjacket/.planning/research/ARCHITECTURE.md
T

470 lines
21 KiB
Markdown

# 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