docs: complete project research for v1.2.1 Format Parity
This commit is contained in:
+438
-369
@@ -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)
|
||||
|
||||
+325
-259
@@ -1,300 +1,366 @@
|
||||
# Feature Landscape: Tag Editing
|
||||
# Feature Landscape: OGG Vorbis + WAV Tag Writing
|
||||
|
||||
**Domain:** Metadata tag editing in desktop music players
|
||||
**Researched:** 2026-03-16
|
||||
**Confidence:** HIGH (based on analysis of MusicBee, foobar2000, Kid3, Mp3tag, Picard patterns + Hydrogenaudio tag standards + existing YellowJacket codebase)
|
||||
**Domain:** Format-specific metadata tag writing for OGG Vorbis and WAV audio files
|
||||
**Researched:** 2026-03-18
|
||||
**Confidence:** HIGH (OGG Vorbis) / MEDIUM (WAV — fragmented standards require approach decision)
|
||||
|
||||
## How Desktop Music Players Implement Tag Editing
|
||||
**Sources:**
|
||||
- Xiph.Org VorbisComment specification (https://xiph.org/vorbis/doc/v-comment.html) — HIGH confidence
|
||||
- Xiph.Org Wiki VorbisComment page (https://wiki.xiph.org/VorbisComment) — HIGH confidence
|
||||
- FLAC METADATA_BLOCK_PICTURE specification (http://flac.sourceforge.net/format.html#metadata_block_picture) — HIGH confidence
|
||||
- Wikipedia WAV article, Metadata section — MEDIUM confidence
|
||||
- go-flac/flacvorbis library (https://github.com/go-flac/flacvorbis) — already in use, HIGH confidence
|
||||
- dhowden/tag library (https://github.com/dhowden/tag) — already in use for reading, HIGH confidence
|
||||
- bogem/id3v2 library (https://github.com/bogem/id3v2) — already in use for MP3 writing, HIGH confidence
|
||||
- Existing YellowJacket codebase analysis — HIGH confidence
|
||||
|
||||
### Reference Players Analyzed
|
||||
---
|
||||
|
||||
| Player | Single Edit | Batch Edit | Cover Art Edit | Auto-Tag | Tag Format Handling |
|
||||
|--------|------------|------------|---------------|----------|-------------------|
|
||||
| foobar2000 | Properties dialog | Multi-select → Properties (shared fields) | Embed/remove from Properties | Via plugins | ID3v2, Vorbis, APEv2; configurable write format |
|
||||
| MusicBee | Inline + dialog | Multi-select → Edit panel (keep/clear/set) | Drag-drop + file picker + paste | Built-in | ID3v2.3/2.4, Vorbis; auto-convert on write |
|
||||
| Kid3 | Side panel + dialog | Multi-select → panel applies to all | File picker + paste + drag | MusicBrainz/Discogs | ID3v1/v2, Vorbis, APEv2; shows raw frames |
|
||||
| Mp3tag | List view + panel | Inherent (panel always applies to selection) | Drag-drop + file picker + clipboard | Tag Sources | All formats; extended tag view |
|
||||
| Picard | Panel per file/album | Album-level batch via MusicBrainz match | Automatic via MusicBrainz + manual | Core feature | All formats; submission to MusicBrainz |
|
||||
## OGG Vorbis Tag Writing
|
||||
|
||||
### Common Patterns Across All Players
|
||||
### Field Mapping: Vorbis Comments → YellowJacket's 8 Fields
|
||||
|
||||
**Single-track editing:**
|
||||
- Dialog/panel with labeled fields, plain text inputs
|
||||
- Title, artist, album shown prominently (larger/bolder)
|
||||
- Cover art displayed alongside fields (150-250px)
|
||||
- Numeric fields (year, track #, disc #) use number inputs or constrained text
|
||||
- Genre usually free-text (not dropdown — genre lists are opinionated and incomplete)
|
||||
- Non-editable properties shown separately (bitrate, sample rate, file path, file size)
|
||||
- Save button writes to file → updates database
|
||||
- Cancel discards all changes
|
||||
OGG Vorbis uses **Vorbis Comments** — the exact same metadata system used by FLAC. Field names are case-insensitive, stored as `FIELDNAME=value` pairs in UTF-8.
|
||||
|
||||
**Batch editing (the critical UX challenge):**
|
||||
- Select multiple tracks → open editor
|
||||
- Fields show current value if identical across selection, blank/placeholder if mixed
|
||||
- A "keep" / "don't change" / "mixed" indicator distinguishes "empty because cleared" from "empty because mixed"
|
||||
- User types a value → it applies to ALL selected tracks on save
|
||||
- Fields left unchanged preserve each track's individual value
|
||||
- Common pattern: three-state per field — "keep original" (default), "set to value", "clear"
|
||||
- Track number is special: batch edit typically excludes it (each track needs unique number) OR offers auto-number (sequential from N)
|
||||
| YellowJacket Field | Vorbis Comment Field | Notes |
|
||||
|---|---|---|
|
||||
| `title` | `TITLE` | Standard recommended field |
|
||||
| `artist` | `ARTIST` | Standard recommended field |
|
||||
| `album` | `ALBUM` | Standard recommended field |
|
||||
| `album_artist` | `ALBUMARTIST` | De facto standard, not in original spec but universally supported |
|
||||
| `genre` | `GENRE` | Standard recommended field |
|
||||
| `year` | `DATE` | Standard recommended field; spec says ISO 8601, most apps store just the year |
|
||||
| `track_number` | `TRACKNUMBER` | Standard recommended field |
|
||||
| `disc_number` | `DISCNUMBER` | De facto standard, universally supported |
|
||||
| `composer` | `COMPOSER` | De facto standard, widely supported |
|
||||
|
||||
**Cover art editing:**
|
||||
- Display current embedded art (or "no cover" placeholder)
|
||||
- Replace from file: file picker (JPEG, PNG)
|
||||
- Remove embedded art (less common, but available in Kid3/Mp3tag)
|
||||
- Cover art in batch edit: applies same image to all selected tracks (common for fixing an album)
|
||||
- No crop/resize UI — users prepare images externally
|
||||
- Players typically accept any size but recommend 500-1000px square
|
||||
**Key insight:** These are the *exact same* field names already used in YellowJacket's FLAC writer (`flac.go` → `applyFlacTextChanges`). The existing `flacvorbis` constants (`FIELD_TITLE`, `FIELD_ARTIST`, etc.) and the manual strings (`ALBUMARTIST`, `DISCNUMBER`, `COMPOSER`) map identically. The Vorbis Comment format is format-agnostic; FLAC and OGG Vorbis share the same comment structure. The difference is the container (FLAC metadata blocks vs. OGG page structure).
|
||||
|
||||
**File safety:**
|
||||
- Write-to-temp-then-rename (atomic write) is universal best practice
|
||||
- Some players (foobar2000) create backups before writing
|
||||
- All players update their internal database after successful file write (no rescan)
|
||||
### OGG Vorbis Cover Art: METADATA_BLOCK_PICTURE
|
||||
|
||||
### Universal Editable Fields (from Hydrogenaudio Tag Mapping + player analysis)
|
||||
Cover art in OGG Vorbis uses the `METADATA_BLOCK_PICTURE` Vorbis Comment field. The process:
|
||||
|
||||
**Basic (ID3v1-level, universal compatibility):**
|
||||
- Title, Artist, Album, Year, Genre, Track Number, Comment
|
||||
1. Construct a binary FLAC picture block (same structure as FLAC's native PICTURE metadata block):
|
||||
- Picture type (3 = Front Cover)
|
||||
- MIME type string (e.g., `image/jpeg`)
|
||||
- Description string (UTF-8)
|
||||
- Width, height, color depth, number of colors (can all be 0 per spec)
|
||||
- Image data
|
||||
2. Base64-encode the entire binary block
|
||||
3. Store as `METADATA_BLOCK_PICTURE=<base64 string>` in Vorbis Comments
|
||||
|
||||
**Standard (ID3v2/Vorbis, widely supported):**
|
||||
- Album Artist, Composer, Disc Number, Track Total, Disc Total, Lyrics
|
||||
**Player compatibility for METADATA_BLOCK_PICTURE in OGG Vorbis:**
|
||||
|
||||
**Extended (advanced users, format-dependent):**
|
||||
- BPM, Initial Key, Mood, Label, Catalog Number, ISRC, MusicBrainz IDs
|
||||
| Player | Reads | Writes | Notes |
|
||||
|---|---|---|---|
|
||||
| foobar2000 | YES | YES | Full support |
|
||||
| MusicBee | YES | YES | Full support |
|
||||
| Mp3tag | YES | YES | Full support (since 2.47b) |
|
||||
| VLC | YES | NO | Displays embedded art |
|
||||
| Audacious | YES | N/A | No issues |
|
||||
| MediaMonkey | YES | YES | Full support |
|
||||
| Windows Media Player | YES | N/A | No issues |
|
||||
| Picard (MusicBrainz) | YES | YES | Full support |
|
||||
|
||||
**The deprecated `COVERART` field** (raw base64 without the FLAC picture block structure) should NOT be written. It lacks type/MIME info and may break some hardware players. If encountered when reading, it could optionally be migrated to `METADATA_BLOCK_PICTURE`, but that's beyond scope for this milestone.
|
||||
|
||||
**Complexity:** LOW — The `go-flac/flacpicture` library already creates the binary FLAC picture block structure (used in `applyFlacCoverArt`). For OGG, the same binary block just needs base64 encoding before being stored as a Vorbis Comment string.
|
||||
|
||||
### OGG Vorbis Writing: The Container Problem
|
||||
|
||||
**This is where OGG differs from FLAC.** In FLAC, Vorbis Comments live in a separate metadata block that can be replaced independently of the audio data. In OGG Vorbis:
|
||||
|
||||
- The Vorbis Comment packet is the **second header packet** in the OGG bitstream
|
||||
- Header packets are stored in the first few OGG pages
|
||||
- Audio data follows in subsequent OGG pages
|
||||
- OGG pages have CRC32 checksums and sequence numbers
|
||||
|
||||
**To modify Vorbis Comments in an OGG file, the approach is:**
|
||||
1. Parse the OGG page structure
|
||||
2. Extract the three Vorbis header packets (identification, comment, setup)
|
||||
3. Modify the comment packet
|
||||
4. Re-serialize the header packets into OGG pages (with recalculated CRCs and sizes)
|
||||
5. Write new header pages + copy audio pages unchanged
|
||||
|
||||
**There is no pure-Go OGG writing library.** The existing Go ecosystem for OGG:
|
||||
- `jfreymuth/oggvorbis` — **decoder only** (reads OGG Vorbis, no writing)
|
||||
- `jfreymuth/vorbis` — **raw Vorbis decoder** (no OGG container awareness)
|
||||
- `go-flac/flacvorbis` — **FLAC metadata blocks only** (not OGG pages)
|
||||
- `dhowden/tag` — **read-only** for all formats
|
||||
|
||||
**The implementation must operate at the OGG container level:**
|
||||
- Parse OGG pages (each page: magic "OggS", version, header type, granule pos, serial, page seq, CRC, segments)
|
||||
- Extract Vorbis header packets from initial pages
|
||||
- Build new comment packet from modified Vorbis Comments
|
||||
- Re-paginate headers and write out with audio pages
|
||||
|
||||
**Complexity: MEDIUM-HIGH.** The OGG page format is well-documented (https://xiph.org/ogg/doc/framing.html) and not complex per se, but implementing page parsing + repagination + CRC32 from scratch is non-trivial. However, only the header pages need to be re-written; audio pages can be copied byte-for-byte. This is the same pattern as the MP3 writer (new tag + copy audio data).
|
||||
|
||||
**Risk mitigation:** The existing AtomicWrite pattern provides crash safety. Round-trip tests (write → read back via dhowden/tag) will validate correctness, following the FLAC precedent (7 round-trip tests).
|
||||
|
||||
---
|
||||
|
||||
## WAV Tag Writing
|
||||
|
||||
WAV files have **no single dominant metadata standard**. There are three approaches, each with different tradeoffs.
|
||||
|
||||
### Approach 1: ID3v2 Chunk in WAV (RECOMMENDED)
|
||||
|
||||
An ID3v2 tag is stored as a RIFF chunk with FourCC `id3 ` (or `ID3 `) inside the WAV RIFF structure.
|
||||
|
||||
**How it works:**
|
||||
1. Parse the WAV RIFF structure to find existing chunks
|
||||
2. Build/modify an ID3v2 tag (reusing the existing `bogem/id3v2` library)
|
||||
3. Write the RIFF header + fmt chunk + data chunk + id3 chunk (+ any other existing chunks to preserve)
|
||||
|
||||
**Player compatibility:**
|
||||
|
||||
| Player | Reads ID3v2 in WAV | Writes ID3v2 in WAV | Notes |
|
||||
|---|---|---|---|
|
||||
| foobar2000 | YES | YES | Primary WAV tag format |
|
||||
| MusicBee | YES | YES | Preferred format |
|
||||
| Mp3tag | YES | YES | Default for WAV |
|
||||
| VLC | YES | NO | Reads for display |
|
||||
| Picard | YES | YES | Default for WAV |
|
||||
| Windows Media Player | PARTIAL | NO | May read title/artist |
|
||||
| Audacity | YES | YES | Via metadata editor |
|
||||
|
||||
**Pros:**
|
||||
- **Reuses existing `bogem/id3v2` library** — all 8 fields + cover art are already implemented in `mp3.go`
|
||||
- Full field support: all our 8 fields map perfectly (same as MP3)
|
||||
- Cover art works identically to MP3 (APIC frame)
|
||||
- The dominant standard among music library managers
|
||||
- UTF-8/UTF-16 support for international characters
|
||||
- Well-tested library with 359 GitHub stars
|
||||
|
||||
**Cons:**
|
||||
- Not the "original" WAV metadata mechanism (RIFF INFO is the native one)
|
||||
- Some older/simpler players may not read it
|
||||
- Requires RIFF chunk-level parsing to place the ID3v2 data correctly
|
||||
|
||||
**Cover art:** YES — same APIC frame mechanism as MP3, fully supported.
|
||||
|
||||
### Approach 2: RIFF INFO Chunks
|
||||
|
||||
The original RIFF metadata mechanism. Uses `LIST` chunk with type `INFO` containing sub-chunks with FourCC identifiers.
|
||||
|
||||
**Field mapping:**
|
||||
|
||||
| YellowJacket Field | RIFF INFO FourCC | Field Name | Notes |
|
||||
|---|---|---|---|
|
||||
| `title` | `INAM` | Name/Title | Supported |
|
||||
| `artist` | `IART` | Artist | Supported |
|
||||
| `album` | `IPRD` | Product (Album) | Supported |
|
||||
| `album_artist` | — | — | **NO STANDARD FIELD** |
|
||||
| `genre` | `IGNR` | Genre | Supported |
|
||||
| `year` | `ICRD` | Creation Date | Supported (full date string, not just year) |
|
||||
| `track_number` | `ITRK` | Track Number | Nonstandard/rare — some use `IPRT` |
|
||||
| `disc_number` | — | — | **NO STANDARD FIELD** |
|
||||
| `composer` | `IMUS` | Music By (Composer) | Rare, not universally read |
|
||||
|
||||
**Problems:**
|
||||
- **Cannot represent album_artist or disc_number** — no RIFF INFO fields exist
|
||||
- Track number support is inconsistent across players
|
||||
- **ASCII/codepage encoding** — RIFF INFO predates Unicode; the `CSET` chunk exists but is rarely used; most implementations assume Windows codepage
|
||||
- **No cover art support** — RIFF INFO has no image field
|
||||
- Limited string length (some implementations cap at 255 bytes per field)
|
||||
|
||||
**Verdict:** NOT RECOMMENDED as primary format. Cannot represent our full 8-field model + cover art.
|
||||
|
||||
### Approach 3: BWF (Broadcast Wave Format)
|
||||
|
||||
Extension of WAV with a `bext` chunk for broadcast metadata (originator, description, date, time reference, etc.).
|
||||
|
||||
**Relevance to music tagging:** NONE. BWF metadata is about broadcast provenance (originator, coding history, loudness), not music metadata (artist, album, genre). No music player uses BWF fields for library management.
|
||||
|
||||
**Verdict:** OUT OF SCOPE. Not relevant for music tagging.
|
||||
|
||||
### WAV Approach Decision: ID3v2 Chunk
|
||||
|
||||
**Use ID3v2 in WAV because:**
|
||||
1. Maps all 8 fields + cover art identically to MP3
|
||||
2. Reuses existing `bogem/id3v2` library code
|
||||
3. Is the approach used by foobar2000, MusicBee, Mp3tag, Picard — the dominant music library managers
|
||||
4. YellowJacket already reads WAV metadata via `dhowden/tag`, which reads ID3v2 chunks in WAV
|
||||
|
||||
**Writing implementation:**
|
||||
1. Parse WAV RIFF structure: read chunk headers sequentially (each chunk: FourCC + uint32 size + data)
|
||||
2. Build ID3v2 tag using `bogem/id3v2` (same code path as MP3 minus the "copy audio data" step)
|
||||
3. Write atomically: RIFF header → all existing chunks (fmt, data, any others) → new `id3 ` chunk
|
||||
4. Any existing `id3 ` chunk is replaced; any existing `LIST INFO` chunk is preserved (don't destroy metadata we don't control)
|
||||
|
||||
**Complexity:** MEDIUM. The RIFF chunk structure is simple (FourCC + 4-byte LE size), but requires:
|
||||
- Parsing all chunks to find/replace the `id3 ` chunk
|
||||
- Recalculating the top-level RIFF size header
|
||||
- Preserving chunk ordering and any padding (RIFF chunks must be word-aligned, i.e., even byte offsets)
|
||||
|
||||
**Cover art in WAV:** YES — via ID3v2 APIC frame, identical to MP3.
|
||||
|
||||
---
|
||||
|
||||
## Table Stakes
|
||||
|
||||
Features users expect. Missing = product feels incomplete.
|
||||
Features users expect when a music player claims to edit metadata for a format.
|
||||
|
||||
| Feature | Why Expected | Complexity | Dependencies | Notes |
|
||||
|---------|-------------|------------|--------------|-------|
|
||||
| Single track tag editing (title, artist, album, genre, year, track#, disc#, composer) | Every player with tag editing supports these 8 fields minimum | Medium | Tag writing library, DB update queries, FTS5 reindex | Existing `track-details` dialog has edit mode UI scaffolded (save is no-op TODO) |
|
||||
| Write tags to MP3 (ID3v2) | MP3 is the most common format; must-have | High | Need tag writing library (dhowden/tag is read-only) | Format-specific: must write ID3v2.3 or ID3v2.4 frames |
|
||||
| Write tags to FLAC (Vorbis Comments) | FLAC is the standard lossless format | High | Same writing library | Vorbis comments in FLAC metadata block |
|
||||
| Write tags to OGG (Vorbis Comments) | Already supported for reading | Medium | Same writing library | Same Vorbis comment format as FLAC |
|
||||
| Write-to-temp-then-rename | File corruption on crash/power loss = unacceptable data loss | Low | `os.Rename` after writing to temp file | Universal best practice; Go stdlib handles this well |
|
||||
| Inline DB + FTS5 update after tag write | Users expect immediate UI update; forcing rescan is unacceptable | Medium | UPDATE queries for recordings, artist_credit, release_groups, genres; FTS5 search_index rebuild for affected rows | Must update the `track_metadata` VIEW's source tables |
|
||||
| Batch editing shared fields across multiple selected tracks | Every tag editor supports this; multi-select already exists in track list | High | Batch editor UI component, backend batch write endpoint, progress tracking | The hard UX problem: mixed-value indicators, three-state fields |
|
||||
| Save confirmation / error feedback | User must know if write succeeded or failed | Low | Event emission, toast/notification UI | Especially important for read-only files or permission errors |
|
||||
| Cover art set/replace from image file | Fundamental tag editing feature; cover art is visually prominent | Medium | File picker (already have `FrontendUtil.OpenFileDialog`), image embedding in tag write, cover art cache update | Must update both embedded tag and cover art cache + thumbnails |
|
||||
| Feature | Why Expected | Complexity | Format | Notes |
|
||||
|---|---|---|---|---|
|
||||
| OGG: Write all 8 text fields | Parity with MP3/FLAC editing | Low | OGG | Same Vorbis Comment fields as FLAC |
|
||||
| OGG: Preserve existing non-edited comments | Users may have ReplayGain, lyrics, etc. | Low | OGG | Filter-and-keep pattern, same as FLAC |
|
||||
| OGG: Preserve audio data perfectly | Users expect lossless round-trip | Low | OGG | Audio pages copied byte-for-byte |
|
||||
| WAV: Write all 8 text fields | Parity with MP3/FLAC editing | Low-Med | WAV | Via ID3v2 chunk, reuse MP3 code |
|
||||
| WAV: Preserve audio data perfectly | Users expect lossless round-trip | Low | WAV | Copy data chunk unchanged |
|
||||
| Crash-safe writes (both formats) | Existing AtomicWrite pattern | Low | Both | Already implemented |
|
||||
| Batch editing works for OGG + WAV | Batch editor already handles all formats | Low | Both | Just need format dispatch in pipeline |
|
||||
| Single-track editing works for OGG + WAV | Track editor already handles all formats | Low | Both | Just need format dispatch in pipeline |
|
||||
|
||||
## Differentiators
|
||||
|
||||
Features that set the product apart. Not expected, but valued.
|
||||
Features that set the product apart. Not expected but valued.
|
||||
|
||||
| Feature | Value Proposition | Complexity | Dependencies | Notes |
|
||||
|---------|------------------|------------|--------------|-------|
|
||||
| Album artist field editing | Distinguishes VA compilations; power users expect it | Low | One additional field in edit form; already extracted by `dhowden/tag` | Not in PROJECT.md active list but low-hanging fruit |
|
||||
| Lyrics field editing | Multi-line text editing for embedded lyrics | Low | Textarea in dialog; lyrics field already in `recordings` schema… wait, it's in `TrackMetadata` struct but not shown in track-details UI | Would need multiline input; niche but straightforward |
|
||||
| Comment field editing | Standard tag field, some users store notes | Low | Already extracted, just needs UI input | Very low effort to include |
|
||||
| Auto-number tracks in batch edit | Select album tracks → auto-assign sequential track numbers | Low | Frontend logic to generate sequential numbers, apply in batch write | Huge time-saver when retagging an album |
|
||||
| Dirty indicator / unsaved changes warning | Prevent accidental dialog close with unsaved edits | Low | Track `editValues` diff vs original values | MusicBee and foobar2000 both do this |
|
||||
| Undo last tag write (restore from backup) | Safety net for mistakes; builds user trust | Medium | Write original tag values to a backup store before overwriting | Most players don't do this — would be a genuine differentiator |
|
||||
| Cover art remove (strip embedded art) | Some users want to remove bloated embedded art | Low | Write tags without picture data | Available in Kid3/Mp3tag but not most players |
|
||||
| Cover art paste from clipboard | Quick workflow: copy image from browser → paste into editor | Medium | Clipboard API in WebView, image data extraction | MusicBee supports this; convenient for web-sourced art |
|
||||
| Progress indicator for batch operations | Visual feedback during multi-file writes (batch of 20+ tracks) | Low | Progress event emission, progress bar in UI | Important when writing to many files (can take seconds per file for FLAC) |
|
||||
| Total Tracks / Total Discs fields | Part of standard tag spec; power users tag these | Low | Two additional number fields; already in `TrackMetadata` struct | Mp3tag and Kid3 expose these; foobar2000 uses "X/Y" format |
|
||||
| Feature | Value Proposition | Complexity | Format | Notes |
|
||||
|---|---|---|---|---|
|
||||
| OGG: Cover art embed/remove | Full parity with MP3/FLAC cover art | Medium | OGG | METADATA_BLOCK_PICTURE via base64; reuse flacpicture binary block |
|
||||
| WAV: Cover art embed/remove | Full parity with MP3/FLAC cover art | Low | WAV | ID3v2 APIC frame, identical to MP3 |
|
||||
| WAV: Preserve existing RIFF INFO chunks | Don't destroy metadata we didn't write | Low | WAV | Just copy LIST INFO chunk through |
|
||||
| OGG: Preserve non-Vorbis OGG streams | Multi-stream OGG files exist (rare) | Low | OGG | Only modify Vorbis stream headers |
|
||||
| Round-trip test coverage | Validates writes don't corrupt files | Medium | Both | dhowden/tag reads what we write, following FLAC precedent |
|
||||
|
||||
## Anti-Features
|
||||
|
||||
Features to explicitly NOT build.
|
||||
|
||||
| Anti-Feature | Why Avoid | What to Do Instead |
|
||||
|--------------|-----------|-------------------|
|
||||
| Inline editing in track list columns | Extremely complex (virtual scrolling + inline inputs + focus management + multi-select conflicts); fragile UX | Use the existing modal dialog approach — click to open editor. This is what foobar2000 does. |
|
||||
| MusicBrainz auto-tagging / lookup | Massive scope expansion (API integration, fuzzy matching, network dependency); separate milestone material | Defer to future "MusicBrainz browser" milestone already in PROJECT.md |
|
||||
| Genre dropdown with predefined list | Genre lists are subjective, never complete, frustrate users who use custom genres | Free-text input with optional suggestions from existing genres in DB (future enhancement) |
|
||||
| Tag format conversion (ID3v1→v2, strip APEv2) | Edge case tool feature; desktop tagger territory (Mp3tag) | Write the "correct" format for each file type; don't expose format internals to users |
|
||||
| Raw tag frame editing | Power-user-only feature; complex UI for marginal value | Edit semantic fields (title, artist, etc.); abstract away ID3 frames vs Vorbis comments |
|
||||
| Custom/arbitrary tag field editing | Requires extensible UI, arbitrary field names, format-specific storage concerns | Support the standard fields; users with custom tags use Mp3tag |
|
||||
| Filename renaming from tags | Common in dedicated taggers (Mp3tag, Kid3) but orthogonal to tag editing; adds file system mutation risk | Out of scope; would need separate file operations system |
|
||||
| ReplayGain scanning/writing | Separate audio analysis feature, not tag editing | Future milestone if ever; requires DSP analysis |
|
||||
| Drag-and-drop cover art from external apps | Complex browser/WebView drag interop; unreliable across platforms | File picker is the reliable universal approach |
|
||||
| Multi-value field editing (multiple artists/genres as separate entries) | ID3v2 and Vorbis support multiple values per field, but the UI complexity is enormous | Store as single string; genre already uses `||` separator internally |
|
||||
|---|---|---|
|
||||
| WAV: RIFF INFO as primary write target | Cannot represent album_artist, disc_number, or cover art | Use ID3v2 chunk; preserve existing RIFF INFO if present |
|
||||
| WAV: BWF bext chunk writing | Broadcast metadata, not music metadata | Ignore; preserve if present |
|
||||
| OGG: Write deprecated COVERART field | Deprecated, inconsistent support, may break hardware players | Write only METADATA_BLOCK_PICTURE |
|
||||
| OGG: Re-encode audio data | Must never touch the audio bitstream | Copy audio pages byte-for-byte |
|
||||
| WAV: Delete RIFF INFO when writing ID3v2 | Would destroy existing metadata user may rely on | Preserve RIFF INFO chunks as-is |
|
||||
| OGG: Custom OGG page library | Over-engineering for the scope needed | Minimal OGG page parser/writer sufficient for header rewrite |
|
||||
| WAV: Write both ID3v2 and RIFF INFO | Dual-write is complex and the RIFF INFO mapping is lossy | Write ID3v2 only; preserve existing RIFF INFO |
|
||||
|
||||
## Feature Dependencies
|
||||
|
||||
```
|
||||
Single Track Edit ──→ Tag Writing Library (MP3/FLAC/OGG)
|
||||
──→ DB Update Queries (recordings, artist_credit, release_groups, genres)
|
||||
──→ FTS5 Reindex (search_index)
|
||||
──→ Event Emission (UI refresh)
|
||||
|
||||
Batch Edit ────────→ Single Track Edit (batch = N × single with shared values)
|
||||
────────→ Mixed-value UI (three-state field indicators)
|
||||
────────→ Multi-select (already exists in track-list)
|
||||
|
||||
Cover Art Edit ───→ Tag Writing Library (picture frame embedding)
|
||||
───→ Cover Art Cache Update (saveCoverArt + thumbnail generation)
|
||||
───→ File Picker Dialog (already exists: FrontendUtil.OpenFileDialog)
|
||||
|
||||
Write Safety ─────→ Temp file + os.Rename (no dependencies on existing code)
|
||||
|
||||
DB Update ────────→ Existing schema: recordings, artist_credit, artists,
|
||||
release_groups, release_group_recordings, genres, genre_recordings,
|
||||
cover_art, audio_files
|
||||
────→ FTS5 search_index rebuild for affected rows
|
||||
────→ track_metadata VIEW reflects changes automatically (it's a VIEW)
|
||||
Existing WriteTrackTags pipeline
|
||||
├── DetectFormat (extend: add .ogg and .wav)
|
||||
├── Format-specific writer dispatch (extend: add OGG and WAV cases)
|
||||
│ ├── writeOggVorbisTags (NEW)
|
||||
│ │ ├── OGG page parser (NEW)
|
||||
│ │ ├── Vorbis Comment serializer (reuse flacvorbis patterns)
|
||||
│ │ ├── METADATA_BLOCK_PICTURE builder (reuse flacpicture + base64)
|
||||
│ │ ├── OGG page writer with CRC32 (NEW)
|
||||
│ │ └── AtomicWrite (existing)
|
||||
│ └── writeWavTags (NEW)
|
||||
│ ├── RIFF chunk parser (NEW)
|
||||
│ ├── ID3v2 tag builder (reuse bogem/id3v2, same as MP3)
|
||||
│ ├── RIFF chunk writer with size recalculation (NEW)
|
||||
│ └── AtomicWrite (existing)
|
||||
├── DB sync (existing, unchanged)
|
||||
└── Event emission (existing, unchanged)
|
||||
```
|
||||
|
||||
### Critical Dependency Chain
|
||||
```
|
||||
Tag Writing Library → Single Track Edit → Batch Edit
|
||||
→ Cover Art Edit
|
||||
```
|
||||
|
||||
The tag writing library choice gates everything. Until a library can write ID3v2 and Vorbis comments, no editing features can ship.
|
||||
|
||||
### Dependency on Existing Architecture
|
||||
|
||||
| Existing Feature | How Tag Editing Uses It |
|
||||
|-----------------|----------------------|
|
||||
| `track-details` component | Already has edit mode scaffolded with input fields, edit/save/cancel buttons, and `editValues` state. Save handler is a TODO stub. |
|
||||
| Multi-select in track-list | Entry point for batch editing — selected file paths already accessible via `selection.getSelectedKeysOrdered()` |
|
||||
| Context menu system | "Edit Tags" menu item for single or multi-select (currently shows "Track Details" for single) |
|
||||
| `FrontendUtil.OpenFileDialog` | File picker for cover art image selection |
|
||||
| `Library.saveCoverArt` + thumbnail pipeline | Reusable for cover art embedding — same hash-based cache, same thumbnail generation |
|
||||
| Event system | New events needed: `TagsWritten`, `TagWriteProgress`, `TagWriteError` |
|
||||
| `backend/metadata/tags.go` | `TrackMetadata` struct defines all writable fields; `ExtractTags` used for reading |
|
||||
|
||||
## Batch Editing UX Patterns (Deep Dive)
|
||||
|
||||
The batch editor is the highest-complexity feature. Here's how mature players handle it:
|
||||
|
||||
### Three-State Field Model
|
||||
|
||||
For each editable field in batch mode:
|
||||
1. **Keep** (default): Shows "[Mixed]" or "[Various]" if values differ, shows the common value if all tracks share it. On save, each track retains its original value.
|
||||
2. **Set**: User has typed a new value. On save, all selected tracks get this value.
|
||||
3. **Clear**: User explicitly cleared the field. On save, all selected tracks have this field emptied.
|
||||
|
||||
**Implementation approach:**
|
||||
```typescript
|
||||
type FieldState = 'keep' | 'set' | 'clear';
|
||||
|
||||
interface BatchField {
|
||||
state: FieldState;
|
||||
value: string; // The new value (only meaningful when state === 'set')
|
||||
commonValue: string; // Value shared across all tracks (empty if mixed)
|
||||
isMixed: boolean; // Whether tracks have different values
|
||||
}
|
||||
```
|
||||
|
||||
### Backend Batch Write Contract
|
||||
|
||||
```go
|
||||
// TagEdits contains the fields to write. nil = don't change, empty string = clear.
|
||||
type TagEdits struct {
|
||||
Title *string
|
||||
Artist *string
|
||||
Album *string
|
||||
Genre *string
|
||||
Year *int
|
||||
TrackNumber *int
|
||||
DiscNumber *int
|
||||
Composer *string
|
||||
CoverArt *CoverArtEdit // nil = keep, non-nil = set/remove
|
||||
}
|
||||
|
||||
type CoverArtEdit struct {
|
||||
ImageData []byte // empty = remove cover art
|
||||
MIMEType string
|
||||
}
|
||||
```
|
||||
|
||||
Using pointer fields: `nil` = keep original, non-nil = set to this value (empty string/zero = clear). This is the standard Go pattern for optional updates and maps directly to the three-state UI model.
|
||||
|
||||
### Batch Write Ordering
|
||||
|
||||
1. Validate all edits before writing any files (fail fast)
|
||||
2. Write files sequentially (not concurrently — avoids disk thrashing and simplifies error handling)
|
||||
3. For each file: read → modify → write-to-temp → rename
|
||||
4. After ALL files written successfully: batch-update DB + FTS5
|
||||
5. Emit success event with count
|
||||
6. On error: stop, report which file failed, files already written are committed (no rollback — file writes are atomic individually)
|
||||
|
||||
## Cover Art Editing Workflow
|
||||
|
||||
### Set/Replace Cover Art (Table Stakes)
|
||||
|
||||
1. User clicks "Change Cover" in edit dialog
|
||||
2. File picker opens (filter: `*.jpg, *.jpeg, *.png`)
|
||||
3. User selects image file
|
||||
4. Preview shown in dialog (replacing current art)
|
||||
5. On save:
|
||||
a. Read image bytes from selected file
|
||||
b. Embed in audio file tag (APIC frame for ID3v2, METADATA_BLOCK_PICTURE for FLAC/OGG)
|
||||
c. Save to cover art cache (via existing `saveCoverArt` pipeline → hash, dedupe, thumbnails)
|
||||
d. Update `cover_art` table if hash changed
|
||||
e. Update UI with new cover art URLs
|
||||
|
||||
### Batch Cover Art (Same Image to All Selected Tracks)
|
||||
|
||||
Common use case: fixing an album where some tracks have wrong/missing cover art.
|
||||
1. In batch editor, cover art section shows "[Mixed]" or common art
|
||||
2. User selects new image → applies to ALL selected tracks on save
|
||||
3. This is the same flow as single-track, just repeated N times
|
||||
|
||||
### What NOT to Build for Cover Art
|
||||
|
||||
- No crop/resize — users use external tools (GIMP, Preview, etc.)
|
||||
- No web search — would require API integration (future MusicBrainz milestone could add this)
|
||||
- No multiple picture types (front, back, booklet) — only front cover. ID3v2 supports picture types but the complexity isn't worth it for v1.
|
||||
|
||||
## Field Mapping: Tag Format → Database Schema
|
||||
|
||||
Understanding how edited fields map through the system:
|
||||
|
||||
| Edit Field | Tag (ID3v2) | Tag (Vorbis) | DB Table | DB Column | Notes |
|
||||
|-----------|------------|-------------|----------|-----------|-------|
|
||||
| Title | TIT2 | TITLE | `recordings` | `name` | |
|
||||
| Artist | TPE1 | ARTIST | `artist_credit` → `artists` | `text` / `name` | May need to create new artist_credit + artist rows |
|
||||
| Album | TALB | ALBUM | `release_groups` | `name` | May need to create new release_group row |
|
||||
| Album Artist | TPE2 | ALBUMARTIST | (not currently stored separately) | — | Would need schema addition or use existing artist credit |
|
||||
| Genre | TCON | GENRE | `genres` + `genre_recordings` | `name` | Multiple genres: split on `;` or `,` |
|
||||
| Year | TYER/TDRC | DATE | `recordings` | `year` | |
|
||||
| Track # | TRCK | TRACKNUMBER | `recordings` | `track_number` | |
|
||||
| Disc # | TPOS | DISCNUMBER | `recordings` | `disc_number` | |
|
||||
| Composer | TCOM | COMPOSER | `recordings` | `composer` | |
|
||||
| Cover Art | APIC | METADATA_BLOCK_PICTURE | `cover_art` | `file_path` | Binary data; separate storage |
|
||||
| Comment | COMM | COMMENT | `recordings` | `comment` | |
|
||||
| Lyrics | USLT | LYRICS | `recordings` | `lyrics` | |
|
||||
|
||||
### Schema Update Complexity
|
||||
|
||||
Simple fields (title, year, track#, disc#, composer, comment, lyrics) → UPDATE `recordings` directly.
|
||||
|
||||
Relational fields (artist, album, genre) → must handle entity lifecycle:
|
||||
- **Artist change:** Look up or create new `artists` + `artist_credit` rows, update `recordings.artist_credit_id`
|
||||
- **Album change:** Look up or create new `release_groups` row, update `release_group_recordings` link
|
||||
- **Genre change:** Parse genre string, look up or create `genres` rows, update `genre_recordings` links
|
||||
|
||||
This entity lookup logic already exists in `library.go`'s `processMetadata` / `saveAudioFile` pipeline — it should be extracted and reused.
|
||||
|
||||
## MVP Recommendation
|
||||
|
||||
**Prioritize (Phase 1 — Tag Editing Core):**
|
||||
1. Tag writing library integration (MP3 + FLAC + OGG)
|
||||
2. Single track editing (the 8 active fields from PROJECT.md)
|
||||
3. Write-to-temp-then-rename safety
|
||||
4. DB + FTS5 inline update
|
||||
5. Cover art set/replace from file
|
||||
**Phase 1 — OGG Vorbis (text fields only):**
|
||||
1. OGG page parser + writer (the core new infrastructure)
|
||||
2. Vorbis Comment extraction and modification (reuse flacvorbis patterns)
|
||||
3. Write modified headers + copy audio pages
|
||||
4. Round-trip tests via dhowden/tag
|
||||
|
||||
**Prioritize (Phase 2 — Batch Editing):**
|
||||
6. Batch editing with three-state field model
|
||||
7. Progress feedback for batch operations
|
||||
8. Error handling and partial-success reporting
|
||||
**Phase 2 — WAV (text fields + cover art):**
|
||||
1. RIFF chunk parser
|
||||
2. ID3v2 tag writing via bogem/id3v2 (reuse MP3 code paths)
|
||||
3. RIFF reassembly with id3 chunk
|
||||
4. Round-trip tests
|
||||
|
||||
**Phase 3 — OGG Vorbis cover art:**
|
||||
1. METADATA_BLOCK_PICTURE encoding (flacpicture binary block → base64)
|
||||
2. Cover art in Vorbis Comments alongside text fields
|
||||
3. Cover art round-trip tests
|
||||
|
||||
**Rationale for this ordering:**
|
||||
- OGG text fields first because they're needed by more users (OGG is more common in music libraries than WAV)
|
||||
- WAV includes cover art from the start because it's trivial (same as MP3 APIC frame)
|
||||
- OGG cover art is separated because it requires additional work (base64 encoding of FLAC picture blocks) and is less critical than basic text editing
|
||||
|
||||
**Defer:**
|
||||
- Album artist editing (schema question, low priority)
|
||||
- Lyrics/comment editing (easy to add later, niche)
|
||||
- Auto-numbering tracks (convenience, not core)
|
||||
- Undo/backup system (nice-to-have, not table stakes)
|
||||
- Cover art paste from clipboard (WebView clipboard API complexity)
|
||||
- RIFF INFO writing: Lossy mapping (can't represent all 8 fields), adds complexity for minimal user benefit
|
||||
- Migrating legacy COVERART → METADATA_BLOCK_PICTURE on read: Nice to have but not required for writing
|
||||
- WAV with both ID3v2 and RIFF INFO: Dual-write complexity not justified
|
||||
|
||||
## Edge Cases and Size Considerations
|
||||
|
||||
### OGG Vorbis: Large Cover Art
|
||||
|
||||
**Problem:** Vorbis Comments in OGG are stored in the comment header packet, which is part of the OGG page structure. Large cover art (e.g., a 5MB PNG) becomes ~6.7MB after base64 encoding. This is stored as a single Vorbis Comment value.
|
||||
|
||||
**Impact:** The Vorbis Comment packet may span multiple OGG pages. The OGG page writer must handle packets larger than a single page (max page size ~65KB). This is standard OGG behavior — pages have a segment table that spans packets across pages.
|
||||
|
||||
**Mitigation:**
|
||||
- Xiph spec explicitly supports this
|
||||
- Major players handle it fine (tested with foobar2000, MediaMonkey, etc.)
|
||||
- YellowJacket could optionally warn on very large cover art (>2MB) but should not refuse
|
||||
- Consider downscaling in the UI before embedding (existing cover art flow already handles this)
|
||||
|
||||
### WAV: Mixed ID3v2 and RIFF INFO
|
||||
|
||||
**Problem:** A WAV file may have both an existing RIFF INFO `LIST` chunk and an `id3 ` chunk.
|
||||
|
||||
**Solution:**
|
||||
- When writing: replace `id3 ` chunk with new one; preserve `LIST INFO` chunk unchanged
|
||||
- When reading: `dhowden/tag` already handles this (it reads ID3v2 from WAV if present, falls back to RIFF INFO)
|
||||
- Never delete the user's RIFF INFO data
|
||||
|
||||
### WAV: RIFF Size Recalculation
|
||||
|
||||
**Problem:** The top-level RIFF chunk has a 32-bit size field. When adding/resizing the `id3 ` chunk, this must be updated.
|
||||
|
||||
**Mitigation:** Simple arithmetic: sum of all chunk sizes + headers. The 4GB RIFF limit is a WAV limitation in general, not specific to our tag writing.
|
||||
|
||||
### WAV: Chunk Alignment
|
||||
|
||||
**Problem:** RIFF chunks must start at even byte offsets. If a chunk has an odd data size, a padding byte must follow.
|
||||
|
||||
**Mitigation:** Standard RIFF handling. The chunk parser/writer must account for this.
|
||||
|
||||
### OGG: Multiple Logical Streams
|
||||
|
||||
**Problem:** OGG files can contain multiple multiplexed streams (e.g., Vorbis audio + cover art stream + metadata stream). Each stream has a unique serial number.
|
||||
|
||||
**Mitigation:** Identify the Vorbis stream by the "vorbis" identification header magic bytes. Only modify that stream's comment packet. Copy all other streams' pages unchanged. In practice, music OGG files almost always have a single Vorbis stream.
|
||||
|
||||
### OGG: Page Sequence Numbers and Granule Positions
|
||||
|
||||
**Problem:** Each OGG page has a sequence number and granule position. Rewriting header pages changes the page count.
|
||||
|
||||
**Mitigation:** Header pages (BOS page, comment pages, setup pages) have their own sequence numbers starting from 0. Audio pages continue from after the headers. If the number of header pages changes (because the new comment is larger/smaller), the audio page sequence numbers and granule positions are *not* affected — they reference the audio stream, not the page stream. However, the continued-page flags and sequence numbers must be correct for the rewritten header pages.
|
||||
|
||||
## Reuse Analysis
|
||||
|
||||
| Component | Existing Code | Reuse Level | Notes |
|
||||
|---|---|---|---|
|
||||
| Vorbis Comment field mapping | `flac.go:applyFlacTextChanges` | HIGH — extract shared helper | Same field names, same logic |
|
||||
| FLAC picture block builder | `flacpicture.NewFromImageData` | HIGH — call directly | Same binary format for OGG |
|
||||
| ID3v2 tag building | `mp3.go:applyTextChanges`, `applyCoverArtChanges` | HIGH — extract shared helper | Same API for WAV ID3v2 |
|
||||
| Atomic file writing | `fileutil.AtomicWrite` | FULL — use as-is | No changes needed |
|
||||
| DB sync pipeline | `dbsync.go:syncDatabase` | FULL — use as-is | Format-independent |
|
||||
| Tag reader (round-trip tests) | `dhowden/tag` via metadata package | FULL — use as-is | Already reads OGG + WAV |
|
||||
| MIME detection | `tagwriter.detectMIME` | FULL — use as-is | Same image formats |
|
||||
| Type helpers (asInt, asBytes) | `tagwriter.go` | FULL — use as-is | Same TagChanges model |
|
||||
| OGG page parser/writer | — | NEW | Must implement |
|
||||
| RIFF chunk parser/writer | — | NEW | Must implement |
|
||||
|
||||
## Sources
|
||||
|
||||
- Hydrogenaudio Knowledgebase: Tag Mapping (https://wiki.hydrogenaud.io/index.php/Tag_Mapping) — HIGH confidence, authoritative tag format reference
|
||||
- Hydrogenaudio Knowledgebase: foobar2000 Encouraged Tag Standards (https://wiki.hydrogenaud.io/index.php/Foobar2000:Encouraged_Tag_Standards) — HIGH confidence
|
||||
- Hydrogenaudio Knowledgebase: Tag (metadata) (https://wiki.hydrogenaud.io/index.php/Tag) — HIGH confidence, basic/advanced/personalized field categorization
|
||||
- YellowJacket codebase analysis: `track-details.ts`, `tags.go`, `coverart.go`, `library.go`, database schemas — PRIMARY source for dependency analysis
|
||||
- MusicBee, foobar2000, Kid3, Mp3tag, Picard — feature set analysis from training data (MEDIUM confidence on specific UI details)
|
||||
- **Vorbis Comment specification:** https://xiph.org/vorbis/doc/v-comment.html — Official Xiph.Org spec defining field names and encoding (HIGH confidence)
|
||||
- **VorbisComment wiki (cover art):** https://wiki.xiph.org/VorbisComment#Cover_art — METADATA_BLOCK_PICTURE standard, player compatibility tests (HIGH confidence)
|
||||
- **FLAC picture block format:** http://flac.sourceforge.net/format.html#metadata_block_picture — Binary structure reused in OGG (HIGH confidence)
|
||||
- **OGG framing specification:** https://xiph.org/ogg/doc/framing.html — Page structure, CRC, segmentation (HIGH confidence)
|
||||
- **WAV/RIFF specification:** IBM & Microsoft, "Multimedia Programming Interface and Data Specifications 1.0", 1991 — RIFF chunk format, INFO chunk (HIGH confidence)
|
||||
- **WAV metadata overview:** https://en.wikipedia.org/wiki/WAV#Metadata — ID3v2 in WAV, XMP in WAV (MEDIUM confidence)
|
||||
- **bogem/id3v2 library:** https://github.com/bogem/id3v2 — Used for MP3 writing, can generate ID3v2 tags for WAV (HIGH confidence)
|
||||
- **dhowden/tag library:** https://github.com/dhowden/tag — Used for reading all formats including OGG + WAV (HIGH confidence)
|
||||
- **go-flac/flacvorbis:** https://github.com/go-flac/flacvorbis — Vorbis Comment manipulation, used in FLAC writer (HIGH confidence)
|
||||
- **go-flac/flacpicture:** https://github.com/go-flac/flacpicture — FLAC picture block builder, usable for OGG METADATA_BLOCK_PICTURE (HIGH confidence)
|
||||
- **YellowJacket codebase:** `backend/tagwriter/*.go` — Existing writer pipeline, field model, atomic write pattern (HIGH confidence)
|
||||
|
||||
+279
-221
@@ -1,382 +1,440 @@
|
||||
# Domain Pitfalls: Tag Editing
|
||||
# Domain Pitfalls: OGG Vorbis + WAV Tag Writing
|
||||
|
||||
**Domain:** Adding tag editing to an existing music player with normalized DB
|
||||
**Researched:** 2026-03-16
|
||||
**Confidence:** HIGH (based on codebase analysis + format specifications + SQLite FTS5 docs)
|
||||
**Domain:** Adding OGG Vorbis and WAV tag writing to an existing tag writing pipeline (MP3 + FLAC already working)
|
||||
**Researched:** 2026-03-18
|
||||
**Confidence:** HIGH (OGG spec + RFC 3533 + codebase analysis + Xiph Vorbis comment spec + RIFF spec)
|
||||
|
||||
---
|
||||
|
||||
## Critical Pitfalls
|
||||
|
||||
Mistakes that cause data loss, file corruption, or require architectural rework.
|
||||
Mistakes that cause file corruption, unplayable audio, or require significant rework.
|
||||
|
||||
### P1: FLAC Tag Writes Require Full File Rewrite
|
||||
### P1: OGG CRC32 Recalculation After Page Modification
|
||||
|
||||
**What goes wrong:** FLAC stores Vorbis Comments in a METADATA_BLOCK after the STREAMINFO block. Unlike MP3 (which has padding in ID3v2 headers), FLAC metadata blocks are tightly packed with no padding by default. Changing a tag that increases the metadata size requires rewriting the entire file — moving every audio frame forward. A crash or power loss during this rewrite corrupts the file irrecoverably.
|
||||
**Severity:** BLOCKS SHIP — corrupted files won't play
|
||||
**What goes wrong:** Every OGG page contains a CRC32 checksum (bytes 22–25 of the page header) computed over the entire page (header with CRC field zeroed + segment table + page data). The polynomial is `0x04c11db7` but uses a **non-standard bit ordering** — it's a direct algorithm (MSB-first), NOT the common CRC32 used in zlib/Ethernet (which is reflected/LSB-first). If you use Go's standard `hash/crc32` package with `crc32.MakeTable(0x04c11db7)`, you get the **wrong CRC** because `hash/crc32` uses reflected bit ordering.
|
||||
|
||||
**Why it happens:** FLAC spec doesn't mandate padding blocks. Most FLAC files in the wild have zero padding. Even if padding exists, adding cover art (which can be 100KB+) almost always exceeds it.
|
||||
**Why it happens:** Developers see "CRC32 with polynomial 0x04c11db7" and reach for the standard library. OGG uses a non-reflected (direct) CRC32 which is different from IEEE CRC32 despite sharing the same polynomial constant. The dhowden/tag source code (ogg.go) and jfreymuth/oggvorbis (crc.go) both implement custom CRC32 lookup tables for this reason.
|
||||
|
||||
**Consequences:** Corrupted FLAC files that won't play. Audio data intact on disk but offset table is wrong, so decoders can't find frames.
|
||||
**Consequences:** Every OGG decoder will reject pages with incorrect CRC. The file appears corrupted. Players show "codec error" or silence. Some players may try to resync and play garbled audio.
|
||||
|
||||
**Prevention:**
|
||||
1. **Write-to-temp-then-rename (mandatory for all formats).** Write modified file to a temp file in the same directory (same filesystem), then `os.Rename()` atomically. This is already listed in PROJECT.md as a target feature.
|
||||
2. For FLAC specifically: read entire file → write new metadata blocks → copy audio frames → rename. There is no in-place shortcut.
|
||||
3. Verify the written file can be opened and has correct duration before replacing the original.
|
||||
4. Consider adding a PADDING metadata block after writing (e.g. 8KB) so small future edits can be done in-place. This is what tools like `metaflac` do.
|
||||
1. **Copy the CRC implementation from jfreymuth/oggvorbis/crc.go** — it's already an indirect dependency and has the correct OGG CRC32 table (MSB-first, polynomial 0x04c11db7).
|
||||
2. **Never use `hash/crc32` from the standard library** for OGG pages.
|
||||
3. Compute CRC with the checksum field set to zero in the header bytes, then write the computed CRC into bytes 22–25.
|
||||
4. **Round-trip test:** write file → verify every page's CRC matches by re-reading with a CRC-checking reader.
|
||||
|
||||
**Detection:** File size changes unexpectedly; beep decoder fails to open the file after write; duration changes after write (offset corruption).
|
||||
**Detection:** After writing, re-read every page and verify CRC matches. This should be a unit test, not just manual verification.
|
||||
|
||||
**Phase:** File write layer (earliest phase)
|
||||
**Phase:** OGG writer core (first thing to get right)
|
||||
|
||||
---
|
||||
|
||||
### P2: Currently-Playing File Cannot Be Written On Windows (And Shouldn't On Any Platform)
|
||||
### P2: OGG Page Sequence Number Continuity
|
||||
|
||||
**What goes wrong:** The player holds an `os.File` handle on the currently playing track (`p.currentFile` in `player.go:461`). On Windows, the OS enforces mandatory file locking — `os.Rename()` will fail with "The process cannot access the file because it is being used by another process." On Linux, the rename succeeds but the player continues reading the old inode (now unlinked), which works until something closes and reopens the path.
|
||||
**Severity:** BLOCKS SHIP — seeking and error recovery break
|
||||
**What goes wrong:** When rewriting OGG pages (because the comment header packet changed size), all subsequent page sequence numbers must be recalculated to remain strictly sequential (0, 1, 2, 3, ...). If you modify the comment header and it now spans a different number of pages, every page after it in the stream has a wrong sequence number. OGG decoders use sequence numbers for page loss detection and seeking.
|
||||
|
||||
**Why it happens:** The player opens files with `os.Open()` and holds them open for the duration of playback (streaming audio data). The beep library reads from this file handle continuously.
|
||||
**Why it happens:** The comment header is typically in page 1 (page 0 = BOS with codec identification). If the comment data grows large enough to span multiple pages, or shrinks from spanning multiple to fitting in one, the total page count changes, and all subsequent audio pages need renumbering.
|
||||
|
||||
**Consequences:** On Windows: tag write fails silently or with confusing error. On Linux: tag write succeeds but the player sees stale data, and if the user seeks, the streamer may read garbage from the new file at old offsets.
|
||||
**Consequences:** Players report "pages missing" or "page sequence gap." Seeking to specific positions fails. Some decoders abort playback entirely. Others may play but report errors in the log.
|
||||
|
||||
**Prevention:**
|
||||
1. **Check if the target file is currently playing before writing.** Compare `player.currentTrackPath` against the edit target.
|
||||
2. If the file IS playing: stop playback, close the file handle, perform the write, then reload and seek to the previous position. This creates a brief audio glitch but is the only safe approach.
|
||||
3. For batch edits that include the current track: edit all other files first, handle the playing file last with the stop-write-reload dance.
|
||||
4. Alternative (simpler): refuse to edit the currently playing file and show a user-facing message. Less ideal UX but avoids complexity.
|
||||
1. **Full-stream rewrite approach:** Read all pages → replace comment packet → regenerate all pages with correct sequence numbers → write as new file. This is the safest approach and matches how FLAC rewrite already works.
|
||||
2. After rewriting, verify: page 0 has sequence 0, page 1 has sequence 1, etc.
|
||||
3. The approach of "only rewrite the comment pages and shift the rest" is theoretically possible but fragile — the full rewrite is correct-by-construction.
|
||||
|
||||
**Detection:** `os.Rename()` returns error on Windows. On Linux, no error but playback becomes corrupted after seek.
|
||||
**Detection:** After writing, scan through all pages and verify monotonically increasing sequence numbers starting from 0.
|
||||
|
||||
**Phase:** File write layer + player integration
|
||||
**Phase:** OGG writer core
|
||||
|
||||
---
|
||||
|
||||
### P3: FTS5 Contentless Table Cannot UPDATE or DELETE Individual Rows
|
||||
### P3: OGG Granule Position Corruption
|
||||
|
||||
**What goes wrong:** The current `search_index` is a contentless FTS5 table (`content=''`). The existing `DeleteSearchIndex()` method is literally a no-op (see `search.go:120-127`). After editing a track's title from "Love Song" to "Heart Song", searching for "Love Song" still returns the track because the old FTS5 entry cannot be removed. The stale entry points to a valid rowid, and the JOIN against `track_metadata` will return the row (now with different data), so the user sees a search result that doesn't match their query.
|
||||
**Severity:** BLOCKS SHIP — playback duration and seeking wrong
|
||||
**What goes wrong:** Each OGG page has a 64-bit absolute granule position field (bytes 6–13) that encodes the total decoded samples up to and including the last completed packet on that page. If you rewrite pages and accidentally modify granule positions, seeking breaks and the reported duration is wrong. A special value of -1 (0xFFFFFFFFFFFFFFFF) means "no packets complete on this page."
|
||||
|
||||
**Why it happens:** Contentless FTS5 (`content=''`) stores only the index, not the original text. Without the original text, FTS5 can't compute what tokens to remove from the index. The current design relies on full rebuilds during rescan, which is fine for the read-only case but breaks for incremental edits.
|
||||
**Why it happens:** When splitting or merging pages during rewrite, it's easy to accidentally assign granule positions to pages that contain the comment header (which should have granule position 0 for the first two pages per the Vorbis spec) or to shift granule positions of audio pages.
|
||||
|
||||
**Consequences:** Search returns false positives after tag edits. The more edits the user makes, the worse search quality gets — until the next full rescan rebuilds the index.
|
||||
**Consequences:** Player reports wrong track duration. Seeking jumps to wrong positions. Progress bar is inaccurate.
|
||||
|
||||
**Prevention — Two Options:**
|
||||
**Prevention:**
|
||||
1. **Header pages (BOS page + comment page(s)) MUST have granule position 0.** This is mandated by the Vorbis I spec.
|
||||
2. **Audio page granule positions must be preserved exactly as-is** from the original file. Since we're only modifying the comment header, audio data doesn't change.
|
||||
3. The full-rewrite approach: copy BOS page (granule=0), write new comment pages (granule=0), then copy all remaining audio pages verbatim (preserving their original granule positions but updating sequence numbers and CRCs).
|
||||
|
||||
**Option A: Migrate to `contentless_delete=1` (Recommended)**
|
||||
SQLite 3.43.0+ supports `contentless_delete=1` which enables DELETE and INSERT OR REPLACE. This requires a schema migration (drop + recreate the FTS5 table). The `modernc.org/sqlite` driver bundles SQLite 3.45+, so this is available. After migration, tag edit can do: DELETE the old row, INSERT the new row. This is the `DELETE + INSERT` pattern already noted in the milestone context.
|
||||
**Detection:** Compare granule positions of audio pages before and after writing.
|
||||
|
||||
**Option B: Rebuild the entire index after each edit session**
|
||||
Call `RebuildSearchIndex()` after completing all tag writes. This is expensive (reads all tracks) but correct. Could be batched — rebuild once after a batch edit, not per-track.
|
||||
|
||||
**Recommendation:** Option A. The migration is straightforward and makes individual updates O(1) instead of O(n). The existing `RebuildSearchIndex()` becomes the migration step.
|
||||
|
||||
**Detection:** Search for old tag values — if they return results with the new values, the index is stale.
|
||||
|
||||
**Phase:** Schema migration (do first, before any tag write code)
|
||||
**Phase:** OGG writer core
|
||||
|
||||
---
|
||||
|
||||
### P4: Shared Entity Fan-Out — Editing Artist on One Track Affects Zero or Fifty Others
|
||||
### P4: OGG Vorbis Packet Structure — Three-Header Requirement
|
||||
|
||||
**What goes wrong:** The normalized schema shares entities across tracks. An `artist_credit` row with text "The Beatles" may be referenced by 200 recordings via `recordings.artist_credit_id`. If the user edits the artist field on one track from "The Beatles" to "Beatles, The", the system must decide: (a) update the shared `artist_credit` row (changing all 200 tracks), (b) create a new `artist_credit` and repoint only this track's recording, or (c) something else.
|
||||
**Severity:** BLOCKS SHIP — file unplayable if headers are wrong
|
||||
**What goes wrong:** A Vorbis stream in OGG has exactly three header packets in order: (1) identification header (starts with `\x01vorbis`), (2) comment header (starts with `\x03vorbis`), (3) setup header (starts with `\x05vorbis`). The identification header MUST be alone on the first page (BOS page). The comment and setup headers MUST appear before any audio data and MUST begin on the second page. If the rewriter corrupts the packet boundary between the comment and setup headers, the decoder can't initialize.
|
||||
|
||||
**Why it happens:** The MusicBrainz-inspired schema (`artists` → `artist_credit` → `recordings`) is designed for read-heavy workloads where entities are shared. Tag editing breaks this assumption by making per-track changes that may or may not be intended as global changes.
|
||||
**Why it happens:** The comment header can span multiple pages (especially with large cover art). The setup header immediately follows in the same stream of pages. If you modify the comment header's size, the boundary between comment and setup packets shifts. If you re-segment into pages incorrectly, the setup header may be split wrong.
|
||||
|
||||
**Consequences:**
|
||||
- If you update the shared row: user edits one track, 199 other tracks silently change. Terrifying.
|
||||
- If you create new rows: orphaned entities accumulate (old `artist_credit` row with only 199 refs, then 198, etc.). The artist browse view shows "The Beatles" AND "Beatles, The" as separate entries.
|
||||
- If you try to be smart about it: complex merge/split logic that's hard to get right.
|
||||
**Consequences:** Decoder fails to initialize. File appears to be an invalid Vorbis stream.
|
||||
|
||||
**Prevention:**
|
||||
1. **Tag editing always creates new entity rows for the edited track.** Create a new `recording`, new `artist_credit` (if changed), new `release_group_recordings` link, new `genre_recordings` links. Point the `audio_file.recording_id` at the new recording. This is the safest approach and matches what the scan pipeline already does (it always creates new recordings).
|
||||
2. **Orphan cleanup after edit.** After repointing the audio_file, check if the old recording is still referenced by any audio_file. If not, delete it (and cascade to its genre links, release_group links). Same for artist_credit, artists, genres, release_groups.
|
||||
3. **Never mutate shared entities in-place** during single-track or batch-within-same-album editing. The only exception is intentional "rename this artist across all tracks" which should be a separate, explicit feature (not part of v1.2).
|
||||
1. Parse the original stream to identify exactly where each header packet starts and ends.
|
||||
2. Replace only the comment packet data. Preserve identification and setup packets byte-for-byte.
|
||||
3. When re-assembling into pages: page 0 = BOS with identification header only. Page 1+ = comment header + setup header (they can share pages, but identification must be alone on page 0).
|
||||
4. After writing, verify all three header packets are parseable.
|
||||
|
||||
**Detection:** After editing one track's artist, check if other tracks in the same album now show the wrong artist.
|
||||
|
||||
**Phase:** Database update layer (core architecture decision — must be settled before writing any DB update code)
|
||||
**Phase:** OGG writer core
|
||||
|
||||
---
|
||||
|
||||
### P5: Race Condition — Scan Runs While Tags Are Being Written
|
||||
### P5: WAV ID3v2 vs RIFF INFO — Choosing Wrong Metadata Format
|
||||
|
||||
**What goes wrong:** User starts editing tags. While the edit is in progress (writing files, updating DB), a library scan starts (either from the scan queue, soft scan on launch, or user-initiated). The scan reads the file's tags (which may be half-written or already-written-but-DB-not-yet-updated), creates new entity rows, and overwrites the DB state that the tag editor just carefully set up.
|
||||
**Severity:** BLOCKS SHIP — metadata not readable by players or our own reader
|
||||
**What goes wrong:** WAV files can contain metadata in multiple formats: RIFF INFO chunks (LIST/INFO), ID3v2 tags (`id3 ` or `ID3 ` chunk), or both. dhowden/tag reads **ID3v2 tags from WAV files** (it looks for the `ID3` marker inside RIFF chunks). If we write RIFF INFO tags but dhowden/tag reads ID3v2, our written tags won't round-trip through our own reader. If we write ID3v2 but the file already has RIFF INFO, players that prefer RIFF INFO will show old data.
|
||||
|
||||
**Why it happens:** The scan pipeline (`scanInternal`) and tag editing are independent operations. The scan loads existing files from DB, walks the filesystem, extracts metadata, and writes to DB. If a file's on-disk tags differ from the DB (because the edit just wrote new tags), the scan treats it as needing an update and overwrites the recording.
|
||||
**Why it happens:** There's no single WAV metadata standard. The music production world uses RIFF INFO (INAM, IART, etc.), while the consumer audio world often uses ID3v2 embedded in WAV. Different tools write different formats. dhowden/tag's WAV support reads ID3v2 tags.
|
||||
|
||||
**Consequences:** Tag edits silently reverted. Or worse: the scan creates duplicate recordings (one from the edit, one from the scan) because the scan's entity cache doesn't know about the edit's newly-created entities.
|
||||
**Consequences:** Tags appear written but don't show up when re-reading the file. Or worse, conflicting metadata between ID3v2 and RIFF INFO confuses players.
|
||||
|
||||
**Prevention:**
|
||||
1. **Mutual exclusion between tag editing and scanning.** While tag writes are in progress, block scan start (or vice versa). The existing `l.mu` mutex protects scan state; extend it to cover "edit in progress" state.
|
||||
2. **Simpler: Use the existing scan queue coordinator.** Tag edits happen on the main goroutine (via Wails binding). Scans run in background goroutines. Since SQLite has `SetMaxOpenConns(1)`, DB writes are already serialized. The risk is the scan re-reading the file AFTER the tag write but BEFORE the DB update. Solution: perform the file write and DB update atomically (in the same critical section), and have the scan skip files that were recently edited (timestamp check or "edited" flag).
|
||||
3. **Best approach: Pause/cancel active scan during tag edit, resume after.** The existing `PauseScan()`/`ResumeScan()` mechanism can be leveraged. Pause the scan, do the edit (file write + DB update), resume the scan.
|
||||
1. **Write ID3v2 tags in WAV files** because that's what dhowden/tag reads back. Use the same `bogem/id3v2/v2` library already used for MP3 tag writing.
|
||||
2. WAV ID3v2 approach: the `id3 ` chunk in RIFF contains a complete ID3v2 tag. Write the ID3v2 tag into this chunk.
|
||||
3. If the file has existing RIFF INFO tags, **leave them alone** — don't delete them, don't try to sync them. Only modify the ID3v2 chunk.
|
||||
4. Round-trip test: write via our writer → read via `metadata.ExtractTags` → verify all fields match.
|
||||
|
||||
**Detection:** Edit a tag, immediately trigger a scan, check if the edit survives.
|
||||
**Detection:** Write a tag, then immediately read it back with dhowden/tag. If any field doesn't round-trip, the format choice is wrong.
|
||||
|
||||
**Phase:** Tag write integration with scan pipeline
|
||||
**Phase:** WAV writer core
|
||||
|
||||
---
|
||||
|
||||
### P6: Temp File Rename Fails Across Filesystem Boundaries
|
||||
### P6: WAV RIFF Chunk Size Updates
|
||||
|
||||
**What goes wrong:** `os.Rename()` is atomic only when source and dest are on the same filesystem. If the temp file is created in `/tmp` (default `os.CreateTemp` behavior) but the music file is on `/mnt/music`, the rename becomes a copy+delete — no longer atomic, and if interrupted, you lose the file.
|
||||
**Severity:** BLOCKS SHIP — file unplayable if sizes are wrong
|
||||
**What goes wrong:** WAV is a RIFF container where every chunk has a 4-byte ID + 4-byte little-endian size. The outermost RIFF chunk's size field must equal the total file size minus 8 bytes. If you add or resize the `id3 ` chunk but don't update the outer RIFF size, the file appears truncated to parsers. Some players ignore the RIFF size and read to EOF, but others (including some decoders) stop at the declared size.
|
||||
|
||||
**Why it happens:** Many developers use `os.CreateTemp("", ...)` which defaults to the system temp directory, which is often a different filesystem/partition from where music files live.
|
||||
**Why it happens:** When adding an ID3v2 chunk to a WAV that didn't have one, or resizing an existing chunk, you need to update not just the chunk's own size but also the parent RIFF size and possibly the `data` chunk boundaries.
|
||||
|
||||
**Consequences:** Non-atomic write. Power loss during copy = corrupted or missing file.
|
||||
**Consequences:** File appears truncated. Some players play it fine (they read to EOF), others refuse to open it or play only partial audio.
|
||||
|
||||
**Prevention:**
|
||||
1. **Create the temp file in the same directory as the target file.** Use `os.CreateTemp(filepath.Dir(targetPath), ".yj-edit-*")` to ensure same-filesystem rename.
|
||||
2. Clean up temp files on startup (find files matching `.yj-edit-*` pattern in library directories — these are orphaned from crashed edits).
|
||||
3. Use the temp file pattern: `<dir>/.yj-edit-<random>` → write → `os.Rename()` → done. If rename fails, the temp file is deleted. The original is untouched.
|
||||
1. **Full-file rewrite approach (like FLAC):** Read original → write RIFF header → write `fmt ` chunk → write `data` chunk → write other chunks → write `id3 ` chunk → fix RIFF size. AtomicWrite handles crash safety.
|
||||
2. Calculate final RIFF size as sum of all chunk sizes + their 8-byte headers + 4 bytes for "WAVE" form type.
|
||||
3. Place the `id3 ` chunk **after** the `data` chunk, not before it. Putting metadata before audio data means players must seek past it, and some naive parsers may not handle it.
|
||||
4. Test with both small WAV files (~1KB) and medium WAV files (~50MB) to catch size calculation bugs.
|
||||
|
||||
**Detection:** Check if `os.Rename()` returns `EXDEV` (cross-device link) error.
|
||||
**Detection:** After writing, verify: `file_size == RIFF_size + 8`. Open with beep's wav.Decode to verify playback still works.
|
||||
|
||||
**Phase:** File write layer (earliest phase)
|
||||
**Phase:** WAV writer core
|
||||
|
||||
---
|
||||
|
||||
## Moderate Pitfalls
|
||||
|
||||
Mistakes that cause bugs, degraded UX, or significant rework.
|
||||
Mistakes that cause incorrect behavior, data loss in edge cases, or significant debugging time.
|
||||
|
||||
### P7: ID3v2 Encoding Mismatch — UTF-8 Written Where Latin-1 Expected
|
||||
### P7: OGG Vorbis Comment Framing Bit
|
||||
|
||||
**What goes wrong:** ID3v2.3 (the most common version) defaults to ISO-8859-1 (Latin-1) encoding for text frames. If the tag writing library writes UTF-8 text into a Latin-1 frame without setting the encoding byte to UTF-8/UTF-16, players that strictly follow the spec will display garbled text (mojibake). Conversely, some players write Latin-1 tags that `dhowden/tag` reads as UTF-8, causing garbled reads.
|
||||
**Severity:** HIGH — file won't read if framing bit is wrong
|
||||
**What goes wrong:** The Vorbis comment header (inside OGG Vorbis streams) ends with a mandatory **framing bit**. This is a single `1` bit at the end of the comment data, byte-aligned. If you construct the comment header packet without this framing bit, Vorbis decoders will reject the header. The Vorbis I spec says: "if framing_bit unset or end of packet then ERROR."
|
||||
|
||||
**Why it happens:** ID3v2.3 only officially supports ISO-8859-1 and UTF-16. UTF-8 support was added in ID3v2.4. Many real-world files are ID3v2.3 with UTF-8 text (spec violation that most players tolerate). When writing tags, the library must match the encoding scheme to the ID3v2 version.
|
||||
|
||||
**Consequences:** Non-ASCII characters (accents, CJK, Cyrillic) display as garbage in other players after editing with YellowJacket.
|
||||
**Why it happens:** The OGG Opus format does NOT have this framing bit (OpusTags format omits it). If you're looking at Opus examples or generic Vorbis comment code, you might omit it. The framing bit is Vorbis-specific, not a general Vorbis Comment feature.
|
||||
|
||||
**Prevention:**
|
||||
1. **Use `bogem/id3v2` (aka `n10v/id3v2`) for MP3 tag writing.** This library handles encoding correctly — it auto-selects UTF-8 for v2.4 and UTF-16 for v2.3, or allows explicit control.
|
||||
2. When writing ID3v2.3 tags with non-ASCII content, use UTF-16 encoding (the only Unicode encoding ID3v2.3 supports).
|
||||
3. Consider upgrading all written tags to ID3v2.4 (which supports UTF-8 natively). This is what most modern taggers do.
|
||||
4. **Read the existing tag version and preserve it** unless the user explicitly requests an upgrade.
|
||||
1. After writing vendor string + comment count + all comments, write a byte `0x01` (framing bit set in LSB position).
|
||||
2. If reusing flacvorbis library to build the comment data: **FLAC Vorbis Comments do NOT have a framing bit** (FLAC metadata blocks have their own length framing). So you can't just take the FLAC Vorbis comment bytes and paste them into an OGG packet — you need to add the framing bit.
|
||||
3. Test: write a file, then verify `oggvorbis.GetCommentHeader()` successfully parses it.
|
||||
|
||||
**Detection:** Edit a track with non-ASCII characters, open in another player (VLC, foobar2000), check for garbled text.
|
||||
|
||||
**Phase:** Tag writing layer
|
||||
**Phase:** OGG writer
|
||||
|
||||
---
|
||||
|
||||
### P8: Cover Art Embedding Size and Format Incompatibilities
|
||||
### P8: OGG Vorbis Comment Packet Prefix
|
||||
|
||||
**What goes wrong:**
|
||||
- **JPEG vs PNG:** Both ID3v2 and FLAC Vorbis Comments support JPEG and PNG cover art. However, some older players only handle JPEG. If the user selects a PNG, it should work but may not display in all contexts.
|
||||
- **Image size:** Users may select a 10MB PNG file as cover art. Embedding this in every track of a 50-track album creates 500MB of overhead. The file write becomes extremely slow, and the FLAC rewrite (P1) is even worse because the entire file must be rewritten.
|
||||
- **FLAC cover art is stored as a PICTURE metadata block** with specific structure (picture type, MIME type, description, width, height, color depth, data). Getting any of these fields wrong causes players to not display the art.
|
||||
- **Vorbis Comments in OGG:** Cover art in OGG Vorbis files is stored as a base64-encoded METADATA_BLOCK_PICTURE in a Vorbis Comment field. This is a different mechanism than FLAC's native PICTURE block, despite both using "Vorbis Comments."
|
||||
**Severity:** HIGH — comment header not recognized
|
||||
**What goes wrong:** In OGG Vorbis, the comment header packet must start with the 7-byte prefix `\x03vorbis`. In OGG Opus, it starts with `OpusTags` (8 bytes). If you omit this prefix or use the wrong one, the reader won't find the comment header. dhowden/tag checks for both prefixes (`\x03vorbis` and `OpusTags`) to dispatch to Vorbis comment parsing.
|
||||
|
||||
**Consequences:** Cover art doesn't display in other players. Enormous file size increase. Slow writes.
|
||||
**Why it happens:** Confusing OGG Vorbis with OGG Opus. Or confusing FLAC Vorbis Comments (which have no packet prefix, they're identified by metadata block type) with OGG Vorbis Comments.
|
||||
|
||||
**Prevention:**
|
||||
1. **Resize cover art before embedding.** Cap at 800x800 or 1000x1000 pixels. Convert to JPEG (quality 90) for embedding — better compression than PNG for photos.
|
||||
2. **Validate image before embedding.** Decode it, check dimensions, re-encode if needed. Use `image/jpeg` and `image/png` standard library packages (already in use for thumbnail generation via `golang.org/x/image`).
|
||||
3. **For FLAC:** Populate ALL required PICTURE block fields (picture type=3 "front cover", MIME type, width, height, bit depth, data).
|
||||
4. **For OGG:** Base64-encode the FLAC PICTURE block structure into a `METADATA_BLOCK_PICTURE` Vorbis Comment field.
|
||||
5. **Show file size impact preview** in the UI before confirming cover art change on batch operations.
|
||||
1. When building the comment packet for OGG Vorbis: prepend `\x03vorbis` (7 bytes) before the Vorbis Comment data.
|
||||
2. The comment data itself (vendor string, comment list) is identical in format to what FLAC uses.
|
||||
3. **Scope decision:** For v1.2.1, only support `.ogg` files containing Vorbis (prefix `\x01vorbis` on identification header). Do NOT attempt to handle Opus files (`.opus`) — that's a different format with different header structure.
|
||||
|
||||
**Detection:** Embed cover art, open in another player, check if art displays. Check file size increase.
|
||||
|
||||
**Phase:** Cover art write layer
|
||||
**Phase:** OGG writer
|
||||
|
||||
---
|
||||
|
||||
### P9: Batch Edit Creates Hundreds of Orphaned Entity Rows
|
||||
### P9: Cover Art in OGG Vorbis — METADATA_BLOCK_PICTURE
|
||||
|
||||
**What goes wrong:** User selects 50 tracks from an album and changes the artist name. Following P4's approach (create new entities, repoint audio_file), this creates 50 new recordings, 1 new artist_credit, and 50 new release_group_recordings links. The old recording rows (and their genre links) are now orphaned — nothing references them. Without cleanup, the artists/albums/genres views show ghost entries.
|
||||
**Severity:** MEDIUM-HIGH — cover art either too large or not recognized
|
||||
**What goes wrong:** Vorbis Comments don't have a dedicated picture block like FLAC. Instead, cover art is stored as a `METADATA_BLOCK_PICTURE` comment field whose value is a **base64-encoded** FLAC PICTURE block. A typical 500KB JPEG becomes ~680KB of base64 text in a single Vorbis Comment entry. This dramatically increases the comment header size, which means the comment header may span many OGG pages (each page is max ~64KB). Additionally, some players only support this format while others look for a `COVERART` field (deprecated).
|
||||
|
||||
**Why it happens:** The "always create new" approach from P4 is correct for safety but generates garbage. The existing scan pipeline never updates entities — it only creates them. There's no existing orphan cleanup for recordings/artists/genres (only for audio_files during scan).
|
||||
**Why it happens:** The Vorbis Comment spec has no native image support. The community adopted the FLAC PICTURE structure encoded as base64 in a comment field, but it's cumbersome and space-inefficient.
|
||||
|
||||
**Consequences:** Ghost artists, albums, and genres appear in browse views. Database grows over time. Genre list fills with duplicates if genre spelling varies slightly across edits.
|
||||
**Consequences:**
|
||||
- A 1MB cover image becomes ~1.36MB of base64, which needs ~21 OGG pages just for the comment header.
|
||||
- Large comment headers stress the page segmentation code — more pages means more CRC calculations, more sequence number management.
|
||||
- If you use the deprecated `COVERART` field instead, dhowden/tag won't read it.
|
||||
|
||||
**Prevention:**
|
||||
1. **Run entity orphan cleanup after every edit (or batch edit).** In a single transaction:
|
||||
- Delete recordings not referenced by any audio_file
|
||||
- Delete release_group_recordings referencing deleted recordings
|
||||
- Delete recording_genres referencing deleted recordings
|
||||
- Delete artist_credits not referenced by any recording or release_group
|
||||
- Delete artists not referenced by any artist_credit_artist
|
||||
- Delete genres not referenced by any recording_genres
|
||||
- Delete release_groups not referenced by any release_group_recordings
|
||||
- Delete cover_art not referenced by any release_groups
|
||||
2. **Use `LEFT JOIN ... WHERE ... IS NULL` pattern** (same approach documented in P4 of the multi-library PITFALLS).
|
||||
3. **Batch the cleanup** — run once per edit session, not per-track.
|
||||
1. Use the `METADATA_BLOCK_PICTURE` field name.
|
||||
2. Value format: base64-encode a FLAC PICTURE structure (picture type + MIME type + description + width/height/depth/colors + image data). The `go-flac/flacpicture` library already knows this format — marshal a `flacpicture.MetadataBlockPicture`, then base64-encode the result.
|
||||
3. **Consider a max cover art size limit** (e.g., 2MB) to avoid pathological page counts. Log a warning for large images.
|
||||
4. Test round-trip: write cover art → read back with dhowden/tag → verify bytes match.
|
||||
|
||||
**Detection:** After batch edit, check that the old artist/album/genre no longer appears in browse views (unless other tracks still reference them).
|
||||
|
||||
**Phase:** Database update layer (immediately after P4's approach is implemented)
|
||||
**Phase:** OGG writer (possibly deferred from initial implementation if too complex)
|
||||
|
||||
---
|
||||
|
||||
### P10: Genre Storage Mismatch — Comma-Separated String vs Multi-Value
|
||||
### P10: WAV Chunk Word Alignment
|
||||
|
||||
**What goes wrong:** The `recordings.genre` column stores genre as a free-text string. The existing `metadata.ParseGenres()` splits on `,` and `;` and normalizes to title case. But the `recording_genres` M:N junction table stores individual genre links. These two representations can diverge: the string says "Rock, Pop" but the junction table has links to "Rock" and "Pop" as separate genre entities. After a tag edit, if only the string is updated (or only the junction table), they fall out of sync.
|
||||
**Severity:** MEDIUM — some parsers fail on unaligned chunks
|
||||
**What goes wrong:** The RIFF specification requires chunks to start at even byte offsets (word-aligned). If a chunk has an odd-length data section, a padding byte (`0x00`) must be added after the data before the next chunk begins. This padding byte is NOT included in the chunk's size field. If you write an `id3 ` chunk with odd-length data and don't add the pad byte, the next chunk's header will be misaligned and parsers will fail to find it.
|
||||
|
||||
**Why it happens:** Dual representation — the raw string in `recordings.genre` and the normalized M:N links in `recording_genres`. The scan pipeline populates both, but an edit might only update one.
|
||||
**Why it happens:** Easy to forget the padding byte, especially since the size field doesn't include it. Many existing WAV files have this bug (written by buggy software), so you might test with files that happen to have even-length chunks and never notice.
|
||||
|
||||
**Consequences:** Genre filtering (which uses `recording_genres`) shows different results than the genre string displayed in the track list (which comes from `recordings.genre` via `track_metadata` VIEW).
|
||||
**Consequences:** Parsers following the misaligned chunk will read garbage as the next chunk ID. Some parsers handle this gracefully (try realignment), others crash or report corruption.
|
||||
|
||||
**Prevention:**
|
||||
1. **Always update both representations in the same transaction.** When the user sets genre to "Rock, Pop":
|
||||
- Update `recordings.genre` = "Rock, Pop"
|
||||
- Delete all `recording_genres` rows for this recording
|
||||
- Insert new `recording_genres` rows for "Rock" and "Pop" (via `ParseGenres()`)
|
||||
2. **Use `ParseGenres()` consistently** for both display and storage.
|
||||
3. **When writing to the audio file**, join the individual genre names with the format's conventional separator (`;` for Vorbis Comments multi-value, `,` for ID3v2 TCON frame).
|
||||
1. After writing each chunk's data, check if `chunk_data_length % 2 != 0`. If so, write one zero byte.
|
||||
2. Don't count this byte in the chunk's size field.
|
||||
3. DO count it when calculating the outer RIFF size (since it's part of the physical file).
|
||||
4. Test with an `id3 ` chunk that has odd-length data.
|
||||
|
||||
**Detection:** Edit genre, verify both the displayed genre string and the genre filter show consistent results.
|
||||
|
||||
**Phase:** Database update layer
|
||||
**Phase:** WAV writer
|
||||
|
||||
---
|
||||
|
||||
### P11: Database Update After File Write — Partial Failure Leaves Inconsistency
|
||||
### P11: Reading/Writing Library Asymmetry
|
||||
|
||||
**What goes wrong:** The tag edit flow is: (1) write new tags to temp file, (2) rename temp to original, (3) update DB entities, (4) update FTS5 index. If step 2 succeeds but step 3 fails (e.g., SQLite busy, constraint violation), the file on disk has new tags but the DB shows old values. The next scan will "fix" this by re-reading the file, but until then the UI shows stale data.
|
||||
**Severity:** MEDIUM — data loss or failure if libraries disagree on format
|
||||
**What goes wrong:** The reading path uses `dhowden/tag` for metadata extraction. The writing path uses different libraries: `bogem/id3v2` for MP3, `go-flac/go-flac` for FLAC, and will use custom code for OGG. If these libraries disagree on field encoding, round-trip testing will fail. For example, dhowden/tag may normalize certain fields that the writer preserves verbatim, or vice versa.
|
||||
|
||||
**Why it happens:** File writes and DB writes can't be in the same transaction (they're different systems). The rename is the point of no return for the file.
|
||||
**Why it happens:** No single Go library handles both reading and writing for all formats. Each library has its own interpretation of edge cases.
|
||||
|
||||
**Consequences:** UI shows old metadata for edited tracks. Search returns old values. User thinks the edit failed and tries again (potentially fine since the file is already correct).
|
||||
**Consequences:** Tags appear to be saved but read back differently. Or dhowden/tag can't parse what the writer produces.
|
||||
|
||||
**Prevention:**
|
||||
1. **DB update first approach:** Update the DB entities BEFORE writing the file. If DB update fails, don't write the file — clean rollback. If DB update succeeds but file write fails, revert the DB change. This makes the DB the "leader" and the file the "follower."
|
||||
2. **Alternative: Accept eventual consistency.** Write file, update DB, if DB fails log a warning and mark the file for re-scan. The scan pipeline already handles files-on-disk-differ-from-DB.
|
||||
3. **For batch edits:** Use a two-phase approach — first update all DBs in a transaction, then write all files. If any file write fails, the DB is already correct for the others. Report per-file errors to the user.
|
||||
4. **Recommendation:** Option 1 (DB first) is simpler and more correct. The file write is the expensive/risky step; the DB update is fast and transactional.
|
||||
1. **Round-trip testing is mandatory for every format.** Write tags → read back with dhowden/tag → assert all 8 fields match.
|
||||
2. For OGG: since we're writing the Vorbis Comment bytes ourselves, we control the exact format. Use the same field names dhowden/tag expects (TITLE, ARTIST, ALBUM, ALBUMARTIST, GENRE, DATE, TRACKNUMBER, DISCNUMBER, COMPOSER).
|
||||
3. For WAV: use `bogem/id3v2` (same as MP3) for the ID3v2 chunk content. The round-trip behavior should match MP3 tests.
|
||||
4. Watch for Vorbis Comment field name case sensitivity: field names are case-insensitive per spec, but dhowden/tag may return them in a specific case. Our writer should use UPPERCASE field names (Vorbis convention).
|
||||
|
||||
**Detection:** Kill the app mid-edit (during file write), restart, verify DB and file are consistent.
|
||||
|
||||
**Phase:** Tag write integration layer
|
||||
**Phase:** All writer implementations
|
||||
|
||||
---
|
||||
|
||||
### P12: `dhowden/tag` Is Read-Only — Need Separate Write Libraries Per Format
|
||||
### P12: AtomicWrite Disk Space for Large Files
|
||||
|
||||
**What goes wrong:** The existing `github.com/dhowden/tag` library is read-only. It extracts tags but cannot write them. Developers may assume the existing dependency can handle writes, waste time trying, then discover late that a separate library is needed.
|
||||
**Severity:** MEDIUM — write fails on low disk space
|
||||
**What goes wrong:** AtomicWrite creates a temp file alongside the original, writes the complete new file, then renames. For a 700MB WAV file, this requires 700MB of free disk space. If the disk is nearly full, the temp file write will fail partway through, and the deferred cleanup removes the partial temp file — no data loss, but the user gets an error.
|
||||
|
||||
**Why it happens:** `dhowden/tag` explicitly only supports reading. Its API has `ReadFrom()` but no `WriteTo()`.
|
||||
**Why it happens:** WAV files can be gigabytes (24-bit, 96kHz stereo recordings). OGG files are typically much smaller (compressed), but large FLAC→OGG conversions at high quality can still be hundreds of MB.
|
||||
|
||||
**Consequences:** Need to add 1-2 new dependencies for tag writing, each with different APIs and behaviors per format.
|
||||
**Consequences:** Write fails with "no space left on device." AtomicWrite correctly cleans up — no corruption. But the user can't save tags without freeing disk space.
|
||||
|
||||
**Prevention:**
|
||||
1. **MP3 (ID3v2):** Use `github.com/bogem/id3v2/v2` (also available as `github.com/n10v/id3v2/v2`). 359 stars, actively maintained, supports read+write for ID3v2.3 and v2.4, handles encoding correctly, supports picture frames. Pure Go.
|
||||
2. **FLAC:** Use `github.com/go-flac/flactag` or handle FLAC metadata blocks manually. FLAC's metadata format is simpler than ID3v2 (well-defined block structure). May need to write a thin wrapper that reads STREAMINFO + other blocks, modifies VORBIS_COMMENT block, and rewrites.
|
||||
3. **OGG Vorbis:** Use `github.com/go-flac/go-ogg` or a Vorbis Comment library. OGG wraps Vorbis Comments in OGG pages, which adds framing complexity.
|
||||
4. **WAV:** WAV tag support is minimal in practice. Defer WAV tag writing (not in v1.2 scope per PROJECT.md which lists MP3, FLAC, OGG only).
|
||||
5. **Keep `dhowden/tag` for reading.** Don't replace it — use it alongside the write libraries.
|
||||
1. **Pre-flight check:** Before starting the write, check available disk space. If free space < file size + some margin (10MB), return a clear error: "insufficient disk space for atomic write."
|
||||
2. Log the file size at INFO level when writing large files (>100MB) so users understand why it takes time.
|
||||
3. The existing `largeSizeThreshold` warning in `writeFlacTags` (500MB) should be applied to all format writers.
|
||||
4. For WAV specifically, where we're rewriting a potentially multi-GB file: consider whether the tag data can be appended at the end without rewriting the audio data. If the `id3 ` chunk is placed after `data`, and the RIFF header size is updated, this is theoretically possible — but tricky to get right and incompatible with the current AtomicWrite pattern.
|
||||
|
||||
**Phase:** Stack decision (before implementation begins)
|
||||
**Phase:** WAV writer, integration testing
|
||||
|
||||
---
|
||||
|
||||
### P13: OGG Multi-Stream Files (Chained and Multiplexed)
|
||||
|
||||
**Severity:** MEDIUM — silent data loss or crash on unusual files
|
||||
**What goes wrong:** OGG supports two types of multiplexing: **chaining** (sequential streams, like concatenated songs in internet radio recordings) and **grouping** (interleaved streams, like video + audio). A chained OGG file has multiple logical bitstreams end-to-end, each with its own BOS/EOS pages. If our writer assumes a single logical bitstream, it will either only modify the first stream's tags (losing all subsequent streams) or crash when it encounters unexpected BOS pages.
|
||||
|
||||
**Why it happens:** Most `.ogg` music files contain a single Vorbis stream. But files from internet radio captures, or OGG files with embedded lyrics/subtitles, may have multiple streams.
|
||||
|
||||
**Consequences:** Chained streams after the first are silently dropped. File plays only the first few seconds/minutes. Data loss that the user may not notice immediately.
|
||||
|
||||
**Prevention:**
|
||||
1. **Detect multi-stream files early:** Check for a second BOS page (after the first EOS). If found, either:
|
||||
- (a) Return an error: "multi-stream OGG files are not supported for tag editing"
|
||||
- (b) Only modify the first stream and pass through all subsequent streams byte-for-byte
|
||||
2. Option (a) is safer for v1.2.1. Option (b) is more user-friendly but requires more careful implementation.
|
||||
3. **Detect multiplexed streams:** If pages with different serial numbers appear interleaved, reject the file. YellowJacket is a music player — multiplexed OGG (video+audio) is out of scope.
|
||||
4. Test with a chained OGG file to verify graceful handling.
|
||||
|
||||
**Phase:** OGG writer (validation at file open time)
|
||||
|
||||
---
|
||||
|
||||
### P14: Round-Trip Data Loss — Fields Present in File But Not in Our Model
|
||||
|
||||
**Severity:** MEDIUM — data loss for power users
|
||||
**What goes wrong:** Our model has 8 editable fields + cover art. Vorbis Comments can contain arbitrary fields (LYRICS, COMMENT, PERFORMER, ORGANIZATION, REPLAYGAIN_*, etc.). When we rewrite the OGG file, if we rebuild the Vorbis Comment block from scratch using only our 8 fields, all other comment fields are lost. The same applies to WAV — an ID3v2 tag may have many frames beyond our 8 fields.
|
||||
|
||||
**Why it happens:** The simplest implementation is "build new comment block from scratch." This is what go-flac/flacvorbis does for FLAC — and it works for FLAC because the library preserves the comment block and we only replace specific fields.
|
||||
|
||||
**Consequences:** ReplayGain data lost (affects volume normalization in other players). Custom fields from other tools lost. Lyrics lost. Users who also use other tagging software will lose data.
|
||||
|
||||
**Prevention:**
|
||||
1. **For OGG:** Parse the existing Vorbis Comment block. Replace/add only the fields we're changing. Preserve all other comment entries verbatim. This is the same approach already used in `replaceVorbisComment()` for FLAC — port that logic.
|
||||
2. **For WAV (ID3v2):** The `bogem/id3v2` library already handles this — opening with `Parse: true` reads all existing frames, and writing only replaces/adds what we change.
|
||||
3. **Cover art special case:** When setting cover art in OGG, remove existing `METADATA_BLOCK_PICTURE` entries but preserve all other fields. When clearing cover art, only remove `METADATA_BLOCK_PICTURE`.
|
||||
4. Test: create a file with extra fields (e.g., REPLAYGAIN_TRACK_GAIN), edit one of our fields, verify the extra fields survive.
|
||||
|
||||
**Phase:** OGG writer, WAV writer
|
||||
|
||||
---
|
||||
|
||||
## Minor Pitfalls
|
||||
|
||||
Mistakes that cause minor issues, confusion, or suboptimal UX.
|
||||
Mistakes that cause edge-case bugs, poor UX, or unexpected behavior.
|
||||
|
||||
### P13: Cover Art Cache Invalidation After Embedded Art Change
|
||||
### P15: OGG Page Segmentation for Large Comment Headers
|
||||
|
||||
**What goes wrong:** Cover art is cached by content hash in `~/.local/share/yellowjacket/covers/`. If the user replaces embedded cover art, the old cached thumbnails (sm/md/lg) still exist and may be served from cache. The cover art hash changes (new image = new hash), so a new cache entry is created, but the `release_groups.cover_art_id` must be updated to point to the new `cover_art` record.
|
||||
**Severity:** LOW-MEDIUM — fails for large cover art
|
||||
**What goes wrong:** OGG pages have a max segment table of 255 entries, each representing up to 255 bytes, giving a max page data size of 65,025 bytes (~63.5KB). A comment header with embedded cover art can easily exceed this. The header packet must then span multiple pages, using the continued-packet mechanism (header type flag 0x01 on continuation pages). If the page segmentation code doesn't handle multi-page packets, writing large cover art will fail or produce corrupt files.
|
||||
|
||||
**Why it happens:** The cover art system is designed for initial extraction during scan. It doesn't expect art to change after initial import.
|
||||
**Why it happens:** Most comment headers fit in a single page. The multi-page case only triggers with cover art or very long field values.
|
||||
|
||||
**Prevention:**
|
||||
1. After writing new cover art to the file, extract it back, compute the new hash, create the new `cover_art` record, update `release_groups.cover_art_id`, generate new thumbnails.
|
||||
2. Delete old `cover_art` record and files only if no other release_group references them (same orphan cleanup as P9).
|
||||
3. Emit an event so the frontend refreshes cover art display (invalidate any cached cover art URLs).
|
||||
1. Implement proper packet-to-page segmentation: split packets into 255-byte segments, fill pages up to 255 segments each, set continuation flag on subsequent pages.
|
||||
2. Test with a comment header that's exactly 65,025 bytes (fits in one page), 65,026 bytes (requires two pages), and ~200KB (requires multiple pages).
|
||||
3. Consider implementing cover art writing as a second phase after text fields are working and tested.
|
||||
|
||||
**Phase:** Cover art write layer
|
||||
**Phase:** OGG writer (advanced)
|
||||
|
||||
---
|
||||
|
||||
### P14: Undo/Redo Expectations — Users Expect to Revert Tag Edits
|
||||
### P16: WAV Files >4GB (RF64 Format)
|
||||
|
||||
**What goes wrong:** User changes artist name, saves, realizes it was wrong, expects Ctrl+Z to work. But tag editing writes to the actual audio file — there's no undo buffer.
|
||||
**Severity:** LOW — rare in music player context
|
||||
**What goes wrong:** Standard WAV uses 32-bit chunk sizes, limiting files to ~4GB. WAV files larger than 4GB use the RF64 extension (magic `RF64` instead of `RIFF`, with a `ds64` chunk for 64-bit sizes). If we encounter an RF64 file and treat it as standard WAV, we'll misparse the chunk sizes and produce a corrupt file.
|
||||
|
||||
**Why it happens:** File writes are destructive. The temp-file-rename approach ensures atomicity but not reversibility.
|
||||
**Why it happens:** RF64 is common in professional audio (long multi-channel recordings). Most music collections won't have these, but a user with high-resolution recordings might.
|
||||
|
||||
**Consequences:** File corruption for files >4GB. Audio data shifted due to wrong chunk size calculation.
|
||||
|
||||
**Prevention:**
|
||||
1. **For v1.2: Don't implement undo.** It's complex (would need to store original tag values per-edit) and users of tag editors generally don't expect undo.
|
||||
2. **Show a confirmation dialog before writing**, especially for batch edits. "You are about to modify 47 files. This cannot be undone."
|
||||
3. **Log what changed.** Write structured log entries like `"tag edit: file=/path/to/song.mp3, field=artist, old=Beatles, new=The Beatles"`. This gives users a recovery path (manual).
|
||||
4. **Future milestone consideration:** Backup original files before edit (copy to `.yj-backup/` directory). Add a "restore original" option.
|
||||
1. **Check for RF64 magic** (`RF64` at offset 0 instead of `RIFF`). If found, return an error: "RF64 WAV files are not supported for tag editing."
|
||||
2. This is acceptable for v1.2.1 — RF64 support can be added later if users report needing it.
|
||||
3. Also check: if the calculated file size would exceed 4GB after adding/expanding the ID3v2 chunk, warn that the file may be problematic.
|
||||
|
||||
**Phase:** UX design
|
||||
**Phase:** WAV writer (validation at file open time)
|
||||
|
||||
---
|
||||
|
||||
### P15: Track Number and Disc Number Edge Cases
|
||||
### P17: Test Fixture Creation for OGG and WAV
|
||||
|
||||
**What goes wrong:** Track number is stored as `sql.NullInt64` in the DB and as `int` in `TrackMetadata`. User enters "1/12" in the track number field (common display format). If parsed as a raw int, this fails. If split on `/`, `TotalTracks` must also be stored. The existing `toNullInt64()` treats 0 as NULL, so track 0 is impossible to store (rare but exists in some compilations).
|
||||
**Severity:** LOW-MEDIUM — blocks test coverage
|
||||
**What goes wrong:** The existing FLAC and MP3 tests create minimal valid files programmatically (see `makeMinimalFLAC` and `createTestMP3`). OGG Vorbis files are harder to create programmatically because you need valid identification, comment, and setup header packets. WAV files are simpler but still require correct RIFF structure.
|
||||
|
||||
**Consequences:** Track numbers display incorrectly or can't be set to certain values.
|
||||
**Why it happens:** OGG Vorbis requires three header packets where the setup header contains Vorbis codebook data. You can't easily generate a valid setup header from scratch without a Vorbis encoder.
|
||||
|
||||
**Prevention:**
|
||||
1. Parse "N/M" format: split on `/`, store track number and total separately.
|
||||
2. Validate inputs: track number must be positive integer (or blank for null).
|
||||
3. Consider whether `toNullInt64()` treating 0 as NULL is correct for the edit case. For display it's fine, but for editing, the user might explicitly set track number to 0. Probably not worth changing for v1.2.
|
||||
**Prevention for OGG:**
|
||||
1. **Embed a minimal OGG fixture as `//go:embed`** in the test file. Create it once with an encoder (e.g., `ffmpeg -f lavfi -i "sine=frequency=440:duration=0.1" -c:a libvorbis -q:a 0 minimal.ogg`). ~5KB file.
|
||||
2. Copy this fixture to a temp directory in each test, then modify it.
|
||||
3. Alternatively, use `jfreymuth/oggvorbis` (already an indirect dep) to understand the page structure and craft test files with known structure.
|
||||
|
||||
**Phase:** Frontend input validation + backend write layer
|
||||
**Prevention for WAV:**
|
||||
1. **Generate minimal WAV programmatically** — it's much simpler than OGG:
|
||||
- RIFF header (12 bytes): "RIFF" + size + "WAVE"
|
||||
- fmt chunk (24 bytes): "fmt " + 16 + PCM format data
|
||||
- data chunk (8+ bytes): "data" + size + silence samples
|
||||
This is similar to `makeMinimalFLAC` in complexity.
|
||||
2. Generate fixtures with and without existing ID3v2 chunks to test both "add new" and "replace existing" paths.
|
||||
|
||||
**Phase:** Test infrastructure (before format writers)
|
||||
|
||||
---
|
||||
|
||||
### P16: Multiple Audio Files Sharing the Same Recording (1:1 Assumption)
|
||||
### P18: Empty/Missing Tags vs Zero-Length Strings
|
||||
|
||||
**What goes wrong:** The scan pipeline creates a new `recordings` row for every audio file (see `processMetadata()` at `library.go:1178`). This means the relationship is effectively 1:1 (each audio_file has its own recording). But the schema allows N:1 (multiple audio_files can share a recording_id). If a future change or manual DB edit creates shared recordings, editing one track's metadata would affect the other track sharing that recording.
|
||||
**Severity:** LOW — cosmetic but confusing
|
||||
**What goes wrong:** When a user clears a field (sets it to empty string), should we write an empty comment entry (`TITLE=`) or omit the field entirely? Different readers handle these differently. dhowden/tag returns empty string for both missing and empty fields, so the distinction is invisible on read. But other tools may show "unknown" for missing fields vs blank for empty fields.
|
||||
|
||||
**Why it happens:** The schema was designed for MusicBrainz-style data where multiple releases of the same recording share a recording ID. The scan pipeline doesn't implement this sharing, but the schema allows it.
|
||||
**Why it happens:** The Vorbis Comment spec says nothing about whether empty values are allowed (they are — any UTF-8 string including empty is valid).
|
||||
|
||||
**Prevention:**
|
||||
1. **Before editing a recording, check how many audio_files reference it.** If more than one, create a new recording for this audio_file (fork the entity).
|
||||
2. This is already handled by P4's "always create new" approach, but worth calling out as a specific guard.
|
||||
1. **Match FLAC behavior:** The existing `replaceVorbisComment` function writes the new value regardless of whether it's empty. An empty string results in `TITLE=`. This is fine.
|
||||
2. For OGG, use the same approach: write `FIELD=value` for all fields in the diff map, whether value is empty or not.
|
||||
3. For WAV ID3v2: an empty string text frame is valid. The bogem/id3v2 library handles this correctly.
|
||||
|
||||
**Phase:** Database update layer
|
||||
**Phase:** All writers
|
||||
|
||||
---
|
||||
|
||||
### P17: Frontend Store Refresh After Tag Edit
|
||||
### P19: Unicode in Vorbis Comment Field Values
|
||||
|
||||
**What goes wrong:** After a tag edit updates the DB, the frontend `libraryStore` still holds the old cached data (tracks, albums, artists, genres). Without a refresh, the UI shows stale values until the user navigates away and back, or triggers a full reload.
|
||||
|
||||
**Why it happens:** The `libraryStore.eagerFetch()` loads all data at startup. There's no mechanism for partial updates — the store either shows cached data or refetches everything.
|
||||
**Severity:** LOW — most data is ASCII but international users need this
|
||||
**What goes wrong:** Vorbis Comment values MUST be valid UTF-8. Go strings are inherently UTF-8, so this is mostly automatic. However, if the user pastes text from a non-UTF-8 source (e.g., Windows-1252), the bytes won't be valid UTF-8 and could cause downstream parsing issues.
|
||||
|
||||
**Prevention:**
|
||||
1. **Emit a `TagsUpdated` event** from the backend after successful tag edit, with the list of affected file paths.
|
||||
2. The frontend store listens for this event and either:
|
||||
- (a) Refetches the full data (simple but expensive for large libraries), or
|
||||
- (b) Patches the affected rows in-place (more complex but instant)
|
||||
3. **Recommendation for v1.2:** Option (a) — full refetch. The existing `eagerFetch()` path is proven. Optimize to partial updates in a future milestone if performance is an issue.
|
||||
4. Also update: search results (refetch if search is active), queue track metadata (emit `QueueTracksModified`), now-playing display (emit `TrackChanged` if the edited track is playing).
|
||||
1. Validate that all string values are valid UTF-8 before writing: `utf8.ValidString(value)`.
|
||||
2. This is unlikely to be an issue since Wails serializes strings as JSON (which mandates UTF-8), but a defensive check is cheap.
|
||||
|
||||
**Phase:** Frontend integration (last phase)
|
||||
**Phase:** All writers (validation layer)
|
||||
|
||||
---
|
||||
|
||||
### P20: Existing BWF (Broadcast Wave Format) Chunks in WAV
|
||||
|
||||
**Severity:** LOW — professional users may care
|
||||
**What goes wrong:** Some WAV files contain `bext` (Broadcast Extension) chunks with professional metadata (origination date, time reference, loudness info). Our writer should not delete or corrupt these chunks.
|
||||
|
||||
**Prevention:**
|
||||
1. **Preserve all unrecognized chunks verbatim.** When rewriting the WAV file, copy all chunks we don't modify byte-for-byte to the output.
|
||||
2. Only modify the `id3 ` chunk (add, replace, or update). Leave `fmt `, `data`, `bext`, `cue `, `smpl`, and all other chunks untouched.
|
||||
3. The full-rewrite approach: iterate chunks in the source file, copy each to the output, replacing `id3 ` with our new version (or appending it at the end if it didn't exist).
|
||||
|
||||
**Phase:** WAV writer
|
||||
|
||||
---
|
||||
|
||||
## Phase-Specific Warnings
|
||||
|
||||
| Phase Topic | Likely Pitfall | Mitigation |
|
||||
|-------------|---------------|------------|
|
||||
| Schema migration | P3: FTS5 contentless can't delete | Migrate to `contentless_delete=1` first |
|
||||
| File write layer | P1: FLAC full rewrite, P6: temp file same dir | Write-to-temp-then-rename in same directory |
|
||||
| File write layer | P2: Currently playing file | Check player state before write, stop if needed |
|
||||
| Tag library selection | P12: dhowden/tag is read-only | Use bogem/id3v2 for MP3, format-specific libs for FLAC/OGG |
|
||||
| DB update design | P4: Shared entities, P9: Orphan cleanup | Always create new entities, clean up orphans per-edit |
|
||||
| DB update design | P10: Genre dual representation | Update both recordings.genre and recording_genres atomically |
|
||||
| Write + DB integration | P5: Scan race condition | Pause scan during edit, or mutual exclusion |
|
||||
| Write + DB integration | P11: Partial failure | DB update first, then file write |
|
||||
| Cover art writes | P8: Size/format compat, P13: Cache invalidation | Resize before embed, invalidate cache after write |
|
||||
| Encoding | P7: ID3v2 Latin-1 vs UTF-8 | Use UTF-16 for v2.3, UTF-8 for v2.4 |
|
||||
| Frontend | P17: Stale cache after edit | Emit event, full refetch |
|
||||
| UX design | P14: No undo for file writes | Confirmation dialog, structured logging |
|
||||
| Phase Topic | Likely Pitfall | Severity | Mitigation |
|
||||
|------------|---------------|----------|------------|
|
||||
| OGG page infrastructure | P1 (CRC32), P2 (seq numbers) | CRITICAL | Port CRC from oggvorbis/crc.go; full-stream rewrite |
|
||||
| OGG Vorbis comment writer | P4 (three headers), P7 (framing bit), P8 (prefix) | HIGH | Follow Vorbis I spec exactly; round-trip test |
|
||||
| OGG cover art | P9 (base64 PICTURE), P15 (large pages) | MEDIUM-HIGH | METADATA_BLOCK_PICTURE format; multi-page support |
|
||||
| OGG edge cases | P13 (multi-stream), P3 (granule positions) | MEDIUM | Detect and reject multi-stream; preserve granule positions |
|
||||
| WAV writer core | P5 (ID3v2 vs RIFF INFO), P6 (RIFF size) | CRITICAL | Write ID3v2; careful size bookkeeping |
|
||||
| WAV chunk handling | P10 (alignment), P16 (RF64), P20 (BWF) | MEDIUM | Pad to even; detect RF64; preserve all chunks |
|
||||
| Integration | P11 (read/write asymmetry), P14 (data loss) | MEDIUM | Round-trip tests; preserve unknown fields |
|
||||
| Large files | P12 (disk space) | MEDIUM | Pre-flight size check; AtomicWrite handles crash safety |
|
||||
| Testing | P17 (fixtures) | LOW-MEDIUM | Embed OGG fixture; generate WAV programmatically |
|
||||
|
||||
## Ordering Implications
|
||||
## Implementation Order Recommendation
|
||||
|
||||
The pitfalls strongly suggest this phase ordering:
|
||||
Based on pitfall severity and dependency chain:
|
||||
|
||||
1. **FTS5 migration first** (P3) — enables all subsequent DB updates to be clean
|
||||
2. **File write layer** (P1, P2, P6) — the atomic write-to-temp-rename mechanism, independent of DB
|
||||
3. **Tag library integration** (P7, P8, P12) — per-format write support using new dependencies
|
||||
4. **DB update design** (P4, P9, P10, P11, P16) — entity creation, orphan cleanup, genre sync
|
||||
5. **Scan pipeline integration** (P5) — mutual exclusion between edit and scan
|
||||
6. **Frontend** (P14, P15, P17) — UI, events, cache refresh
|
||||
1. **OGG page infrastructure** (P1, P2, P3) — must be correct before anything else works
|
||||
2. **OGG comment writer** (P7, P8, P4, P14) — build on page infrastructure
|
||||
3. **WAV writer** (P5, P6, P10, P20) — independent of OGG, can parallelize
|
||||
4. **OGG cover art** (P9, P15) — can be deferred if text fields work
|
||||
5. **Edge case handling** (P13, P16, P12) — validation and error reporting
|
||||
6. **Round-trip testing** (P11, P17) — continuous throughout, but formalize at end
|
||||
|
||||
## Key Decision Points
|
||||
|
||||
### OGG: Full Rewrite vs Surgical Edit
|
||||
**Recommendation: Full rewrite.** The approach of "read all pages → replace comment packet → rewrite all pages" is simpler, more correct, and matches the FLAC precedent. The performance cost is acceptable because OGG files are compressed (typically 3-10MB for a song). A surgical edit (only rewriting comment pages and adjusting subsequent pages) saves I/O but dramatically increases complexity for CRC, sequence numbers, and page boundary management.
|
||||
|
||||
### WAV: Where to Place ID3v2 Chunk
|
||||
**Recommendation: After the `data` chunk.** This ensures naive parsers that stop reading after `data` still play the file. More sophisticated parsers (like dhowden/tag) scan the entire RIFF structure and will find the `id3 ` chunk wherever it is.
|
||||
|
||||
### Cover Art in OGG: v1.2.1 or Defer?
|
||||
**Recommendation: Implement text fields first, add cover art as stretch goal.** Cover art in OGG (P9, P15) adds significant complexity (base64 encoding, multi-page packets, FLAC PICTURE structure). Text-only tag editing is valuable on its own. If cover art doesn't make v1.2.1, it's a clean addition in a follow-up.
|
||||
|
||||
## Sources
|
||||
|
||||
- SQLite FTS5 documentation: contentless tables section (sqlite.org/fts5.html#contentless_tables) — HIGH confidence
|
||||
- SQLite FTS5 contentless_delete: sqlite.org/fts5.html#contentless_delete_tables — HIGH confidence
|
||||
- YellowJacket codebase analysis: search.go, library.go, player.go, schema files — HIGH confidence
|
||||
- FLAC format spec: metadata block structure, PICTURE block format — HIGH confidence (well-established spec)
|
||||
- ID3v2.3/2.4 spec: encoding requirements for text frames — HIGH confidence
|
||||
- `bogem/id3v2` GitHub (n10v/id3v2): read+write ID3v2 library, 359 stars — MEDIUM confidence (verified repo exists and has write support)
|
||||
- `dhowden/tag` API: read-only confirmed from codebase usage — HIGH confidence
|
||||
- OGG Framing Specification: https://xiph.org/ogg/doc/framing.html (HIGH confidence — canonical spec)
|
||||
- RFC 3533 — The Ogg Encapsulation Format: https://www.rfc-editor.org/rfc/rfc3533 (HIGH confidence — IETF RFC)
|
||||
- Vorbis I Comment Spec: https://xiph.org/vorbis/doc/v-comment.html (HIGH confidence — canonical spec)
|
||||
- OGG Opus Mapping: https://wiki.xiph.org/OggOpus (HIGH confidence — Xiph wiki)
|
||||
- jfreymuth/oggvorbis source (crc.go, ogg.go): https://github.com/jfreymuth/oggvorbis (HIGH confidence — direct code review)
|
||||
- dhowden/tag source (ogg.go): https://github.com/dhowden/tag/blob/master/ogg.go (HIGH confidence — direct code review)
|
||||
- RIFF tag reference: https://exiftool.org/TagNames/RIFF.html (MEDIUM confidence — ExifTool documentation)
|
||||
- YellowJacket codebase analysis: tagwriter/, fileutil/, metadata/ packages (HIGH confidence — direct code review)
|
||||
|
||||
+470
-161
@@ -1,203 +1,512 @@
|
||||
# Technology Stack: Tag Editing
|
||||
# Technology Stack: OGG Vorbis + WAV Tag Writing
|
||||
|
||||
**Project:** YellowJacket v1.2 Tag Editing
|
||||
**Researched:** 2026-03-16
|
||||
**Project:** YellowJacket v1.2.1 Format Parity
|
||||
**Researched:** 2026-03-18
|
||||
**Scope:** Stack additions/changes needed ONLY for OGG Vorbis and WAV tag writing
|
||||
|
||||
## Recommended Stack
|
||||
> **Context:** MP3 writing (bogem/id3v2) and FLAC writing (go-flac ecosystem) are already
|
||||
> implemented and validated in v1.2. This document focuses exclusively on what's needed
|
||||
> for OGG Vorbis and WAV tag writing.
|
||||
|
||||
### MP3 Tag Writing — `bogem/id3v2/v2`
|
||||
---
|
||||
|
||||
| Technology | Version | Purpose | Why |
|
||||
|------------|---------|---------|-----|
|
||||
| `github.com/bogem/id3v2/v2` | v2.1.4 | ID3v2.3/v2.4 read + write for MP3 | Only mature pure-Go ID3v2 writing library. 359 stars, 57 importers, 579 commits. Supports SetTitle/SetArtist/SetAlbum/SetGenre/SetYear, AddAttachedPicture (cover art embedding), AddTextFrame (track/disc numbers, composer via TRCK/TPOS/TCOM), and tag.Save(). |
|
||||
## OGG Vorbis Tag Writing
|
||||
|
||||
**Key capabilities verified (HIGH confidence — pkg.go.dev docs):**
|
||||
- `tag.SetArtist()`, `tag.SetTitle()`, `tag.SetAlbum()`, `tag.SetGenre()`, `tag.SetYear()` — direct setters
|
||||
- `tag.AddTextFrame("TRCK", id3v2.EncodingUTF8, "5/12")` — track number
|
||||
- `tag.AddTextFrame("TPOS", id3v2.EncodingUTF8, "1/2")` — disc number
|
||||
- `tag.AddTextFrame("TCOM", id3v2.EncodingUTF8, "Bach")` — composer
|
||||
- `tag.AddAttachedPicture(PictureFrame{...})` — cover art embedding with MIME type, picture type (front cover), and raw image bytes
|
||||
- `tag.Save()` — writes modified tag back to file
|
||||
- `tag.DeleteFrames(id)` — remove specific frame types (needed for replacing cover art)
|
||||
- ID3v2.3 and v2.4 version support with `tag.SetVersion()`
|
||||
- UTF-8 encoding default for v2.4, ISO-8859-1 for v2.3
|
||||
- `id3v2.Open(path, Options{Parse: true})` — open existing file, parse all frames, then modify and save
|
||||
### The Problem
|
||||
|
||||
**Integration with existing dhowden/tag:**
|
||||
- dhowden/tag stays for READ operations (already integrated in `backend/metadata/tags.go`)
|
||||
- bogem/id3v2 used ONLY for WRITE operations
|
||||
- No conflict: dhowden/tag reads from `io.ReadSeeker`, bogem/id3v2 reads from file path and writes back
|
||||
- Read flow unchanged: `dhowden/tag.ReadFrom()` → `TrackMetadata` struct
|
||||
- Write flow new: `id3v2.Open()` → modify → `tag.Save()` → close
|
||||
OGG Vorbis stores metadata as Vorbis Comments in the second header packet of the OGG
|
||||
bitstream. Modifying these comments requires:
|
||||
|
||||
**Dependency footprint:** Only dependency is `golang.org/x/text` (already in go.mod). Pure Go, no CGo.
|
||||
1. Parsing OGG pages to extract the three Vorbis header packets (identification, comment, setup)
|
||||
2. Decoding and modifying the Vorbis Comment packet
|
||||
3. Re-encoding the modified comment packet into new OGG pages (with correct segment tables, sequence numbers, and CRC32 checksums)
|
||||
4. Copying all audio data pages unchanged
|
||||
5. Writing the result atomically
|
||||
|
||||
### FLAC Tag Writing — `go-flac/go-flac` + `go-flac/flacvorbis` + `go-flac/flacpicture`
|
||||
No pure-Go library exists that provides this end-to-end. The recommendation is a **custom OGG page rewriter** using existing building blocks.
|
||||
|
||||
| Technology | Version | Purpose | Why |
|
||||
|------------|---------|---------|-----|
|
||||
| `github.com/go-flac/go-flac/v2` | v2.x | FLAC metadata block manipulation (parse + save) | Purpose-built for FLAC metadata manipulation. Parses metadata blocks separately from audio frames. `f.Save()` writes back metadata blocks + raw audio frames without re-encoding. 44 stars, clean API. |
|
||||
| `github.com/go-flac/flacvorbis/v2` | v2.x | Vorbis Comment read/write for FLAC metadata blocks | Companion to go-flac. Provides `ParseFromMetaDataBlock()`, `Add()`, `Marshal()` for Vorbis Comment manipulation. Has field constants (`FIELD_TITLE`, `FIELD_ARTIST`, etc.). |
|
||||
| `github.com/go-flac/flacpicture` | latest | PICTURE metadata block manipulation for FLAC | Companion to go-flac. `NewFromImageData()` creates PICTURE blocks, `Marshal()` serializes for embedding. |
|
||||
### Option Analysis
|
||||
|
||||
**Why go-flac over mewkiz/flac for WRITING:**
|
||||
- `mewkiz/flac` is primarily a FLAC **codec** (encoder/decoder). Its `Encode()` API re-encodes audio data, which is unacceptably slow and potentially lossy for metadata-only edits.
|
||||
- `go-flac/go-flac` is specifically designed for **metadata manipulation**. It stores audio frames as raw bytes and copies them verbatim on save — no re-encoding.
|
||||
- `mewkiz/flac` stays as an indirect dependency (via beep) for FLAC **decoding** during playback and duration extraction. No conflict.
|
||||
#### Option 1: Custom OGG Page Rewriter (RECOMMENDED)
|
||||
|
||||
**Key capabilities verified (HIGH confidence — GitHub README + examples):**
|
||||
- `flac.ParseFile(fileName)` — returns `File` with `Meta` (metadata blocks) and `Frames` (raw audio data)
|
||||
- `flacvorbis.ParseFromMetaDataBlock(*meta)` — parse existing Vorbis Comment block
|
||||
- `cmts.Add(flacvorbis.FIELD_TITLE, "New Title")` — add/modify comment fields
|
||||
- `cmts.Marshal()` — serialize back to MetaDataBlock
|
||||
- `f.Meta[idx] = &cmtsmeta` — replace metadata block in-place
|
||||
- `f.Save(fileName)` — write modified file (metadata blocks + raw audio frames, no re-encoding)
|
||||
- `flacpicture.NewFromImageData(PictureTypeFrontCover, "Front cover", imgData, "image/jpeg")` — create picture block
|
||||
- `picture.Marshal()` → append to `f.Meta` — embed cover art
|
||||
| Component | Source | Purpose |
|
||||
|-----------|--------|---------|
|
||||
| OGG page read/write | Custom (~300-400 LOC) | Parse OGG pages, rewrite with new comment packet |
|
||||
| Vorbis Comment encode/decode | Reuse `go-flac/flacvorbis` patterns | Same binary format as FLAC Vorbis Comments |
|
||||
| CRC32 computation | Custom (~30 LOC, lookup table) | OGG uses CRC32 with polynomial 0x04c11db7 |
|
||||
| METADATA_BLOCK_PICTURE | Reuse `go-flac/flacpicture` | Same binary format, base64-wrapped for OGG |
|
||||
|
||||
**FLAC tag writing approach — metadata block replacement:**
|
||||
1. `flac.ParseFile(path)` — parses metadata blocks + stores audio frames as raw bytes
|
||||
2. Find existing VorbisComment block in `f.Meta` slice, or create new via `flacvorbis.New()`
|
||||
3. Modify/add comment fields via `cmts.Add()` (handles field replacement)
|
||||
4. Marshal back: `f.Meta[idx] = &cmts.Marshal()`
|
||||
5. For cover art: create via `flacpicture.NewFromImageData()`, append to `f.Meta`
|
||||
6. `f.Save(tmpPath)` — writes "fLaC" + metadata blocks + raw audio frames to temp file
|
||||
7. Atomic rename temp file over original
|
||||
**Why this is the right approach:**
|
||||
|
||||
**Critical detail:** `go-flac/go-flac`'s `Save()` copies audio frames as raw bytes — no re-encoding. A metadata-only edit of a 50MB FLAC file takes ~50ms, not minutes.
|
||||
- The OGG container format is simple and well-documented (27-byte header + segment table + page data)
|
||||
- `jfreymuth/oggvorbis` already has a working OGG page reader in `ogg.go` (~255 LOC) that can serve as reference
|
||||
- The Vorbis Comment binary format is identical to what's used in FLAC — the existing `flacvorbis` library's comment manipulation code can be reused or adapted
|
||||
- Audio data pages are copied byte-for-byte — no audio re-encoding
|
||||
- Full control over the implementation with no external dependency risk
|
||||
|
||||
### OGG Vorbis Tag Writing — Custom Implementation Required
|
||||
**Implementation complexity:** MEDIUM. The OGG page format has exactly one tricky aspect:
|
||||
packets can span multiple pages (via the continuation-of-packet flag and lacing values).
|
||||
The comment packet is typically small enough to fit in one page, but the implementation
|
||||
must handle the general case (especially when large cover art is embedded, inflating the
|
||||
comment packet well beyond one page's ~65KB limit).
|
||||
|
||||
| Technology | Version | Purpose | Why |
|
||||
|------------|---------|---------|-----|
|
||||
| Custom OGG page rewriter | n/a | OGG Vorbis Comment + Picture writing | No pure-Go OGG tag writing library exists. `jfreymuth/oggvorbis` is decode-only. OGG tag writing requires parsing OGG pages, modifying the Vorbis Comment header packet, and rewriting pages. |
|
||||
**Estimated LOC:** ~500-600 for a complete `ogg.go` writer in the tagwriter package.
|
||||
|
||||
**Why custom OGG writing is necessary:**
|
||||
- `jfreymuth/oggvorbis` (existing indirect dep) is a **decoder only** — no write API
|
||||
- No other pure-Go OGG Vorbis tag writer exists in the ecosystem
|
||||
- OGG Vorbis comments are stored in the second header packet (comment header), which is an OGG page
|
||||
- Modifying comments changes page sizes, requiring page-level rewriting
|
||||
#### Option 2: `mccoyst/ogg` for OGG Page Encoding
|
||||
|
||||
**OGG tag writing approach:**
|
||||
1. Parse OGG pages to find the three Vorbis header packets (identification, comment, setup)
|
||||
2. Decode existing Vorbis Comment from the comment header packet
|
||||
3. Modify comment fields (same key=value format as FLAC Vorbis Comments)
|
||||
4. Re-encode comment packet into new OGG pages
|
||||
5. Copy identification and setup headers unchanged
|
||||
6. Copy all audio data pages unchanged
|
||||
7. Write to temp file, atomic rename
|
||||
| Technology | Version | Stars | Purpose |
|
||||
|------------|---------|-------|---------|
|
||||
| `github.com/mccoyst/ogg` | latest (no semver tags) | 37 | OGG page encode/decode |
|
||||
|
||||
**Complexity assessment:** MEDIUM-HIGH. OGG page framing is well-documented but requires careful implementation. The Vorbis Comment format itself is simple (same as FLAC). The OGG page CRC and segment tables are the tricky parts.
|
||||
**What it provides:**
|
||||
- `ogg.Decoder` — reads OGG pages, extracts packets
|
||||
- `ogg.Encoder` — writes packets into OGG pages with correct framing, CRC, segment tables
|
||||
- `EncodeBOS()`, `Encode()`, `EncodeEOS()` — page-type-aware encoding
|
||||
- Handles packet splitting across pages automatically
|
||||
- 100% test coverage claimed, 52 commits, MIT license
|
||||
|
||||
**Cover art in OGG:** Stored as `METADATA_BLOCK_PICTURE` Vorbis Comment tag (base64-encoded FLAC Picture block). Same encoding as FLAC Picture but base64-wrapped in a comment field.
|
||||
**Assessment:** This is the strongest external option. The Encoder handles the hardest parts
|
||||
(lacing values, page splitting, CRC). However:
|
||||
|
||||
**Recommendation:** Implement OGG writing LAST. Start with MP3 and FLAC (libraries exist). OGG uses the same Vorbis Comment format as FLAC, so the comment serialization code is shared — only the OGG page framing is new work.
|
||||
- **No semver releases** — importing is `github.com/mccoyst/ogg` with no version guarantee
|
||||
- **37 stars, 1 open issue** — small community, low bus factor
|
||||
- **Still requires Vorbis-specific logic** — mccoyst/ogg handles OGG pages but knows nothing about Vorbis header packets. We'd still need to:
|
||||
- Identify and extract the three Vorbis header packets
|
||||
- Parse/modify the Vorbis Comment packet
|
||||
- Handle the `\x03vorbis` packet type prefix
|
||||
- Re-encode everything in the correct order
|
||||
|
||||
### Atomic File Writing — No New Dependency
|
||||
**Verdict:** POSSIBLE but the marginal benefit over custom code is small. The OGG page format
|
||||
is well-specified; the Encoder's value is mainly in lacing value calculation and CRC, which
|
||||
are ~80 LOC total. Adding a dependency for ~80 LOC of saved work introduces a maintenance
|
||||
and stability risk for a library with no tagged releases.
|
||||
|
||||
| Technology | Version | Purpose | Why |
|
||||
|------------|---------|---------|-----|
|
||||
| `os.CreateTemp` + `os.Rename` (stdlib) | Go 1.25 | Write-to-temp-then-rename pattern | The stdlib approach is simpler and sufficient. `natefinch/atomic` is already an indirect dep but provides `WriteFile(filename, io.Reader)` which doesn't match our use case (we need to write to temp first, THEN rename). The stdlib pattern gives more control over temp file location (same directory as target for same-filesystem rename). |
|
||||
**Recommendation: Custom implementation.** If OGG page writing proves harder than expected
|
||||
during implementation, `mccoyst/ogg` can be pulled in as a fallback.
|
||||
|
||||
**Pattern:**
|
||||
```go
|
||||
// Create temp file in same directory as target (ensures same filesystem for atomic rename)
|
||||
dir := filepath.Dir(targetPath)
|
||||
tmp, err := os.CreateTemp(dir, ".yj-tag-*.tmp")
|
||||
// ... write tag data to tmp ...
|
||||
tmp.Close()
|
||||
// Atomic rename (POSIX guarantees atomicity for same-filesystem rename)
|
||||
os.Rename(tmp.Name(), targetPath)
|
||||
#### Option 3: `jfreymuth/oggvorbis` (Already a Dependency)
|
||||
|
||||
| Technology | Version | Stars | Purpose |
|
||||
|------------|---------|-------|---------|
|
||||
| `github.com/jfreymuth/oggvorbis` | v1.0.5 | 75 | OGG Vorbis decoder |
|
||||
|
||||
**Assessment:** Decode-only. No write API. The `ogg.go` internal reader is useful as
|
||||
**reference code** for understanding OGG page parsing, but the types are unexported
|
||||
and the package provides no page-level write capability.
|
||||
|
||||
**Verdict:** NOT SUITABLE for writing. Useful as reference only.
|
||||
|
||||
#### Option 4: `dhowden/tag` Write Support
|
||||
|
||||
**Assessment:** dhowden/tag is read-only (642 stars, no write API). Its OGG parsing is
|
||||
minimal — it extracts Vorbis Comments for reading but does not provide any mechanism
|
||||
to modify or write back. Forking would require building the entire OGG page rewriter
|
||||
anyway, plus inheriting maintenance burden.
|
||||
|
||||
**Verdict:** NOT SUITABLE.
|
||||
|
||||
#### Option 5: External CLI Tools (vorbiscomment, ffmpeg)
|
||||
|
||||
| Tool | What It Does | Issue |
|
||||
|------|-------------|-------|
|
||||
| `vorbiscomment` | CLI for reading/writing Vorbis Comments in OGG | Requires packaging/distributing a C binary |
|
||||
| `ffmpeg` | Swiss-army multimedia tool | Massive binary (~100MB), CGo-equivalent burden |
|
||||
| `opustags` | Opus metadata editor | Wrong codec (Opus, not Vorbis) |
|
||||
|
||||
**Assessment:** Shelling out to external CLI tools would work but violates the pure-Go
|
||||
spirit of the project. It introduces:
|
||||
- Distribution complexity (packaging native binaries per platform)
|
||||
- Runtime dependency management (checking tool availability, version compat)
|
||||
- Error handling complexity (parsing CLI output)
|
||||
- No Windows/macOS availability guarantee without extra bundling
|
||||
|
||||
**Verdict:** NOT RECOMMENDED. Only consider as absolute last resort if custom Go
|
||||
implementation proves infeasible (it won't — the format is well-understood).
|
||||
|
||||
### OGG Vorbis Writing: Recommended Stack
|
||||
|
||||
| Component | Approach | New Dependency? |
|
||||
|-----------|----------|-----------------|
|
||||
| OGG page parsing | Custom `oggRewriter` in tagwriter package | No |
|
||||
| OGG page writing | Custom (header + segment table + CRC32) | No |
|
||||
| Vorbis Comment manipulation | Reuse `flacvorbis` patterns + shared helpers | No (already a dep) |
|
||||
| Cover art (METADATA_BLOCK_PICTURE) | `go-flac/flacpicture` for binary encoding + base64 wrapper | No (already a dep) |
|
||||
| Atomic file write | Existing `fileutil.AtomicWrite` | No |
|
||||
|
||||
**Total new dependencies: ZERO.** Everything needed is either already in the dep tree
|
||||
or implementable from well-documented specifications.
|
||||
|
||||
### OGG Vorbis Cover Art
|
||||
|
||||
**YES — OGG Vorbis can embed cover art.**
|
||||
|
||||
Cover art in OGG Vorbis uses the `METADATA_BLOCK_PICTURE` Vorbis Comment field:
|
||||
|
||||
1. Create a FLAC Picture binary block (same as used in FLAC files):
|
||||
- Picture type (3 = front cover)
|
||||
- MIME type string ("image/jpeg" or "image/png")
|
||||
- Description string ("Front cover")
|
||||
- Width, height, color depth, palette size (can be set to 0)
|
||||
- Raw image data
|
||||
2. Base64-encode the entire binary block
|
||||
3. Add as Vorbis Comment: `METADATA_BLOCK_PICTURE=<base64 data>`
|
||||
|
||||
**Integration with existing code:** The `go-flac/flacpicture` library already creates
|
||||
the binary FLAC Picture block. For FLAC files, this block goes into a metadata block.
|
||||
For OGG files, the same binary block gets base64-encoded and inserted as a Vorbis
|
||||
Comment string. We can reuse `flacpicture.NewFromImageData()` and add a base64 wrapper.
|
||||
|
||||
**Confidence:** HIGH — This is the official Xiph.org recommendation per
|
||||
https://wiki.xiph.org/VorbisComment#METADATA_BLOCK_PICTURE
|
||||
|
||||
### OGG Vorbis Format Details (Implementation Reference)
|
||||
|
||||
**OGG Page Structure (27 bytes header):**
|
||||
```
|
||||
Bytes 0-3: "OggS" capture pattern
|
||||
Byte 4: Stream structure version (always 0)
|
||||
Byte 5: Header type flag (0x01=continued, 0x02=BOS, 0x04=EOS)
|
||||
Bytes 6-13: Absolute granule position (int64, little-endian)
|
||||
Bytes 14-17: Stream serial number (uint32, little-endian)
|
||||
Bytes 18-21: Page sequence number (uint32, little-endian)
|
||||
Bytes 22-25: CRC32 checksum (computed with this field zeroed)
|
||||
Byte 26: Number of segments (0-255)
|
||||
Bytes 27+: Segment table (one byte per segment, lacing values)
|
||||
Page data follows immediately
|
||||
```
|
||||
|
||||
**Why NOT `natefinch/atomic`:** It's designed for `io.Reader` → file workflows. Our workflow is: read original → write modified to temp → rename. The stdlib `os.CreateTemp` + `os.Rename` is the right primitive. `natefinch/atomic` also uses `os.Rename` internally on Unix anyway.
|
||||
**Vorbis Header Packets in OGG:**
|
||||
- Packet 1: Identification header (starts with `\x01vorbis`)
|
||||
- Packet 2: Comment header (starts with `\x03vorbis`) ← THIS IS WHAT WE MODIFY
|
||||
- Packet 3: Setup header (starts with `\x05vorbis`)
|
||||
- Packets 4+: Audio data
|
||||
|
||||
**Why same-directory temp file matters:** `os.Rename` is only atomic when source and destination are on the same filesystem. Music files could be on any mount point. Creating the temp file in the same directory guarantees this.
|
||||
**Vorbis Comment Binary Format (within packet 2, after `\x03vorbis` prefix):**
|
||||
```
|
||||
[vendor_length: uint32 LE] [vendor_string: bytes]
|
||||
[comment_count: uint32 LE]
|
||||
for each comment:
|
||||
[length: uint32 LE] [comment: bytes] // e.g. "ARTIST=Bob Dylan"
|
||||
[framing_bit: 1 bit, must be 1]
|
||||
```
|
||||
|
||||
## Alternatives Considered
|
||||
**Algorithm for tag writing:**
|
||||
1. Read all OGG pages from source file
|
||||
2. Extract packets 1, 2, 3 from the first few pages (header pages)
|
||||
3. Parse Vorbis Comment from packet 2 (skip `\x03vorbis` prefix)
|
||||
4. Modify comments (add/replace/remove fields)
|
||||
5. Re-serialize Vorbis Comment packet (with `\x03vorbis` prefix)
|
||||
6. Write to temp file via AtomicWrite:
|
||||
a. Write packet 1 (identification) as BOS page
|
||||
b. Write modified packet 2 (comment) + packet 3 (setup) as continuation pages
|
||||
c. Copy all remaining audio pages, re-sequencing page numbers
|
||||
7. Atomic rename over original
|
||||
|
||||
| Category | Recommended | Alternative | Why Not |
|
||||
|----------|-------------|-------------|---------|
|
||||
| MP3 write | bogem/id3v2/v2 | dhowden/tag | dhowden/tag is read-only. No write API. Would require forking. |
|
||||
| MP3 write | bogem/id3v2/v2 | go-id3 (mikkyang) | Dead project, archived, no v2 module support, last commit 2015 |
|
||||
| FLAC write | go-flac/go-flac + flacvorbis | mewkiz/flac | mewkiz/flac is a codec (encoder/decoder); its Encode() re-encodes audio. go-flac is purpose-built for metadata manipulation — copies audio frames as raw bytes. |
|
||||
| FLAC write | go-flac/go-flac + flacvorbis | Custom FLAC writer | go-flac handles the format correctly with proven Save(); reinventing would be fragile |
|
||||
| OGG write | Custom | CGo (libvorbis) | Violates no-CGo constraint |
|
||||
| OGG write | Custom | dhowden/tag fork | dhowden/tag OGG parsing is minimal, not designed for writing |
|
||||
| Atomic write | stdlib os.CreateTemp+Rename | natefinch/atomic | Doesn't match our write pattern; stdlib is sufficient |
|
||||
| Atomic write | stdlib os.CreateTemp+Rename | renameio | Unnecessary dep for a 5-line pattern |
|
||||
---
|
||||
|
||||
## WAV Tag Writing
|
||||
|
||||
### The Problem
|
||||
|
||||
WAV files use the RIFF container format. Metadata in WAV files can be stored in:
|
||||
|
||||
1. **RIFF INFO chunks** (`LIST`/`INFO` sub-chunks like `IART`, `INAM`, `IPRD`) — the oldest and most widely supported mechanism
|
||||
2. **ID3v2 chunks** (`id3 ` RIFF chunk containing a full ID3v2 tag) — newer, more expressive, used by some modern tools
|
||||
3. **BEXT chunks** (Broadcast Wave Extension) — professional/broadcast use only
|
||||
|
||||
The challenge is that no dominant pure-Go library exists for WAV metadata writing, and the ecosystem recently lost its most popular option.
|
||||
|
||||
### Option Analysis
|
||||
|
||||
#### Option 1: Custom RIFF Chunk Writer for LIST/INFO (RECOMMENDED)
|
||||
|
||||
| Component | Source | Purpose |
|
||||
|-----------|--------|---------|
|
||||
| RIFF chunk reader | Custom (~200 LOC) | Parse WAV RIFF structure, find/modify LIST INFO chunk |
|
||||
| INFO field writing | Custom (~150 LOC) | Write INFO sub-chunks with metadata |
|
||||
| Atomic file write | Existing `fileutil.AtomicWrite` | Crash-safe file operations |
|
||||
|
||||
**RIFF INFO Chunk Field Mapping:**
|
||||
|
||||
| YellowJacket Field | INFO Chunk ID | Description |
|
||||
|--------------------|---------------|-------------|
|
||||
| Title | `INAM` | Name/title of the work |
|
||||
| Artist | `IART` | Artist name |
|
||||
| Album | `IPRD` | Product/album name |
|
||||
| Genre | `IGNR` | Genre |
|
||||
| Year | `ICRD` | Creation date |
|
||||
| Track Number | `ITRK` | Track number |
|
||||
| Composer | `IMUS` | Composer/music by |
|
||||
| Comment | `ICMT` | Comment |
|
||||
|
||||
**Why RIFF INFO over ID3v2-in-WAV:**
|
||||
- RIFF INFO is the native WAV metadata format — it's part of the RIFF specification
|
||||
- Universal player support (Windows Media Player, VLC, foobar2000, etc.)
|
||||
- Simple format: 4-byte chunk ID + 4-byte size + null-terminated string
|
||||
- dhowden/tag already reads RIFF INFO chunks, so round-trip works
|
||||
- No additional dependencies needed
|
||||
|
||||
**Why NOT ID3v2-in-WAV:**
|
||||
- The `id3 ` chunk is a de facto standard, not an official RIFF spec feature
|
||||
- Not all players/tools support it
|
||||
- dhowden/tag reads ID3v2 in WAV (it detects it), but our existing `bogem/id3v2` expects MP3 file structure (it calls `tag.Open()` which reads from position 0, expecting an ID3v2 header). Using bogem/id3v2 for WAV would require significant adaptation to handle the RIFF wrapper.
|
||||
- More complex: we'd need to create an ID3v2 tag, serialize it, then embed it as a RIFF chunk
|
||||
|
||||
**Implementation approach:**
|
||||
1. Read entire WAV file (parse RIFF chunks: `RIFF`, `fmt `, `data`, `LIST`, etc.)
|
||||
2. Find or create `LIST`/`INFO` chunk
|
||||
3. Write/replace INFO sub-chunks with new metadata values
|
||||
4. Reassemble file: RIFF header → fmt chunk → data chunk → LIST/INFO chunk → other chunks
|
||||
5. Update RIFF header size
|
||||
6. Write via AtomicWrite
|
||||
|
||||
**Estimated LOC:** ~300-400 for RIFF INFO reading/writing.
|
||||
|
||||
#### Option 2: `go-audio/wav` + `go-audio/riff`
|
||||
|
||||
| Technology | Status | Stars |
|
||||
|------------|--------|-------|
|
||||
| `github.com/go-audio/wav` | **ARCHIVED Feb 21, 2026** | 383 |
|
||||
| `github.com/go-audio/riff` | **ARCHIVED Feb 21, 2026** | 11 |
|
||||
|
||||
**Assessment:** Both libraries were archived less than one month ago. They provided WAV
|
||||
encoding/decoding and RIFF chunk parsing, but:
|
||||
- `go-audio/wav` is focused on audio encoding/decoding, not metadata manipulation
|
||||
- `go-audio/riff` has only a parser (3 commits total), no writer
|
||||
- Neither supports writing RIFF INFO metadata
|
||||
- Both are now unmaintained/archived — using them would be a dead-end dependency
|
||||
|
||||
**Verdict:** NOT SUITABLE. Archived, no metadata write support.
|
||||
|
||||
#### Option 3: `bogem/id3v2` for ID3v2-in-WAV
|
||||
|
||||
**Assessment:** bogem/id3v2's `Tag.WriteTo(w io.Writer)` writes raw ID3v2 tag bytes to
|
||||
any writer. In theory, we could:
|
||||
1. Create an ID3v2 tag with bogem/id3v2
|
||||
2. Serialize it to bytes via `WriteTo()`
|
||||
3. Wrap it in a RIFF `id3 ` chunk
|
||||
4. Insert the chunk into the WAV file
|
||||
|
||||
This is technically feasible but:
|
||||
- Still requires custom RIFF chunk manipulation code
|
||||
- ID3v2-in-WAV has worse player compatibility than RIFF INFO
|
||||
- More complex than just writing RIFF INFO chunks directly
|
||||
- `tag.Open("file.wav")` and `tag.Save()` won't work — those expect MP3 file structure
|
||||
|
||||
**Verdict:** NOT RECOMMENDED as primary approach. Could be added later as an enhancement
|
||||
for richer metadata (cover art via APIC), but RIFF INFO should be the primary mechanism.
|
||||
|
||||
#### Option 4: External CLI Tools (ffmpeg, exiftool)
|
||||
|
||||
Same issues as OGG: distribution complexity, runtime dependencies, error handling overhead.
|
||||
|
||||
**Verdict:** NOT RECOMMENDED.
|
||||
|
||||
### WAV Writing: Recommended Stack
|
||||
|
||||
| Component | Approach | New Dependency? |
|
||||
|-----------|----------|-----------------|
|
||||
| RIFF container parsing | Custom RIFF reader in tagwriter package | No |
|
||||
| RIFF INFO chunk writing | Custom (4-byte IDs + null-terminated strings) | No |
|
||||
| Atomic file write | Existing `fileutil.AtomicWrite` | No |
|
||||
|
||||
**Total new dependencies: ZERO.**
|
||||
|
||||
### WAV Cover Art
|
||||
|
||||
**RIFF INFO: NO — cannot embed cover art.**
|
||||
|
||||
RIFF INFO chunks are limited to simple text key-value pairs. There is no standard
|
||||
INFO sub-chunk for binary image data.
|
||||
|
||||
**ID3v2-in-WAV: YES — via APIC frame.**
|
||||
|
||||
If an `id3 ` RIFF chunk is present (or added), it can contain a full ID3v2 tag with
|
||||
APIC (Attached Picture) frames, just like MP3 files.
|
||||
|
||||
**Recommendation for v1.2.1:** Do NOT implement WAV cover art. The RIFF INFO approach
|
||||
gives us text tag support with zero dependencies, and WAV cover art support is rare
|
||||
in the wild. Cover art for WAV can be deferred to a future enhancement using the
|
||||
ID3v2-in-WAV approach if there's user demand.
|
||||
|
||||
### WAV RIFF Format Details (Implementation Reference)
|
||||
|
||||
**RIFF File Structure:**
|
||||
```
|
||||
"RIFF" [file_size: uint32 LE] "WAVE"
|
||||
"fmt " [chunk_size: uint32 LE] [format data...]
|
||||
"data" [chunk_size: uint32 LE] [audio samples...]
|
||||
"LIST" [chunk_size: uint32 LE] "INFO"
|
||||
"INAM" [size: uint32 LE] "Track Title\0"
|
||||
"IART" [size: uint32 LE] "Artist Name\0"
|
||||
...
|
||||
```
|
||||
|
||||
**Key implementation details:**
|
||||
- All integers are little-endian (unlike OGG which is a mix)
|
||||
- Chunk sizes must be even (pad with 0x00 byte if odd)
|
||||
- Strings in INFO chunks are null-terminated
|
||||
- The RIFF header size field = total file size - 8
|
||||
- LIST/INFO chunk can appear anywhere after `fmt ` and `data`
|
||||
- Multiple LIST chunks may exist; only `LIST`/`INFO` contains metadata
|
||||
|
||||
**Algorithm for tag writing:**
|
||||
1. Parse RIFF chunks by reading 4-byte ID + 4-byte size pairs
|
||||
2. Collect all chunks, preserving order
|
||||
3. Find or create LIST/INFO chunk
|
||||
4. Replace/add INFO sub-chunks for changed fields
|
||||
5. Reassemble file via AtomicWrite:
|
||||
a. Write RIFF header with updated total size
|
||||
b. Write fmt chunk (unchanged)
|
||||
c. Write data chunk (unchanged — just copy bytes)
|
||||
d. Write LIST/INFO chunk with metadata
|
||||
e. Write any other chunks (unchanged)
|
||||
6. Atomic rename
|
||||
|
||||
---
|
||||
|
||||
## Integration with Existing Pipeline
|
||||
|
||||
### Format Detection Changes
|
||||
|
||||
Current `tagwriter.DetectFormat()` supports MP3 and FLAC. Add OGG and WAV:
|
||||
|
||||
```go
|
||||
const (
|
||||
FormatMP3 AudioFormat = "mp3"
|
||||
FormatFLAC AudioFormat = "flac"
|
||||
FormatOGG AudioFormat = "ogg" // NEW
|
||||
FormatWAV AudioFormat = "wav" // NEW
|
||||
)
|
||||
|
||||
func DetectFormat(filePath string) (AudioFormat, error) {
|
||||
switch strings.ToLower(filepath.Ext(filePath)) {
|
||||
case ".mp3": return FormatMP3, nil
|
||||
case ".flac": return FormatFLAC, nil
|
||||
case ".ogg": return FormatOGG, nil // NEW
|
||||
case ".wav": return FormatWAV, nil // NEW
|
||||
default: return "", errUnsupportedFormat
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pipeline Dispatch Changes
|
||||
|
||||
Current `WriteTrackTags()` switch in `pipeline.go`:
|
||||
|
||||
```go
|
||||
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) // NEW
|
||||
case FormatWAV: err = writeWavTags(tw.logger, audioFile.FilePath, changes) // NEW
|
||||
}
|
||||
```
|
||||
|
||||
### Shared Code Reuse
|
||||
|
||||
**Vorbis Comment helpers** (already exist in `flac.go`):
|
||||
- `replaceVorbisComment()` — remove existing field, add new value
|
||||
- `applyFlacTextChanges()` — map TagChanges to Vorbis Comment fields
|
||||
- Field mapping constants (`FIELD_TITLE`, `FIELD_ARTIST`, etc.)
|
||||
|
||||
These should be **extracted to a shared file** (e.g., `vorbis_comments.go`) and reused
|
||||
by both `flac.go` and the new `ogg.go`. The logic is identical — both formats use Vorbis
|
||||
Comments with the same field names.
|
||||
|
||||
**Cover art helpers** (already exist in `tagwriter.go`):
|
||||
- `detectMIME()` — determine image MIME type from magic bytes
|
||||
- `asBytes()` — extract byte slice from TagChanges value
|
||||
|
||||
**AtomicWrite** (already exists in `fileutil/atomicwrite.go`):
|
||||
- Used identically for all formats: write to temp → sync → rename
|
||||
|
||||
---
|
||||
|
||||
## Dependency Summary
|
||||
|
||||
### New Dependencies Required
|
||||
|
||||
**NONE.** Both OGG Vorbis and WAV tag writing are implemented as custom code in the
|
||||
tagwriter package, using only:
|
||||
- Go standard library (`encoding/binary`, `encoding/base64`, `bytes`, `io`, `os`)
|
||||
- Existing dependencies (`go-flac/flacpicture` for METADATA_BLOCK_PICTURE binary format)
|
||||
- Existing utilities (`fileutil.AtomicWrite`)
|
||||
|
||||
### Existing Dependencies Unchanged
|
||||
|
||||
| Library | Current Use | Use in v1.2.1 |
|
||||
|---------|------------|---------------|
|
||||
| `dhowden/tag` v0.0.0-20240417 | Tag reading (all formats) | Unchanged — validates OGG/WAV round-trip |
|
||||
| `go-flac/flacvorbis/v2` v2.0.2 | FLAC Vorbis Comment manipulation | Shared patterns for OGG comment encoding |
|
||||
| `go-flac/flacpicture/v2` v2.0.2 | FLAC PICTURE block creation | Reused for OGG METADATA_BLOCK_PICTURE |
|
||||
| `go-flac/go-flac/v2` v2.0.4 | FLAC metadata block manipulation | Unchanged |
|
||||
| `bogem/id3v2/v2` v2.1.4 | MP3 ID3v2 tag writing | Unchanged |
|
||||
| `jfreymuth/oggvorbis` v1.0.5 | OGG Vorbis decoding (via beep) | Reference for OGG page structure |
|
||||
|
||||
---
|
||||
|
||||
## What NOT To Add
|
||||
|
||||
| Library | Why Avoid |
|
||||
|---------|-----------|
|
||||
| Any CGo-based tag library (taglib-go, etc.) | Violates pure-Go constraint from PROJECT.md |
|
||||
| go-id3 (mikkyang/id3-go) | Archived, unmaintained since 2015, no module support |
|
||||
| Any "universal tag writer" that wraps TagLib via CGo | Violates pure-Go constraint |
|
||||
| natefinch/atomic as direct dep | Already indirect; stdlib pattern is more appropriate for this use case |
|
||||
| goflac (CGo wrapper around libFLAC) | Violates pure-Go constraint |
|
||||
| Library / Approach | Why Avoid |
|
||||
|--------------------|-----------|
|
||||
| `mccoyst/ogg` | No semver releases, 37 stars. The OGG page format is simple enough to implement directly (~80 LOC for the encoding part). Avoids dependency risk for minimal gain. |
|
||||
| `go-audio/wav` or `go-audio/riff` | **Archived Feb 21, 2026.** Do not depend on abandoned libraries. |
|
||||
| Any CGo-based library (taglib-go, etc.) | Violates pure-Go constraint |
|
||||
| `bogem/id3v2` for WAV files | Its `Open()`/`Save()` API expects MP3 file structure. Would need significant wrapping for RIFF container. RIFF INFO is simpler and more compatible. |
|
||||
| ID3v2-in-WAV for v1.2.1 | Adds complexity for marginal benefit. RIFF INFO covers the primary use case. Defer ID3v2-in-WAV to a future milestone if cover art in WAV is needed. |
|
||||
| External CLI tools (vorbiscomment, ffmpeg) | Distribution complexity, runtime deps, violates pure-Go spirit |
|
||||
|
||||
## Existing Dependencies Leveraged (No Version Changes)
|
||||
---
|
||||
|
||||
| Library | Current Use | New Use in Tag Editing |
|
||||
|---------|------------|----------------------|
|
||||
| `dhowden/tag` v0.0.0-20240417 | Tag reading during library scan | Unchanged — still used for all READ operations |
|
||||
| `mewkiz/flac` v1.0.12 (indirect via beep) | FLAC audio decoding during playback + duration extraction | Unchanged — remains indirect for decoding only |
|
||||
| `golang.org/x/image` v0.12.0 | Cover art thumbnail generation | Image validation before embedding (ensure valid JPEG/PNG) |
|
||||
| `natefinch/atomic` v1.0.1 (indirect) | Not directly used | Remains indirect; not needed for our pattern |
|
||||
## Format Coverage Matrix (v1.2.1 Target)
|
||||
|
||||
## Installation
|
||||
| Format | Text Tags | Cover Art | Approach | Complexity | Confidence |
|
||||
|--------|-----------|-----------|----------|------------|------------|
|
||||
| MP3 (ID3v2) | ✓ All fields | ✓ APIC frame | bogem/id3v2 (existing) | Done | HIGH |
|
||||
| FLAC | ✓ All fields | ✓ PICTURE block | go-flac ecosystem (existing) | Done | HIGH |
|
||||
| OGG Vorbis | ✓ All fields | ✓ METADATA_BLOCK_PICTURE | Custom OGG page rewriter | Medium | MEDIUM-HIGH |
|
||||
| WAV | ✓ Core fields | ✗ Not in v1.2.1 | Custom RIFF INFO writer | Low-Medium | MEDIUM-HIGH |
|
||||
|
||||
```bash
|
||||
# New direct dependencies
|
||||
go get github.com/bogem/id3v2/v2@v2.1.4
|
||||
go get github.com/go-flac/go-flac/v2
|
||||
go get github.com/go-flac/flacvorbis/v2
|
||||
go get github.com/go-flac/flacpicture
|
||||
```
|
||||
**"Core fields" for WAV:** Title, Artist, Album, Genre, Year, Track Number, Composer.
|
||||
Album Artist and Disc Number have no standard RIFF INFO chunk IDs. They can be omitted
|
||||
or stored in non-standard INFO chunks if needed.
|
||||
|
||||
## Format Coverage Matrix
|
||||
---
|
||||
|
||||
| Format | Text Tags | Cover Art Embed | Library | Confidence |
|
||||
|--------|-----------|-----------------|---------|------------|
|
||||
| MP3 (ID3v2) | ✓ Full | ✓ APIC frame | bogem/id3v2/v2 | HIGH |
|
||||
| FLAC | ✓ Full | ✓ Picture block | go-flac/go-flac + flacvorbis + flacpicture | HIGH |
|
||||
| OGG Vorbis | ✓ Full | ✓ METADATA_BLOCK_PICTURE | Custom (built on Vorbis Comment format) | MEDIUM |
|
||||
| WAV | ✗ Not supported | ✗ Not supported | n/a — WAV has no standard tag format | n/a |
|
||||
## Risk Assessment
|
||||
|
||||
**WAV exclusion rationale:** WAV files have no widely-adopted metadata standard. Some players use INFO chunks, some use ID3v2 headers prepended to WAV. The project already supports WAV playback but doesn't extract meaningful tags from WAV during scanning. Tag editing for WAV is out of scope.
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|-----------|--------|------------|
|
||||
| OGG page splitting with large cover art | Medium | Medium | Test with cover art >64KB (forces multi-page comment packet) |
|
||||
| WAV files with unusual RIFF chunk ordering | Low | Low | Parse all chunks generically, preserve unknown chunks |
|
||||
| dhowden/tag can't read what we write (OGG) | Low | High | Round-trip tests: write with custom writer, read with dhowden/tag |
|
||||
| dhowden/tag can't read what we write (WAV) | Low | High | Round-trip tests for RIFF INFO chunks |
|
||||
| OGG CRC32 computation mismatch | Low | High | Use the exact polynomial from OGG spec (0x04c11db7), test against known files |
|
||||
| WAV INFO chunk padding errors | Medium | Low | RIFF spec requires even-aligned chunks; easy to forget padding byte |
|
||||
|
||||
## Vorbis Comment Field Mapping
|
||||
|
||||
Both FLAC and OGG use Vorbis Comments. Field names are standardized:
|
||||
|
||||
| YellowJacket Field | Vorbis Comment Key | ID3v2 Frame ID |
|
||||
|--------------------|-------------------|----------------|
|
||||
| Title | TITLE | TIT2 |
|
||||
| Artist | ARTIST | TPE1 |
|
||||
| Album | ALBUM | TALB |
|
||||
| Album Artist | ALBUMARTIST | TPE2 |
|
||||
| Genre | GENRE | TCON |
|
||||
| Year | DATE | TDRC (v2.4) / TYER (v2.3) |
|
||||
| Track Number | TRACKNUMBER | TRCK |
|
||||
| Total Tracks | TRACKTOTAL | TRCK (as "N/Total") |
|
||||
| Disc Number | DISCNUMBER | TPOS |
|
||||
| Total Discs | DISCTOTAL | TPOS (as "N/Total") |
|
||||
| Composer | COMPOSER | TCOM |
|
||||
| Comment | COMMENT | COMM |
|
||||
| Lyrics | LYRICS | USLT |
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- bogem/id3v2: https://pkg.go.dev/github.com/bogem/id3v2/v2 (HIGH confidence — official docs)
|
||||
- bogem/id3v2 GitHub: https://github.com/n10v/id3v2 (HIGH confidence — 359 stars, v2.1.4 release Feb 2023)
|
||||
- go-flac/go-flac GitHub: https://github.com/go-flac/go-flac (HIGH confidence — 44 stars, metadata manipulation library with Save())
|
||||
- go-flac/flacvorbis GitHub: https://github.com/go-flac/flacvorbis (HIGH confidence — Vorbis Comment add/parse/marshal, v2 module)
|
||||
- go-flac/flacpicture GitHub: https://github.com/go-flac/flacpicture (HIGH confidence — PICTURE block creation from image data)
|
||||
- mewkiz/flac GitHub: https://github.com/mewkiz/flac (HIGH confidence — confirmed codec, not suitable for metadata-only writes)
|
||||
- dhowden/tag GitHub: https://github.com/dhowden/tag (HIGH confidence — confirmed read-only, no write API)
|
||||
- jfreymuth/oggvorbis GitHub: https://github.com/jfreymuth/oggvorbis (HIGH confidence — confirmed decode-only)
|
||||
- natefinch/atomic GitHub: https://github.com/natefinch/atomic (HIGH confidence — confirmed API mismatch for our use case)
|
||||
- Vorbis Comment spec: https://www.xiph.org/vorbis/doc/v-comment.html
|
||||
- FLAC format spec: https://www.xiph.org/flac/format.html
|
||||
- OGG framing spec: https://www.xiph.org/ogg/doc/framing.html
|
||||
### Official Specifications (HIGH confidence)
|
||||
- OGG framing: https://xiph.org/ogg/doc/framing.html
|
||||
- OGG RFC: https://xiph.org/ogg/doc/rfc3533.txt
|
||||
- Vorbis I Spec (Section 5 — comment field): https://xiph.org/vorbis/doc/Vorbis_I_spec.html
|
||||
- Vorbis Comment spec: https://xiph.org/vorbis/doc/v-comment.html
|
||||
- METADATA_BLOCK_PICTURE: https://wiki.xiph.org/VorbisComment#METADATA_BLOCK_PICTURE
|
||||
- FLAC Picture block format: https://xiph.org/flac/format.html#metadata_block_picture
|
||||
|
||||
### Libraries Evaluated (HIGH confidence — GitHub repos)
|
||||
- jfreymuth/oggvorbis: https://github.com/jfreymuth/oggvorbis (75 stars, decode-only)
|
||||
- mccoyst/ogg: https://github.com/mccoyst/ogg (37 stars, encode+decode, no semver)
|
||||
- dhowden/tag: https://github.com/dhowden/tag (642 stars, read-only)
|
||||
- go-audio/wav: https://github.com/go-audio/wav (383 stars, **ARCHIVED 2026-02-21**)
|
||||
- go-audio/riff: https://github.com/go-audio/riff (11 stars, **ARCHIVED 2026-02-21**)
|
||||
- bogem/id3v2: https://github.com/n10v/id3v2 (359 stars, MP3-focused API)
|
||||
|
||||
### Existing Codebase (HIGH confidence — already validated in v1.2)
|
||||
- `backend/tagwriter/flac.go` — Vorbis Comment manipulation patterns
|
||||
- `backend/tagwriter/mp3.go` — AtomicWrite integration pattern
|
||||
- `backend/tagwriter/tagwriter.go` — TagChanges, format detection, helper functions
|
||||
- `backend/fileutil/atomicwrite.go` — Crash-safe file write utility
|
||||
|
||||
+119
-121
@@ -1,190 +1,188 @@
|
||||
# Project Research Summary
|
||||
|
||||
**Project:** YellowJacket v1.2 Tag Editing
|
||||
**Domain:** Audio metadata editing in a desktop music player (Go/Wails)
|
||||
**Researched:** 2026-03-16
|
||||
**Project:** YellowJacket v1.2.1 Format Parity
|
||||
**Domain:** Audio metadata tag writing — OGG Vorbis and WAV format support
|
||||
**Researched:** 2026-03-18
|
||||
**Confidence:** HIGH
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Tag editing for YellowJacket is a cross-cutting feature that touches file I/O (three audio formats), a normalized relational database, an FTS5 search index, a cover art cache pipeline, and the frontend state — all from a single user action. Mature desktop music players (foobar2000, MusicBee, Mp3tag, Kid3) converge on a consistent pattern: modal dialog editing with atomic file writes, inline DB updates (no rescan), and batch editing with three-state field semantics (keep/set/clear). The existing codebase has substantial scaffolding already in place — the `track-details` component has an edit mode UI stub with a no-op save handler, multi-select works in the track list, and the cover art pipeline is fully operational.
|
||||
YellowJacket v1.2.1 adds OGG Vorbis and WAV tag writing to achieve full format parity across all four supported audio formats. The existing `tagwriter` pipeline was designed for format extension — a switch-case in `pipeline.go` dispatches to format-specific writer functions, and everything downstream (DB sync, events, UI) is format-agnostic. Adding OGG and WAV requires two new writer functions, zero new dependencies, and no frontend changes.
|
||||
|
||||
The recommended approach uses **three external libraries** for tag writing — `bogem/id3v2/v2` for MP3, `go-flac/go-flac` + `go-flac/flacvorbis` + `go-flac/flacpicture` for FLAC — plus a **custom OGG page rewriter** (deferred to last, since no pure-Go OGG tag writing library exists). A new `backend/tageditor/` package orchestrates the full pipeline: validate → write tags to temp file → atomic rename → update DB entities in a single transaction → update FTS5 → emit event → frontend refreshes. This keeps the existing `library`, `metadata`, and `database` packages unchanged.
|
||||
The recommended approach is **custom implementations for both formats**: a custom OGG page rewriter (~350 LOC) for OGG Vorbis, and a custom RIFF chunk parser (~200 LOC) wrapping the existing `bogem/id3v2` library for WAV. No pure-Go library exists for OGG Vorbis tag writing, and the Go WAV ecosystem recently lost its most popular library (`go-audio/wav` archived Feb 2026). The OGG container format and RIFF chunk format are both well-documented and simple enough to implement directly. Vorbis Comments (used by OGG) are the exact same metadata format already used in FLAC — field names, encoding, and comment structure are identical, enabling significant code reuse.
|
||||
|
||||
The dominant risks are: (1) **FLAC files require full rewrite** for tag changes (no in-place edit), making atomic write-to-temp-then-rename mandatory; (2) the **FTS5 contentless index cannot delete rows**, requiring a schema migration to `contentless_delete=1` before any tag writing code ships; (3) the **normalized schema shares entities** (artists, albums, genres) across tracks, so editing one track must create new entity rows and repoint references rather than mutating shared rows in-place; and (4) a **race condition** between tag editing and library scanning requires mutual exclusion. All four risks have well-understood mitigations documented in the research.
|
||||
The primary risk is the OGG page infrastructure: CRC32 checksums use a non-standard bit ordering (MSB-first, not the Go standard library's reflected CRC32), page sequence numbers must be strictly sequential, and granule positions must be preserved exactly. These are well-understood constraints with clear specifications, but incorrect implementation produces silently corrupted files. Round-trip testing (write → read back via `dhowden/tag`) is the primary mitigation, following the pattern established by the existing MP3 and FLAC writers.
|
||||
|
||||
## Key Findings
|
||||
|
||||
### Recommended Stack
|
||||
|
||||
Pure-Go tag writing is well-supported for MP3 and FLAC via established libraries. OGG Vorbis tag writing requires custom implementation but shares the Vorbis Comment format with FLAC, so serialization code is reusable. No new dependencies beyond `golang.org/x/text` (already in go.mod) are pulled in transitively. The existing `dhowden/tag` library stays for all READ operations — no conflict with the new write libraries.
|
||||
Zero new dependencies. Both formats are implemented using Go standard library primitives (`encoding/binary`, `encoding/base64`, `bytes`, `io`) plus existing dependencies for shared functionality.
|
||||
|
||||
**Core technologies:**
|
||||
- **`bogem/id3v2/v2`** (v2.1.4): MP3 ID3v2 read+write — 359 stars, 57 importers, handles encoding (UTF-8/UTF-16) correctly, supports picture frames. HIGH confidence.
|
||||
- **`go-flac/go-flac/v2` + `go-flac/flacvorbis/v2` + `go-flac/flacpicture`**: FLAC metadata manipulation — copies audio frames as raw bytes (no re-encoding), ~50ms for metadata-only edits on large files. HIGH confidence.
|
||||
- **Custom OGG page rewriter**: No pure-Go OGG tag writer exists. OGG page framing (CRC, segment tables) is the only new work — Vorbis Comment serialization is shared with FLAC. MEDIUM confidence.
|
||||
- **stdlib `os.CreateTemp` + `os.Rename`**: Atomic file write pattern — temp file in same directory guarantees same-filesystem rename. No external dependency needed.
|
||||
|
||||
**Critical version requirements:**
|
||||
- SQLite ≥ 3.43.0 for `contentless_delete=1` FTS5 support (bundled `modernc.org/sqlite` provides 3.45+, so already satisfied)
|
||||
- **Custom OGG page rewriter:** Parse/write OGG pages with CRC32 and segmentation — no pure-Go OGG writing library exists; `mccoyst/ogg` (37 stars, no semver) saves ~80 LOC but adds dependency risk
|
||||
- **Custom RIFF chunk parser/writer:** Read/write WAV RIFF structure — `go-audio/wav` and `go-audio/riff` were archived Feb 2026; RIFF is simple enough for custom code
|
||||
- **`bogem/id3v2` (existing):** Generate ID3v2 tags for WAV `id3 ` chunks — same library already used for MP3 writing, all 8 fields + cover art work identically
|
||||
- **`go-flac/flacpicture` (existing):** Build METADATA_BLOCK_PICTURE binary blocks for OGG cover art — same binary format, just base64-wrapped for OGG
|
||||
- **`fileutil.AtomicWrite` (existing):** Crash-safe file writes for both formats — proven pattern from MP3/FLAC writers
|
||||
|
||||
### Expected Features
|
||||
|
||||
**Must have (table stakes):**
|
||||
- Single track tag editing (title, artist, album, genre, year, track#, disc#, composer)
|
||||
- Write tags to MP3 (ID3v2) and FLAC (Vorbis Comments)
|
||||
- Write-to-temp-then-rename corruption safety
|
||||
- Inline DB + FTS5 update after tag write (no rescan)
|
||||
- Batch editing with three-state field model (keep/set/clear)
|
||||
- Cover art set/replace from image file
|
||||
- Save confirmation and error feedback
|
||||
- Write all 8 text fields for OGG Vorbis (same Vorbis Comment field names as FLAC)
|
||||
- Write all 8 text fields for WAV (via ID3v2 in RIFF chunk)
|
||||
- Preserve existing non-edited metadata (ReplayGain, lyrics, etc.)
|
||||
- Preserve audio data byte-for-byte (no re-encoding)
|
||||
- Crash-safe writes via AtomicWrite
|
||||
- Batch and single-track editing work for both formats
|
||||
|
||||
**Should have (differentiators):**
|
||||
- Progress indicator for batch operations (20+ files)
|
||||
- Auto-number tracks in batch edit
|
||||
- Cover art remove (strip embedded art)
|
||||
- Dirty indicator / unsaved changes warning
|
||||
- Album artist, comment, lyrics field editing (low-effort additions)
|
||||
- Total tracks / total discs fields
|
||||
- OGG cover art via METADATA_BLOCK_PICTURE (base64-encoded FLAC picture block)
|
||||
- WAV cover art via ID3v2 APIC frame (identical to MP3)
|
||||
- Preserve existing RIFF INFO chunks when writing ID3v2 to WAV
|
||||
- Round-trip test coverage (7 tests per format, following FLAC precedent)
|
||||
|
||||
**Defer (v2+):**
|
||||
- MusicBrainz auto-tagging (separate milestone already in PROJECT.md)
|
||||
- Undo/backup system for tag edits
|
||||
- Cover art paste from clipboard
|
||||
- Inline editing in track list columns (fragile UX, complex)
|
||||
- Raw tag frame editing, custom fields, filename renaming
|
||||
- OGG Vorbis tag writing (implement last due to custom work required)
|
||||
- RIFF INFO writing (lossy — can't represent album_artist, disc_number, or cover art)
|
||||
- Dual-write ID3v2 + RIFF INFO in WAV
|
||||
- Migrating deprecated OGG `COVERART` field to `METADATA_BLOCK_PICTURE`
|
||||
- RF64 (>4GB WAV) support
|
||||
- OGG Opus tag writing (different header structure from Vorbis)
|
||||
|
||||
### Architecture Approach
|
||||
|
||||
Tag editing is implemented as a new `backend/tageditor/` package that orchestrates the full write pipeline, keeping existing packages focused on their current responsibilities. The service exposes `EditTrack()`, `EditTracks()`, and `SetCoverArt()` as Wails bindings. It uses pointer fields (`*string`, `*int`) to distinguish "no change" (nil) from "set to empty" — mapping directly to the three-state UI model for batch editing.
|
||||
Both writers integrate into the existing pipeline with minimal modification: add two format constants, extend the `DetectFormat()` switch, and add two cases to the `WriteTrackTags()` format dispatch. No new interfaces, no refactoring. The frontend is completely format-agnostic and requires zero changes.
|
||||
|
||||
**Major components:**
|
||||
1. **`backend/tageditor/tageditor.go`** — Service orchestrator: validates input, coordinates file write → DB update → FTS5 → events
|
||||
2. **`backend/tageditor/writer.go`** — Format-specific tag writing (MP3 via bogem/id3v2, FLAC via go-flac, OGG via custom)
|
||||
3. **`backend/events/events.go`** (modified) — New `TagsUpdated` and `TagEditFailed` event constants
|
||||
4. **`frontend/src/components/track-details/`** (modified) — Wire existing edit UI stub to backend, add batch edit variant
|
||||
5. **`frontend/src/store/library-store.ts`** (modified) — Listen for `TagsUpdated` event, full re-fetch on change
|
||||
1. **`ogg.go`** (~350 LOC) — OGG page parser/writer, Vorbis Comment serializer, CRC32, METADATA_BLOCK_PICTURE encoding, `writeOggTags()` orchestrator
|
||||
2. **`wav.go`** (~200 LOC) — RIFF chunk parser/writer, ID3v2 tag in `id3 ` chunk via `bogem/id3v2`, `writeWavTags()` orchestrator
|
||||
3. **`tagwriter.go` + `pipeline.go`** (~15 LOC changes) — Format constants, detection, dispatch
|
||||
4. **`ogg_test.go` + `wav_test.go`** (~600 LOC) — 7 round-trip tests each, following FLAC pattern
|
||||
|
||||
**Key patterns:**
|
||||
- Write-to-temp-then-rename (temp in same directory as target)
|
||||
- Upsert-and-relink for shared entities (never mutate shared artist/album/genre rows)
|
||||
- Pointer fields for optional partial updates
|
||||
- Lazy orphan cleanup (defer to next rescan)
|
||||
**Key code reuse:**
|
||||
- Vorbis Comment field mapping: identical to FLAC (extract shared helpers from `flac.go`)
|
||||
- ID3v2 tag building for WAV: identical to MP3 (`applyTextChanges()`, `applyCoverArtChanges()`)
|
||||
- AtomicWrite: used as-is by both writers
|
||||
- `dhowden/tag` for read-back verification in tests
|
||||
|
||||
### Critical Pitfalls
|
||||
|
||||
1. **FTS5 contentless index can't delete rows (P3)** — Migrate to `contentless_delete=1` before writing any tag edit code. Without this, search returns stale results after every edit. This is a prerequisite schema migration.
|
||||
2. **FLAC requires full file rewrite (P1)** — No in-place edit possible. Write-to-temp-then-rename is mandatory. Temp file must be in the same directory for atomic rename. Verify written file before replacing original.
|
||||
3. **Shared entity fan-out (P4)** — Editing one track's artist must NOT modify the shared `artist_credit` row (would silently change 200 other tracks). Always create new entity rows and repoint the edited track's foreign keys.
|
||||
4. **Currently-playing file lock (P2)** — On Windows, `os.Rename()` fails if the player holds an open file handle. Must check player state and stop playback before editing the current track.
|
||||
5. **Scan/edit race condition (P5)** — A library scan running during tag editing can overwrite changes. Pause scan during edits using the existing `PauseScan()`/`ResumeScan()` mechanism.
|
||||
1. **OGG CRC32 non-standard bit ordering (P1)** — OGG uses MSB-first CRC32 with polynomial 0x04c11db7. Go's `hash/crc32` uses reflected (LSB-first) ordering and produces wrong checksums. Must implement custom CRC or port from `jfreymuth/oggvorbis/crc.go`.
|
||||
|
||||
2. **OGG page sequence number continuity (P2)** — Rewriting comment header may change the number of header pages, requiring all subsequent page sequence numbers to be renumbered. Use full-stream rewrite approach (correct by construction).
|
||||
|
||||
3. **OGG granule position preservation (P3)** — Header pages must have granule position 0; audio pages must preserve original granule positions exactly. Corruption here breaks seeking and duration reporting.
|
||||
|
||||
4. **WAV RIFF chunk size updates (P6)** — Adding or resizing the `id3 ` chunk requires updating the outer RIFF header size field. Wrong size makes the file appear truncated to some players.
|
||||
|
||||
5. **WAV chunk word alignment (P10)** — RIFF chunks must start at even byte offsets. Odd-length chunks need a padding byte that's NOT included in the chunk's size field but IS part of the physical file.
|
||||
|
||||
### WAV Metadata Approach Decision
|
||||
|
||||
Research revealed a tension between two approaches:
|
||||
- **RIFF INFO:** Native WAV format, simple, but can't represent album_artist, disc_number, or cover art
|
||||
- **ID3v2-in-WAV:** Reuses existing `bogem/id3v2`, full field + cover art support, read by `dhowden/tag`
|
||||
|
||||
**Decision: ID3v2-in-WAV.** This gives full field parity with MP3, enables cover art, reuses existing code, and round-trips through `dhowden/tag` (our reader). RIFF INFO is preserved when present but not written to.
|
||||
|
||||
## Implications for Roadmap
|
||||
|
||||
Based on research, suggested phase structure:
|
||||
|
||||
### Phase 1: Schema Migration & Write Safety Foundation
|
||||
### Phase 1: WAV Tag Writer
|
||||
**Rationale:** Lower risk, faster to implement. Reuses existing `bogem/id3v2` library and `applyTextChanges()`/`applyCoverArtChanges()` from MP3 writer. RIFF container is simpler than OGG (no checksums, no page segmentation). Building this first proves the pipeline extension pattern works before tackling the harder OGG format.
|
||||
**Delivers:** WAV text tag writing (all 8 fields) + cover art + round-trip tests
|
||||
**Addresses:** WAV table stakes + WAV cover art (differentiator, but trivial since it reuses MP3 APIC code)
|
||||
**Avoids:** P5 (use ID3v2, not RIFF INFO), P6 (careful RIFF size bookkeeping), P10 (chunk alignment padding)
|
||||
**New code:** ~200 LOC `wav.go` + ~250 LOC `wav_test.go` + ~15 LOC pipeline changes
|
||||
**Estimated effort:** Small — RIFF parsing is straightforward binary parsing
|
||||
|
||||
**Rationale:** The FTS5 migration (P3) is a hard prerequisite — without `contentless_delete=1`, tag edits degrade search quality. The atomic file write mechanism (P1, P6) is the foundation all tag writing depends on. These are small, testable, independent pieces that de-risk everything downstream.
|
||||
**Delivers:** FTS5 schema migration; atomic write-to-temp-then-rename utility; temp file cleanup on startup
|
||||
**Addresses:** Write-to-temp-then-rename (table stakes), FTS5 inline update capability
|
||||
**Avoids:** P3 (stale search), P1 (file corruption), P6 (cross-filesystem rename failure)
|
||||
### Phase 2: OGG Vorbis Text Tag Writer
|
||||
**Rationale:** OGG requires the most new infrastructure (page parser, CRC32, segmentation). Text-only tag writing exercises all the hard parts (page rewrite, CRC, sequence numbers) without the added complexity of multi-page comment packets from large cover art. This is the riskiest phase and benefits from Phase 1 having proven the pipeline extension works.
|
||||
**Delivers:** OGG Vorbis text tag writing (all 8 fields) + round-trip tests
|
||||
**Addresses:** OGG text field table stakes, audio data preservation, existing comment preservation
|
||||
**Avoids:** P1 (CRC32), P2 (sequence numbers), P3 (granule positions), P4 (three-header structure), P7 (framing bit), P8 (packet prefix)
|
||||
**New code:** ~300 LOC `ogg.go` (page infra + text writer) + ~300 LOC `ogg_test.go`
|
||||
**Estimated effort:** Medium — OGG page infrastructure is the hardest new code in this milestone
|
||||
|
||||
### Phase 2: Tag Writing Library Integration
|
||||
### Phase 3: OGG Vorbis Cover Art
|
||||
**Rationale:** Separated from Phase 2 because it adds multi-page packet complexity (large base64-encoded images can exceed the ~64KB OGG page limit). Text fields exercise the page infrastructure with small comment packets; cover art stress-tests it with large ones. Can be deferred if Phase 2 runs long without blocking the milestone.
|
||||
**Delivers:** OGG Vorbis cover art embed/remove via METADATA_BLOCK_PICTURE
|
||||
**Addresses:** OGG cover art differentiator
|
||||
**Avoids:** P9 (METADATA_BLOCK_PICTURE format), P15 (multi-page segmentation for large payloads)
|
||||
**New code:** ~50 LOC additions to `ogg.go` + ~50 LOC additions to `ogg_test.go`
|
||||
**Estimated effort:** Small if Phase 2's page infrastructure is solid; medium if multi-page edge cases surface
|
||||
|
||||
**Rationale:** With the write safety layer in place, integrate the format-specific tag writing libraries. MP3 first (most common format, best library), then FLAC. This phase is pure backend — no UI changes yet. Unit tests with real audio files validate round-trip correctness.
|
||||
**Delivers:** `backend/tageditor/writer.go` with MP3 + FLAC tag writing; encoding handling (P7); cover art embedding capability
|
||||
**Uses:** `bogem/id3v2/v2`, `go-flac/go-flac/v2` + `go-flac/flacvorbis/v2` + `go-flac/flacpicture`
|
||||
**Avoids:** P7 (encoding mismatch), P8 (cover art format issues), P12 (dhowden/tag is read-only)
|
||||
|
||||
### Phase 3: Single Track Edit Pipeline
|
||||
|
||||
**Rationale:** Wire the full pipeline end-to-end for a single track: backend service → file write → DB entity update → FTS5 re-index → event emission → frontend refresh. This is the core loop that all other features build on. Includes the shared entity upsert-and-relink pattern (P4) and genre dual-representation sync (P10).
|
||||
**Delivers:** `backend/tageditor/tageditor.go` service; `EditTrack()` Wails binding; wired `track-details` save handler; `TagsUpdated` event; library store refresh
|
||||
**Implements:** Tageditor service, DB update logic, event system, frontend integration
|
||||
**Avoids:** P4 (shared entity mutation), P5 (scan race), P10 (genre mismatch), P11 (partial failure), P17 (stale frontend cache)
|
||||
|
||||
### Phase 4: Cover Art Editing
|
||||
|
||||
**Rationale:** Cover art embedding builds on Phase 2's writer and Phase 3's pipeline but adds image validation, the cover art cache pipeline integration, and file picker UX. Separated because cover art has its own pitfalls (P8, P13) and is independently testable.
|
||||
**Delivers:** `SetCoverArt()` binding; image resize/validation before embed; cover art cache invalidation and thumbnail regeneration; cover art remove capability
|
||||
**Avoids:** P8 (oversized images, format issues), P13 (stale cached thumbnails)
|
||||
|
||||
### Phase 5: Batch Editing
|
||||
|
||||
**Rationale:** Batch editing is the highest-complexity UI feature (three-state field model, mixed-value indicators, progress tracking). It depends on the single-track pipeline being solid. The backend is straightforward (loop over `EditTrack()`), but the frontend UX is where the complexity lives.
|
||||
**Delivers:** Batch edit dialog with three-state fields; `EditTracks()` binding; progress indicator; auto-number tracks; confirmation dialog for destructive batch operations
|
||||
**Addresses:** Batch editing (table stakes), progress indicator (differentiator), auto-number (differentiator)
|
||||
**Avoids:** P9 (orphan entity accumulation — run cleanup after batch), P14 (no undo — confirmation dialog)
|
||||
|
||||
### Phase 6: OGG Vorbis Tag Writing (Stretch)
|
||||
|
||||
**Rationale:** OGG tag writing requires a custom OGG page rewriter — MEDIUM-HIGH complexity with no library support. The Vorbis Comment serialization is shared with FLAC (Phase 2), so only the OGG page framing is new. This can ship after the core MP3/FLAC editing is stable.
|
||||
**Delivers:** Custom OGG page rewriter; OGG Vorbis tag writing support; full format coverage (MP3 + FLAC + OGG)
|
||||
**Avoids:** Scope creep — if OGG proves too complex, MP3 + FLAC cover the vast majority of user libraries
|
||||
### Phase 4: Edge Cases and Cleanup
|
||||
**Rationale:** Validation and hardening after core functionality works. Adds detection/rejection of unsupported edge cases, size warnings, and documentation updates.
|
||||
**Delivers:** RF64 detection, multi-stream OGG detection, large file warnings, PROJECT.md updates
|
||||
**Addresses:** P13 (multi-stream OGG), P16 (RF64 WAV), P12 (disk space for large files)
|
||||
**New code:** ~30 LOC validation checks + documentation updates
|
||||
**Estimated effort:** Small
|
||||
|
||||
### Phase Ordering Rationale
|
||||
|
||||
- **Schema migration first** because FTS5 `contentless_delete=1` is a hard prerequisite that must be in place before any DB update code is written for tag editing.
|
||||
- **Write safety before tag libraries** because the atomic write mechanism is tested independently of any format-specific code.
|
||||
- **MP3 before FLAC before OGG** because library quality/maturity decreases in that order, and MP3 covers the largest user base.
|
||||
- **Single track before batch** because batch editing is N × single with UI complexity on top — the underlying pipeline must be solid.
|
||||
- **Cover art as a separate phase** because it has independent pitfalls (image validation, cache invalidation) and is testable in isolation.
|
||||
- **OGG last** because it requires custom implementation and MP3 + FLAC cover the majority of use cases.
|
||||
- **WAV before OGG:** WAV is lower risk (reuses existing ID3v2 library, simpler container) and proves the pipeline extension pattern. OGG requires all-new page infrastructure with correctness-critical CRC and sequencing.
|
||||
- **OGG text before OGG cover art:** Text fields exercise the page rewrite with small comment packets. Cover art adds multi-page complexity that should only be attempted once the core page infrastructure is validated by round-trip tests.
|
||||
- **Edge cases last:** Detection/rejection of unusual files (RF64, multi-stream) is low risk and low effort — just validation guards at file-open time.
|
||||
|
||||
### Research Flags
|
||||
|
||||
Phases likely needing deeper research during planning:
|
||||
- **Phase 2 (Tag Writing):** `go-flac` libraries have smaller communities (44 stars) — verify FLAC write round-trip with edge cases (large files, existing padding blocks, multiple PICTURE blocks) during implementation.
|
||||
- **Phase 6 (OGG Writing):** Custom OGG page rewriter needs specification-level research (OGG framing RFC). Consider prototyping before committing to scope.
|
||||
- **Phase 2 (OGG text writer):** The OGG page re-segmentation and CRC implementation is the most complex new code. The spec is clear, but implementation details (lacing values, continuation flags, packet splitting across pages) benefit from studying `jfreymuth/oggvorbis` source as reference. Phase-level research recommended.
|
||||
|
||||
Phases with standard patterns (skip research-phase):
|
||||
- **Phase 1 (Schema Migration):** Well-documented SQLite FTS5 migration. `contentless_delete=1` is a one-line schema change.
|
||||
- **Phase 3 (Single Track Edit):** The architecture is fully designed — pointer fields, upsert-and-relink, event emission are all standard Go/Wails patterns.
|
||||
- **Phase 5 (Batch Editing):** The three-state field model is well-understood from foobar2000/MusicBee analysis. Frontend-heavy but no novel backend work.
|
||||
- **Phase 1 (WAV writer):** RIFF parsing is trivial; ID3v2 tag generation reuses existing code. Well-documented, no unknowns.
|
||||
- **Phase 3 (OGG cover art):** METADATA_BLOCK_PICTURE format is well-specified; base64 encoding is trivial. Only depends on Phase 2's page infrastructure being correct.
|
||||
- **Phase 4 (edge cases):** Simple validation checks with clear specifications.
|
||||
|
||||
## Confidence Assessment
|
||||
|
||||
| Area | Confidence | Notes |
|
||||
|------|------------|-------|
|
||||
| Stack | HIGH | MP3 library (bogem/id3v2) verified via pkg.go.dev docs with 359 stars/57 importers. FLAC libraries verified via GitHub READMEs. OGG is the only gap (custom work). |
|
||||
| Features | HIGH | Cross-referenced 5 desktop music players + Hydrogenaudio tag standards + existing codebase analysis. Table stakes are unambiguous. |
|
||||
| Architecture | HIGH | Based on full codebase analysis — every integration point verified against actual source files (library.go, search.go, tags.go, track-details.ts, player.go). |
|
||||
| Pitfalls | HIGH | All critical pitfalls derived from format specifications (FLAC, ID3v2, OGG, FTS5) and codebase analysis (shared entities, file locking, scan race). Mitigations are concrete. |
|
||||
| Stack | HIGH | Zero new dependencies; all recommendations based on official specs and existing codebase analysis |
|
||||
| Features | HIGH | Feature set derived from official format specs (Xiph.org, RIFF) and existing codebase field model |
|
||||
| Architecture | HIGH | Full codebase analysis confirms pipeline was designed for format extension; minimal changes needed |
|
||||
| Pitfalls | HIGH | Pitfalls sourced from official OGG/RIFF specs, cross-referenced with existing library implementations |
|
||||
|
||||
**Overall confidence:** HIGH
|
||||
|
||||
### Gaps to Address
|
||||
|
||||
- **OGG Vorbis tag writing:** No pure-Go library exists. Custom implementation complexity is estimated at MEDIUM-HIGH but not prototyped. Validate feasibility during Phase 6 planning — consider whether OGG support is worth the custom code, or whether to accept MP3+FLAC-only for v1.2.
|
||||
- **`go-flac` edge cases:** The go-flac library has 44 stars and a small community. Round-trip testing with edge-case FLAC files (files with existing PADDING blocks, multiple PICTURE blocks, unusual metadata block orders) should be done early in Phase 2 to surface any library bugs.
|
||||
- **Windows file locking behavior:** The currently-playing-file lock (P2) is well-understood conceptually but the exact interaction between Go's `os.Open`, beep's streamer, and Windows mandatory locking needs validation on a Windows build.
|
||||
- **Album artist storage:** FEATURES.md notes album artist editing is low-hanging fruit, but ARCHITECTURE.md flags that album artist isn't currently stored as a separate entity. Schema implications should be resolved during Phase 3 planning.
|
||||
- **`bogem/id3v2` WAV compatibility:** The library's `Open()`/`Save()` API expects MP3 file structure. For WAV, we'll need to use `ParseReader()` to read existing ID3v2 tags from a byte slice, and `WriteTo()` to serialize the tag to bytes for embedding in the RIFF chunk. This needs validation during Phase 1 implementation — if `ParseReader` doesn't work for standalone tag parsing, we may need to create tags from scratch (losing existing ID3v2 data in the WAV).
|
||||
- **OGG test fixture creation:** Cannot programmatically generate a valid OGG Vorbis file (requires Vorbis codebook data in setup header). Need to embed a minimal OGG fixture via `//go:embed`. Can be created once with ffmpeg during Phase 2 setup.
|
||||
- **Multi-stream OGG prevalence:** Research confirms multi-stream OGG music files are extremely rare, but we should detect and reject them rather than silently corrupting. Validation during Phase 2.
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- `bogem/id3v2` (n10v/id3v2): https://pkg.go.dev/github.com/bogem/id3v2/v2 — API docs, v2.1.4, write support verified
|
||||
- `go-flac/go-flac`: https://github.com/go-flac/go-flac — metadata manipulation, Save() copies audio frames as raw bytes
|
||||
- `go-flac/flacvorbis`: https://github.com/go-flac/flacvorbis — Vorbis Comment add/parse/marshal
|
||||
- `go-flac/flacpicture`: https://github.com/go-flac/flacpicture — PICTURE block creation from image data
|
||||
- SQLite FTS5 docs: https://www.sqlite.org/fts5.html — contentless tables, contentless_delete=1
|
||||
- FLAC format spec: https://www.xiph.org/flac/format.html — metadata block structure
|
||||
- Vorbis Comment spec: https://www.xiph.org/vorbis/doc/v-comment.html — field format
|
||||
- OGG framing spec: https://www.xiph.org/ogg/doc/framing.html — page structure
|
||||
- Hydrogenaudio Tag Mapping: https://wiki.hydrogenaud.io/index.php/Tag_Mapping — field name standards
|
||||
- YellowJacket codebase: library.go, search.go, tags.go, player.go, track-details.ts, schema files — architecture analysis
|
||||
- OGG framing specification: https://xiph.org/ogg/doc/framing.html
|
||||
- OGG RFC 3533: https://xiph.org/ogg/doc/rfc3533.txt
|
||||
- Vorbis I Specification (comment field): https://xiph.org/vorbis/doc/Vorbis_I_spec.html
|
||||
- Vorbis Comment specification: https://xiph.org/vorbis/doc/v-comment.html
|
||||
- METADATA_BLOCK_PICTURE: https://wiki.xiph.org/VorbisComment#METADATA_BLOCK_PICTURE
|
||||
- FLAC Picture block format: https://xiph.org/flac/format.html#metadata_block_picture
|
||||
- RIFF/WAV format: https://www.mmsp.ece.mcgill.ca/documents/AudioFormats/WAVE/WAVE.html
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- `dhowden/tag`: https://github.com/dhowden/tag — confirmed read-only, no write API
|
||||
- `mewkiz/flac`: https://github.com/mewkiz/flac — confirmed codec (encoder/decoder), unsuitable for metadata-only writes
|
||||
- `jfreymuth/oggvorbis`: https://github.com/jfreymuth/oggvorbis — confirmed decode-only
|
||||
- MusicBee, foobar2000, Kid3, Mp3tag, Picard — feature pattern analysis
|
||||
- WAV metadata overview: https://en.wikipedia.org/wiki/WAV#Metadata
|
||||
- RIFF tag reference: https://exiftool.org/TagNames/RIFF.html
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- OGG Vorbis custom writer feasibility — estimated MEDIUM-HIGH complexity based on spec analysis, not prototyped
|
||||
### Libraries (HIGH confidence — direct code review)
|
||||
- `jfreymuth/oggvorbis` v1.0.5: OGG page reader reference, CRC32 lookup table
|
||||
- `dhowden/tag`: Reads OGG + WAV tags; validates round-trip correctness
|
||||
- `bogem/id3v2` v2.1.4: ID3v2 tag generation for WAV `id3 ` chunks
|
||||
- `go-flac/flacpicture` v2.0.2: FLAC picture block builder, reused for OGG METADATA_BLOCK_PICTURE
|
||||
- `go-audio/wav` (ARCHIVED 2026-02-21): Evaluated and rejected
|
||||
- `mccoyst/ogg` (37 stars, no semver): Evaluated and rejected — marginal benefit vs dependency risk
|
||||
|
||||
### Codebase (HIGH confidence — validated in v1.2)
|
||||
- `backend/tagwriter/flac.go` — Vorbis Comment manipulation patterns to reuse
|
||||
- `backend/tagwriter/mp3.go` — ID3v2 + AtomicWrite patterns to reuse for WAV
|
||||
- `backend/tagwriter/pipeline.go` — Format dispatch switch to extend
|
||||
- `backend/tagwriter/tagwriter.go` — TagChanges model, format detection, helpers
|
||||
- `backend/fileutil/atomicwrite.go` — Crash-safe file write utility
|
||||
|
||||
---
|
||||
*Research completed: 2026-03-16*
|
||||
*Research completed: 2026-03-18*
|
||||
*Ready for roadmap: yes*
|
||||
|
||||
Reference in New Issue
Block a user