diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 446d8a8..2906ee2 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -84,7 +84,11 @@ Plans: 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 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 **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 | | 18. Batch Edit | v1.2 | 2/2 | Complete | 2026-03-18 | | 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 | - | --- diff --git a/.planning/phases/20-ogg-vorbis-tag-writer/20-01-PLAN.md b/.planning/phases/20-ogg-vorbis-tag-writer/20-01-PLAN.md new file mode 100644 index 0000000..5653aee --- /dev/null +++ b/.planning/phases/20-ogg-vorbis-tag-writer/20-01-PLAN.md @@ -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" +--- + + +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`. + + + +@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md +@/home/caleb/.config/opencode/get-shit-done/templates/summary.md + + + +@.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 + + + + + +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 +``` + + + + + + + Task 1: Create OGG page parser/writer with CRC32 and writeOggTags entry point + + backend/tagwriter/ogg.go + backend/tagwriter/ogg_vorbis.go + backend/tagwriter/tagwriter.go + backend/tagwriter/pipeline.go + + +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=` 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. + + + go build ./backend/tagwriter/ && go vet ./backend/tagwriter/ + + + - `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 + + + + + + +```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 +``` + + + +- `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) + + + +After completion, create `.planning/phases/20-ogg-vorbis-tag-writer/20-01-SUMMARY.md` + diff --git a/.planning/phases/20-ogg-vorbis-tag-writer/20-02-PLAN.md b/.planning/phases/20-ogg-vorbis-tag-writer/20-02-PLAN.md new file mode 100644 index 0000000..1550aa7 --- /dev/null +++ b/.planning/phases/20-ogg-vorbis-tag-writer/20-02-PLAN.md @@ -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" +--- + + +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. + + + +@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md +@/home/caleb/.config/opencode/get-shit-done/templates/summary.md + + + +@.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 + + + + +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 +``` + + + + + + + Task 1: Create OGG test fixture and CRC32 validation + backend/tagwriter/ogg_test.go + +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`. + + + go test ./backend/tagwriter/ -run "TestOggCRC|TestCreateTestOGG" -count=1 -v + + + - createTestOGG helper produces a valid OGG Vorbis file + - CRC32 test validates against known vectors + - Test fixture file is parseable by parseOggPages + + + + + Task 2: Write round-trip tests for all OGG requirements + backend/tagwriter/ogg_test.go + +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 + + + go test ./backend/tagwriter/ -run "TestWriteOggTags" -count=1 -v + + + - 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 + + + + + Task 3: Full test suite verification + backend/tagwriter/ogg_test.go + +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. + + + go test ./backend/tagwriter/ -count=1 && go vet ./backend/tagwriter/ + + + - Full tagwriter test suite passes (MP3 + FLAC + WAV + OGG) + - go vet clean + - No regressions in existing tests + + + + + + +```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 +``` + + + +- 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 + + + +After completion, create `.planning/phases/20-ogg-vorbis-tag-writer/20-02-SUMMARY.md` +