# 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/` - 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 com.apple.security.files.user-selected.read-only ``` with: ```xml com.apple.security.files.user-selected.read-write ``` 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 { var changed: Set = [] 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) { let base = EditableTrackFields(from: tracks[0]) var mixed: Set = [] 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, 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, 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, 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) -> Void var onCancel: () -> Void @State private var fields: EditableTrackFields @State private var mixed: Set @State private var edited: Set = [] @State private var tab = 0 init(tracks: [Track], onSave: @escaping (EditableTrackFields, Set) -> 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) -> Binding { 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) -> Binding { 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(_ 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).