You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
Music/docs/superpowers/specs/2026-05-30-track-get-info-d...

206 lines
10 KiB

# 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): <reason>").
## 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.