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/plans/2026-05-30-track-get-info.md

1194 lines
56 KiB

# Track "Get Info" Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a macOS-Music-style "Get Info" dialog (right-click + ⌘I) that views and edits a track's metadata, persisting edits to the SQLite DB always and writing them back into the audio file's tags (mp3 + m4a/alac/aac) best-effort.
**Architecture:** A `TagWriter` protocol abstracts per-format tag writing (ID3TagEditor for mp3, AVFoundation passthrough-export for m4a-family; flac/wav/aiff fall back to DB-only). A `TrackEditService` orchestrates each save (apply edited fields → write file tags → refresh file stats → DB update). A tabbed SwiftUI `TrackInfoSheet` collects edits; the context menu resolves single- or multi-track targets from the table selection.
**Tech Stack:** Swift 6 / SwiftUI / AppKit (NSTableView), GRDB (SQLite + FTS5), AVFoundation, ID3TagEditor (new SPM dep), Swift Testing.
---
## Verified facts (from codebase inspection — trust these over assumptions)
- **`Track`** (`Music/Models/Track.swift`): 23 stored props incl. `id: Int64?`, `fileURL: String` (stored as `url.absoluteString`, i.e. `file://…`), the editable set (`title, artist, albumArtist, album, genre, composer: String`; `year, trackNumber, discNumber, bpm: Int?`; `rating: Int`), and `fileSize: Int64`, `dateModified: Date`, `fileHash: String`. Conforms to `FetchableRecord, MutablePersistableRecord`, `databaseTableName = "tracks"`. Has `Track.computeHash(fileSize:modificationDate:) -> String` returning `"\(fileSize)_\(Int(modificationDate.timeIntervalSince1970))"`. Has `Track.fixture(...)` (DEBUG) for tests.
- **`DatabaseService`** (`Music/Services/DatabaseService.swift`): `nonisolated final class … Sendable`; connection is `let dbPool: DatabaseWriter`; writes via `dbPool.write { db in … }`. `init(inMemory: Bool)` for tests. FTS5 `tracks_ft` is created with `t.synchronize(withTable: "tracks")`, which installs INSERT/UPDATE/DELETE **triggers** — so a plain `track.update(db)` keeps FTS in sync automatically (no manual FTS code). No `updateTrack` exists yet.
- **`ScannerService.extractMetadata`** (`Music/Services/ScannerService.swift:162-188`) computes stats as: `let attrs = try FileManager.default.attributesOfItem(atPath: url.path); let fileSize = attrs[.size] as? Int64 ?? 0; let modDate = attrs[.modificationDate] as? Date ?? Date()` then `fileHash: Track.computeHash(fileSize: fileSize, modificationDate: modDate)`. Supported extensions: `mp3, m4a, aac, wav, aiff, alac, flac`.
- **`LibraryViewModel`** (`Music/ViewModels/LibraryViewModel.swift`): `@Observable final class` (implicitly `@MainActor`). Exposes `var tracks: [Track]`. Holds `private let db: DatabaseService`. Uses GRDB **`ValueObservation`** (`updateQuery()`), so `tracks` **auto-refreshes whenever the DB changes** — there is NO `loadTracks()` and none is needed after an edit.
- **`TrackTableView`** (`Music/Views/TrackTableView.swift`): `NSViewRepresentable`. Props: `tracks, playingTrackId, sortColumn, sortAscending, onSort, onDoubleClick, contextMenuConfig, onReorder, scrollToPlayingTrigger`. **`allowsMultipleSelection = false` (line 57)** and there is **no selection binding** — selection lives in `NSTableView.selectedRowIndexes`. The `Coordinator` (NSMenuDelegate) builds the menu in `menuNeedsUpdate(_:)` off `tableView.clickedRow` and maps rows via its own `tracks` array. `@objc` handlers (`addToPlaylist` etc.) follow the pattern: guard `clickedRow`, read `config`, call closure.
- **`TrackContextMenuConfig`** (`Music/Models/TrackContextMenuConfig.swift`): `nonisolated struct` with `playlists, lastUsedPlaylistName, selectedPlaylist, onAddToPlaylist, onAddToLastPlaylist?, onRemoveFromPlaylist?`.
- **`TrackContextMenuModifier`** (`Music/Views/TrackContextMenuModifier.swift`): `struct … ViewModifier` whose `body(content:)` adds `content.contextMenu { if let track, let config { … } }`; plus `extension View { func trackContextMenu(track:config:) }`. Used by `PlayerControlsView` (`.trackContextMenu(track: currentTrack, config: contextMenuConfig)`).
- **`ContentView`** (`Music/ContentView.swift` — note: under `Music/`, not `Music/Views/`): has a computed `private var trackContextMenuConfig: TrackContextMenuConfig` that delegates to the `playlist` view model; passes it into `TrackTableView(... contextMenuConfig: trackContextMenuConfig ...)`. `displayedTracks` returns `library.tracks`. Already holds `var library: LibraryViewModel` and `var db: DatabaseService`. Uses `.sheet(isPresented:)` and `.sheet(item:)` patterns.
- **Sheet UI pattern** (`Music/Views/SmartPlaylistBuilderSheet.swift`): `struct` with `var onSave/onCancel` closures, `@State` form fields set in a custom `init`, a `canSave` computed flag, body is a `VStack(alignment:.leading, spacing:16)` with `.padding(20).frame(width: 540)`, and an `HStack { Spacer(); Button("Cancel", action: onCancel); Button("Save"){…}.disabled(!canSave).keyboardShortcut(.defaultAction) }`.
- **Sandbox (CRITICAL):** `Music/Music.entitlements` has `com.apple.security.files.user-selected.read-only = true` and `com.apple.security.files.bookmarks.app-scope = true` — i.e. the app currently has **READ-ONLY** access to the user's music folder. `Music/MusicApp.swift` lets the user pick a folder via `NSOpenPanel` (line ~175), saves a security-scoped bookmark (`url.bookmarkData(options: .withSecurityScope)`, key `"musicFolderBookmark"`), and resolves it at launch with `url.startAccessingSecurityScopedResource()`. **File-tag writeback is impossible until the entitlement is changed to read-write AND a read-write bookmark is obtained** (Task 1).
- **SPM:** GRDB is integrated as a remote SPM package (`packageReferences` in `Music.xcodeproj/project.pbxproj`, lines ~234, ~658-685). ID3TagEditor must be added the same way (Task 2).
- **Tests:** `MusicTests/` uses **Swift Testing** (`import Testing`, `@testable import Music`, `struct SomeTests { @Test func name() throws { … } }`, assertions via `#expect(...)` and `try #require(...)`), constructs `try DatabaseService(inMemory: true)`, and uses `Track.fixture(...)`. (Do NOT use XCTest.) Async tests are `@Test func name() async throws`. To reach bundled fixtures from a `struct` suite, use a token class: `private final class BundleToken {}` then `Bundle(for: BundleToken.self)`.
### Commands used throughout
- Build: `xcodebuild build -scheme Music -destination 'platform=macOS,arch=arm64'`
- Test one class: `xcodebuild test -scheme Music -destination 'platform=macOS,arch=arm64' -only-testing:MusicTests/<ClassName>`
- If `-scheme Music` is wrong, list with `xcodebuild -list -project Music.xcodeproj` and use the app scheme.
- A "failing test" in Swift/Xcode is often a **build failure** (symbol not found). That counts as red.
---
## Task 1: Enable read-write file access and prove a write works
**Files:**
- Modify: `Music/Music.entitlements`
- Verify: `Music/MusicApp.swift` (bookmark save/resolve), no code change required unless re-grant needed
- [ ] **Step 1: Flip the user-selected entitlement to read-write**
In `Music/Music.entitlements`, replace:
```xml
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
```
with:
```xml
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
```
If the Xcode target also carries a managed build setting `ENABLE_USER_SELECTED_FILES = readonly`, set it to `readwrite` (Target → Signing & Capabilities → App Sandbox → User Selected File → Read/Write) so it doesn't override the entitlements file. Confirm: `grep -n "user-selected" Music/Music.entitlements` shows `read-write`.
- [ ] **Step 2: Re-grant the music folder with write access**
The existing bookmark in `UserDefaults["musicFolderBookmark"]` was created read-only. After the entitlement change, run the app and **re-select the music folder** via the existing folder picker so `saveBookmark(for:)` stores a read-write security-scoped bookmark. (No code change — just exercise the existing picker once.)
- [ ] **Step 3: Build**
Run: `xcodebuild build -scheme Music -destination 'platform=macOS,arch=arm64'`
Expected: BUILD SUCCEEDED.
- [ ] **Step 4: Permission spike — prove a write succeeds**
Add a temporary throwaway `@Test` that copies a fixture audio file into the user music folder and writes a byte, OR (simpler) run the app, open a track in the folder, and in a scratch action call:
```swift
let url = URL(string: track.fileURL)!
let fh = try FileHandle(forWritingTo: url) // throws if not writable
try fh.close()
```
Expected: no permission error (`FileHandle(forWritingTo:)` succeeds). If it throws "Operation not permitted", the bookmark is still read-only — repeat Step 2. **Do not proceed past this task until a write to a real library file succeeds.** Remove the scratch code afterward.
- [ ] **Step 5: Commit**
```bash
git add Music/Music.entitlements
git commit -m "feat: enable read-write file access for tag writeback"
```
---
## Task 2: Add the ID3TagEditor SPM dependency
**Files:**
- Modify: `Music.xcodeproj/project.pbxproj` + `Music.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved` (Xcode-managed)
- [ ] **Step 1: Add the package**
In Xcode: File → Add Package Dependencies → `https://github.com/chicio/ID3TagEditor` → Up to Next Major `4.0.0` → add the `ID3TagEditor` product to the **Music** app target only. (Headless fallback: mirror the GRDB entries in `project.pbxproj``XCRemoteSwiftPackageReference`, `XCSwiftPackageProductDependency`, `packageReferences`, and the Music target's `Frameworks` build phase + `packageProductDependencies` — then `xcodebuild -resolvePackageDependencies -project Music.xcodeproj`. This is fiddly; prefer the Xcode UI, and surface to the user if it can't be done headless.)
- [ ] **Step 2: Verify it links**
Add `import ID3TagEditor` to the top of `Music/Services/ScannerService.swift` temporarily, then build:
Run: `xcodebuild build -scheme Music -destination 'platform=macOS,arch=arm64'`
Expected: BUILD SUCCEEDED. Remove the temporary import.
- [ ] **Step 3: Commit**
```bash
git add Music.xcodeproj
git commit -m "build: add ID3TagEditor SPM dependency"
```
---
## Task 3: `TrackFileStats` helper (shared stat/hash computation)
**Files:**
- Create: `Music/Services/TrackFileStats.swift`
- Create: `MusicTests/TrackFileStatsTests.swift`
- Modify: `Music/Services/ScannerService.swift:162-188` (use the helper)
- [ ] **Step 1: Write the failing test**
`MusicTests/TrackFileStatsTests.swift`:
```swift
import Foundation
import Testing
@testable import Music
// Verifies the shared file-stat helper reads size/mod-date from disk and
// produces a fileHash identical to Track.computeHash (the existing canonical formula).
struct TrackFileStatsTests {
@Test func compute_matchesTrackComputeHash() throws {
// Step 1: write a temp file with known bytes.
let url = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent(UUID().uuidString + ".bin")
try Data(repeating: 0xAB, count: 1234).write(to: url)
defer { try? FileManager.default.removeItem(at: url) }
// Step 2: compute stats via the helper.
let stats = try TrackFileStats.compute(for: url)
// Step 3: independently read attrs and assert the helper agrees.
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
let size = attrs[.size] as? Int64 ?? -1
let mod = attrs[.modificationDate] as? Date ?? Date.distantPast
#expect(stats.fileSize == size)
#expect(stats.dateModified == mod)
#expect(stats.fileHash == Track.computeHash(fileSize: size, modificationDate: mod))
}
}
```
- [ ] **Step 2: Run it (expect failure)**
Run: `xcodebuild test -scheme Music -destination 'platform=macOS,arch=arm64' -only-testing:MusicTests/TrackFileStatsTests`
Expected: FAIL — build error "cannot find 'TrackFileStats' in scope".
- [ ] **Step 3: Implement the helper**
`Music/Services/TrackFileStats.swift`:
```swift
import Foundation
// Reads a file's size + modification date and derives the library fileHash.
// Centralizes the computation so ScannerService (import) and TrackEditService
// (post-writeback refresh) can never drift. Hash uses Track.computeHash so the
// format stays identical to import-time hashes.
nonisolated struct TrackFileStats: Sendable {
let fileSize: Int64
let dateModified: Date
let fileHash: String
static func compute(for url: URL) throws -> TrackFileStats {
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
let fileSize = attrs[.size] as? Int64 ?? 0
let modDate = attrs[.modificationDate] as? Date ?? Date()
return TrackFileStats(
fileSize: fileSize,
dateModified: modDate,
fileHash: Track.computeHash(fileSize: fileSize, modificationDate: modDate)
)
}
}
```
- [ ] **Step 4: Run it (expect pass)**
Run: `xcodebuild test -scheme Music -destination 'platform=macOS,arch=arm64' -only-testing:MusicTests/TrackFileStatsTests`
Expected: PASS.
- [ ] **Step 5: Refactor ScannerService to use it (behavior-preserving)**
In `Music/Services/ScannerService.swift`, replace lines 162-164:
```swift
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
let fileSize = attrs[.size] as? Int64 ?? 0
let modDate = attrs[.modificationDate] as? Date ?? Date()
```
with:
```swift
let stats = try TrackFileStats.compute(for: url)
```
and update the `Track(...)` initializer to use `fileSize: stats.fileSize`, `dateModified: stats.dateModified`, `fileHash: stats.fileHash` (replacing the three `fileSize` / `modDate` / `Track.computeHash(...)` usages at lines 182, 187, 188).
- [ ] **Step 6: Build + run scanner-related tests**
Run: `xcodebuild build -scheme Music -destination 'platform=macOS,arch=arm64'`
Expected: BUILD SUCCEEDED.
- [ ] **Step 7: Commit**
```bash
git add Music/Services/TrackFileStats.swift MusicTests/TrackFileStatsTests.swift Music/Services/ScannerService.swift
git commit -m "refactor: extract TrackFileStats shared stat/hash helper"
```
---
## Task 4: `EditableTrackFields` + `TrackField` + diff/shared/apply logic
**Files:**
- Create: `Music/Models/EditableTrackFields.swift`
- Create: `MusicTests/EditableTrackFieldsTests.swift`
- [ ] **Step 1: Write the failing test**
`MusicTests/EditableTrackFieldsTests.swift`:
```swift
import Foundation
import Testing
@testable import Music
// Verifies the pure single/multi-track edit logic: extraction, change detection,
// shared-vs-mixed across many tracks, and applying only edited fields.
struct EditableTrackFieldsTests {
@Test func initCopiesEditableValues() {
// Step 1: build fields from a fixture track.
let t = Track.fixture(title: "A", artist: "B", album: "C", year: 2001, rating: 3)
let f = EditableTrackFields(from: t)
// Step 2: editable values match.
#expect(f.title == "A"); #expect(f.artist == "B")
#expect(f.album == "C"); #expect(f.year == 2001); #expect(f.rating == 3)
}
@Test func changedFieldsDetectsOnlyDifferences() {
// Step 1: two field sets differing only in genre + bpm.
let a = EditableTrackFields(from: .fixture(genre: "Rock", bpm: 120))
var b = a; b.genre = "Jazz"; b.bpm = 90
// Step 2: change set is exactly {genre, bpm}.
#expect(a.changedFields(to: b) == [.genre, .bpm])
}
@Test func sharedMarksDifferingFieldsMixed() {
// Step 1: two tracks share artist but differ in genre.
let t1 = Track.fixture(artist: "Same", genre: "Rock")
let t2 = Track.fixture(artist: "Same", genre: "Pop")
// Step 2: shared() returns common artist and flags genre as mixed.
let (values, mixed) = EditableTrackFields.shared(across: [t1, t2])
#expect(values.artist == "Same")
#expect(mixed.contains(.genre))
#expect(!mixed.contains(.artist))
}
@Test func applyOnlyWritesEditedFields() {
// Step 1: a track and a fields object that changes album only.
let t = Track.fixture(album: "Old", genre: "Rock")
var f = EditableTrackFields(from: t); f.album = "New"; f.genre = "IGNORED"
// Step 2: applying with editing={.album} changes album, leaves genre.
let out = f.apply(editing: [.album], to: t)
#expect(out.album == "New")
#expect(out.genre == "Rock")
}
@Test func applyEmptyEditSetReturnsUnchanged() {
let t = Track.fixture(title: "Keep")
let f = EditableTrackFields(from: t)
#expect(f.apply(editing: [], to: t) == t)
}
}
```
- [ ] **Step 2: Run it (expect failure)**
Run: `xcodebuild test -scheme Music -destination 'platform=macOS,arch=arm64' -only-testing:MusicTests/EditableTrackFieldsTests`
Expected: FAIL — "cannot find 'EditableTrackFields' / 'TrackField'".
- [ ] **Step 3: Implement**
`Music/Models/EditableTrackFields.swift`:
```swift
import Foundation
// The user-editable subset of Track, plus the pure logic for single- and
// multi-track editing. No UI, no I/O — fully unit-testable.
nonisolated enum TrackField: CaseIterable, Sendable {
case title, artist, albumArtist, album, genre, composer
case year, trackNumber, discNumber, bpm, rating
}
nonisolated struct EditableTrackFields: Equatable, Sendable {
var title: String
var artist: String
var albumArtist: String
var album: String
var genre: String
var composer: String
var year: Int?
var trackNumber: Int?
var discNumber: Int?
var bpm: Int?
var rating: Int
init(from t: Track) {
title = t.title; artist = t.artist; albumArtist = t.albumArtist
album = t.album; genre = t.genre; composer = t.composer
year = t.year; trackNumber = t.trackNumber; discNumber = t.discNumber
bpm = t.bpm; rating = t.rating
}
func changedFields(to other: EditableTrackFields) -> Set<TrackField> {
var changed: Set<TrackField> = []
if title != other.title { changed.insert(.title) }
if artist != other.artist { changed.insert(.artist) }
if albumArtist != other.albumArtist { changed.insert(.albumArtist) }
if album != other.album { changed.insert(.album) }
if genre != other.genre { changed.insert(.genre) }
if composer != other.composer { changed.insert(.composer) }
if year != other.year { changed.insert(.year) }
if trackNumber != other.trackNumber { changed.insert(.trackNumber) }
if discNumber != other.discNumber { changed.insert(.discNumber) }
if bpm != other.bpm { changed.insert(.bpm) }
if rating != other.rating { changed.insert(.rating) }
return changed
}
// Returns prefill values (from the first track) plus the set of fields whose
// values are NOT identical across all tracks (shown as "Mixed" in the UI).
static func shared(across tracks: [Track]) -> (values: EditableTrackFields, mixed: Set<TrackField>) {
let base = EditableTrackFields(from: tracks[0])
var mixed: Set<TrackField> = []
for t in tracks.dropFirst() {
mixed.formUnion(base.changedFields(to: EditableTrackFields(from: t)))
}
return (base, mixed)
}
// Copies ONLY the edited fields onto the track; everything else is untouched.
func apply(editing edited: Set<TrackField>, to track: Track) -> Track {
var t = track
if edited.contains(.title) { t.title = title }
if edited.contains(.artist) { t.artist = artist }
if edited.contains(.albumArtist) { t.albumArtist = albumArtist }
if edited.contains(.album) { t.album = album }
if edited.contains(.genre) { t.genre = genre }
if edited.contains(.composer) { t.composer = composer }
if edited.contains(.year) { t.year = year }
if edited.contains(.trackNumber) { t.trackNumber = trackNumber }
if edited.contains(.discNumber) { t.discNumber = discNumber }
if edited.contains(.bpm) { t.bpm = bpm }
if edited.contains(.rating) { t.rating = rating }
return t
}
}
```
- [ ] **Step 4: Run it (expect pass)**
Run: `xcodebuild test -scheme Music -destination 'platform=macOS,arch=arm64' -only-testing:MusicTests/EditableTrackFieldsTests`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add Music/Models/EditableTrackFields.swift MusicTests/EditableTrackFieldsTests.swift
git commit -m "feat: add EditableTrackFields with diff/shared/apply logic"
```
---
## Task 5: `TagWriter` protocol, factory, and format writers
**Files:**
- Create: `Music/Services/TagWriting/TagWriter.swift`
- Create: `Music/Services/TagWriting/MP4TagWriter.swift`
- Create: `Music/Services/TagWriting/ID3TagWriter.swift`
- Create: `MusicTests/TagWriterTests.swift`
- Create: `MusicTests/Fixtures/sample.m4a`, `MusicTests/Fixtures/sample.mp3` (added to the MusicTests target as bundle resources)
> **Fixtures:** generate `sample.m4a` with `say "test" -o /tmp/s.aiff && afconvert -d aac -f m4af /tmp/s.aiff MusicTests/Fixtures/sample.m4a`. macOS has no mp3 encoder; obtain a tiny `sample.mp3` with `ffmpeg -i /tmp/s.aiff -b:a 64k MusicTests/Fixtures/sample.mp3` if ffmpeg is available, else commit any small (<100 KB) mp3. Add both to the MusicTests target's "Copy Bundle Resources". If no mp3 can be obtained, the mp3 round-trip test returns early (passes trivially) — **say so explicitly** in the task output rather than letting it silently pass.
- [ ] **Step 1: Write the failing test**
`MusicTests/TagWriterTests.swift`:
```swift
import Foundation
import AVFoundation
import Testing
@testable import Music
private final class BundleToken {} // locates the test bundle from a struct suite
// Verifies format routing and that writing tags round-trips through a real file
// without corrupting audio.
struct TagWriterTests {
private func fixtureURL(_ name: String, _ ext: String) -> URL? {
Bundle(for: BundleToken.self).url(forResource: name, withExtension: ext)
}
private func tempCopy(of url: URL) throws -> URL {
let dst = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent(UUID().uuidString + "." + url.pathExtension)
try FileManager.default.copyItem(at: url, to: dst)
return dst
}
private func readCommonTitle(_ url: URL) async throws -> String? {
let md = try await AVURLAsset(url: url).load(.metadata)
let items = AVMetadataItem.metadataItems(from: md, withKey: AVMetadataKey.commonKeyTitle, keySpace: .common)
return try await items.first?.load(.stringValue)
}
@Test func factoryRoutesByExtension() {
#expect(TagWriterFactory.writer(for: URL(fileURLWithPath: "/a.mp3")) is ID3TagWriter)
#expect(TagWriterFactory.writer(for: URL(fileURLWithPath: "/a.m4a")) is MP4TagWriter)
#expect(TagWriterFactory.writer(for: URL(fileURLWithPath: "/a.flac")) == nil)
#expect(TagWriterFactory.writer(for: URL(fileURLWithPath: "/a.wav")) == nil)
}
@Test func m4aRoundTrips() async throws {
// Step 1: copy the fixture so we don't mutate the bundled file.
let src = try #require(fixtureURL("sample", "m4a"), "missing sample.m4a fixture")
let url = try tempCopy(of: src)
defer { try? FileManager.default.removeItem(at: url) }
// Step 2: write new title/artist.
var f = EditableTrackFields(from: .fixture())
f.title = "Round Trip"; f.artist = "The Verifier"
try MP4TagWriter().write(f, to: url)
// Step 3: re-read and assert, and assert audio still loads.
#expect(try await readCommonTitle(url) == "Round Trip")
let tracks = try await AVURLAsset(url: url).loadTracks(withMediaType: .audio)
#expect(!tracks.isEmpty) // audio track survived the write
}
@Test func mp3RoundTrips() async throws {
// If no mp3 fixture is available, pass trivially (note this in the run output).
guard let src = fixtureURL("sample", "mp3") else { return }
let url = try tempCopy(of: src)
defer { try? FileManager.default.removeItem(at: url) }
var f = EditableTrackFields(from: .fixture())
f.title = "ID3 Round Trip"; f.artist = "Tagger"
try ID3TagWriter().write(f, to: url)
#expect(try await readCommonTitle(url) == "ID3 Round Trip")
}
}
```
- [ ] **Step 2: Run it (expect failure)**
Run: `xcodebuild test -scheme Music -destination 'platform=macOS,arch=arm64' -only-testing:MusicTests/TagWriterTests`
Expected: FAIL — "cannot find 'TagWriterFactory' / 'MP4TagWriter' / 'ID3TagWriter'".
- [ ] **Step 3: Implement protocol + factory**
`Music/Services/TagWriting/TagWriter.swift`:
```swift
import Foundation
// Writes the editable, tag-mappable fields into an audio file. rating is
// intentionally NOT written (DB-only in v1). Implementations write atomically.
nonisolated protocol TagWriter: Sendable {
func write(_ fields: EditableTrackFields, to url: URL) throws
}
nonisolated enum TagWriterError: Error { case exportUnavailable, exportFailed }
nonisolated enum TagWriterFactory {
// Returns nil for formats with no v1 writer (flac/wav/aiff) → DB-only.
static func writer(for url: URL) -> TagWriter? {
switch url.pathExtension.lowercased() {
case "mp3": return ID3TagWriter()
case "m4a", "alac", "aac": return MP4TagWriter()
default: return nil
}
}
}
```
- [ ] **Step 4: Implement MP4TagWriter**
`Music/Services/TagWriting/MP4TagWriter.swift`:
```swift
import Foundation
import AVFoundation
// Writes iTunes/common metadata into m4a-family files via a passthrough export
// to a temp file, then an atomic replace of the original. NOTE: passthrough
// export rewrites the metadata set, so unmodeled atoms (e.g. custom tags) may
// not survive — acceptable for v1; TagLib would remove this limitation.
nonisolated struct MP4TagWriter: TagWriter {
func write(_ fields: EditableTrackFields, to url: URL) throws {
let asset = AVURLAsset(url: url)
guard let export = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetPassthrough) else {
throw TagWriterError.exportUnavailable
}
let tmp = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent(UUID().uuidString + ".m4a")
export.outputURL = tmp
export.outputFileType = .m4a
export.metadata = Self.items(from: fields)
// Block this background thread until export completes (callers run this
// off the main actor). Safe for v1's sequential saves.
let sema = DispatchSemaphore(value: 0)
var exportError: Error?
export.exportAsynchronously {
if export.status != .completed { exportError = export.error ?? TagWriterError.exportFailed }
sema.signal()
}
sema.wait()
if let exportError { try? FileManager.default.removeItem(at: tmp); throw exportError }
_ = try FileManager.default.replaceItemAt(url, withItemAt: tmp)
}
private static func items(from f: EditableTrackFields) -> [AVMetadataItem] {
func item(_ id: AVMetadataIdentifier, _ value: (any NSCopying & NSObjectProtocol)?) -> AVMetadataItem? {
guard let value else { return nil }
let m = AVMutableMetadataItem()
m.identifier = id
m.value = value
return m
}
var out: [AVMetadataItem?] = [
item(.commonIdentifierTitle, f.title as NSString),
item(.commonIdentifierArtist, f.artist as NSString),
item(.commonIdentifierAlbumName, f.album as NSString),
item(.iTunesMetadataAlbumArtist, f.albumArtist as NSString),
item(.iTunesMetadataUserGenre, f.genre as NSString),
item(.iTunesMetadataComposer, f.composer as NSString),
]
if let y = f.year { out.append(item(.iTunesMetadataReleaseDate, String(y) as NSString)) }
if let n = f.trackNumber { out.append(item(.iTunesMetadataTrackNumber, NSNumber(value: n))) }
if let d = f.discNumber { out.append(item(.iTunesMetadataDiscNumber, NSNumber(value: d))) }
if let b = f.bpm { out.append(item(.iTunesMetadataBeatsPerMin, NSNumber(value: b))) }
return out.compactMap { $0 }
}
}
```
- [ ] **Step 5: Implement ID3TagWriter**
`Music/Services/TagWriting/ID3TagWriter.swift`:
```swift
import Foundation
import ID3TagEditor
// Writes ID3v2.3 string frames into mp3 files. ID3TagEditor writes the new tag
// in place. NOTE: builds a fresh tag with the managed string frames; unmodeled
// frames (e.g. attached artwork) may not be preserved in v1 — acceptable; TagLib
// later. VERIFY the exact ID3TagEditor 4.x builder API against its README; the
// shape below targets 4.x.
nonisolated struct ID3TagWriter: TagWriter {
func write(_ fields: EditableTrackFields, to url: URL) throws {
let editor = ID3TagEditor()
var builder = ID32v3TagBuilder()
.title(frame: ID3FrameWithStringContent(content: fields.title))
.artist(frame: ID3FrameWithStringContent(content: fields.artist))
.albumArtist(frame: ID3FrameWithStringContent(content: fields.albumArtist))
.album(frame: ID3FrameWithStringContent(content: fields.album))
.genre(genre: ID3FrameGenre(genre: nil, description: fields.genre))
.composer(frame: ID3FrameWithStringContent(content: fields.composer))
if let y = fields.year {
builder = builder.recordingYear(frame: ID3FrameWithIntegerContent(value: y))
}
if let n = fields.trackNumber {
builder = builder.trackPosition(frame: ID3FramePartOfTotal(part: n, total: nil))
}
if let d = fields.discNumber {
builder = builder.discPosition(frame: ID3FramePartOfTotal(part: d, total: nil))
}
if let b = fields.bpm {
builder = builder.beatsPerMinute(frame: ID3FrameWithIntegerContent(value: b))
}
let tag = builder.build()
try editor.write(tag: tag, to: url.path) // ID3TagEditor writes in place
}
}
```
- [ ] **Step 6: Run it (expect pass)**
Run: `xcodebuild test -scheme Music -destination 'platform=macOS,arch=arm64' -only-testing:MusicTests/TagWriterTests`
Expected: PASS (mp3 test may be skipped if no fixture — note it explicitly). If ID3TagEditor symbol names differ, fix per its README until green.
- [ ] **Step 7: Commit**
```bash
git add Music/Services/TagWriting MusicTests/TagWriterTests.swift MusicTests/Fixtures
git commit -m "feat: add TagWriter protocol with mp3/m4a writers"
```
---
## Task 6: `DatabaseService.updateTrack`
**Files:**
- Modify: `Music/Services/DatabaseService.swift` (add method under `// MARK: - Write`, near line 204)
- Modify: `MusicTests/DatabaseServiceTests.swift` (add cases)
- [ ] **Step 1: Write the failing test**
Append inside the existing `struct DatabaseServiceTests { … }`:
```swift
// Verifies updateTrack persists edited fields and that the tracks_ft index
// stays in sync (the synchronize-installed triggers fire on UPDATE).
@Test func updateTrackPersistsFieldsAndSyncsFTS() throws {
// Step 1: insert a track.
let db = try DatabaseService(inMemory: true)
var t = Track.fixture(title: "Original Title", artist: "X")
try db.insert(&t)
// Step 2: edit fields and update.
t.title = "Renamed Title"; t.album = "New Album"
try db.updateTrack(t)
// Step 3: re-fetch and assert persisted.
let fetched = try #require(db.fetchTracksByIds([t.id!]).first)
#expect(fetched.title == "Renamed Title")
#expect(fetched.album == "New Album")
// Step 4: FTS reflects the new title and not the old (triggers keep it synced).
#expect(try db.fetchTracks(search: "Renamed", sortColumn: "title", ascending: true).count == 1)
#expect(try db.fetchTracks(search: "Original", sortColumn: "title", ascending: true).count == 0)
}
```
- [ ] **Step 2: Run it (expect failure)**
Run: `xcodebuild test -scheme Music -destination 'platform=macOS,arch=arm64' -only-testing:MusicTests/DatabaseServiceTests/updateTrackPersistsFieldsAndSyncsFTS`
Expected: FAIL — "value of type 'DatabaseService' has no member 'updateTrack'".
- [ ] **Step 3: Implement**
In `Music/Services/DatabaseService.swift`, after `updatePlayStats(...)` (line 204):
```swift
// Full-record update for metadata edits. The tracks_ft FTS5 index is kept in
// sync automatically by the triggers installed via synchronize(withTable:),
// so no manual FTS write is needed here.
func updateTrack(_ track: Track) throws {
try dbPool.write { db in
try track.update(db)
}
}
```
- [ ] **Step 4: Run it (expect pass)**
Run: `xcodebuild test -scheme Music -destination 'platform=macOS,arch=arm64' -only-testing:MusicTests/DatabaseServiceTests/updateTrackPersistsFieldsAndSyncsFTS`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add Music/Services/DatabaseService.swift MusicTests/DatabaseServiceTests.swift
git commit -m "feat: add DatabaseService.updateTrack"
```
---
## Task 7: `TrackEditService` orchestration
**Files:**
- Create: `Music/Services/TrackEditService.swift`
- Create: `MusicTests/TrackEditServiceTests.swift`
- [ ] **Step 1: Write the failing test**
`MusicTests/TrackEditServiceTests.swift`:
```swift
import Foundation
import Testing
@testable import Music
// Verifies the save orchestration: DB always updated; file writeback best-effort;
// stats refreshed on success; warnings on unsupported format / writer failure.
struct TrackEditServiceTests {
// A spy writer we can make succeed or throw.
struct SpyWriter: TagWriter {
let shouldThrow: Bool
func write(_ fields: EditableTrackFields, to url: URL) throws {
if shouldThrow { throw TagWriterError.exportFailed }
// simulate a real write by appending a byte so size/mtime change.
let h = try FileHandle(forWritingTo: url); try h.seekToEnd()
try h.write(contentsOf: Data([0])); try h.close()
}
}
private func tempTrack(ext: String) throws -> Track {
let url = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent(UUID().uuidString + "." + ext)
try Data(repeating: 1, count: 100).write(to: url)
return .fixture(fileURL: url.absoluteString, fileFormat: ext)
}
@Test func supportedFormatSuccessUpdatesDBAndRefreshesStats() throws {
// Step 1: DB + a real temp file + an edit changing the title.
let db = try DatabaseService(inMemory: true)
var t = try tempTrack(ext: "mp3"); try db.insert(&t)
let original = EditableTrackFields(from: t)
var edited = original; edited.title = "Edited"
// Step 2: save via a succeeding writer.
let svc = TrackEditService(database: db, writerFactory: { _ in SpyWriter(shouldThrow: false) })
let warnings = svc.save(edited, editing: original.changedFields(to: edited), to: [t])
// Step 3: no warnings; DB has new title and refreshed hash/size.
#expect(warnings.isEmpty)
let f = try #require(db.fetchTracksByIds([t.id!]).first)
#expect(f.title == "Edited")
#expect(f.fileHash != t.fileHash) // writer changed the file
try? FileManager.default.removeItem(at: URL(string: t.fileURL)!)
}
@Test func unsupportedFormatSavesDBOnlyWithWarning() throws {
let db = try DatabaseService(inMemory: true)
var t = try tempTrack(ext: "flac"); try db.insert(&t)
var edited = EditableTrackFields(from: t); edited.album = "DB Only"
let svc = TrackEditService(database: db, writerFactory: TagWriterFactory.writer) // nil for flac
let warnings = svc.save(edited, editing: [.album], to: [t])
#expect(warnings.count == 1)
#expect(warnings.first?.kind == .dbOnlyUnsupported)
#expect(try #require(db.fetchTracksByIds([t.id!]).first).album == "DB Only")
try? FileManager.default.removeItem(at: URL(string: t.fileURL)!)
}
@Test func writerThrowsSavesDBOnlyWithFailureWarning() throws {
let db = try DatabaseService(inMemory: true)
var t = try tempTrack(ext: "mp3"); try db.insert(&t)
var edited = EditableTrackFields(from: t); edited.genre = "Still Saved"
let svc = TrackEditService(database: db, writerFactory: { _ in SpyWriter(shouldThrow: true) })
let warnings = svc.save(edited, editing: [.genre], to: [t])
#expect(warnings.first?.kind == .fileWriteFailed)
#expect(try #require(db.fetchTracksByIds([t.id!]).first).genre == "Still Saved")
try? FileManager.default.removeItem(at: URL(string: t.fileURL)!)
}
@Test func multiTrackAppliesOnlyEditedFields() throws {
let db = try DatabaseService(inMemory: true)
var a = try tempTrack(ext: "flac"); a.album = "OldA"; a.genre = "RockA"; try db.insert(&a)
var b = try tempTrack(ext: "flac"); b.album = "OldB"; b.genre = "RockB"; try db.insert(&b)
var edited = EditableTrackFields(from: a); edited.album = "Shared"
let svc = TrackEditService(database: db, writerFactory: TagWriterFactory.writer)
_ = svc.save(edited, editing: [.album], to: [a, b])
// album applied to both; each genre untouched.
#expect(try #require(db.fetchTracksByIds([a.id!]).first).album == "Shared")
#expect(try #require(db.fetchTracksByIds([b.id!]).first).album == "Shared")
#expect(try #require(db.fetchTracksByIds([b.id!]).first).genre == "RockB")
try? FileManager.default.removeItem(at: URL(string: a.fileURL)!)
try? FileManager.default.removeItem(at: URL(string: b.fileURL)!)
}
}
```
- [ ] **Step 2: Run it (expect failure)**
Run: `xcodebuild test -scheme Music -destination 'platform=macOS,arch=arm64' -only-testing:MusicTests/TrackEditServiceTests`
Expected: FAIL — "cannot find 'TrackEditService'".
- [ ] **Step 3: Implement**
`Music/Services/TrackEditService.swift`:
```swift
import Foundation
nonisolated struct TrackEditWarning: Sendable, Equatable {
enum Kind: Sendable, Equatable { case dbOnlyUnsupported, fileWriteFailed }
let trackId: Int64?
let fileURL: String
let kind: Kind
let reason: String
}
// Orchestrates a metadata save: apply edited fields → best-effort file-tag write
// → refresh file stats on success → DB update. The DB is ALWAYS updated; file
// writeback failures are collected as warnings, never blocking the library edit.
nonisolated final class TrackEditService: Sendable {
private let database: DatabaseService
private let writerFactory: @Sendable (URL) -> TagWriter?
init(database: DatabaseService,
writerFactory: @escaping @Sendable (URL) -> TagWriter? = TagWriterFactory.writer) {
self.database = database
self.writerFactory = writerFactory
}
func save(_ values: EditableTrackFields,
editing edited: Set<TrackField>,
to tracks: [Track]) -> [TrackEditWarning] {
var warnings: [TrackEditWarning] = []
for track in tracks {
var updated = values.apply(editing: edited, to: track)
// rating is DB-only; only attempt file writes if tag-mappable fields changed.
let tagFieldsChanged = !edited.subtracting([.rating]).isEmpty
if let url = URL(string: track.fileURL), tagFieldsChanged {
if let writer = writerFactory(url) {
do {
try writer.write(values, to: url)
if let stats = try? TrackFileStats.compute(for: url) {
updated.fileSize = stats.fileSize
updated.dateModified = stats.dateModified
updated.fileHash = stats.fileHash
}
} catch {
warnings.append(.init(trackId: track.id, fileURL: track.fileURL,
kind: .fileWriteFailed, reason: error.localizedDescription))
}
} else {
warnings.append(.init(trackId: track.id, fileURL: track.fileURL,
kind: .dbOnlyUnsupported,
reason: "Tag writing not supported for .\(url.pathExtension)"))
}
}
try? database.updateTrack(updated)
}
return warnings
}
}
```
- [ ] **Step 4: Run it (expect pass)**
Run: `xcodebuild test -scheme Music -destination 'platform=macOS,arch=arm64' -only-testing:MusicTests/TrackEditServiceTests`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add Music/Services/TrackEditService.swift MusicTests/TrackEditServiceTests.swift
git commit -m "feat: add TrackEditService save orchestration"
```
---
## Task 8: `LibraryViewModel.applyTrackEdits`
**Files:**
- Modify: `Music/ViewModels/LibraryViewModel.swift`
> The list auto-refreshes via the existing `ValueObservation` once the DB changes — no manual reload. Run the save off the main actor (file I/O), then return warnings to the caller.
- [ ] **Step 1: Add an edit service + method**
In `Music/ViewModels/LibraryViewModel.swift`, add a lazily-built service and a method:
```swift
private lazy var editService = TrackEditService(database: db)
// Applies edits to one or more tracks. File writes run off the main actor;
// the library list refreshes automatically via the DB observation.
func applyTrackEdits(_ values: EditableTrackFields,
editing edited: Set<TrackField>,
to tracks: [Track]) async -> [TrackEditWarning] {
let service = editService
return await Task.detached { service.save(values, editing: edited, to: tracks) }.value
}
```
(`db` is already a stored `private let`; `TrackEditService` is `Sendable`, so capturing it in `Task.detached` is safe.)
- [ ] **Step 2: Build**
Run: `xcodebuild build -scheme Music -destination 'platform=macOS,arch=arm64'`
Expected: BUILD SUCCEEDED.
- [ ] **Step 3: Commit**
```bash
git add Music/ViewModels/LibraryViewModel.swift
git commit -m "feat: add LibraryViewModel.applyTrackEdits"
```
---
## Task 9: `TrackInfoSheet` UI (tabbed Details / File)
**Files:**
- Create: `Music/Views/TrackInfoSheet.swift`
- [ ] **Step 1: Implement the sheet**
`Music/Views/TrackInfoSheet.swift` (mirrors `SmartPlaylistBuilderSheet`'s structure):
```swift
import SwiftUI
// Get Info dialog. Edits one or many tracks. For multi-edit, fields that differ
// across tracks show a "Mixed" placeholder and only fields the user touches are
// applied. onSave hands back the edited values + the set of edited fields.
struct TrackInfoSheet: View {
let tracks: [Track]
var onSave: (EditableTrackFields, Set<TrackField>) -> Void
var onCancel: () -> Void
@State private var fields: EditableTrackFields
@State private var mixed: Set<TrackField>
@State private var edited: Set<TrackField> = []
@State private var tab = 0
init(tracks: [Track],
onSave: @escaping (EditableTrackFields, Set<TrackField>) -> Void,
onCancel: @escaping () -> Void) {
self.tracks = tracks
self.onSave = onSave
self.onCancel = onCancel
let (values, mixed) = EditableTrackFields.shared(across: tracks)
_fields = State(initialValue: values)
_mixed = State(initialValue: mixed)
}
private var isMulti: Bool { tracks.count > 1 }
private var hasUnsupported: Bool {
tracks.contains { t in
["flac", "wav", "aiff"].contains((URL(string: t.fileURL)?.pathExtension ?? "").lowercased())
}
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text(isMulti ? "Get Info — \(tracks.count) tracks" : "Get Info")
.font(.headline)
if hasUnsupported {
Text("Edits save to your library only — tag writing isn’t supported for some selected formats yet.")
.font(.caption).foregroundStyle(.secondary)
}
Picker("", selection: $tab) {
Text("Details").tag(0)
if !isMulti { Text("File").tag(1) }
}
.pickerStyle(.segmented)
.labelsHidden()
if tab == 0 { detailsTab } else { fileTab }
Divider()
HStack {
Spacer()
Button("Cancel", action: onCancel)
Button("Save") { onSave(fields, edited) }
.keyboardShortcut(.defaultAction)
}
}
.padding(20)
.frame(width: 460)
}
// Binding helper that marks a field edited whenever it changes.
private func text(_ field: TrackField, _ keyPath: WritableKeyPath<EditableTrackFields, String>) -> Binding<String> {
Binding(
get: { mixed.contains(field) && !edited.contains(field) ? "" : fields[keyPath: keyPath] },
set: { fields[keyPath: keyPath] = $0; edited.insert(field) }
)
}
private func int(_ field: TrackField, _ keyPath: WritableKeyPath<EditableTrackFields, Int?>) -> Binding<String> {
Binding(
get: { mixed.contains(field) && !edited.contains(field) ? "" : (fields[keyPath: keyPath].map(String.init) ?? "") },
set: { fields[keyPath: keyPath] = Int($0.filter(\.isNumber)); edited.insert(field) }
)
}
private func placeholder(_ field: TrackField) -> String {
mixed.contains(field) && !edited.contains(field) ? "Mixed" : ""
}
private var detailsTab: some View {
VStack(alignment: .leading, spacing: 8) {
labeled("Title") { TextField(placeholder(.title), text: text(.title, \.title)) }
labeled("Artist") { TextField(placeholder(.artist), text: text(.artist, \.artist)) }
labeled("Album Artist") { TextField(placeholder(.albumArtist), text: text(.albumArtist, \.albumArtist)) }
labeled("Album") { TextField(placeholder(.album), text: text(.album, \.album)) }
labeled("Genre") { TextField(placeholder(.genre), text: text(.genre, \.genre)) }
labeled("Composer") { TextField(placeholder(.composer), text: text(.composer, \.composer)) }
HStack(spacing: 12) {
labeled("Year") { TextField(placeholder(.year), text: int(.year, \.year)).frame(width: 70) }
labeled("Track") { TextField(placeholder(.trackNumber), text: int(.trackNumber, \.trackNumber)).frame(width: 50) }
labeled("Disc") { TextField(placeholder(.discNumber), text: int(.discNumber, \.discNumber)).frame(width: 50) }
labeled("BPM") { TextField(placeholder(.bpm), text: int(.bpm, \.bpm)).frame(width: 60) }
}
labeled("Rating") {
Stepper(value: Binding(
get: { fields.rating },
set: { fields.rating = max(0, min(5, $0)); edited.insert(.rating) }
), in: 0...5) { Text(String(repeating: "★", count: fields.rating)) }
}
}
.textFieldStyle(.roundedBorder)
}
@ViewBuilder private var fileTab: some View {
if let t = tracks.first {
VStack(alignment: .leading, spacing: 6) {
row("Kind", t.fileFormat.uppercased())
row("Bit Rate", t.bitrate.map { "\($0) kbps" } ?? "—")
row("Sample Rate", t.sampleRate.map { "\($0) Hz" } ?? "—")
row("Size", ByteCountFormatter.string(fromByteCount: t.fileSize, countStyle: .file))
row("Duration", String(format: "%d:%02d", Int(t.duration) / 60, Int(t.duration) % 60))
row("Plays", "\(t.playCount)")
row("Where", URL(string: t.fileURL)?.path ?? t.fileURL)
}
.font(.system(size: 12))
}
}
private func labeled<C: View>(_ title: String, @ViewBuilder _ content: () -> C) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(title).font(.caption).foregroundStyle(.secondary)
content()
}
}
private func row(_ k: String, _ v: String) -> some View {
HStack(alignment: .top) {
Text(k).foregroundStyle(.secondary).frame(width: 90, alignment: .leading)
Text(v).textSelection(.enabled)
}
}
}
```
- [ ] **Step 2: Build**
Run: `xcodebuild build -scheme Music -destination 'platform=macOS,arch=arm64'`
Expected: BUILD SUCCEEDED.
- [ ] **Step 3: Commit**
```bash
git add Music/Views/TrackInfoSheet.swift
git commit -m "feat: add tabbed TrackInfoSheet"
```
---
## Task 10: Wire "Get Info" into the context menus and ContentView
**Files:**
- Modify: `Music/Models/TrackContextMenuConfig.swift`
- Modify: `Music/Views/TrackTableView.swift` (enable multi-select; add menu item + handler)
- Modify: `Music/Views/TrackContextMenuModifier.swift`
- Modify: `Music/ContentView.swift`
- [ ] **Step 1: Add the closure to the config**
In `Music/Models/TrackContextMenuConfig.swift`, add a field (after `onRemoveFromPlaylist`):
```swift
let onGetInfo: ([Track]) -> Void
```
- [ ] **Step 2: Enable multi-select in the table**
In `Music/Views/TrackTableView.swift` line 57, change:
```swift
tableView.allowsMultipleSelection = false
```
to:
```swift
tableView.allowsMultipleSelection = true
```
- [ ] **Step 3: Add the "Get Info" menu item**
In `Music/Views/TrackTableView.swift` `menuNeedsUpdate(_:)`, insert at the TOP of the menu (right after the `guard let config …` on line 331, before the "Add to Last Playlist" block):
```swift
let infoItem = NSMenuItem(title: "Get Info", action: #selector(getInfo(_:)), keyEquivalent: "i")
infoItem.target = self
menu.addItem(infoItem)
menu.addItem(.separator())
```
- [ ] **Step 4: Add the handler with target resolution**
In `Music/Views/TrackTableView.swift`, alongside the other `@objc` handlers (after `removeFromPlaylist`, line 394):
```swift
// macOS Music behavior: operate on the full selection if the right-clicked
// row is part of it; otherwise just the clicked row.
@objc func getInfo(_ sender: NSMenuItem) {
guard let tableView, tableView.clickedRow >= 0, tableView.clickedRow < tracks.count else { return }
guard let config = parent.contextMenuConfig else { return }
let clicked = tableView.clickedRow
let rows: [Int] = tableView.selectedRowIndexes.contains(clicked)
? tableView.selectedRowIndexes.sorted()
: [clicked]
let targets = rows.compactMap { $0 < tracks.count ? tracks[$0] : nil }
config.onGetInfo(targets)
}
```
- [ ] **Step 5: Add "Get Info" to the SwiftUI modifier**
In `Music/Views/TrackContextMenuModifier.swift`, inside `content.contextMenu { if let track, let config { … } }`, add at the top of the block:
```swift
Button("Get Info") { config.onGetInfo([track]) }
.keyboardShortcut("i")
Divider()
```
- [ ] **Step 6: Wire ContentView — state, config closure, sheet**
In `Music/ContentView.swift`:
(a) Add presentation state near the other `@State` (after line 27):
```swift
@State private var infoRequest: TrackInfoRequest?
@State private var saveWarning: String?
```
(b) Add an identifiable wrapper (top-level, below the imports or above `ContentView`):
```swift
struct TrackInfoRequest: Identifiable {
let id = UUID()
let tracks: [Track]
}
```
(c) In the computed `trackContextMenuConfig`, add the closure (alongside the other `on…` args):
```swift
onGetInfo: { tracks in
if !tracks.isEmpty { infoRequest = TrackInfoRequest(tracks: tracks) }
}
```
(d) Add the sheet + warning alert to `body` (after the existing `.sheet(item: $smartPlaylistBuilderEditing) { … }` block, which ends at line 355):
```swift
.sheet(item: $infoRequest) { req in
TrackInfoSheet(
tracks: req.tracks,
onSave: { values, edited in
let targets = req.tracks
infoRequest = nil
Task {
let warnings = await library.applyTrackEdits(values, editing: edited, to: targets)
if !warnings.isEmpty {
let failed = warnings.filter { $0.kind == .fileWriteFailed }.count
let dbOnly = warnings.filter { $0.kind == .dbOnlyUnsupported }.count
var msg = "Saved to your library."
if failed > 0 { msg += " Couldn’t write tags to \(failed) file(s)." }
if dbOnly > 0 { msg += " \(dbOnly) file(s) use a format without tag writing." }
saveWarning = msg
}
}
},
onCancel: { infoRequest = nil }
)
}
.alert("Edit Saved", isPresented: Binding(
get: { saveWarning != nil },
set: { if !$0 { saveWarning = nil } }
)) {
Button("OK", role: .cancel) { saveWarning = nil }
} message: {
Text(saveWarning ?? "")
}
```
- [ ] **Step 7: Build**
Run: `xcodebuild build -scheme Music -destination 'platform=macOS,arch=arm64'`
Expected: BUILD SUCCEEDED. (If any other site constructs `TrackContextMenuConfig`, add the `onGetInfo:` argument there too — `grep -rn "TrackContextMenuConfig(" Music/` to find them.)
- [ ] **Step 8: Commit**
```bash
git add Music/Models/TrackContextMenuConfig.swift Music/Views/TrackTableView.swift Music/Views/TrackContextMenuModifier.swift Music/ContentView.swift
git commit -m "feat: wire Get Info (⌘I) into context menus and ContentView"
```
---
## Task 11: Manual end-to-end verification
**Files:** none (verification only)
- [ ] **Step 1: Run the full suite**
Run: `xcodebuild test -scheme Music -destination 'platform=macOS,arch=arm64'`
Expected: all tests pass (note if the mp3 round-trip test passed trivially due to a missing fixture).
- [ ] **Step 2: mp3 writeback** — In the running app, right-click an mp3 under the music folder → Get Info → change Title → Save. Reopen Get Info: the new title shows. In Finder "Get Info" / another tag reader, the file tag changed and the file still plays.
- [ ] **Step 3: m4a writeback** — Repeat Step 2 for an m4a.
- [ ] **Step 4: flac DB-only** — Edit a flac: the library updates, the "library only" note showed in the sheet, and the post-save alert mentions a format without tag writing (no failure error).
- [ ] **Step 5: multi-track** — Select 3 tracks, right-click one of them → Get Info → set Album → Save. All three get the new album; fields left as "Mixed" are unchanged per-track.
- [ ] **Step 6: no re-scan churn** — Trigger a library rescan and confirm edited files are not reprocessed (the refreshed `fileHash` matches), and FTS search finds the edited tracks by their new titles.
- [ ] **Step 7: Commit any fixups**
```bash
git add -A
git commit -m "test: verify Get Info end-to-end"
```
---
## Self-review notes
- **Spec coverage:** DB+file writeback (Tasks 5–8), single+multi-track with Mixed (Tasks 4, 9, 10), existing-fields-only + read-only File tab (Task 9), mp3/m4a writers behind a TagLib-ready protocol (Task 5), tabbed UI (Task 9), DB-always/file-best-effort failure model (Task 7), ⌘I context menu (Task 10), rating DB-only (enforced in Task 7 via `edited.subtracting([.rating])` and excluded from writers). The sandbox risk flagged in the spec is resolved in Task 1; FTS sync confirmed automatic (Task 6).
- **Type consistency:** `EditableTrackFields`, `TrackField`, `TagWriter`, `TagWriterFactory.writer`, `TagWriterError`, `TrackFileStats.compute`, `TrackEditService.save`, `TrackEditWarning.Kind`, `DatabaseService.updateTrack`, `LibraryViewModel.applyTrackEdits`, `TrackContextMenuConfig.onGetInfo`, `TrackInfoRequest` — names used identically across tasks.
- **Known v1 limitations (intentional):** unmodeled tags (artwork/custom atoms) may not survive a writeback; raw `.aac` may fail export and fall back to DB-only with a warning; rating is not written to files.
- **Verify-at-implementation:** exact ID3TagEditor 4.x builder API (Task 5 Step 5); the Xcode scheme name; that adding the SPM package can be done in the working environment (Task 2).