docs: complete project research for v1.2.1 Format Parity

This commit is contained in:
2026-03-18 14:33:42 -04:00
parent 664de001ed
commit 279501f972
5 changed files with 1631 additions and 1131 deletions
+438 -369
View File
@@ -1,469 +1,538 @@
# Architecture Patterns: Tag Editing Integration
# Architecture Patterns: OGG Vorbis + WAV Tag Writer Integration
**Domain:** Audio metadata editing in existing music player
**Researched:** 2026-03-16
**Confidence:** HIGH (based on full codebase analysis of existing architecture)
**Domain:** Adding OGG Vorbis and WAV tag writing to existing music player tag editing pipeline
**Researched:** 2026-03-18
**Confidence:** HIGH (based on full codebase analysis of existing architecture + container format research)
## 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.
OGG Vorbis and WAV tag writing integrate into the existing `tagwriter` package by implementing two new format-specific writer functions (`writeOggTags` and `writeWavTags`) that follow the exact pattern established by `writeMp3Tags` and `writeFlacTags`. The existing pipeline (`WriteTrackTags` → file write → DB sync → event emission) requires only a switch-case extension — no new interfaces, no refactoring.
### High-Level Data Flow
### High-Level Data Flow (Unchanged)
```
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
→ Wails binding: TagWriter.WriteTrackTagsByPath(filePath, changes)
→ 1. Resolve audio_file by path
→ 2. DetectFormat(filePath) ← EXTEND: add .ogg, .wav cases
→ 3. AcquirePipelineLock
→ 4. PlayerStopper check
→ 5. Format switch:
case FormatMP3: writeMp3Tags(...) ← existing
case FormatFLAC: writeFlacTags(...) ← existing
case FormatOGG: writeOggTags(...) ← NEW
case FormatWAV: writeWavTags(...) ← NEW
6. syncDatabase(...) ← NO CHANGES
7. EventsEmit(TrackMetadataChanged) ← NO CHANGES
```
**Key insight:** The existing architecture was designed for format extension. The `pipeline.go` switch statement is the single point of modification. Everything downstream (DB sync, FTS5, orphan cleanup, event emission, batch processing, cover art pipeline) is format-agnostic and works unchanged.
### 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 |
| Component | Status | Changes |
|-----------|--------|---------|
| `backend/tagwriter/tagwriter.go` | MODIFY | Add `FormatOGG`, `FormatWAV` constants; extend `DetectFormat()` switch |
| `backend/tagwriter/pipeline.go` | MODIFY | Add two cases to format switch in `WriteTrackTags()` |
| `backend/tagwriter/ogg.go` | **NEW** | `writeOggTags()` — OGG container rewrite with Vorbis Comment manipulation |
| `backend/tagwriter/wav.go` | **NEW** | `writeWavTags()` — RIFF/WAV chunk manipulation with ID3v2 or LIST-INFO |
| `backend/tagwriter/ogg_test.go` | **NEW** | 7 round-trip tests following FLAC pattern |
| `backend/tagwriter/wav_test.go` | **NEW** | 7 round-trip tests following FLAC pattern |
| `backend/tagwriter/dbsync.go` | UNCHANGED | Format-agnostic entity sync |
| `backend/tagwriter/helpers_test.go` | UNCHANGED | Shared test helpers (`tinyJPEG`, `assertEqual`, etc.) |
| `backend/metadata/tags.go` | UNCHANGED | `dhowden/tag` already reads OGG and WAV tags |
| `backend/fileutil/atomicwrite.go` | UNCHANGED | Used by both new writers |
| `frontend/src/components/track-details/` | UNCHANGED | Format-agnostic edit UI |
## New Package: `backend/tageditor/`
## OGG Vorbis Writer: `writeOggTags()`
### Why a Separate Package
### Container Structure
The tag editing flow does NOT fit cleanly into the existing `library` package because:
An OGG Vorbis file consists of OGG pages containing three types of Vorbis packets:
1. **Identification header** (page 0, BOS flag) — audio parameters, must not be modified
2. **Comment header** (page 1) — Vorbis Comments (tags) + optional METADATA_BLOCK_PICTURE
3. **Setup header** (page 1 or 2) — codebooks, must not be modified
4. **Audio data** (remaining pages) — compressed audio, must not be modified
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.
Writing tags means **replacing only the comment header packet** while preserving everything else exactly.
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/`
### Approach: Full-File Rewrite via AtomicWrite
### Package Structure
**Why full rewrite, not in-place:** Changing the comment header changes its size. OGG pages have fixed-size segment tables (max 255 segments × 255 bytes = ~64KB per page). A larger comment header may require different page segmentation. Every subsequent page has a page sequence number and CRC-32 checksum that must be recalculated. In-place editing is impossible — the file must be rewritten.
```
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
```
**This is fine:** AtomicWrite already does full-file rewrite for MP3 and FLAC. OGG Vorbis files are typically 3-10MB (compressed audio). Memory usage is bounded because we stream page-by-page, not load the entire file.
### Service API (Wails-Bound)
### Implementation Strategy
```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()
func writeOggTags(logger *slog.Logger, filePath string, changes TagChanges) error {
// 1. Open and parse OGG file page-by-page
// 2. Read the three header packets (identification, comment, setup)
// 3. Parse existing Vorbis Comment from comment header packet
// 4. Apply changes to Vorbis Comment fields (same field mapping as FLAC)
// 5. If cover_art changed: encode METADATA_BLOCK_PICTURE and add/remove
// from Vorbis Comment as base64 field
// 6. Re-serialize comment header packet
// 7. AtomicWrite: stream all pages to temp file
// - Page 0 (BOS): rewrite with correct CRC (identification header unchanged)
// - Page 1+: rewrite with new comment header + setup header, recalculate
// segmentation and CRC
// - Remaining pages: copy byte-for-byte (page sequence numbers and CRCs
// only need recalculation if page boundaries shifted)
// 8. Rename over original
}
// 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.
### OGG Page Structure (For Implementation)
### Two-Phase Initialization
```
OGG Page Header (27 bytes + segment table):
- Capture pattern: "OggS" (4 bytes)
- Stream structure version: 0 (1 byte)
- Header type flag: BOS/EOS/continued (1 byte)
- Absolute granule position: int64 (8 bytes)
- Stream serial number: uint32 (4 bytes)
- Page sequence number: uint32 (4 bytes)
- CRC-32 checksum: uint32 (4 bytes)
- Number of page segments: uint8 (1 byte)
- Segment table: [num_segments]uint8
Follows the existing `NewService()` + `SetContext()` pattern:
OGG Page Body:
- Raw data (sum of segment table values bytes)
```
### Vorbis Comment Format (Shared with FLAC)
The comment header packet uses the exact same Vorbis Comment format as FLAC's Vorbis Comment metadata block, with one difference:
- **In FLAC:** Vorbis Comments are a metadata block (type 4), binary data
- **In OGG:** Vorbis Comments are preceded by a 7-byte Vorbis packet header (`\x03vorbis`)
The field mapping is identical to `applyFlacTextChanges()`:
| Field | Vorbis Comment Key |
|-------|-------------------|
| title | TITLE |
| artist | ARTIST |
| album | ALBUM |
| album_artist | ALBUMARTIST |
| genre | GENRE |
| year | DATE |
| track_number | TRACKNUMBER |
| disc_number | DISCNUMBER |
| composer | COMPOSER |
### Code Reuse Opportunity
The `replaceVorbisComment()` function in `flac.go` operates on `*flacvorbis.MetaDataBlockVorbisComment` which is specific to the `go-flac/flacvorbis` library. However, the Vorbis Comment binary format is identical across FLAC and OGG. Two approaches:
1. **Build a minimal Vorbis Comment parser/serializer** (~80 lines) directly in `ogg.go` that reads/writes the `vendor_string + comments[]` binary format. This avoids pulling in FLAC dependencies for OGG files. The existing `parseVorbisComment()` helper in `flac_test.go` already demonstrates the parsing logic — promote it to a shared implementation.
2. **Reuse `go-flac/flacvorbis`** by constructing a fake `MetaDataBlock` from the OGG comment packet (strip the 7-byte `\x03vorbis` prefix). This is hacky and creates a false dependency.
**Recommendation:** Approach 1. Write a self-contained `vorbisComment` type in `ogg.go` with `parse(data []byte)` and `marshal() []byte` methods. The format is simple (little-endian length-prefixed strings) and the test already has the parser. This is ~80 lines and avoids coupling.
### Cover Art in OGG: METADATA_BLOCK_PICTURE
OGG Vorbis stores cover art as a Vorbis Comment field named `METADATA_BLOCK_PICTURE`. The value is a base64-encoded binary blob using the same FLAC PICTURE block format:
```
METADATA_BLOCK_PICTURE binary format:
- Picture type: uint32 BE (3 = front cover)
- MIME string length: uint32 BE
- MIME string: UTF-8
- Description length: uint32 BE
- Description: UTF-8
- Width: uint32 BE
- Height: uint32 BE
- Color depth: uint32 BE
- Colors used: uint32 BE
- Data length: uint32 BE
- Data: raw image bytes
```
This is then base64-encoded and stored as: `METADATA_BLOCK_PICTURE=<base64 data>`
**Implementation:** Encode using `encoding/base64` and the same `detectMIME()` helper. Width/height/depth can be set to 0 (players derive them from the image data). This adds ~30 lines to the cover art handling.
**Read-back verification:** `dhowden/tag` already reads `METADATA_BLOCK_PICTURE` from OGG files and returns it via the `Picture()` method. Round-trip tests will work with `metadata.ExtractTags()` unchanged.
### No Existing Go Library
**Confirmed:** There is no pure-Go library for writing OGG Vorbis tags. The existing `jfreymuth/oggvorbis` library (already an indirect dependency via beep) is **read-only** — it provides `NewReader()`, `Read()`, `CommentHeader()`, and `GetCommentHeader()` but no write functionality. The `ogg.go` source reveals the page-level reading infrastructure (`page.read()`, `page.readHeader()`, `page.readContent()`) but no page writing.
**Impact:** We must implement OGG page writing ourselves. This is ~200 lines of code for the page serializer + CRC calculation. The `jfreymuth/oggvorbis` package's `crc.go` provides the CRC-32 lookup table (`crcUpdate()`) we can reference for the polynomial (0x04C11DB7, same as in the OGG spec). However, since it's unexported, we must either vendor the CRC table or compute it at init time.
### Risk Assessment
| Risk | Severity | Mitigation |
|------|----------|------------|
| OGG page re-segmentation correctness | HIGH | Comprehensive round-trip tests; verify with `dhowden/tag` read-back |
| CRC-32 calculation error | HIGH | Use the same polynomial as the OGG spec; test against known-good files |
| METADATA_BLOCK_PICTURE encoding | LOW | Well-documented format; base64 encoding is trivial |
| Large OGG files (>100MB) | LOW | OGG Vorbis files are typically <15MB; stream page-by-page |
| Multi-stream OGG files | LOW | Music files are single-stream; reject multi-stream as unsupported |
## WAV Writer: `writeWavTags()`
### Container Structure
WAV files use the RIFF (Resource Interchange File Format) container:
```
RIFF header:
"RIFF" (4 bytes)
File size - 8 (uint32 LE)
"WAVE" (4 bytes)
Chunks (in any order):
"fmt " chunk — audio format parameters (required)
"data" chunk — raw PCM audio samples (required)
"LIST" chunk (subtype "INFO") — metadata as sub-chunks
"id3 " or "ID3 " chunk — embedded ID3v2 tag
other chunks (fact, cue, etc.)
```
### Metadata Approach: ID3v2 in RIFF Chunk
WAV metadata can be stored in two ways:
1. **LIST-INFO chunks:** Simple key-value pairs (`INAM`=title, `IART`=artist, `IPRD`=album, etc.). No cover art support. Limited charset (originally ASCII, some tools use UTF-8).
2. **id3 chunk:** A full ID3v2 tag embedded in a RIFF chunk named `id3 ` or `ID3 `. Supports all fields including cover art. Most modern tools (foobar2000, MusicBee, TagLib) use this approach.
**Recommendation:** Use the **id3 chunk** approach because:
- Reuses the existing `bogem/id3v2` library already used by `writeMp3Tags()`
- Supports cover art embedding (LIST-INFO does not)
- `dhowden/tag` reads ID3v2 from WAV files, so round-trip tests work
- Consistent field semantics with MP3 tag writing
### Implementation Strategy
```go
// In NewYellowJacketApp():
yjApp.tagEditor = tageditor.NewService(logger, db)
// In OnStartup():
yj.tagEditor.SetContext(ctx)
// In FEBindings:
yjApp.FEBindings = []any{
// ... existing bindings ...
yjApp.tagEditor,
func writeWavTags(logger *slog.Logger, filePath string, changes TagChanges) error {
// 1. Open WAV file, parse RIFF header
// 2. Enumerate chunks: find existing "id3 " chunk (if any),
// locate "fmt " and "data" chunks
// 3. If existing id3 chunk found:
// a. Parse existing ID3v2 tag
// b. Apply changes using same applyTextChanges()/applyCoverArtChanges()
// as MP3 writer
// c. Serialize updated tag
// 4. If no existing id3 chunk:
// a. Create new ID3v2 tag
// b. Apply changes
// c. Serialize
// 5. AtomicWrite: rebuild RIFF file
// a. Write RIFF header with new total size
// b. Copy all original chunks EXCEPT old id3 chunk
// c. Append new id3 chunk (with proper RIFF chunk header)
// d. Update RIFF file size in header
}
```
## File Writing Strategy
### Code Reuse with MP3 Writer
### Write-to-Temp-Then-Rename (Corruption Safety)
The MP3 writer's `applyTextChanges(tag, changes)` and `applyCoverArtChanges(tag, changes)` functions operate on `*id3v2.Tag` objects. These exact functions can be reused by the WAV writer since the ID3v2 tag format is identical:
```go
// In wav.go — reuse existing functions from mp3.go:
tag, err := id3v2.Open(...) // or id3v2.ParseReader(...)
applyTextChanges(tag, changes) // ← same function from mp3.go
applyCoverArtChanges(tag, changes) // ← same function from mp3.go
```
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
### RIFF Chunk Parser (~100 lines)
For cover art, the writer embeds the image data directly into the audio file:
A minimal RIFF parser needs to:
1. Read 12-byte RIFF header (`"RIFF" + size + "WAVE"`)
2. Iterate chunks: 8-byte chunk header (`id[4] + size[4]`), skip body
3. Track positions and sizes of each chunk
4. Handle padding bytes (RIFF chunks are word-aligned — if data size is odd, a pad byte follows)
- **MP3**: `id3v2.PictureFrame` with `PTFrontCover` type
- **FLAC**: `flac.MetaDataBlockPicture` (FLAC picture metadata block)
This is straightforward binary parsing. No external library needed.
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
### AtomicWrite for Large WAV Files
## Database Update Strategy
**Concern:** WAV files can be very large (uncompressed audio: a 60-minute CD-quality WAV is ~630MB). AtomicWrite creates a full copy in a temp file before renaming.
### The Shared Entity Problem
**Assessment:** This is acceptable because:
1. AtomicWrite already streams data via `io.Copy` — it doesn't load the file into memory
2. The WAV writer copies chunks sequentially: read chunk from source → write to temp file
3. Disk space for the temp file is the only cost (~2× file size temporarily)
4. The alternative (in-place chunk modification) risks corruption if the process is interrupted mid-write
5. The existing FLAC writer already handles large files this way (with a warning for >500MB)
The normalized schema means entities are shared across tracks:
**Practical consideration:** WAV files >500MB are rare in music libraries (they're typically ripped CDs at ~30-50MB per track, or high-resolution at ~150MB). Add the same size warning as the FLAC writer.
```go
if info.Size() > largeSizeThreshold {
logger.Warn("large WAV file may take extra time/space for atomic write",
slog.String("path", filePath),
slog.Int64("size", info.Size()),
)
}
```
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:
### Cover Art in WAV
**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
Since we're using the id3 chunk approach, cover art embedding uses the exact same APIC frame mechanism as MP3. The `applyCoverArtChanges()` function handles this already.
**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)
### Risk Assessment
**Decision: Option A.** The existing rescan orphan cleanup handles this. Tag editing should be fast and simple.
| Risk | Severity | Mitigation |
|------|----------|------------|
| Large file temp space | MEDIUM | Log warning for >500MB; streaming copy avoids memory issues |
| RIFF chunk alignment (pad bytes) | MEDIUM | Follow spec: odd-sized chunks get 1 pad byte |
| Existing LIST-INFO metadata | LOW | Preserve LIST-INFO chunks as-is; only modify/add id3 chunk |
| `bogem/id3v2` reading from WAV | LOW | Library may need `ParseReader` instead of `Open` (investigate) |
| WAV files without existing tags | LOW | Create new id3 chunk; all other chunks preserved |
### Transaction Shape
## Format Detection Extension
Single transaction per track edit:
### Current Implementation (`tagwriter.go`)
```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;
```go
func DetectFormat(filePath string) (AudioFormat, error) {
ext := strings.ToLower(filepath.Ext(filePath))
switch ext {
case ".mp3":
return FormatMP3, nil
case ".flac":
return FormatFLAC, nil
default:
return "", fmt.Errorf("%w: %s", errUnsupportedFormat, ext)
}
}
```
### 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
### Required Changes
```go
// Tag editing events.
const (
TagsUpdated = "TagsUpdated" // Single or batch edit complete
TagEditFailed = "TagEditFailed" // Edit failed (file write error, etc.)
FormatMP3 AudioFormat = "mp3"
FormatFLAC AudioFormat = "flac"
FormatOGG AudioFormat = "ogg" // NEW
FormatWAV AudioFormat = "wav" // NEW
)
func DetectFormat(filePath string) (AudioFormat, error) {
ext := strings.ToLower(filepath.Ext(filePath))
switch ext {
case ".mp3":
return FormatMP3, nil
case ".flac":
return FormatFLAC, nil
case ".ogg": // NEW
return FormatOGG, nil // NEW
case ".wav": // NEW
return FormatWAV, nil // NEW
default:
return "", fmt.Errorf("%w: %s", errUnsupportedFormat, ext)
}
}
```
### Event Payloads
**Extension-based detection is sufficient.** The existing approach works because:
1. The metadata package already uses extension-based routing for decoding (`metadata/decoder.go`)
2. YellowJacket only indexes files with known extensions (`.mp3`, `.flac`, `.ogg`, `.wav`)
3. File magic-byte detection is unnecessary — if a file is in the DB, it was already validated during scan
### Pipeline Switch Extension
In `pipeline.go`, the format switch becomes:
```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"`
switch format {
case FormatMP3:
err = writeMp3Tags(tw.logger, audioFile.FilePath, changes)
case FormatFLAC:
err = writeFlacTags(tw.logger, audioFile.FilePath, changes)
case FormatOGG:
err = writeOggTags(tw.logger, audioFile.FilePath, changes)
case FormatWAV:
err = writeWavTags(tw.logger, audioFile.FilePath, changes)
default:
err = fmt.Errorf("%w: %s", errUnsupportedFormat, format)
}
```
### Frontend Event Handling
## Frontend Impact: None
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)
The track-details dialog component is **completely format-agnostic**. It:
1. Shows the same 8 editable fields regardless of format
2. Shows the same cover art pick/replace/remove UI
3. Sends the same `TagChanges` diff map to `WriteTrackTagsByPath()`
4. Receives the same `TrackMetadataChanged` event
5. Uses the same three-state field model for batch editing
**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.
The backend handles all format-specific logic. The frontend never sees or cares about the audio format.
## Frontend Integration
**Verified:** No frontend changes needed for OGG or WAV tag writing.
### Existing `track-details` Component
## Test Strategy
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)
### Follow FLAC Round-Trip Pattern (7 Tests per Format)
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()`
The FLAC writer has 7 tests that verify the complete write→read cycle using `metadata.ExtractTags()` for read-back. The same test structure applies to OGG and WAV:
### Batch Editing (Multi-Select)
| Test | What It Verifies |
|------|-----------------|
| `TestWriteOggTags_TextFields` | All 9 text fields round-trip correctly |
| `TestWriteOggTags_CoverArt` | METADATA_BLOCK_PICTURE embedded and readable |
| `TestWriteOggTags_ClearCoverArt` | Cover art removal works |
| `TestWriteOggTags_PartialUpdate` | Unchanged fields preserved |
| `TestWriteOggTags_PreservesAudioData` | Audio stream intact after tag write |
| `TestWriteOggTags_ReplaceComment` | No duplicate Vorbis Comment entries |
| `TestWriteOggTags_AtomicSafety` | Failed write leaves file untouched |
The track list already has multi-select via `SelectionController`. Batch editing needs:
Same 7 tests for WAV (`TestWriteWavTags_*`).
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
### Test Fixture Helpers
### Store Updates
Each format needs a `makeMinimal*()` helper:
`library-store.ts` needs:
```typescript
// In constructor, add event listener:
EventsOn(Events.TagsUpdated, () => {
// Re-fetch all data to reflect changes
this.eagerFetch();
});
```
- **`makeMinimalOGG(t, path)`** — Creates a minimal valid OGG Vorbis file containing: BOS page with identification header, comment header page (empty Vorbis Comment), EOS page with setup header + minimal audio frame. This is complex (~60 lines) but required for round-trip testing.
This ensures all views (tracks, albums, artists, genres) reflect the updated metadata without manual cache invalidation.
- **`makeMinimalWAV(t, path)`** — Creates a minimal valid WAV file containing: RIFF header, `fmt ` chunk (PCM, 44100Hz, 16-bit, mono), `data` chunk (brief silence). This is simple (~30 lines) — just binary header construction.
## Integration Points Summary
### Read-Back Verification
| 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` |
Both formats use `metadata.ExtractTags()` (which uses `dhowden/tag`) for read-back verification:
- **OGG:** `dhowden/tag` reads Vorbis Comments from OGG files including `METADATA_BLOCK_PICTURE`. **Verified:** the library's `ogg.go` and `vorbis.go` handle this.
- **WAV:** `dhowden/tag` reads ID3v2 tags from WAV files (it detects the `id3 ` chunk in the RIFF container). **Verified:** the library handles this per its README (MP3/MP4/OGG/FLAC metadata parsing).
**Confidence:** HIGH — `dhowden/tag` is already the read-back library for MP3 and FLAC tests. It supports OGG and WAV reading.
## Suggested Build Order
Based on dependency analysis and risk levels:
### Phase 1: WAV Writer (Lower Risk, Faster)
**Rationale:** WAV writing reuses the existing `bogem/id3v2` library and the existing `applyTextChanges()`/`applyCoverArtChanges()` functions. The RIFF container is much simpler than OGG (no checksums, no page segmentation). This can be built and tested quickly, giving confidence in the pipeline extension pattern before tackling OGG.
1. Add `FormatWAV` constant and extend `DetectFormat()`
2. Write RIFF chunk parser (~100 lines in `wav.go`)
3. Implement `writeWavTags()` using id3v2 tag in RIFF chunk
4. Add pipeline switch case
5. Write `makeMinimalWAV()` test fixture
6. Write 7 round-trip tests
7. Verify with `make lint`
### Phase 2: OGG Vorbis Writer (Higher Risk, More Code)
**Rationale:** OGG requires implementing the page-level write infrastructure (CRC-32, segmentation, page serialization) from scratch. This is the riskiest part of the milestone and benefits from having the WAV writer already proving the pipeline extension pattern works.
1. Implement OGG CRC-32 calculation (~30 lines)
2. Implement OGG page serializer (~80 lines)
3. Implement Vorbis Comment parser/serializer (~80 lines)
4. Implement METADATA_BLOCK_PICTURE encoding (~40 lines)
5. Implement `writeOggTags()` orchestrator (~100 lines)
6. Add `FormatOGG` constant and pipeline switch case
7. Write `makeMinimalOGG()` test fixture (~60 lines)
8. Write 7 round-trip tests
9. Verify with `make lint`
### Phase 3: Cleanup
1. Remove OGG/WAV from "Out of Scope" in PROJECT.md
2. Update milestone status
3. Fix any lint warnings from v1.2
### Total New Code Estimate
| Component | Lines (approx) |
|-----------|----------------|
| `ogg.go` (writer + helpers) | ~350 |
| `wav.go` (writer + RIFF parser) | ~200 |
| `ogg_test.go` | ~350 |
| `wav_test.go` | ~250 |
| `tagwriter.go` changes | ~10 |
| `pipeline.go` changes | ~5 |
| **Total** | **~1,165** |
## 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
### Pattern 1: Format Writer Function Signature
**What:** All format writers follow the same signature: `func write*Tags(logger *slog.Logger, filePath string, changes TagChanges) error`
**Why:** Keeps the pipeline switch clean and uniform. No interface needed — package-internal functions with consistent signatures are simpler.
**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
}
func writeOggTags(logger *slog.Logger, filePath string, changes TagChanges) error { ... }
func writeWavTags(logger *slog.Logger, filePath string, changes TagChanges) error { ... }
```
### 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:**
### Pattern 2: AtomicWrite Integration
**What:** Every writer creates the complete output file inside the `AtomicWrite` callback, writing to `tmp *os.File`.
**Example (from existing FLAC writer):**
```go
tmpPath := filepath.Join(dir, "."+base+".yjtmp")
// Write to tmpPath...
if err := os.Rename(tmpPath, originalPath); err != nil {
os.Remove(tmpPath)
return err
}
return fileutil.AtomicWrite(logger, filePath, func(tmp *os.File) error {
_, writeErr := f.WriteTo(tmp)
return writeErr
})
```
### 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
### Pattern 3: Test Fixture + Round-Trip Verification
**What:** Each format has a `makeMinimal*()` helper that creates a valid file, and tests verify by writing tags then reading back with `metadata.ExtractTags()`.
**Why:** Tests validate the complete pipeline without external tools. The same `metadata.ExtractTags()` function used in production reads back the tags, ensuring what we write is what gets read.
## 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 1: FormatWriter Interface
### 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
**What:** Creating a `FormatWriter` interface with `Write(path string, changes TagChanges) error`
### 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
**Why bad:** The package has only 4 format writers, all internal. An interface adds abstraction without value. The switch statement is clearer and the functions share a signature by convention.
### 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
**Instead:** Keep format writers as package-internal functions with matching signatures.
### Anti-Pattern 2: In-Place OGG Page Modification
**What:** Attempting to modify OGG pages in-place to avoid full file rewrite.
**Why bad:** OGG pages have checksums and sequence numbers. Changing the comment packet size shifts all subsequent page offsets. In-place modification requires recalculating every downstream page's CRC, which is effectively a full rewrite anyway — but without crash safety.
**Instead:** Full-file rewrite via AtomicWrite.
### Anti-Pattern 3: In-Place RIFF Chunk Insertion
**What:** Attempting to insert or resize RIFF chunks in-place.
**Why bad:** Inserting a new id3 chunk or resizing an existing one shifts all subsequent chunks. The RIFF header's total size must be updated. In-place modification risks corruption.
**Instead:** Full-file rewrite via AtomicWrite.
### Anti-Pattern 4: Shared Vorbis Comment Code via go-flac/flacvorbis
**What:** Importing `go-flac/flacvorbis` in the OGG writer to reuse Vorbis Comment parsing.
**Why bad:** Creates a dependency on a FLAC-specific library for OGG files. The `flacvorbis` types are coupled to FLAC `MetaDataBlock` structures. The binary format is simple enough to parse directly.
**Instead:** Self-contained Vorbis Comment parser in `ogg.go` (~80 lines).
## 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)
| Concern | Typical Case | Edge Case | Approach |
|---------|--------------|-----------|----------|
| OGG file size | 3-10 MB | 50 MB live recording | Stream page-by-page, no full-file memory load |
| WAV file size | 30-50 MB (CD track) | 2 GB (24-bit/96kHz long recording) | Streaming copy via `io.Copy`; warn for >500MB |
| Temp disk space | 2× file size briefly | 2× 2GB = 4GB temp | Same concern as FLAC; document as known limitation |
| Batch edit 100 WAV files | Sequential, 30-50 MB each | 100 × 50 MB = 5 GB total I/O | Existing batch pipeline with progress events |
## 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
- OGG container format specification: https://xiph.org/ogg/doc/rfc3533.txt (HIGH confidence)
- Vorbis Comment specification: https://xiph.org/vorbis/doc/v-comment.html (HIGH confidence)
- Vorbis I specification: https://xiph.org/vorbis/doc/Vorbis_I_spec.html (HIGH confidence)
- METADATA_BLOCK_PICTURE in Vorbis Comments: https://xiph.org/flac/format.html#metadata_block_picture (HIGH confidence)
- RIFF/WAV format: https://www.mmsp.ece.mcgill.ca/documents/AudioFormats/WAVE/WAVE.html (HIGH confidence)
- `jfreymuth/oggvorbis` package API: https://pkg.go.dev/github.com/jfreymuth/oggvorbis (HIGH confidence — verified read-only)
- `jfreymuth/vorbis` CommentHeader type: https://pkg.go.dev/github.com/jfreymuth/vorbis (HIGH confidence)
- `dhowden/tag` OGG/WAV reading: https://github.com/dhowden/tag (HIGH confidence — used in existing tests)
- `bogem/id3v2` for WAV id3 chunk: https://github.com/bogem/id3v2 (HIGH confidence — used in existing MP3 writer)
- Existing codebase: `backend/tagwriter/` package (analyzed in full)