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...

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 TagWriters 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.