# Track "Get Info" — Design Spec **Date:** 2026-05-30 **Status:** Approved (design); pending implementation plan **Branch:** feat/music-streaming ## Goal Replicate the macOS Music app's right-click **Get Info** experience: a dialog that shows all of a track's metadata and lets the user edit it. Edits persist to the app's library (SQLite DB) **and** are written back into the audio file's embedded tags, mirroring how the real Music app mutates files. ## Decisions (locked) | Question | Decision | |---|---| | Where edits are saved | **DB + write file tags** (best-effort file writeback) | | Single vs multi-track | **Both** — single track shows all fields; multiple selected tracks use mixed-value handling | | Field scope | **Existing model fields only** — no schema migration; read-only File info section | | Writeback formats (v1) | **mp3** (ID3TagEditor) + **m4a / alac / aac** (AVFoundation). flac/wav/aiff → DB only with a UI note | | Tag library strategy | **Lighter path, TagLib-ready** — abstract behind a `TagWriter` protocol so a TagLib writer can be added later for flac/wav/aiff with no rework | | Layout | **Tabbed** (Details / File), like macOS Music | | Failure model | **DB is always saved; file writeback is best-effort** with a non-blocking warning on failure | ## Non-goals (v1) - No new metadata fields (no comments, lyrics, sorting, artwork). Editing is limited to fields already present on the `Track` model. - No album-artwork display or editing. - No flac/wav/aiff **file-tag** writeback (those edits save to the DB only for now). The architecture leaves a clean seam to add TagLib later. ## Data model context `Track` (Music/Models/Track.swift) already holds every field we need. No migration. **Editable fields** (surfaced on the Details tab): `title`, `artist`, `albumArtist`, `album`, `genre`, `composer` (String); `year`, `trackNumber`, `discNumber`, `bpm` (Int?); `rating` (Int 0–5). > **rating is DB-only in v1.** It's an app/iTunes concept with format-specific > scales (ID3 POPM 0–255, iTunes atom 0–100), so writing it to file tags is > deferred. `EditableTrackFields` includes `rating`, but the `TagWriter`s ignore it > — rating persists only via `updateTrack`. All other editable fields are written to > both file and DB (where a writer exists). **Read-only fields** (File tab): `fileURL` (path), `fileFormat`, `bitrate`, `sampleRate`, `fileSize`, `duration`, `playCount`, `lastPlayedAt`, `dateAdded`, `dateModified`. (`playCount`/`lastPlayedAt`/`rating` are app-managed, not file tags.) > Metadata source today: tracks are read from file tags via AVFoundation **once at > import** (ScannerService), then the DB is the source of truth. `insertBatch` uses > `.ignore` on conflict, so re-scans do not clobber DB edits. ## Architecture & components Each unit has one responsibility and a well-defined interface. ### `EditableTrackFields` (new value type) A plain struct of the ~11 editable fields. The unit of "what the user can change" and "what changed." Decouples the sheet and the writer from the full `Track`. ### `TagWriter` protocol + factory (new) ``` protocol TagWriter { func write(_ fields: EditableTrackFields, to fileURL: URL) throws } ``` - `TagWriterFactory.writer(for: URL) -> TagWriter?` selects by file extension; returns `nil` for unsupported formats (flac/wav/aiff in v1). - Implementations: - `ID3TagWriter` — mp3, via the **ID3TagEditor** SPM package. - `MP4TagWriter` — m4a / alac / aac, via **AVFoundation** `AVAssetExportSession` (`AVAssetExportPresetPassthrough`) writing iTunes/common metadata items. - **No code outside the writers knows how tags are encoded per format.** ### `TrackEditService` (new) Orchestrates one save. For each target track: 1. Diff `EditableTrackFields` against the original → set of changed fields. 2. If a `TagWriter` exists for the format: write tags to a **temp copy**, then `FileManager.replaceItemAt` to atomically swap the original (never leaves a half-written file). 3. Recompute `fileSize`, `dateModified`, `fileHash` from the new file. 4. Build the updated `Track` (changed fields + refreshed stats) and persist via `DatabaseService.updateTrack`. 5. On file-write failure (or unsupported format): still persist DB changes; collect a warning for that track. Owns the **single- and multi-track diff logic** as pure, testable functions (no UI, no I/O in the diff step). ### `TrackInfoSheet` (new SwiftUI view) The Get Info dialog (see Layout). Holds local `@State` for the edited fields, prefilled from the target(s). On Save, hands `EditableTrackFields` + the target track set to `TrackEditService`. Models the `.sheet` pattern already used by `SmartPlaylistBuilderSheet`. ### `DatabaseService.updateTrack(_ track: Track) throws` (new method) GRDB `track.update(db)` inside `dbPool.write`. **Implementation plan must verify whether the `tracks_ft` FTS5 table is kept in sync automatically (triggers / external-content) and, if not, update it here.** ### Context-menu integration (edits) - Add **Get Info** (⌘I) to `TrackContextMenuConfig`, `TrackTableView`'s `NSMenu` (`menuNeedsUpdate`), and `TrackContextMenuModifier`. - Target resolution: the menu operates on the **current selection if the right-clicked row is part of it**, otherwise just the clicked row (matches macOS Music). This requires the config to expose the current multi-selection (or a callback that returns the target set), not just a single `Track`. - `ContentView` holds the presented-target state and shows the `.sheet`. ## Save sequence (per track) ``` edit fields ─▶ diff vs original ─▶ writer for format? │ yes │ no / unsupported ▼ ▼ write tags to temp copy (skip file write, ─▶ atomic replace original collect "DB-only" note) │ success? │ fail ─────────┐ ▼ ▼ recompute size/mod/hash keep old stats + warning │ │ └──────┬────────┘ ▼ updateTrack(...) in DB ``` Result: the **DB edit always lands**; file writeback is best-effort. Failures surface as a single non-blocking summary alert ("Saved to library. Couldn't write tags to N file(s): "). ## Multi-track behavior - Prefill: fields with one shared value across all targets prefill normally; fields that differ show a **"Mixed"** placeholder and start empty. - Apply: **only fields the user actually edits** are applied — to all targets. Untouched "Mixed" fields are left per-track unchanged. - Saving N tracks runs in a background `Task`, sequentially, with a small progress indicator when N is large. Per-track failures aggregate into one summary. ## UI layout (tabbed) ``` ┌─ Get Info ──────────────────────────────┐ │ [ Details ] [ File ] │ ├──────────────────────────────────────────┤ │ Title [_______________________] │ │ Artist [_______________________] │ │ Album Artist[_______________________] │ │ Album [_______________________] │ │ Genre [_______________________] │ │ Composer [_______________________] │ │ Year [____] Track [__]/[__] Disc [__]/[__] │ BPM [____] Rating ★★★☆☆ │ ├──────────────────────────────────────────┤ │ File tab: format, bitrate, sample rate, │ │ size, duration, path, date added, │ │ play count, last played — read-only │ ├──────────────────────────────────────────┤ │ [ Cancel ] [ Save ] │ └──────────────────────────────────────────┘ ``` - Numeric fields (year / track / disc / bpm) validate on input. - flac/wav/aiff targets show a subtle note under the tabs: *"Edits save to your library only — tag writing isn't supported for .flac yet."* - Cancel = `.cancelAction`, Save = `.defaultAction`. ## Error handling & risks - **Atomic replace** (temp file + `replaceItemAt`) prevents audio-file corruption on a failed/interrupted write. - **File-write failure** → DB still saved + non-blocking warning with the reason. - **Sandbox / permissions (must verify early):** if the app is sandboxed, writing to user audio files requires the appropriate entitlement and/or a security-scoped bookmark for the music folder. If writing is blocked, file writeback cannot work regardless of library — DB edits still work. Verify before building the writers. - **New dependency:** ID3TagEditor added via SPM to the Xcode project. - **Hash drift:** writing tags changes file size/mod-date → `fileHash`. Step 3 refreshes these so the next scan doesn't treat the file as changed. ## Testing (TDD) - **TagWriter round-trip:** write `EditableTrackFields` to bundled mp3 and m4a fixtures, re-read via AVFoundation, assert each field; assert the file remains a valid, playable asset after atomic replace. - **Diff / multi-track logic:** pure-function, table-driven tests for "which fields changed," "shared vs Mixed across N tracks," and "apply only edited fields to all." - **Stat refresh:** `fileSize` / `dateModified` / `fileHash` recomputed after writeback. - **Factory:** correct writer per extension; `nil` for flac/wav/aiff. - **`updateTrack`:** persists edited fields and keeps `tracks_ft` in sync. ## Implementation outline 1. Add ID3TagEditor SPM dependency; confirm sandbox/file-write permissions. 2. `EditableTrackFields` + diff/multi-track pure logic (+ tests). 3. `TagWriter` protocol, factory, `ID3TagWriter`, `MP4TagWriter` (+ round-trip tests). 4. `DatabaseService.updateTrack` (+ FTS sync) (+ test). 5. `TrackEditService` wiring the save sequence (+ tests). 6. `TrackInfoSheet` UI (tabs, validation, Mixed handling). 7. Context-menu "Get Info" (⌘I) + target resolution + `ContentView` sheet presentation. 8. Manual verification against real mp3/m4a/flac files.