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.
206 lines
10 KiB
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.
|
|
|