diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 65722f6..a6f91cc 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -41,7 +41,7 @@ ### v1.2 Tag Editing (Phases 15-19) - [x] **Phase 15: Schema Migration & Write Safety** — FTS5 contentless_delete migration and atomic file write utility (completed 2026-03-16) -- [ ] **Phase 16: Tag Writing & Database Sync** — Format-specific tag writers (MP3, FLAC, cover art) with inline DB + FTS5 update pipeline +- [ ] **Phase 16: Tag Writing & Database Sync** — Format-specific tag writers (MP3, FLAC, cover art) with inline DB + FTS5 update pipeline (3 plans) - [ ] **Phase 17: Single Track Edit** — End-to-end single track editing: UI → file write → DB sync → view refresh - [ ] **Phase 18: Batch Edit** — Multi-select batch editing with three-state field model, progress, and batch cover art - [ ] **Phase 19: OGG Vorbis Tag Writing** — Custom OGG page rewriter for Vorbis Comment tag writing (stretch) @@ -72,7 +72,11 @@ Plans: 3. Cover art images (JPEG/PNG) can be embedded in both MP3 and FLAC files — the embedded image is readable back and the existing cover art pipeline (extraction, thumbnails) works with the newly embedded art 4. After a tag write, the database reflects the new metadata within the same operation: artist/album/genre entities are created or relinked (never mutated in-place), orphaned entities with zero remaining references are cleaned up, and the FTS5 index is updated — no library rescan needed 5. If the currently-playing track is being edited, playback is stopped before the file write begins — the user does not experience a crash or corrupted audio stream -**Plans:** TBD +**Plans:** 3 plans +Plans: +- [ ] 16-01-PLAN.md — Tagwriter foundation + sqlc queries + MP3 writer (Wave 1) +- [ ] 16-02-PLAN.md — FLAC writer with go-flac ecosystem (Wave 1) +- [ ] 16-03-PLAN.md — DB sync pipeline + player/scan safety + events + app wiring (Wave 2) ### Phase 17: Single Track Edit **Goal:** Users can edit any track's metadata and cover art from within the app and see changes reflected everywhere immediately @@ -125,7 +129,7 @@ Plans: | 13. Library Views & Phantom Tracks | v1.1 | 2/2 | Complete | 2026-03-16 | | 14. Performance Optimization | v1.1 | 4/4 | Complete | 2026-03-15 | | 15. Schema Migration & Write Safety | 2/2 | Complete | 2026-03-16 | - | -| 16. Tag Writing & Database Sync | v1.2 | 0/? | Not started | - | +| 16. Tag Writing & Database Sync | v1.2 | 0/3 | Not started | - | | 17. Single Track Edit | v1.2 | 0/? | Not started | - | | 18. Batch Edit | v1.2 | 0/? | Not started | - | | 19. OGG Vorbis Tag Writing | v1.2 | 0/? | Not started | - | diff --git a/.planning/phases/16-tag-writing-database-sync/16-01-PLAN.md b/.planning/phases/16-tag-writing-database-sync/16-01-PLAN.md new file mode 100644 index 0000000..2122fc4 --- /dev/null +++ b/.planning/phases/16-tag-writing-database-sync/16-01-PLAN.md @@ -0,0 +1,308 @@ +--- +phase: 16-tag-writing-database-sync +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - backend/database/sql/queries/recordings.sql + - backend/database/sql/queries/artist_credit.sql + - backend/database/sql/queries/release_groups.sql + - backend/database/sql/queries/genres.sql + - backend/database/sql/sqlcgen/recordings.sql.go + - backend/database/sql/sqlcgen/artist_credit.sql.go + - backend/database/sql/sqlcgen/release_groups.sql.go + - backend/database/sql/sqlcgen/genres.sql.go + - go.mod + - go.sum + - backend/tagwriter/tagwriter.go + - backend/tagwriter/mp3.go + - backend/tagwriter/mp3_test.go +autonomous: true +requirements: [WRITE-01, WRITE-04] + +must_haves: + truths: + - "MP3 text tag fields (title, artist, album, genre, year, track#, disc#, composer) can be written and read back correctly" + - "Cover art (JPEG/PNG) can be embedded in an MP3 file as an APIC frame and read back" + - "Writing tags uses AtomicWrite for crash safety — original file is never partially modified" + - "Orphan-counting sqlc queries exist for artist_credit, release_group, and genre entities" + artifacts: + - path: "backend/tagwriter/tagwriter.go" + provides: "Package declaration, diff map types (TagChanges), field name constants, format detection, MIME detection" + min_lines: 30 + - path: "backend/tagwriter/mp3.go" + provides: "writeMp3Tags function using n10v/id3v2 + AtomicWrite" + min_lines: 60 + - path: "backend/tagwriter/mp3_test.go" + provides: "Round-trip tests for MP3 tag writing (text fields + cover art)" + min_lines: 80 + key_links: + - from: "backend/tagwriter/mp3.go" + to: "backend/fileutil/atomicwrite.go" + via: "fileutil.AtomicWrite call" + pattern: "fileutil\\.AtomicWrite" + - from: "backend/tagwriter/mp3.go" + to: "github.com/bogem/id3v2/v2" + via: "id3v2.Open + tag.WriteTo" + pattern: "id3v2\\." +--- + + +Create the tagwriter package foundation with diff map types and implement the MP3 tag writer using n10v/id3v2, plus add sqlc queries needed for orphan cleanup in Plan 03. + +Purpose: Establish the package structure and deliver a working MP3 writer that Phase 17's UI can eventually call through Plan 03's WriteTrackTags entry point. +Output: `backend/tagwriter/` package with types and MP3 writer, new sqlc orphan-counting queries. + + + +@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md +@/home/caleb/.config/opencode/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/16-tag-writing-database-sync/16-CONTEXT.md +@.planning/phases/16-tag-writing-database-sync/16-RESEARCH.md +@.planning/phases/15-schema-migration-write-safety/15-02-SUMMARY.md + + + + +From backend/fileutil/atomicwrite.go: +```go +var ErrCrossDevice = errors.New("atomic write: cross-device rename not supported") +func AtomicWrite(logger *slog.Logger, targetPath string, fn func(tmp *os.File) error) error +``` + +From backend/metadata/metadata.go: +```go +type TrackMetadata struct { + Title, Artist, Album, AlbumArtist, Composer, Genre string + Year, TrackNumber, TotalTracks, DiscNumber, TotalDiscs int + Lyrics, Comment string + Picture *PictureData + TagFormat string + FileFormat string +} +type PictureData struct { + Data []byte + MIMEType string + Ext string +} +func ExtractTags(path string) (*TrackMetadata, error) +``` + +From backend/database/search.go: +```go +func (d *DB) InsertSearchIndex(rowid int64, filePath, title, artist, album string) error +func (d *DB) DeleteSearchIndex(rowid int64) error +``` + +Existing sqlc queries (backend/database/sql/queries/): +- recordings.sql: UpdateRecordingFull (name, artist_credit_id, track_number, disc_number, year, genre, composer, lyrics, comment) +- artist_credit.sql: UpsertArtistCredit, DeleteArtistCredit +- release_groups.sql: UpsertReleaseGroup, DeleteReleaseGroup, UpdateReleaseGroupCoverArt +- genres.sql: UpsertGenre, CreateRecordingGenre, DeleteRecordingGenres +- artist_credit_artists.sql: CreateArtistCreditArtist, DeleteArtistCreditArtist +- cover_art.sql: UpsertCoverArt, DeleteCoverArt + + + + + + + Task 1: Add orphan-counting sqlc queries and regenerate + + backend/database/sql/queries/recordings.sql + backend/database/sql/queries/artist_credit.sql + backend/database/sql/queries/release_groups.sql + backend/database/sql/queries/genres.sql + + +Add new sqlc queries needed for Plan 03's orphan cleanup. These must use sqlc-compatible syntax (no hand-crafted SQL needed since these are simple counts): + +**recordings.sql** — add: +```sql +-- name: CountRecordingsByArtistCredit :one +SELECT COUNT(*) FROM recordings WHERE artist_credit_id = ?; +``` + +**artist_credit.sql** — add: +```sql +-- name: CountArtistCreditReferences :one +SELECT + (SELECT COUNT(*) FROM recordings WHERE artist_credit_id = ?1) + + (SELECT COUNT(*) FROM release_groups WHERE album_artist_credit_id = ?1) +AS total; +``` + +**release_groups.sql** — add: +```sql +-- name: CountReleaseGroupRecordings :one +SELECT COUNT(*) FROM release_group_recordings WHERE release_group_id = ?; +``` + +**genres.sql** — add: +```sql +-- name: CountGenreReferences :one +SELECT COUNT(*) FROM recording_genres WHERE genre_id = ?; + +-- name: DeleteGenre :exec +DELETE FROM genres WHERE id = ?; +``` + +After adding queries, run `sqlc generate` from the `backend/database/` directory to regenerate Go code: +```bash +cd backend/database && sqlc generate +``` + +Verify generated files compile: +```bash +go build ./backend/database/... +``` + + + cd backend/database && sqlc generate && cd ../.. && go build ./backend/database/... + + New orphan-counting queries exist in sqlc query files, generated Go code compiles, queries return correct types (int64 counts) + + + + Task 2: Create tagwriter package with types and MP3 writer + + go.mod + go.sum + backend/tagwriter/tagwriter.go + backend/tagwriter/mp3.go + backend/tagwriter/mp3_test.go + + +**Step 1: Add n10v/id3v2 dependency:** +```bash +go get github.com/bogem/id3v2/v2@latest +``` + +**Step 2: Create `backend/tagwriter/tagwriter.go`:** + +Package declaration with doc comment ending in period. Define: + +```go +// Package tagwriter writes metadata tags to audio files. +package tagwriter + +// TagChanges is a diff map of field name → new value. Only changed +// fields are present. Callers specify changed fields; unchanged +// fields are left as-is in the file. +type TagChanges map[string]any + +// Field name constants for the diff map. +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 +) + +// AudioFormat represents a supported audio file format. +type AudioFormat string + +const ( + FormatMP3 AudioFormat = "mp3" + FormatFLAC AudioFormat = "flac" +) +``` + +Add a `DetectFormat(filePath string) (AudioFormat, error)` function that checks the file extension (`.mp3` → FormatMP3, `.flac` → FormatFLAC, else error). + +Add a `detectMIME(data []byte) string` helper that checks JPEG magic bytes (`0xFF 0xD8`) → `"image/jpeg"`, PNG magic bytes (`0x89 0x50 0x4E 0x47`) → `"image/png"`, else `"application/octet-stream"`. + +**Step 3: Create `backend/tagwriter/mp3.go`:** + +Implement `writeMp3Tags(logger *slog.Logger, filePath string, changes TagChanges) error`: + +1. Open existing file with `id3v2.Open(filePath, id3v2.Options{Parse: true})`. Defer `tag.Close()`. +2. Apply text changes from the diff map: + - `FieldTitle` → `tag.SetTitle(v.(string))` + - `FieldArtist` → `tag.SetArtist(v.(string))` + - `FieldAlbum` → `tag.SetAlbum(v.(string))` + - `FieldGenre` → `tag.SetGenre(v.(string))` + - `FieldYear` → `tag.SetYear(strconv.Itoa(v.(int)))` (year is int in diff map, string in ID3v2) + - `FieldTrackNumber` → `tag.DeleteFrames(tag.CommonID("Track number/Position in set"))` then `tag.AddTextFrame(tag.CommonID("Track number/Position in set"), id3v2.EncodingUTF8, strconv.Itoa(v.(int)))` + - `FieldDiscNumber` → `tag.DeleteFrames(tag.CommonID("Part of a set"))` then `tag.AddTextFrame(tag.CommonID("Part of a set"), id3v2.EncodingUTF8, strconv.Itoa(v.(int)))` + - `FieldComposer` → `tag.DeleteFrames("TCOM")` then `tag.AddTextFrame("TCOM", id3v2.EncodingUTF8, v.(string))` +3. Apply cover art: + - If `FieldCoverArt` is present with `[]byte` data (len > 0): `tag.DeleteFrames(tag.CommonID("Attached picture"))`, then add `id3v2.PictureFrame{Encoding: id3v2.EncodingUTF8, MimeType: detectMIME(data), PictureType: id3v2.PTFrontCover, Description: "Front cover", Picture: data}` via `tag.AddAttachedPicture(pic)`. + - If `FieldCoverArt` is present with nil value: `tag.DeleteFrames(tag.CommonID("Attached picture"))` (clear art). +4. Write atomically via `fileutil.AtomicWrite(logger, filePath, func(tmp *os.File) error { ... })`: + - Inside the callback: use `tag.WriteTo(tmp)` to write the ID3v2 tag to the temp file. + - Then copy audio data from original file. The audio data starts after the original ID3v2 tag. Open the original file, seek past the tag header. The `n10v/id3v2` library's `tag` tracks the original tag size — examine `tag.Size()` method. The original tag occupies bytes 0 through `10 + tag.Size()` (10-byte ID3v2 header + tag body). Seek the original file to that offset, then `io.Copy(tmp, originalFile)` to append all audio frames. + - IMPORTANT: Read the `n10v/id3v2` source for `Save()` to understand how it handles the audio data copy. The `tag` struct stores the original file reference internally. If `tag.Save()` does `WriteTo + copy audio`, replicate that exact logic. The key is: `originalFile.Seek(int64(10 + tag.Size()), io.SeekStart)` to position past the old tag, then `io.Copy(tmp, originalFile)`. + - Close the original file handle after the copy (before AtomicWrite renames). + +**Step 4: Create `backend/tagwriter/mp3_test.go`:** + +Create a test MP3 fixture. Use `n10v/id3v2` to create a minimal valid MP3 file in a temp directory: +- Create a file with valid ID3v2 tag + minimal silent MP3 audio frame (you can use a hardcoded minimal MP3 frame — 4 bytes `0xFF 0xFB 0x90 0x00` is a valid MP3 sync word + header for a 128kbps frame, followed by enough zero bytes to fill one frame). +- Alternative: embed a tiny real MP3 test fixture file as `testdata/silence.mp3`. + +Tests to write: +1. `TestWriteMp3Tags_TextFields` — Create fixture, write title/artist/album/genre/year/track#/disc#/composer, read back with `metadata.ExtractTags()`, verify each field matches. +2. `TestWriteMp3Tags_CoverArt` — Create fixture, write a small JPEG cover art (create a 1x1 JPEG programmatically or embed a tiny fixture), read back, verify picture data matches. +3. `TestWriteMp3Tags_ClearCoverArt` — Create fixture with art, write with `FieldCoverArt: nil`, read back, verify no picture. +4. `TestWriteMp3Tags_PartialUpdate` — Create fixture with all fields set, update only title and artist, verify other fields unchanged. +5. `TestWriteMp3Tags_AtomicSafety` — Verify original file is unmodified if write callback returns error (mock by wrapping AtomicWrite or checking file content before/after a simulated failure). + +Use `t.TempDir()` for all test files. Use the existing `metadata.ExtractTags` to verify round-trip correctness (this validates that dhowden/tag can read what n10v/id3v2 writes). + +Run linter after writing: +```bash +golangci-lint run ./backend/tagwriter/... +``` + + + go test ./backend/tagwriter/... -v -count=1 && golangci-lint run ./backend/tagwriter/... + + + - `backend/tagwriter/tagwriter.go` exists with TagChanges type, field constants, format detection, and MIME detection + - `backend/tagwriter/mp3.go` exists with writeMp3Tags that uses id3v2 + AtomicWrite + - All 5 MP3 tests pass demonstrating round-trip correctness for text fields, cover art embed, cover art clear, partial updates, and atomic safety + - `go test` passes, `golangci-lint` passes + + + + + + +```bash +# All new sqlc queries compile +go build ./backend/database/... + +# MP3 writer tests pass with round-trip verification +go test ./backend/tagwriter/... -v -count=1 + +# Lint clean +golangci-lint run ./backend/tagwriter/... ./backend/database/... +``` + + + +- n10v/id3v2 v2 added to go.mod +- Orphan-counting sqlc queries generated and compiling +- TagChanges type and field constants defined +- MP3 tags (all 8 text fields + cover art) write and read back correctly via round-trip tests +- AtomicWrite integration verified — original file safe on write failure +- Linter passes + + + +After completion, create `.planning/phases/16-tag-writing-database-sync/16-01-SUMMARY.md` + diff --git a/.planning/phases/16-tag-writing-database-sync/16-02-PLAN.md b/.planning/phases/16-tag-writing-database-sync/16-02-PLAN.md new file mode 100644 index 0000000..5803948 --- /dev/null +++ b/.planning/phases/16-tag-writing-database-sync/16-02-PLAN.md @@ -0,0 +1,284 @@ +--- +phase: 16-tag-writing-database-sync +plan: 02 +type: execute +wave: 1 +depends_on: [] +files_modified: + - go.mod + - go.sum + - backend/tagwriter/flac.go + - backend/tagwriter/flac_test.go +autonomous: true +requirements: [WRITE-02, WRITE-04] + +must_haves: + truths: + - "FLAC text tag fields (title, artist, album, genre, year, track#, disc#, composer) can be written via Vorbis Comments and read back correctly" + - "Cover art (JPEG/PNG) can be embedded in a FLAC file as a PICTURE metadata block and read back" + - "Writing FLAC tags uses AtomicWrite for crash safety — original file is never partially modified" + - "Existing FLAC metadata blocks (StreamInfo) are preserved during tag writes" + artifacts: + - path: "backend/tagwriter/flac.go" + provides: "writeFlacTags function using go-flac ecosystem + AtomicWrite" + min_lines: 80 + - path: "backend/tagwriter/flac_test.go" + provides: "Round-trip tests for FLAC tag writing (text fields + cover art)" + min_lines: 80 + key_links: + - from: "backend/tagwriter/flac.go" + to: "backend/fileutil/atomicwrite.go" + via: "fileutil.AtomicWrite call" + pattern: "fileutil\\.AtomicWrite" + - from: "backend/tagwriter/flac.go" + to: "github.com/go-flac/go-flac/v2" + via: "flac.ParseFile + file marshaling" + pattern: "flac\\." +--- + + +Implement the FLAC tag writer using the go-flac ecosystem (go-flac, flacvorbis, flacpicture) with AtomicWrite integration and round-trip tests. + +Purpose: Deliver a working FLAC writer so the tagwriter package supports both major lossless and lossy formats. Combined with Plan 01's MP3 writer, this completes format-specific tag writing (WRITE-02, WRITE-04). +Output: `backend/tagwriter/flac.go` with writer, `backend/tagwriter/flac_test.go` with round-trip tests. + + + +@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md +@/home/caleb/.config/opencode/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/16-tag-writing-database-sync/16-CONTEXT.md +@.planning/phases/16-tag-writing-database-sync/16-RESEARCH.md +@.planning/phases/15-schema-migration-write-safety/15-02-SUMMARY.md + + + + +From backend/tagwriter/tagwriter.go (created by Plan 01): +```go +package tagwriter + +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" +) + +type AudioFormat string +const ( + FormatMP3 AudioFormat = "mp3" + FormatFLAC AudioFormat = "flac" +) + +func DetectFormat(filePath string) (AudioFormat, error) +func detectMIME(data []byte) string +``` + +From backend/fileutil/atomicwrite.go: +```go +func AtomicWrite(logger *slog.Logger, targetPath string, fn func(tmp *os.File) error) error +``` + +From backend/metadata/metadata.go: +```go +func ExtractTags(path string) (*TrackMetadata, error) +type PictureData struct { + Data []byte + MIMEType string + Ext string +} +``` + + + + + + + Task 1: Add go-flac dependencies and implement FLAC writer + + go.mod + go.sum + backend/tagwriter/flac.go + + +**Step 1: Add go-flac ecosystem dependencies:** +```bash +go get github.com/go-flac/go-flac/v2@latest +go get github.com/go-flac/flacvorbis/v2@latest +go get github.com/go-flac/flacpicture/v2@latest +``` + +**Step 2: Create `backend/tagwriter/flac.go`:** + +Implement `writeFlacTags(logger *slog.Logger, filePath string, changes TagChanges) error`: + +1. Parse the FLAC file: `f, err := flac.ParseFile(filePath)`. This loads the entire file (metadata blocks + audio frames) into memory. Log a warning if file size > 500MB: `logger.Warn("large FLAC file may use significant memory", "path", filePath, "size", fileSize)`. + +2. Find existing Vorbis Comment block: + ```go + var cmt *flacvorbis.MetadataBlockVorbisComment + var cmtIdx int = -1 + for idx, meta := range f.Meta { + if meta.Type == flac.VorbisComment { + cmt, err = flacvorbis.ParseFromMetaDataBlock(*meta) + cmtIdx = idx + break + } + } + if cmt == nil { + cmt = flacvorbis.New() + } + ``` + +3. Implement a `replaceVorbisComment(cmt *flacvorbis.MetadataBlockVorbisComment, field string, value string)` helper: + - Get existing values: `existing, _ := cmt.Get(field)` + - Remove all existing entries for this field. The flacvorbis library stores comments as a `[]string` slice. Access the `Comments` field directly and filter out entries starting with `FIELD=` (case-insensitive). + - Add new value: `cmt.Add(field, value)` — note: flacvorbis `Add` appends. + - IMPORTANT: Vorbis Comment field names are case-insensitive per spec but conventionally UPPERCASE. Use the `flacvorbis` field constants (FIELD_TITLE, FIELD_ARTIST, etc.). + +4. Apply text changes from diff map: + - `FieldTitle` → `replaceVorbisComment(cmt, flacvorbis.FIELD_TITLE, v.(string))` + - `FieldArtist` → `replaceVorbisComment(cmt, flacvorbis.FIELD_ARTIST, v.(string))` + - `FieldAlbum` → `replaceVorbisComment(cmt, flacvorbis.FIELD_ALBUM, v.(string))` + - `FieldAlbumArtist` → `replaceVorbisComment(cmt, "ALBUMARTIST", v.(string))` (no flacvorbis constant for this — use string literal) + - `FieldGenre` → `replaceVorbisComment(cmt, flacvorbis.FIELD_GENRE, v.(string))` + - `FieldYear` → `replaceVorbisComment(cmt, "DATE", strconv.Itoa(v.(int)))` (Vorbis uses DATE not YEAR) + - `FieldTrackNumber` → `replaceVorbisComment(cmt, flacvorbis.FIELD_TRACKNUMBER, strconv.Itoa(v.(int)))` + - `FieldDiscNumber` → `replaceVorbisComment(cmt, "DISCNUMBER", strconv.Itoa(v.(int)))` + - `FieldComposer` → `replaceVorbisComment(cmt, "COMPOSER", v.(string))` + +5. Marshal Vorbis Comment block back and update f.Meta: + ```go + cmtMeta := cmt.Marshal() + if cmtIdx >= 0 { + f.Meta[cmtIdx] = &cmtMeta + } else { + f.Meta = append(f.Meta, &cmtMeta) + } + ``` + +6. Handle cover art — PICTURE metadata block: + - If `FieldCoverArt` is present with `[]byte` data (len > 0): + - Remove existing PICTURE blocks: filter `f.Meta` to exclude blocks where `meta.Type == flac.Picture`. + - Create new picture: `pic, err := flacpicture.NewFromImageData(flacpicture.PictureTypeFrontCover, "Front cover", data, detectMIME(data))` + - Marshal and append: `picMeta := pic.Marshal(); f.Meta = append(f.Meta, &picMeta)` + - If `FieldCoverArt` is present with nil value (clear art): + - Remove all PICTURE blocks from `f.Meta`. + +7. Write atomically via `fileutil.AtomicWrite(logger, filePath, func(tmp *os.File) error { ... })`: + - Inside the callback: serialize the FLAC data and write to the temp file. + - **CRITICAL**: Check if `go-flac`'s `f.Save(path)` can write to an existing file (the temp file AtomicWrite creates). If `Save` creates/truncates the file independently, it may conflict with AtomicWrite's already-opened temp file. Two approaches: + - **Option A (preferred if f.Marshal() exists):** `data, err := f.Marshal(); tmp.Write(data)` — serialize to bytes, write to AtomicWrite's temp file. + - **Option B (if no Marshal):** `f.Save(tmp.Name())` — tell go-flac to write to the temp file path. After Save, AtomicWrite's rename step swaps it in. This works because AtomicWrite creates the temp file first, and Save will truncate+rewrite it. + - Verify which approach works by reading go-flac source during implementation. The research suggests go-flac has a `Marshal` method — prefer it for cleaner AtomicWrite integration. + +Sentinel errors: +```go +var errUnsupportedFormat = errors.New("tagwriter: unsupported audio format") +``` + +Run linter after writing: +```bash +golangci-lint run ./backend/tagwriter/... +``` + + + go build ./backend/tagwriter/... && golangci-lint run ./backend/tagwriter/... + + + - `backend/tagwriter/flac.go` exists with writeFlacTags using go-flac + AtomicWrite + - replaceVorbisComment helper handles field replacement correctly + - PICTURE block handling (add/replace/clear) implemented + - Code compiles and lint passes + + + + + Task 2: FLAC writer round-trip tests + + backend/tagwriter/flac_test.go + + +Create round-trip tests for the FLAC writer. The test strategy must create valid FLAC test fixtures that `metadata.ExtractTags()` (which uses `dhowden/tag`) can read back. + +**Creating FLAC test fixtures:** + +Option A (preferred): Embed a tiny real FLAC file as `backend/tagwriter/testdata/silence.flac`. Generate one externally or use `go-flac` to construct a minimal valid FLAC: +- StreamInfo block (required, must be first) — 34 bytes minimum: min/max block size, min/max frame size, sample rate, channels, bits per sample, total samples, MD5 signature. Use: 4096 block size, 44100 sample rate, 1 channel, 16 bits, 0 total samples, all-zero MD5. +- One silent audio frame (or borrow from an existing test asset in the codebase). + +Option B: If constructing a valid FLAC programmatically is too complex, embed a ~1KB silence.flac in testdata/. Check if the project has any existing FLAC test files that can be reused. + +**Tests to write:** +1. `TestWriteFlacTags_TextFields` — Create fixture, write title/artist/album/genre/year/track#/disc#/composer, read back with `metadata.ExtractTags()`, verify each field matches. +2. `TestWriteFlacTags_CoverArt` — Create fixture, write a small JPEG cover art, read back, verify picture data matches. +3. `TestWriteFlacTags_ClearCoverArt` — Create fixture with art, write with `FieldCoverArt: nil`, read back, verify no picture. +4. `TestWriteFlacTags_PartialUpdate` — Create fixture with all fields, update only title and genre, verify other fields unchanged. +5. `TestWriteFlacTags_PreservesStreamInfo` — Create fixture, write tags, verify the audio data is still present and StreamInfo block is intact (file should still be parseable by `go-flac`). +6. `TestWriteFlacTags_ReplaceComment` — Write a field twice, verify only the latest value is present (no duplicate Vorbis Comments). +7. `TestWriteFlacTags_AtomicSafety` — Verify original file is unmodified on failure. + +Use `t.TempDir()` for all test files. Copy the fixture to a temp location before each test (so tests are independent). + +For cover art test data: create a minimal 1x1 JPEG programmatically using `image/jpeg` and `image.NewRGBA`. Or create a small PNG. The generated image should be small (< 1KB). + +Verify with linter: +```bash +golangci-lint run ./backend/tagwriter/... +``` + +NOTE: If Plan 01 is executing in parallel and tagwriter.go doesn't exist yet, the FLAC tests will still compile because they're in the same package. But if there are import issues, the executor should ensure Plan 01's tagwriter.go exists first (both plans are Wave 1, so they may run sequentially). + + + go test ./backend/tagwriter/... -v -count=1 -run TestWriteFlac && golangci-lint run ./backend/tagwriter/... + + + - 7 FLAC tests pass demonstrating round-trip correctness for text fields, cover art embed/clear, partial updates, StreamInfo preservation, comment replacement, and atomic safety + - Tests use the existing metadata.ExtractTags for read-back verification (proving dhowden/tag reads what go-flac writes) + - Linter passes + + + + + + +```bash +# FLAC writer tests pass with round-trip verification +go test ./backend/tagwriter/... -v -count=1 -run TestWriteFlac + +# All tagwriter tests pass (MP3 + FLAC combined) +go test ./backend/tagwriter/... -v -count=1 + +# Lint clean +golangci-lint run ./backend/tagwriter/... +``` + + + +- go-flac, flacvorbis, flacpicture v2 added to go.mod +- FLAC text tags (all 8 fields) write and read back correctly via round-trip tests +- FLAC cover art (JPEG/PNG) embeds and reads back correctly +- Cover art clear operation works (removes PICTURE blocks) +- StreamInfo and audio data preserved through tag writes +- No duplicate Vorbis Comments after field replacement +- AtomicWrite integration verified — original file safe on write failure +- Linter passes + + + +After completion, create `.planning/phases/16-tag-writing-database-sync/16-02-SUMMARY.md` + diff --git a/.planning/phases/16-tag-writing-database-sync/16-03-PLAN.md b/.planning/phases/16-tag-writing-database-sync/16-03-PLAN.md new file mode 100644 index 0000000..23f6956 --- /dev/null +++ b/.planning/phases/16-tag-writing-database-sync/16-03-PLAN.md @@ -0,0 +1,594 @@ +--- +phase: 16-tag-writing-database-sync +plan: 03 +type: execute +wave: 2 +depends_on: [16-01, 16-02] +files_modified: + - backend/tagwriter/pipeline.go + - backend/tagwriter/dbsync.go + - backend/tagwriter/pipeline_test.go + - backend/events/events.go + - frontend/src/events.ts + - backend/library/library.go + - backend/app.go +autonomous: true +requirements: [SYNC-01, SYNC-02, SYNC-03, SYNC-04, WRITE-06] + +must_haves: + truths: + - "WriteTrackTags accepts a track ID and diff map, writes file tags, updates DB entities, updates FTS5, cleans up orphans — all in one call" + - "After tag write, changed artist/album/genre entities are upserted-and-relinked (never mutated in-place)" + - "Orphaned entities (artist_credit, release_group, genre with zero remaining references) are deleted immediately" + - "FTS5 search index is updated after tag write (delete old entry + insert new)" + - "If the currently-playing track is being edited, playback is stopped before file write" + - "Scan and write pipelines use mutual exclusion — cannot run concurrently" + - "TrackMetadataChanged event is emitted after successful write + sync" + artifacts: + - path: "backend/tagwriter/pipeline.go" + provides: "TagWriter struct with WriteTrackTags entry point, player safety, scan/write mutex coordination" + exports: ["TagWriter", "WriteTrackTags", "NewTagWriter"] + min_lines: 100 + - path: "backend/tagwriter/dbsync.go" + provides: "Database sync transaction: entity relink, FTS5 update, orphan cleanup, cover art processing" + min_lines: 120 + - path: "backend/tagwriter/pipeline_test.go" + provides: "Integration tests for the full write pipeline with in-memory DB" + min_lines: 100 + - path: "backend/events/events.go" + provides: "TrackMetadataChanged event constant" + contains: "TrackMetadataChanged" + key_links: + - from: "backend/tagwriter/pipeline.go" + to: "backend/player/player.go" + via: "Player interface for GetCurrentTrackInfo + UnloadTrack" + pattern: "UnloadTrack|GetCurrentTrackInfo" + - from: "backend/tagwriter/pipeline.go" + to: "backend/library/library.go" + via: "Scan/write mutual exclusion via shared mutex or pipeline-active flag" + pattern: "AcquireWriteLock|mu\\.Lock" + - from: "backend/tagwriter/dbsync.go" + to: "backend/database" + via: "DB transaction for entity relink + FTS5 update + orphan cleanup" + pattern: "BeginTx|WithTx" + - from: "backend/tagwriter/pipeline.go" + to: "backend/events/events.go" + via: "Emit TrackMetadataChanged event" + pattern: "EventsEmit.*TrackMetadataChanged" + - from: "backend/app.go" + to: "backend/tagwriter/pipeline.go" + via: "NewTagWriter creation and wiring" + pattern: "tagwriter\\.NewTagWriter" +--- + + +Implement the WriteTrackTags entry point that orchestrates the full tag writing pipeline: player safety → scan/write mutex → format-specific file write → DB sync transaction (entity relink + FTS5 + orphan cleanup + cover art) → event emission. Wire into app.go. + +Purpose: This is the single function call that Phase 17's UI will invoke. It ties together Plan 01's MP3 writer, Plan 02's FLAC writer, the database sync, and cross-cutting safety concerns. +Output: Complete `WriteTrackTags` pipeline, DB sync module, event wiring, app.go integration. + + + +@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md +@/home/caleb/.config/opencode/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/16-tag-writing-database-sync/16-CONTEXT.md +@.planning/phases/16-tag-writing-database-sync/16-RESEARCH.md +@.planning/phases/15-schema-migration-write-safety/15-01-SUMMARY.md +@.planning/phases/15-schema-migration-write-safety/15-02-SUMMARY.md +@.planning/phases/16-tag-writing-database-sync/16-01-SUMMARY.md +@.planning/phases/16-tag-writing-database-sync/16-02-SUMMARY.md + + + + +From backend/tagwriter/tagwriter.go (Plan 01): +```go +type TagChanges map[string]any +const ( + FieldTitle, FieldArtist, FieldAlbum, FieldAlbumArtist, + FieldGenre, FieldYear, FieldTrackNumber, FieldDiscNumber, + FieldComposer, FieldCoverArt string +) +type AudioFormat string +func DetectFormat(filePath string) (AudioFormat, error) +func detectMIME(data []byte) string +``` + +From backend/tagwriter/mp3.go (Plan 01): +```go +func writeMp3Tags(logger *slog.Logger, filePath string, changes TagChanges) error +``` + +From backend/tagwriter/flac.go (Plan 02): +```go +func writeFlacTags(logger *slog.Logger, filePath string, changes TagChanges) error +``` + +From backend/player/player.go: +```go +func (p *Player) GetCurrentTrackInfo() TrackInfo // TrackInfo.FilePath +func (p *Player) UnloadTrack() // Stops + releases file handle +``` + +From backend/library/library.go: +```go +type Library struct { + mu sync.Mutex // Protects scanActive, scanCancel, scanPaused, scanPauseCh, scanQueue + scanActive bool +} +``` + +From backend/database/database.go: +```go +func (d *DB) BeginTx() (*sql.Tx, error) +func (d *DB) InsertSearchIndex(rowid int64, filePath, title, artist, album string) error +func (d *DB) DeleteSearchIndex(rowid int64) error +``` + +From backend/database/sql/sqlcgen (existing + Plan 01 additions): +```go +// Lookups: +func (q *Queries) GetAudioFile(ctx, id int64) (AudioFile, error) +func (q *Queries) GetRecording(ctx, id int64) (Recording, error) +func (q *Queries) GetRecordingReleaseGroups(ctx, recordingID int64) ([]ReleaseGroupRecording, error) + +// Upserts: +func (q *Queries) UpsertArtistCredit(ctx, text string) (ArtistCredit, error) +func (q *Queries) UpsertArtist(ctx, name string) (Artist, error) +func (q *Queries) UpsertGenre(ctx, name string) (Genre, error) +func (q *Queries) UpsertReleaseGroup(ctx, params UpsertReleaseGroupParams) (ReleaseGroup, error) +func (q *Queries) UpsertCoverArt(ctx, params UpsertCoverArtParams) (CoverArt, error) + +// Updates: +func (q *Queries) UpdateRecordingFull(ctx, params UpdateRecordingFullParams) error +func (q *Queries) UpdateReleaseGroupCoverArt(ctx, params) error + +// Linking: +func (q *Queries) CreateArtistCreditArtist(ctx, params) (ArtistCreditArtist, error) +func (q *Queries) CreateRecordingGenre(ctx, params) error +func (q *Queries) DeleteRecordingGenres(ctx, recordingID int64) error +func (q *Queries) DeleteReleaseGroupRecordingByFK(ctx, params) error +func (q *Queries) CreateReleaseGroupRecording(ctx, params) (ReleaseGroupRecording, error) + +// Orphan counting (Plan 01 additions): +func (q *Queries) CountRecordingsByArtistCredit(ctx, artistCreditID int64) (int64, error) +func (q *Queries) CountArtistCreditReferences(ctx, id int64) (int64, error) +func (q *Queries) CountReleaseGroupRecordings(ctx, releaseGroupID int64) (int64, error) +func (q *Queries) CountGenreReferences(ctx, genreID int64) (int64, error) + +// Deletes (existing): +func (q *Queries) DeleteArtistCredit(ctx, id int64) error +func (q *Queries) DeleteArtist(ctx, id int64) error +func (q *Queries) DeleteReleaseGroup(ctx, id int64) error +func (q *Queries) DeleteGenre(ctx, id int64) error +func (q *Queries) DeleteArtistCreditArtist(ctx, id int64) error +``` + +From backend/library/coverart.go: +```go +// Cover art save pattern — SHA-256 hash, dedup, thumbnail generation +func (l *Library) saveCoverArt(pic *metadata.PictureData, metrics *ScanMetrics, thumbChan chan<- thumbnailWork) (string, error) +// Thumbnail tiers: _sm (100px), _md (200px), _lg (400px) +``` + +From backend/coverart/coverart.go: +```go +func CoversDir() (string, error) +func ResolveURLs(filesystemPath string) URLs +func SizedFilename(originalFilename, suffix string) string +``` + +From backend/events/events.go: +```go +// Pattern: const TrackMetadataChanged = "TrackMetadataChanged" +// //go:generate go run ./cmd/genevents -source events.go -output ../../frontend/src/events.ts +``` + +From backend/app.go: +```go +// Two-phase init: NewYellowJacketApp() then OnStartup(ctx) +// Bindings registered via FEBindings slice +type YellowJacketApp struct { + player *player.Player + queue *queue.Queue + library *library.Library + database *database.DB + // ... other fields +} +``` + + + + + + + Task 1: Implement DB sync module and cover art processing + + backend/tagwriter/dbsync.go + + +Create `backend/tagwriter/dbsync.go` containing the database synchronization logic. This runs inside a single DB transaction after a successful file write. + +**Define a `dbSyncParams` struct:** +```go +type dbSyncParams struct { + audioFileID int64 + recordingID int64 + filePath string + changes TagChanges + oldRecording sqlcgen.Recording + oldRGLinks []sqlcgen.ReleaseGroupRecording +} +``` + +**Implement `syncDatabase(ctx context.Context, logger *slog.Logger, db *database.DB, params dbSyncParams) error`:** + +This function runs the entire DB update in a single transaction: + +1. **Begin transaction:** + ```go + tx, err := db.BeginTx() + txq := db.Queries.WithTx(tx) + defer tx.Rollback() // No-op if committed + ``` + +2. **Track old entity IDs for orphan cleanup later:** + - `oldArtistCreditID := params.oldRecording.ArtistCreditID` + - `oldReleaseGroupIDs` from `params.oldRGLinks` + +3. **Handle artist change (`FieldArtist` in changes):** + - Upsert new artist_credit: `newAC, _ := txq.UpsertArtistCredit(ctx, newArtistName)` + - Upsert artist: `newArtist, _ := txq.UpsertArtist(ctx, newArtistName)` + - Link artist to credit: `txq.CreateArtistCreditArtist(ctx, ...)` — use `INSERT OR IGNORE` pattern (the sqlc query already has this via CreateArtistCreditArtist). + - The new `artist_credit_id` will be used in UpdateRecordingFull below. + +4. **Handle album change (`FieldAlbum` in changes):** + - Determine album artist credit ID: if `FieldAlbumArtist` also changed, upsert new album artist credit. Otherwise, use the track artist credit (same pattern as library scan's `resolveAlbumArtistCredit`). + - Upsert release group: `newRG, _ := txq.UpsertReleaseGroup(ctx, UpsertReleaseGroupParams{Name: newAlbumName, AlbumArtistCreditID: albumArtistCreditID, Year: yearValue})` + - Unlink old release_group_recording(s): for each old link, `txq.DeleteReleaseGroupRecordingByFK(ctx, DeleteReleaseGroupRecordingByFKParams{ReleaseGroupID: oldRGID, RecordingID: params.recordingID})` + - Create new link: `txq.CreateReleaseGroupRecording(ctx, CreateReleaseGroupRecordingParams{ReleaseGroupID: newRG.ID, RecordingID: params.recordingID, TrackNumber: trackNum, DiscNumber: discNum})` + +5. **Handle genre change (`FieldGenre` in changes):** + - Delete all existing recording_genres: `txq.DeleteRecordingGenres(ctx, params.recordingID)` + - Parse new genres: `genres := metadata.ParseGenres(newGenre)` — reuse existing multi-genre parser + - For each genre: `g, _ := txq.UpsertGenre(ctx, genreName)` then `txq.CreateRecordingGenre(ctx, CreateRecordingGenreParams{RecordingID: params.recordingID, GenreID: g.ID})` + +6. **Handle cover art change (`FieldCoverArt` in changes):** + - If setting new art (`[]byte` data): + - Hash: `hash := sha256.Sum256(data); hashStr := hex.EncodeToString(hash[:8])` + - Determine extension from MIME type + - Save to covers dir: `coverDir, _ := coverart.CoversDir(); filePath := filepath.Join(coverDir, fmt.Sprintf("%s.%s", hashStr, ext))` + - Write file if not exists (dedup by hash): `os.WriteFile(filePath, data, 0o644)` + - Generate thumbnails (3 tiers: _sm 100px, _md 200px, _lg 400px). Reuse the thumbnail generation logic from `library/coverart.go`. Since `generateSizedVariants` is an unexported method on `Library`, **extract the thumbnail generation into a shared function** OR duplicate the logic inline. Prefer extracting if feasible, but if the function has tight coupling to `Library`, duplicate with a clear comment referencing the source. + - Upsert cover_art DB record: `ca, _ := txq.UpsertCoverArt(ctx, UpsertCoverArtParams{IsEmbedded: true, FilePath: filePath, MimeType: mimeType})` + - For each release group linked to this recording, update cover_art_id: `txq.UpdateReleaseGroupCoverArt(ctx, UpdateReleaseGroupCoverArtParams{CoverArtID: sql.NullInt64{Int64: ca.ID, Valid: true}, ID: rgID})` + - If clearing art (nil value): + - Update release group cover_art_id to NULL: `txq.UpdateReleaseGroupCoverArt(ctx, UpdateReleaseGroupCoverArtParams{CoverArtID: sql.NullInt64{Valid: false}, ID: rgID})` + +7. **Update recording with all changed fields:** + - Build `UpdateRecordingFullParams` using new values where changed, old values where not. The recording already has the old values from `params.oldRecording`. + - `txq.UpdateRecordingFull(ctx, params)` + +8. **Update FTS5 search index:** + - `db.DeleteSearchIndex(params.audioFileID)` — IMPORTANT: This uses `db` directly (not the transaction) because FTS5 operations go through hand-crafted SQL on the DB struct. The FTS5 functions use `d.db.ExecContext`, so they run on the same underlying connection pool. However, since SQLite is single-writer (`SetMaxOpenConns(1)`), this is safe — the transaction holds the write lock, and these FTS operations will execute within the same connection. BUT: to be safe, consider passing the raw `*sql.Tx` and executing FTS SQL directly on the tx. + - Actually, the safest approach: execute FTS5 INSERT/DELETE directly on the transaction: + ```go + tx.ExecContext(ctx, "DELETE FROM search_index WHERE rowid = ?", params.audioFileID) + tx.ExecContext(ctx, "INSERT INTO search_index(rowid, file_path, title, artist, album) VALUES (?, ?, ?, ?, ?)", + params.audioFileID, params.filePath, newTitle, newArtist, newAlbum) + ``` + +9. **Orphan cleanup (within same transaction):** + - If artist changed and `oldArtistCreditID != newArtistCreditID`: + - `count, _ := txq.CountArtistCreditReferences(ctx, oldArtistCreditID)` + - If count == 0: delete artist_credit_artist entries for this credit, then `txq.DeleteArtistCredit(ctx, oldArtistCreditID)`. Also check if the old artist (from artist_credit_artist) is now orphaned. + - If album changed: + - For each old release_group_id: `count, _ := txq.CountReleaseGroupRecordings(ctx, oldRGID)` + - If count == 0: `txq.DeleteReleaseGroup(ctx, oldRGID)`. Also cleanup cover_art if the release_group's cover_art_id is now unreferenced. + - If genre changed: + - Old genre IDs aren't easily tracked (genres were deleted before re-linking). Use the global orphan cleanup pattern from `library/crud.go`: `tx.ExecContext(ctx, "DELETE FROM genres WHERE id NOT IN (SELECT DISTINCT genre_id FROM recording_genres)")`. This is safe and covers all cases. Add SAFETY comment. + +10. **Commit:** + ```go + return tx.Commit() + ``` + +Use `toNullInt64` and `toNullString` helper functions (define locally or import from library package if exported). Check if these helpers exist in the codebase — they're likely unexported in `library/library.go`. If so, define local versions in dbsync.go. + +All hand-crafted SQL must have `// SAFETY:` comments per codebase convention. + + + go build ./backend/tagwriter/... + + + - `backend/tagwriter/dbsync.go` exists with syncDatabase function + - Single transaction handles: entity upsert-and-relink, genre re-linking, cover art save + thumbnail generation, FTS5 delete + reinsert, orphan cleanup + - All hand-crafted SQL has SAFETY comments + - Code compiles + + + + + Task 2: Implement WriteTrackTags entry point with player/scan coordination, events, and app wiring + + backend/tagwriter/pipeline.go + backend/events/events.go + frontend/src/events.ts + backend/library/library.go + backend/app.go + backend/tagwriter/pipeline_test.go + + +**Step 1: Add TrackMetadataChanged event constant.** + +In `backend/events/events.go`, add a new const group: +```go +// Tag writing events. +const ( + TrackMetadataChanged = "TrackMetadataChanged" +) +``` + +Then regenerate the TypeScript events file: +```bash +cd backend/events && go generate +``` + +Verify `frontend/src/events.ts` now contains `TrackMetadataChanged`. + +**Step 2: Add scan/write mutual exclusion to Library.** + +In `backend/library/library.go`, add methods for write pipeline coordination: + +```go +// AcquireWriteLock acquires the library mutex for a tag write +// operation. The caller must call ReleaseWriteLock when done. +// If a scan is currently active, AcquireWriteLock blocks until +// it completes. +func (l *Library) AcquireWriteLock() { + l.mu.Lock() + // scanActive may still be true — the scan loop also holds mu + // only intermittently. For true mutual exclusion, we need + // the scan to check a writeActive flag too. +} + +// ReleaseWriteLock releases the library mutex after a tag write. +func (l *Library) ReleaseWriteLock() { + l.mu.Unlock() +} + +// IsScanActive returns whether a library scan is currently running. +func (l *Library) IsScanActive() bool { + l.mu.Lock() + defer l.mu.Unlock() + return l.scanActive +} +``` + +IMPORTANT: The current `Library.mu` is used briefly during scan operations (not held for the entire scan duration). For true mutual exclusion between scan and write, we need a different approach. Two options: + +**Option A (preferred — simple RWMutex):** Add a new `sync.RWMutex` field `pipelineMu` to Library. The scan pipeline acquires `pipelineMu.RLock()` at the start and releases at the end (multiple readers OK). The write pipeline acquires `pipelineMu.Lock()` (exclusive writer blocks until all readers done, and blocks readers while writing). This gives us: scans can run concurrently with each other (via the queue, not actually parallel), writes block until scan finishes, scans block while write is in progress. + +Actually, simpler: use a regular `sync.Mutex` as `pipelineMu`. Scan acquires at start, releases at end. Write acquires, releases. Only one can run at a time. This matches the user decision: "If a scan is running, the write waits for it to finish (and vice versa)." + +Add to Library struct: +```go +// pipelineMu provides mutual exclusion between the scan +// pipeline and the tag write pipeline. Acquired at the +// start of each pipeline, released at the end. +pipelineMu sync.Mutex +``` + +Expose methods: +```go +func (l *Library) AcquirePipelineLock() { l.pipelineMu.Lock() } +func (l *Library) ReleasePipelineLock() { l.pipelineMu.Unlock() } +``` + +Update the scan pipeline entry point (`ScanLibrary` or the internal `scan` method) to acquire/release `pipelineMu` around the scan. Find where the scan starts (in the scan queue drain loop) and add `l.pipelineMu.Lock()` before scan start and `defer l.pipelineMu.Unlock()` at scan end. Verify this doesn't deadlock by checking `l.mu` usage within the scan — `pipelineMu` must be acquired BEFORE `l.mu` if both are needed, or they must never be held simultaneously. + +**Step 3: Create `backend/tagwriter/pipeline.go`:** + +Define the `TagWriter` struct and `WriteTrackTags` method: + +```go +// TagWriter orchestrates the complete tag writing pipeline: +// file write → DB sync → event emission. +type TagWriter struct { + logger *slog.Logger + db *database.DB + ctx context.Context // Wails context for event emission + + // Player interface for checking/stopping currently-playing track. + player PlayerStopper + + // Library interface for scan/write mutual exclusion. + library PipelineLocker +} + +// PlayerStopper abstracts the player operations needed by the +// write pipeline. Breaks the import cycle (tagwriter cannot +// import player directly if player imports tagwriter). +type PlayerStopper interface { + GetCurrentTrackInfo() player.TrackInfo + UnloadTrack() +} + +// PipelineLocker abstracts the library's pipeline mutex. +type PipelineLocker interface { + AcquirePipelineLock() + ReleasePipelineLock() +} +``` + +Wait — check if there's a circular import issue. `tagwriter` needs `player.TrackInfo` type. If we define the interface with the concrete type, we need to import player. Instead, define a minimal interface: + +```go +// PlayerStopper checks whether a file is currently playing +// and stops playback if needed. +type PlayerStopper interface { + // CurrentFilePath returns the file path of the currently- + // loaded track, or empty string if nothing is loaded. + CurrentFilePath() string + // StopAndRelease stops playback and releases the file + // handle. + StopAndRelease() +} +``` + +Then in `app.go`, create a small adapter that wraps `*player.Player` to satisfy `PlayerStopper`: +```go +type playerAdapter struct{ p *player.Player } +func (a *playerAdapter) CurrentFilePath() string { + return a.p.GetCurrentTrackInfo().FilePath +} +func (a *playerAdapter) StopAndRelease() { a.p.UnloadTrack() } +``` + +**`NewTagWriter` constructor:** +```go +func NewTagWriter( + logger *slog.Logger, + db *database.DB, + player PlayerStopper, + library PipelineLocker, +) *TagWriter +``` + +Uses `logger.WithGroup("tagwriter")`. + +**`SetContext(ctx context.Context)`** — two-phase init pattern. Stores the Wails context for event emission. + +**`WriteTrackTags(trackID int64, changes TagChanges) error`:** + +1. **Validate inputs:** changes must not be empty. + +2. **Look up track:** `audioFile, err := tw.db.Queries.GetAudioFile(ctx, trackID)`. Get `recording, err := tw.db.Queries.GetRecording(ctx, audioFile.RecordingID)`. Get `rgLinks, err := tw.db.Queries.GetRecordingReleaseGroups(ctx, recording.ID)`. + +3. **Detect format:** `format, err := DetectFormat(audioFile.FilePath)`. + +4. **Acquire pipeline lock:** `tw.library.AcquirePipelineLock(); defer tw.library.ReleasePipelineLock()`. + +5. **Player safety check:** `if tw.player.CurrentFilePath() == audioFile.FilePath { tw.player.StopAndRelease() }`. + +6. **Write file tags:** + ```go + switch format { + case FormatMP3: + err = writeMp3Tags(tw.logger, audioFile.FilePath, changes) + case FormatFLAC: + err = writeFlacTags(tw.logger, audioFile.FilePath, changes) + } + ``` + If error, return immediately (DB untouched per user decision). + +7. **Sync database:** `err = syncDatabase(ctx, tw.logger, tw.db, dbSyncParams{...})`. + If error, log and return. Note: file has new tags but DB has old data. This is acceptable per user decision ("next scan would reconcile"). + +8. **Emit event:** + ```go + runtime.EventsEmit(tw.ctx, events.TrackMetadataChanged, map[string]any{ + "trackId": trackID, + "filePath": audioFile.FilePath, + }) + ``` + +9. Log success with timing. + +**Step 4: Wire into `backend/app.go`:** + +- Add `tagWriter *tagwriter.TagWriter` field to `YellowJacketApp`. +- In `NewYellowJacketApp`: create `tagWriter` after database, player, library are created. + ```go + yjApp.tagWriter = tagwriter.NewTagWriter( + yjApp.logger, + yjApp.database, + &playerAdapter{p: yjApp.player}, + yjApp.library, + ) + ``` +- In `OnStartup`: call `yj.tagWriter.SetContext(ctx)`. +- Add `tagWriter` to `FEBindings` slice so `WriteTrackTags` is accessible from the frontend via Wails. + +**Step 5: Create `backend/tagwriter/pipeline_test.go`:** + +Integration tests using `database.NewTestDB(t)` for an in-memory database: + +1. `TestWriteTrackTags_MP3_FullPipeline` — Create a test MP3 file, insert audio_file + recording + artist_credit + release_group into test DB. Call `WriteTrackTags` with title + artist + album changes. Verify: + - File has new tags (read back with `metadata.ExtractTags`) + - DB recording has new values + - New artist_credit exists + - Old artist_credit is orphaned and deleted (if it was the only reference) + - FTS5 search index has new values + +2. `TestWriteTrackTags_PlayerSafety` — Create a mock PlayerStopper that records calls. Set `CurrentFilePath` to match the target file. Verify `StopAndRelease` is called before write. + +3. `TestWriteTrackTags_ScanMutex` — Verify that `AcquirePipelineLock` is called (mock PipelineLocker that records calls). + +4. `TestWriteTrackTags_OrphanCleanup` — Set up a recording with artist credit referenced by only one track. Change the artist. Verify old artist_credit and artist are deleted. + +5. `TestWriteTrackTags_GenreRelink` — Change genre from "Rock" to "Jazz; Blues" (multi-genre). Verify old recording_genres deleted, new ones created, old genre orphan deleted if unreferenced. + +Use mock implementations of `PlayerStopper` and `PipelineLocker` for unit testing. For the DB-related tests, use `database.NewTestDB(t)` which provides a real in-memory SQLite with production schema. + +Run: +```bash +go test ./backend/tagwriter/... -v -count=1 +golangci-lint run ./backend/tagwriter/... ./backend/library/... ./backend/events/... +``` + + + go test ./backend/tagwriter/... -v -count=1 && golangci-lint run ./backend/tagwriter/... ./backend/library/... ./backend/events/... && go build ./... + + + - `backend/tagwriter/pipeline.go` exists with TagWriter struct and WriteTrackTags entry point + - Player safety: currently-playing track is stopped before write + - Scan/write mutual exclusion via pipelineMu on Library + - `backend/events/events.go` has TrackMetadataChanged constant + - `frontend/src/events.ts` auto-generated with TrackMetadataChanged + - `backend/app.go` creates TagWriter, wires dependencies, registers as Wails binding + - 5 pipeline integration tests pass + - Full project compiles (`go build ./...`) + - Linter passes + + + + + + +```bash +# Full pipeline tests +go test ./backend/tagwriter/... -v -count=1 + +# Full project build (no compilation errors from wiring) +go build ./... + +# Lint clean +golangci-lint run ./backend/tagwriter/... ./backend/library/... ./backend/events/... + +# Events generated +grep TrackMetadataChanged frontend/src/events.ts +``` + + + +- WriteTrackTags accepts track ID + TagChanges, writes file, syncs DB, emits event — single function call per user decision +- Entity relink uses upsert-and-relink pattern (never mutates shared rows) +- Orphan cleanup deletes unreferenced artist_credits, release_groups, genres immediately +- FTS5 updated within the DB transaction (delete old + insert new) +- Player is auto-stopped before writing currently-playing file +- Scan and write pipelines use pipelineMu for mutual exclusion +- TrackMetadataChanged event emitted on success +- TagWriter is wired into app.go as Wails binding (accessible from frontend) +- All tests pass, lint clean, project builds + + + +After completion, create `.planning/phases/16-tag-writing-database-sync/16-03-SUMMARY.md` +