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
Trackmodel. - 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.
EditableTrackFieldsincludesrating, but theTagWriters ignore it — rating persists only viaupdateTrack. 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.
insertBatchuses.ignoreon 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; returnsnilfor unsupported formats (flac/wav/aiff in v1).- Implementations:
ID3TagWriter— mp3, via the ID3TagEditor SPM package.MP4TagWriter— m4a / alac / aac, via AVFoundationAVAssetExportSession(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:
- Diff
EditableTrackFieldsagainst the original → set of changed fields. - If a
TagWriterexists for the format: write tags to a temp copy, thenFileManager.replaceItemAtto atomically swap the original (never leaves a half-written file). - Recompute
fileSize,dateModified,fileHashfrom the new file. - Build the updated
Track(changed fields + refreshed stats) and persist viaDatabaseService.updateTrack. - 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'sNSMenu(menuNeedsUpdate), andTrackContextMenuModifier. - 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. ContentViewholds 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
EditableTrackFieldsto 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/fileHashrecomputed after writeback. - Factory: correct writer per extension;
nilfor flac/wav/aiff. updateTrack: persists edited fields and keepstracks_ftin sync.
Implementation outline
- Add ID3TagEditor SPM dependency; confirm sandbox/file-write permissions.
EditableTrackFields+ diff/multi-track pure logic (+ tests).TagWriterprotocol, factory,ID3TagWriter,MP4TagWriter(+ round-trip tests).DatabaseService.updateTrack(+ FTS sync) (+ test).TrackEditServicewiring the save sequence (+ tests).TrackInfoSheetUI (tabs, validation, Mixed handling).- Context-menu "Get Info" (⌘I) + target resolution +
ContentViewsheet presentation. - Manual verification against real mp3/m4a/flac files.