docs: complete tag editing project research
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user