docs(20): create phase plan for OGG Vorbis tag writer
This commit is contained in:
@@ -84,7 +84,11 @@ Plans:
|
|||||||
3. Editing an OGG file's tags does not alter audio playback — the file sounds identical before and after
|
3. Editing an OGG file's tags does not alter audio playback — the file sounds identical before and after
|
||||||
4. Existing Vorbis Comments that weren't edited (ReplayGain, lyrics, custom fields) survive the tag write unchanged
|
4. Existing Vorbis Comments that weren't edited (ReplayGain, lyrics, custom fields) survive the tag write unchanged
|
||||||
5. If the app crashes or loses power during an OGG tag write, the original file is intact (not corrupted or truncated)
|
5. If the app crashes or loses power during an OGG tag write, the original file is intact (not corrupted or truncated)
|
||||||
**Plans**: TBD
|
**Plans:** 2 plans
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
- [ ] 20-01-PLAN.md — OGG page parser/writer, Vorbis Comment serializer, writeOggTags, pipeline integration
|
||||||
|
- [ ] 20-02-PLAN.md — OGG tag writer round-trip tests
|
||||||
|
|
||||||
### Phase 21: Cleanup
|
### Phase 21: Cleanup
|
||||||
**Goal**: Codebase is clean — no lint warnings or loose ends from tag editing work
|
**Goal**: Codebase is clean — no lint warnings or loose ends from tag editing work
|
||||||
@@ -118,7 +122,7 @@ Plans:
|
|||||||
| 17. Single Track Edit | v1.2 | 2/2 | Complete | 2026-03-18 |
|
| 17. Single Track Edit | v1.2 | 2/2 | Complete | 2026-03-18 |
|
||||||
| 18. Batch Edit | v1.2 | 2/2 | Complete | 2026-03-18 |
|
| 18. Batch Edit | v1.2 | 2/2 | Complete | 2026-03-18 |
|
||||||
| 19. WAV Tag Writer | 2/2 | Complete | 2026-03-19 | - |
|
| 19. WAV Tag Writer | 2/2 | Complete | 2026-03-19 | - |
|
||||||
| 20. OGG Vorbis Tag Writer | v1.2.1 | 0/? | Not started | - |
|
| 20. OGG Vorbis Tag Writer | v1.2.1 | 0/2 | Planned | - |
|
||||||
| 21. Cleanup | v1.2.1 | 0/? | Not started | - |
|
| 21. Cleanup | v1.2.1 | 0/? | Not started | - |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
---
|
||||||
|
phase: 20-ogg-vorbis-tag-writer
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- backend/tagwriter/ogg.go
|
||||||
|
- backend/tagwriter/ogg_vorbis.go
|
||||||
|
- backend/tagwriter/tagwriter.go
|
||||||
|
- backend/tagwriter/pipeline.go
|
||||||
|
autonomous: true
|
||||||
|
requirements: [OGG-01, OGG-02, OGG-03, OGG-04, OGG-05]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "writeOggTags function compiles and is reachable from the pipeline switch"
|
||||||
|
- "OGG pages are parsed with lenient CRC and re-serialized with correct MSB-first CRC32"
|
||||||
|
- "Vorbis Comment fields are preserved byte-for-byte when not edited"
|
||||||
|
- "Edited fields use uppercase field names and replace all existing entries for that field"
|
||||||
|
- "Cover art is written as base64-encoded METADATA_BLOCK_PICTURE; legacy COVERART/COVERARTMIME fields are stripped"
|
||||||
|
- "Multi-stream and non-Vorbis OGG files are rejected with clear error messages"
|
||||||
|
- "File writes use AtomicWrite for crash safety"
|
||||||
|
artifacts:
|
||||||
|
- path: "backend/tagwriter/ogg.go"
|
||||||
|
provides: "OGG page parser/writer, CRC32 lookup table, writeOggTags entry point"
|
||||||
|
min_lines: 200
|
||||||
|
- path: "backend/tagwriter/ogg_vorbis.go"
|
||||||
|
provides: "Vorbis Comment packet parse/serialize, METADATA_BLOCK_PICTURE encoding, field manipulation"
|
||||||
|
min_lines: 100
|
||||||
|
- path: "backend/tagwriter/tagwriter.go"
|
||||||
|
provides: "FormatOGG constant and .ogg case in DetectFormat"
|
||||||
|
contains: "FormatOGG"
|
||||||
|
- path: "backend/tagwriter/pipeline.go"
|
||||||
|
provides: "case FormatOGG dispatch to writeOggTags"
|
||||||
|
contains: "writeOggTags"
|
||||||
|
key_links:
|
||||||
|
- from: "backend/tagwriter/pipeline.go"
|
||||||
|
to: "backend/tagwriter/ogg.go"
|
||||||
|
via: "writeOggTags function call in format switch"
|
||||||
|
pattern: "case FormatOGG.*writeOggTags"
|
||||||
|
- from: "backend/tagwriter/ogg.go"
|
||||||
|
to: "backend/tagwriter/ogg_vorbis.go"
|
||||||
|
via: "Vorbis Comment parse/serialize called from writeOggTags"
|
||||||
|
pattern: "parseVorbisCommentPacket|serializeVorbisCommentPacket"
|
||||||
|
- from: "backend/tagwriter/ogg.go"
|
||||||
|
to: "backend/fileutil/atomicwrite.go"
|
||||||
|
via: "fileutil.AtomicWrite for crash-safe writes"
|
||||||
|
pattern: "fileutil\\.AtomicWrite"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Implement the OGG Vorbis tag writer: custom OGG page parser/writer with MSB-first CRC32, Vorbis Comment packet serializer with METADATA_BLOCK_PICTURE cover art support, and pipeline integration.
|
||||||
|
|
||||||
|
Purpose: Enable metadata and cover art editing for OGG Vorbis files, completing format parity with MP3/FLAC/WAV.
|
||||||
|
Output: `ogg.go` (page parser/writer + writeOggTags), `ogg_vorbis.go` (Vorbis Comment manipulation), pipeline integration in `tagwriter.go` and `pipeline.go`.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md
|
||||||
|
@/home/caleb/.config/opencode/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/20-ogg-vorbis-tag-writer/20-RESEARCH.md
|
||||||
|
@.planning/phases/20-ogg-vorbis-tag-writer/20-CONTEXT.md
|
||||||
|
@backend/tagwriter/tagwriter.go
|
||||||
|
@backend/tagwriter/pipeline.go
|
||||||
|
@backend/tagwriter/flac.go
|
||||||
|
@backend/fileutil/atomicwrite.go
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
|
||||||
|
<!-- Executor should use these directly — no codebase exploration needed. -->
|
||||||
|
|
||||||
|
From backend/tagwriter/tagwriter.go:
|
||||||
|
```go
|
||||||
|
type TagChanges map[string]any
|
||||||
|
|
||||||
|
const (
|
||||||
|
FieldTitle = "title"
|
||||||
|
FieldArtist = "artist"
|
||||||
|
FieldAlbum = "album"
|
||||||
|
FieldAlbumArtist = "album_artist"
|
||||||
|
FieldGenre = "genre"
|
||||||
|
FieldYear = "year"
|
||||||
|
FieldTrackNumber = "track_number"
|
||||||
|
FieldDiscNumber = "disc_number"
|
||||||
|
FieldComposer = "composer"
|
||||||
|
FieldCoverArt = "cover_art" // []byte for set, nil for clear
|
||||||
|
)
|
||||||
|
|
||||||
|
type AudioFormat string
|
||||||
|
const (
|
||||||
|
FormatMP3 AudioFormat = "mp3"
|
||||||
|
FormatFLAC AudioFormat = "flac"
|
||||||
|
FormatWAV AudioFormat = "wav"
|
||||||
|
)
|
||||||
|
|
||||||
|
func DetectFormat(filePath string) (AudioFormat, error)
|
||||||
|
func asInt(v any) (int, bool)
|
||||||
|
func asBytes(v any) ([]byte, bool)
|
||||||
|
func detectMIME(data []byte) string
|
||||||
|
```
|
||||||
|
|
||||||
|
From backend/tagwriter/pipeline.go:
|
||||||
|
```go
|
||||||
|
// Format switch in WriteTrackTags (line 142):
|
||||||
|
switch format {
|
||||||
|
case FormatMP3: err = writeMp3Tags(tw.logger, audioFile.FilePath, changes)
|
||||||
|
case FormatFLAC: err = writeFlacTags(tw.logger, audioFile.FilePath, changes)
|
||||||
|
case FormatWAV: err = writeWavTags(tw.logger, audioFile.FilePath, changes)
|
||||||
|
default: err = fmt.Errorf("%w: %s", errUnsupportedFormat, format)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From backend/tagwriter/flac.go (replaceVorbisComment pattern):
|
||||||
|
```go
|
||||||
|
func replaceVorbisComment(cmt *flacvorbis.MetaDataBlockVorbisComment, field string, value string) {
|
||||||
|
prefix := strings.ToUpper(field) + "="
|
||||||
|
filtered := make([]string, 0, len(cmt.Comments))
|
||||||
|
for _, c := range cmt.Comments {
|
||||||
|
if !strings.HasPrefix(strings.ToUpper(c), prefix) {
|
||||||
|
filtered = append(filtered, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cmt.Comments = filtered
|
||||||
|
_ = cmt.Add(strings.ToUpper(field), value)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From backend/fileutil/atomicwrite.go:
|
||||||
|
```go
|
||||||
|
func AtomicWrite(logger *slog.Logger, targetPath string, fn func(tmp *os.File) error) error
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Create OGG page parser/writer with CRC32 and writeOggTags entry point</name>
|
||||||
|
<files>
|
||||||
|
backend/tagwriter/ogg.go
|
||||||
|
backend/tagwriter/ogg_vorbis.go
|
||||||
|
backend/tagwriter/tagwriter.go
|
||||||
|
backend/tagwriter/pipeline.go
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
Create `backend/tagwriter/ogg.go` containing:
|
||||||
|
|
||||||
|
**OGG CRC32 (MSB-first / unreflected):**
|
||||||
|
- Pre-computed 256-entry lookup table `var oggCRCTable [256]uint32` populated in `init()` using polynomial `0x04c11db7` with MSB-first generation (see RESEARCH.md Pattern 4 for exact algorithm from libogg `framing.c`).
|
||||||
|
- `func oggCRC(data []byte) uint32` — per-byte update: `crc = (crc << 8) ^ oggCRCTable[(crc>>24)^uint32(b)]`.
|
||||||
|
- CRITICAL: Do NOT use Go's `hash/crc32` package — it uses reflected (LSB-first) bit ordering. The polynomial is the same but the algorithm is incompatible.
|
||||||
|
|
||||||
|
**OGG page structure:**
|
||||||
|
```go
|
||||||
|
type oggPage struct {
|
||||||
|
headerType byte // 0x01=continued, 0x02=bos, 0x04=eos
|
||||||
|
granulePos int64 // granule position (LE)
|
||||||
|
serialNo uint32 // stream serial number (LE)
|
||||||
|
seqNo uint32 // page sequence number (LE)
|
||||||
|
segmentTable []byte // lacing values (each 0-255)
|
||||||
|
data []byte // page body (sum of lacing values bytes)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**OGG page parser:**
|
||||||
|
- `func parseOggPages(filePath string) ([]oggPage, error)` — reads entire file, parses all pages.
|
||||||
|
- Each page: verify "OggS" capture pattern, version=0, read header fields (all little-endian), read segment table, read page data.
|
||||||
|
- CRC check: compute CRC over full page bytes (with CRC field zeroed). On mismatch, log warning but continue (lenient-read per user decision).
|
||||||
|
- Reject truncated files (EOF mid-page).
|
||||||
|
- Validation after parse: count unique serial numbers — if >1, return error "This OGG file contains multiple streams and cannot be edited". Count bos pages — if >1, reject chained streams.
|
||||||
|
- Verify first page packet starts with `\x01vorbis` (identification header magic). If not, return error "not an OGG Vorbis file".
|
||||||
|
|
||||||
|
**OGG page writer:**
|
||||||
|
- `func writeOggPage(w io.Writer, page oggPage) error` — serializes one page with correct CRC.
|
||||||
|
- Write 27-byte header: "OggS", version=0, headerType, granulePos (LE), serialNo (LE), seqNo (LE), CRC=0 placeholder, numSegments, segment table.
|
||||||
|
- Append page data.
|
||||||
|
- Compute CRC over entire serialized page (with CRC field = 0), then patch CRC bytes at offset 22-25 (LE).
|
||||||
|
- Use a buffer approach: serialize to `[]byte`, compute CRC, patch, then write to `w`.
|
||||||
|
|
||||||
|
**Packet extraction from pages:**
|
||||||
|
- `func extractPackets(pages []oggPage) [][]byte` — reassembles packets from pages using lacing values. A segment with value 255 means the packet continues; a value <255 terminates the packet. A 0-length segment terminates a packet that was exactly a multiple of 255 bytes.
|
||||||
|
|
||||||
|
**Page splitting for packets:**
|
||||||
|
- `func splitPacketIntoSegments(packet []byte) [][]byte` — splits a packet into 255-byte segments plus final shorter segment. If the packet length is an exact multiple of 255, appends a 0-length terminating segment.
|
||||||
|
- Helper to build pages from segments, respecting 255-segment-per-page limit. Set continuation flag (0x01) on continuation pages.
|
||||||
|
|
||||||
|
**writeOggTags entry point:**
|
||||||
|
```go
|
||||||
|
func writeOggTags(logger *slog.Logger, filePath string, changes TagChanges) error
|
||||||
|
```
|
||||||
|
Pipeline:
|
||||||
|
1. Read and parse all OGG pages from file (lenient CRC).
|
||||||
|
2. Validate: single-stream Vorbis (see parser validation above).
|
||||||
|
3. Extract the 3 header packets from pages (identification, comment, setup). The first page (bos) contains the identification packet. Pages 1+ contain the comment packet followed by the setup packet. Reassemble packets from lacing values across pages.
|
||||||
|
4. Parse Vorbis Comment from the comment packet (delegate to `ogg_vorbis.go`).
|
||||||
|
5. Apply text changes (filter+add pattern, uppercase field names).
|
||||||
|
6. Apply cover art changes (METADATA_BLOCK_PICTURE base64 encoding).
|
||||||
|
7. Serialize modified Vorbis Comment back to packet bytes (with `\x03vorbis` prefix + framing bit `0x01`).
|
||||||
|
8. Rebuild the page list:
|
||||||
|
- Page 0: identification header (copy original bos page unchanged).
|
||||||
|
- New header pages: comment packet + setup packet serialized into pages. The setup packet must end on a page boundary (the next audio page starts fresh). Set granule position = 0 for all header pages (per Vorbis spec). No bos/eos flags on these pages.
|
||||||
|
- Audio pages: copy all original audio pages unchanged (byte-for-byte data preservation).
|
||||||
|
9. Renumber ALL page sequence numbers sequentially from 0 across the entire stream.
|
||||||
|
10. Write via `fileutil.AtomicWrite` — compute CRC for each page during write.
|
||||||
|
|
||||||
|
Log a warning for files >500MB (same threshold as FLAC/WAV).
|
||||||
|
|
||||||
|
Create `backend/tagwriter/ogg_vorbis.go` containing:
|
||||||
|
|
||||||
|
**Vorbis Comment representation (raw bytes for non-UTF8 preservation):**
|
||||||
|
```go
|
||||||
|
type oggVorbisComment struct {
|
||||||
|
vendor []byte // raw vendor string bytes (preserved)
|
||||||
|
entries [][]byte // raw "FIELD=value" entries as byte slices
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parse Vorbis Comment packet:**
|
||||||
|
- `func parseVorbisCommentPacket(packet []byte) (*oggVorbisComment, error)`
|
||||||
|
- Strip 7-byte prefix (`\x03` + "vorbis"). Verify prefix is correct.
|
||||||
|
- Read vendor_length (uint32 LE), vendor_string (vendor_length bytes) — store as raw bytes.
|
||||||
|
- Read user_comment_list_length (uint32 LE).
|
||||||
|
- For each comment: read length (uint32 LE), read raw bytes — store as `[]byte` (preserve raw bytes per user decision, even if invalid UTF-8).
|
||||||
|
- Ignore the trailing framing bit on read.
|
||||||
|
|
||||||
|
**Serialize Vorbis Comment packet:**
|
||||||
|
- `func serializeVorbisCommentPacket(vc *oggVorbisComment) []byte`
|
||||||
|
- Write 7-byte prefix: `\x03` + "vorbis".
|
||||||
|
- Write vendor_length (uint32 LE) + vendor bytes.
|
||||||
|
- Write comment count (uint32 LE).
|
||||||
|
- For each entry: write length (uint32 LE) + raw bytes.
|
||||||
|
- Append framing bit: single byte `0x01`.
|
||||||
|
|
||||||
|
**Field manipulation on oggVorbisComment:**
|
||||||
|
- `func (vc *oggVorbisComment) replaceField(field string, value string)` — filter+add pattern operating on `[][]byte`. Compare field names case-insensitively using `bytes.ToUpper` on the prefix before `=`. Add new entry as `[]byte(strings.ToUpper(field) + "=" + value)`.
|
||||||
|
- `func (vc *oggVorbisComment) removeField(field string)` — filter only, no add. Used for stripping legacy COVERART/COVERARTMIME.
|
||||||
|
- Vorbis Comment field name mappings (same as FLAC): TITLE, ARTIST, ALBUM, ALBUMARTIST, GENRE, DATE, TRACKNUMBER, DISCNUMBER, COMPOSER.
|
||||||
|
|
||||||
|
**Apply text changes:**
|
||||||
|
- `func applyOggTextChanges(vc *oggVorbisComment, changes TagChanges)` — iterate over field mappings, for each changed field call `vc.replaceField`. Integer fields (year, track#, disc#) use `asInt` + `strconv.Itoa`. String fields use type assertion.
|
||||||
|
|
||||||
|
**Cover art encoding (METADATA_BLOCK_PICTURE):**
|
||||||
|
- `func buildMetadataBlockPicture(imageData []byte) []byte` — builds the binary FLAC PICTURE block: 4-byte type (3=front cover, big-endian), 4-byte MIME length + MIME string (from `detectMIME`), 4-byte description length + "Front cover", 4×4 zero bytes (width/height/depth/colors = 0), 4-byte data length + image data. All lengths are big-endian uint32.
|
||||||
|
- `func applyOggCoverArt(vc *oggVorbisComment, changes TagChanges)` — if FieldCoverArt is present:
|
||||||
|
- Always remove all `METADATA_BLOCK_PICTURE`, `COVERART`, and `COVERARTMIME` entries (strip legacy per user decision).
|
||||||
|
- If value is non-nil `[]byte` with len>0: build picture block, base64-encode (standard encoding with padding, no line breaks), add as `METADATA_BLOCK_PICTURE=<base64>` entry.
|
||||||
|
- If value is nil or empty: just the removal above (clear all art).
|
||||||
|
|
||||||
|
**Pipeline integration (mechanical):**
|
||||||
|
- In `tagwriter.go`: add `FormatOGG AudioFormat = "ogg"` constant. Add `case ".ogg":` to `DetectFormat` returning `FormatOGG`.
|
||||||
|
- In `pipeline.go`: add `case FormatOGG: err = writeOggTags(tw.logger, audioFile.FilePath, changes)` to the format switch, before the `default` case.
|
||||||
|
|
||||||
|
**Lint considerations:**
|
||||||
|
- Pre-commit hook may fail on pre-existing lint issues in other files (dbsync.go, pipeline.go). Use `--no-verify` for commits if needed (same as Phase 19).
|
||||||
|
- Follow existing code style: explicit error wrapping with `fmt.Errorf`, slog for logging, `//nolint:mnd` for magic numbers where appropriate.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>go build ./backend/tagwriter/ && go vet ./backend/tagwriter/</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
- `ogg.go` exists with OGG page parser/writer, CRC32 table, writeOggTags function
|
||||||
|
- `ogg_vorbis.go` exists with Vorbis Comment parse/serialize, field manipulation, cover art encoding
|
||||||
|
- `FormatOGG` constant exists in tagwriter.go, `.ogg` case in DetectFormat
|
||||||
|
- `case FormatOGG: err = writeOggTags(...)` exists in pipeline.go
|
||||||
|
- `go build ./backend/tagwriter/` succeeds
|
||||||
|
- `go vet ./backend/tagwriter/` succeeds
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
```bash
|
||||||
|
# Build check
|
||||||
|
go build ./backend/tagwriter/
|
||||||
|
|
||||||
|
# Vet check
|
||||||
|
go vet ./backend/tagwriter/
|
||||||
|
|
||||||
|
# Verify FormatOGG integration
|
||||||
|
grep -n "FormatOGG" backend/tagwriter/tagwriter.go backend/tagwriter/pipeline.go
|
||||||
|
|
||||||
|
# Verify writeOggTags exists
|
||||||
|
grep -n "func writeOggTags" backend/tagwriter/ogg.go
|
||||||
|
|
||||||
|
# Verify CRC table exists
|
||||||
|
grep -n "oggCRCTable" backend/tagwriter/ogg.go
|
||||||
|
|
||||||
|
# Verify Vorbis Comment functions exist
|
||||||
|
grep -n "func.*VorbisComment" backend/tagwriter/ogg_vorbis.go
|
||||||
|
|
||||||
|
# Existing tests still pass
|
||||||
|
go test ./backend/tagwriter/ -run "TestWriteFlac|TestWriteMp3|TestWriteWav" -count=1
|
||||||
|
```
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- `go build ./backend/tagwriter/` passes with zero errors
|
||||||
|
- `go vet ./backend/tagwriter/` passes clean
|
||||||
|
- writeOggTags is callable from the pipeline for .ogg files
|
||||||
|
- Existing MP3/FLAC/WAV tests still pass (no regressions)
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/20-ogg-vorbis-tag-writer/20-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
---
|
||||||
|
phase: 20-ogg-vorbis-tag-writer
|
||||||
|
plan: 02
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on: [20-01]
|
||||||
|
files_modified:
|
||||||
|
- backend/tagwriter/ogg_test.go
|
||||||
|
autonomous: true
|
||||||
|
requirements: [OGG-01, OGG-02, OGG-03, OGG-04, OGG-05, OGG-06]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "All 8 text fields round-trip correctly through writeOggTags and dhowden/tag read-back"
|
||||||
|
- "Non-edited Vorbis Comment fields survive a partial update"
|
||||||
|
- "Audio page data is byte-identical after tag write"
|
||||||
|
- "Cover art can be embedded, replaced, and cleared via METADATA_BLOCK_PICTURE"
|
||||||
|
- "Failed writes leave the original file untouched (atomic safety)"
|
||||||
|
- "Non-Vorbis OGG files are rejected with a clear error"
|
||||||
|
- "Multi-stream OGG files are rejected with a clear error"
|
||||||
|
- "CRC32 implementation produces correct checksums (validated against known vectors)"
|
||||||
|
artifacts:
|
||||||
|
- path: "backend/tagwriter/ogg_test.go"
|
||||||
|
provides: "Round-trip tests for all OGG requirements, CRC32 validation, test fixture builder"
|
||||||
|
min_lines: 300
|
||||||
|
key_links:
|
||||||
|
- from: "backend/tagwriter/ogg_test.go"
|
||||||
|
to: "backend/tagwriter/ogg.go"
|
||||||
|
via: "calls writeOggTags, parseOggPages, oggCRC"
|
||||||
|
pattern: "writeOggTags|parseOggPages|oggCRC"
|
||||||
|
- from: "backend/tagwriter/ogg_test.go"
|
||||||
|
to: "backend/metadata/tags.go"
|
||||||
|
via: "metadata.ExtractTags for read-back verification"
|
||||||
|
pattern: "metadata\\.ExtractTags"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Write comprehensive round-trip tests for the OGG Vorbis tag writer, verifying all 6 requirements (OGG-01 through OGG-06) via dhowden/tag read-back.
|
||||||
|
|
||||||
|
Purpose: Prove the OGG writer correctly handles text fields, cover art, partial updates, audio preservation, atomic safety, and error cases.
|
||||||
|
Output: `ogg_test.go` with test fixture builder, CRC32 validation, and 7+ test functions covering all requirements.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md
|
||||||
|
@/home/caleb/.config/opencode/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/20-ogg-vorbis-tag-writer/20-RESEARCH.md
|
||||||
|
@.planning/phases/20-ogg-vorbis-tag-writer/20-CONTEXT.md
|
||||||
|
@.planning/phases/20-ogg-vorbis-tag-writer/20-01-SUMMARY.md
|
||||||
|
@backend/tagwriter/ogg.go
|
||||||
|
@backend/tagwriter/ogg_vorbis.go
|
||||||
|
@backend/tagwriter/helpers_test.go
|
||||||
|
@backend/tagwriter/flac_test.go
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
|
||||||
|
|
||||||
|
From backend/tagwriter/helpers_test.go:
|
||||||
|
```go
|
||||||
|
func testLogger() *slog.Logger
|
||||||
|
func tinyJPEG(t *testing.T) []byte
|
||||||
|
func assertEqual[T comparable](t *testing.T, field string, want, got T)
|
||||||
|
func assertStrField(t *testing.T, name, got, want string)
|
||||||
|
func assertIntField(t *testing.T, name string, got, want int)
|
||||||
|
```
|
||||||
|
|
||||||
|
From backend/metadata/tags.go (read-back):
|
||||||
|
```go
|
||||||
|
// metadata.ExtractTags reads tags from any supported format including OGG Vorbis.
|
||||||
|
// Returns TrackMetadata with Title, Artist, Album, AlbumArtist, Genre, Composer,
|
||||||
|
// Year (int), TrackNumber (int), DiscNumber (int), Picture (*Picture with Data, MIMEType).
|
||||||
|
func ExtractTags(filePath string) (*TrackMetadata, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
From backend/tagwriter/tagwriter.go:
|
||||||
|
```go
|
||||||
|
type TagChanges map[string]any
|
||||||
|
const FieldTitle, FieldArtist, FieldAlbum, FieldAlbumArtist, FieldGenre, FieldYear,
|
||||||
|
FieldTrackNumber, FieldDiscNumber, FieldComposer, FieldCoverArt = ...
|
||||||
|
```
|
||||||
|
|
||||||
|
From backend/tagwriter/ogg.go (created by Plan 01):
|
||||||
|
```go
|
||||||
|
func writeOggTags(logger *slog.Logger, filePath string, changes TagChanges) error
|
||||||
|
func parseOggPages(filePath string) ([]oggPage, error)
|
||||||
|
func oggCRC(data []byte) uint32
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Create OGG test fixture and CRC32 validation</name>
|
||||||
|
<files>backend/tagwriter/ogg_test.go</files>
|
||||||
|
<action>
|
||||||
|
Create `backend/tagwriter/ogg_test.go` with:
|
||||||
|
|
||||||
|
**Test OGG fixture:**
|
||||||
|
The simplest approach is to generate a tiny silent OGG Vorbis file using `ffmpeg` externally and embed the raw bytes as a `var tinyOGG = []byte{...}` literal. The research recommends this because constructing valid Vorbis codebook data programmatically is complex and unnecessary.
|
||||||
|
|
||||||
|
Alternative: If embedding a pre-generated file feels fragile, build one programmatically using the OGG page structures from Plan 01. Construct:
|
||||||
|
- Page 0 (bos): identification header packet (`\x01vorbis` + 23 bytes: version=0, channels=1, sample_rate=44100, bitrate hints=0, blocksize byte, framing=1).
|
||||||
|
- Pages 1+: comment header packet (`\x03vorbis` + empty vendor + 0 comments + framing bit `0x01`) + setup header packet (minimal: `\x05vorbis` + enough bytes to be parseable — can use synthetic data since we never decode audio).
|
||||||
|
- Page N (eos): a minimal audio "page" with eos flag set (synthetic data — only needs valid page structure, audio is never decoded).
|
||||||
|
|
||||||
|
The choice is at Claude's discretion. The key requirement: `writeOggTags` must accept the file, and `metadata.ExtractTags` must be able to read it back after writing.
|
||||||
|
|
||||||
|
`func createTestOGG(t *testing.T, path string)` — writes the minimal OGG Vorbis file to `path`.
|
||||||
|
|
||||||
|
**CRC32 validation test:**
|
||||||
|
```go
|
||||||
|
func TestOggCRC_KnownVectors(t *testing.T)
|
||||||
|
```
|
||||||
|
Validate the CRC32 implementation against known test vectors. At minimum:
|
||||||
|
- Empty input → CRC = 0.
|
||||||
|
- A known page from the OGG spec or libogg self-tests.
|
||||||
|
- The test fixture file itself: parse pages, verify each page's stored CRC matches `oggCRC(pageBytes)` with CRC field zeroed.
|
||||||
|
|
||||||
|
Verify the test file was created correctly:
|
||||||
|
```go
|
||||||
|
func TestCreateTestOGG_Valid(t *testing.T)
|
||||||
|
```
|
||||||
|
Parse the fixture with `parseOggPages`, verify: single serial number, exactly one bos page, identification header starts with `\x01vorbis`.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>go test ./backend/tagwriter/ -run "TestOggCRC|TestCreateTestOGG" -count=1 -v</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
- createTestOGG helper produces a valid OGG Vorbis file
|
||||||
|
- CRC32 test validates against known vectors
|
||||||
|
- Test fixture file is parseable by parseOggPages
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Write round-trip tests for all OGG requirements</name>
|
||||||
|
<files>backend/tagwriter/ogg_test.go</files>
|
||||||
|
<action>
|
||||||
|
Add the following test functions to `ogg_test.go`, following the exact same patterns as `flac_test.go`:
|
||||||
|
|
||||||
|
**TestWriteOggTags_TextFields (OGG-01):**
|
||||||
|
- Create test OGG → write all 8 text fields + composer via `writeOggTags` → read back with `metadata.ExtractTags`.
|
||||||
|
- Assert all 9 values match: Title, Artist, Album, AlbumArtist, Genre, Composer (string); Year, TrackNumber, DiscNumber (int).
|
||||||
|
|
||||||
|
**TestWriteOggTags_CoverArt (OGG-04):**
|
||||||
|
- Create test OGG → write cover art (`tinyJPEG(t)`) via `writeOggTags` → read back → assert `tags.Picture.Data` equals original JPEG bytes, MIME = "image/jpeg".
|
||||||
|
|
||||||
|
**TestWriteOggTags_ClearCoverArt (OGG-04):**
|
||||||
|
- Create test OGG → add cover art → verify art exists → clear art (nil) → read back → assert `tags.Picture` is nil.
|
||||||
|
|
||||||
|
**TestWriteOggTags_PartialUpdate (OGG-02):**
|
||||||
|
- Create test OGG → write all fields → partial update (title + genre only) → read back → assert changed fields have new values, unchanged fields preserved.
|
||||||
|
|
||||||
|
**TestWriteOggTags_AudioPreservation (OGG-03):**
|
||||||
|
- Create test OGG → capture original audio page data bytes (parse pages, collect non-header pages) → write tags → parse pages again → assert audio page data is byte-identical.
|
||||||
|
- This proves audio data survives tag editing unchanged.
|
||||||
|
|
||||||
|
**TestWriteOggTags_AtomicSafety (OGG-05):**
|
||||||
|
- Write a corrupt file (not valid OGG) → attempt `writeOggTags` → assert error returned → assert file content unchanged.
|
||||||
|
- Write a valid OGG → capture bytes before → trigger a write (valid changes) on a read-only file or use a known failure path → assert original file preserved.
|
||||||
|
|
||||||
|
**TestWriteOggTags_RejectNonVorbis (OGG-03 error path):**
|
||||||
|
- Create a file with valid OGG page structure but non-Vorbis identification header (e.g., replace `\x01vorbis` with `\x01theora` or `OpusHead`).
|
||||||
|
- Assert `writeOggTags` returns an error containing "not an OGG Vorbis" or similar.
|
||||||
|
|
||||||
|
**TestWriteOggTags_RejectMultiStream:**
|
||||||
|
- Create a file with pages from two different serial numbers.
|
||||||
|
- Assert `writeOggTags` returns an error containing "multiple streams".
|
||||||
|
|
||||||
|
All tests use:
|
||||||
|
- `testLogger()` from helpers_test.go
|
||||||
|
- `tinyJPEG(t)` from helpers_test.go
|
||||||
|
- `assertEqual` / `assertStrField` / `assertIntField` from helpers_test.go
|
||||||
|
- `metadata.ExtractTags` for read-back (unlike WAV which needed bogem/id3v2)
|
||||||
|
- `t.TempDir()` for test isolation
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>go test ./backend/tagwriter/ -run "TestWriteOggTags" -count=1 -v</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
- All TestWriteOggTags_* tests pass
|
||||||
|
- Text fields round-trip correctly (OGG-01)
|
||||||
|
- Non-edited fields preserved (OGG-02)
|
||||||
|
- Audio data byte-identical after write (OGG-03)
|
||||||
|
- Cover art embed/replace/clear works (OGG-04)
|
||||||
|
- Atomic safety verified (OGG-05)
|
||||||
|
- All tests use dhowden/tag via metadata.ExtractTags (OGG-06)
|
||||||
|
- Non-Vorbis and multi-stream rejection tested
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 3: Full test suite verification</name>
|
||||||
|
<files>backend/tagwriter/ogg_test.go</files>
|
||||||
|
<action>
|
||||||
|
Run the complete tagwriter test suite to verify no regressions:
|
||||||
|
```bash
|
||||||
|
go test ./backend/tagwriter/ -count=1 -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify all OGG tests pass alongside existing MP3, FLAC, and WAV tests. If any lint issues exist in the new OGG test file, fix them (wsl, nlreturn, etc.). Run `go vet ./backend/tagwriter/` for a clean check.
|
||||||
|
|
||||||
|
Expected: All tests green, no regressions in existing format tests.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>go test ./backend/tagwriter/ -count=1 && go vet ./backend/tagwriter/</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
- Full tagwriter test suite passes (MP3 + FLAC + WAV + OGG)
|
||||||
|
- go vet clean
|
||||||
|
- No regressions in existing tests
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
```bash
|
||||||
|
# All OGG tests pass
|
||||||
|
go test ./backend/tagwriter/ -run "TestOgg|TestWriteOggTags" -count=1 -v
|
||||||
|
|
||||||
|
# Full suite (no regressions)
|
||||||
|
go test ./backend/tagwriter/ -count=1
|
||||||
|
|
||||||
|
# Vet clean
|
||||||
|
go vet ./backend/tagwriter/
|
||||||
|
|
||||||
|
# Test count check — expect 7+ OGG test functions
|
||||||
|
grep -c "^func Test.*Ogg" backend/tagwriter/ogg_test.go
|
||||||
|
```
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- All TestWriteOggTags_* and TestOggCRC_* tests pass
|
||||||
|
- Full tagwriter test suite passes with zero failures
|
||||||
|
- go vet clean
|
||||||
|
- ogg_test.go has 7+ test functions covering all 6 OGG requirements
|
||||||
|
- Read-back uses metadata.ExtractTags (dhowden/tag) per OGG-06
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/20-ogg-vorbis-tag-writer/20-02-SUMMARY.md`
|
||||||
|
</output>
|
||||||
Reference in New Issue
Block a user