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 asurl.absoluteString, i.e.file://…), the editable set (title, artist, albumArtist, album, genre, composer: String;year, trackNumber, discNumber, bpm: Int?;rating: Int), andfileSize: Int64,dateModified: Date,fileHash: String. Conforms toFetchableRecord, MutablePersistableRecord,databaseTableName = "tracks". HasTrack.computeHash(fileSize:modificationDate:) -> Stringreturning"\(fileSize)_\(Int(modificationDate.timeIntervalSince1970))". HasTrack.fixture(...)(DEBUG) for tests.DatabaseService(Music/Services/DatabaseService.swift):nonisolated final class … Sendable; connection islet dbPool: DatabaseWriter; writes viadbPool.write { db in … }.init(inMemory: Bool)for tests. FTS5tracks_ftis created witht.synchronize(withTable: "tracks"), which installs INSERT/UPDATE/DELETE triggers — so a plaintrack.update(db)keeps FTS in sync automatically (no manual FTS code). NoupdateTrackexists 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()thenfileHash: 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). Exposesvar tracks: [Track]. Holdsprivate let db: DatabaseService. Uses GRDBValueObservation(updateQuery()), sotracksauto-refreshes whenever the DB changes — there is NOloadTracks()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 inNSTableView.selectedRowIndexes. TheCoordinator(NSMenuDelegate) builds the menu inmenuNeedsUpdate(_:)offtableView.clickedRowand maps rows via its owntracksarray.@objchandlers (addToPlaylistetc.) follow the pattern: guardclickedRow, readconfig, call closure.TrackContextMenuConfig(Music/Models/TrackContextMenuConfig.swift):nonisolated structwithplaylists, lastUsedPlaylistName, selectedPlaylist, onAddToPlaylist, onAddToLastPlaylist?, onRemoveFromPlaylist?.TrackContextMenuModifier(Music/Views/TrackContextMenuModifier.swift):struct … ViewModifierwhosebody(content:)addscontent.contextMenu { if let track, let config { … } }; plusextension View { func trackContextMenu(track:config:) }. Used byPlayerControlsView(.trackContextMenu(track: currentTrack, config: contextMenuConfig)).ContentView(Music/ContentView.swift— note: underMusic/, notMusic/Views/): has a computedprivate var trackContextMenuConfig: TrackContextMenuConfigthat delegates to theplaylistview model; passes it intoTrackTableView(... contextMenuConfig: trackContextMenuConfig ...).displayedTracksreturnslibrary.tracks. Already holdsvar library: LibraryViewModelandvar db: DatabaseService. Uses.sheet(isPresented:)and.sheet(item:)patterns.- Sheet UI pattern (
Music/Views/SmartPlaylistBuilderSheet.swift):structwithvar onSave/onCancelclosures,@Stateform fields set in a custominit, acanSavecomputed flag, body is aVStack(alignment:.leading, spacing:16)with.padding(20).frame(width: 540), and anHStack { Spacer(); Button("Cancel", action: onCancel); Button("Save"){…}.disabled(!canSave).keyboardShortcut(.defaultAction) }. - Sandbox (CRITICAL):
Music/Music.entitlementshascom.apple.security.files.user-selected.read-only = trueandcom.apple.security.files.bookmarks.app-scope = true— i.e. the app currently has READ-ONLY access to the user's music folder.Music/MusicApp.swiftlets the user pick a folder viaNSOpenPanel(line ~175), saves a security-scoped bookmark (url.bookmarkData(options: .withSecurityScope), key"musicFolderBookmark"), and resolves it at launch withurl.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 (
packageReferencesinMusic.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(...)andtry #require(...)), constructstry DatabaseService(inMemory: true), and usesTrack.fixture(...). (Do NOT use XCTest.) Async tests are@Test func name() async throws. To reach bundled fixtures from astructsuite, use a token class:private final class BundleToken {}thenBundle(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 Musicis wrong, list withxcodebuild -list -project Music.xcodeprojand 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:
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
with:
<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:
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
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
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:
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:
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:
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
let fileSize = attrs[.size] as? Int64 ?? 0
let modDate = attrs[.modificationDate] as? Date ?? Date()
with:
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
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:
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:
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
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.m4awithsay "test" -o /tmp/s.aiff && afconvert -d aac -f m4af /tmp/s.aiff MusicTests/Fixtures/sample.m4a. macOS has no mp3 encoder; obtain a tinysample.mp3withffmpeg -i /tmp/s.aiff -b:a 64k MusicTests/Fixtures/sample.mp3if 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:
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:
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:
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:
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
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 { … }:
// 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):
// 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
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:
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:
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
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
ValueObservationonce 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:
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
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):
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
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):
let onGetInfo: ([Track]) -> Void
- Step 2: Enable multi-select in the table
In Music/Views/TrackTableView.swift line 57, change:
tableView.allowsMultipleSelection = false
to:
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):
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):
// 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:
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):
@State private var infoRequest: TrackInfoRequest?
@State private var saveWarning: String?
(b) Add an identifiable wrapper (top-level, below the imports or above ContentView):
struct TrackInfoRequest: Identifiable {
let id = UUID()
let tracks: [Track]
}
(c) In the computed trackContextMenuConfig, add the closure (alongside the other on… args):
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):
.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
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
fileHashmatches), and FTS search finds the edited tracks by their new titles. -
Step 7: Commit any fixups
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
.aacmay 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).