You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
Music/docs/superpowers/plans/2026-05-30-track-get-info.md

56 KiB

Track "Get Info" Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add a macOS-Music-style "Get Info" dialog (right-click + ⌘I) that views and edits a track's metadata, persisting edits to the SQLite DB always and writing them back into the audio file's tags (mp3 + m4a/alac/aac) best-effort.

Architecture: A TagWriter protocol abstracts per-format tag writing (ID3TagEditor for mp3, AVFoundation passthrough-export for m4a-family; flac/wav/aiff fall back to DB-only). A TrackEditService orchestrates each save (apply edited fields → write file tags → refresh file stats → DB update). A tabbed SwiftUI TrackInfoSheet collects edits; the context menu resolves single- or multi-track targets from the table selection.

Tech Stack: Swift 6 / SwiftUI / AppKit (NSTableView), GRDB (SQLite + FTS5), AVFoundation, ID3TagEditor (new SPM dep), Swift Testing.


Verified facts (from codebase inspection — trust these over assumptions)

  • Track (Music/Models/Track.swift): 23 stored props incl. id: Int64?, fileURL: String (stored as url.absoluteString, i.e. file://…), the editable set (title, artist, albumArtist, album, genre, composer: String; year, trackNumber, discNumber, bpm: Int?; rating: Int), and fileSize: Int64, dateModified: Date, fileHash: String. Conforms to FetchableRecord, MutablePersistableRecord, databaseTableName = "tracks". Has Track.computeHash(fileSize:modificationDate:) -> String returning "\(fileSize)_\(Int(modificationDate.timeIntervalSince1970))". Has Track.fixture(...) (DEBUG) for tests.
  • DatabaseService (Music/Services/DatabaseService.swift): nonisolated final class … Sendable; connection is let dbPool: DatabaseWriter; writes via dbPool.write { db in … }. init(inMemory: Bool) for tests. FTS5 tracks_ft is created with t.synchronize(withTable: "tracks"), which installs INSERT/UPDATE/DELETE triggers — so a plain track.update(db) keeps FTS in sync automatically (no manual FTS code). No updateTrack exists yet.
  • ScannerService.extractMetadata (Music/Services/ScannerService.swift:162-188) computes stats as: let attrs = try FileManager.default.attributesOfItem(atPath: url.path); let fileSize = attrs[.size] as? Int64 ?? 0; let modDate = attrs[.modificationDate] as? Date ?? Date() then fileHash: Track.computeHash(fileSize: fileSize, modificationDate: modDate). Supported extensions: mp3, m4a, aac, wav, aiff, alac, flac.
  • LibraryViewModel (Music/ViewModels/LibraryViewModel.swift): @Observable final class (implicitly @MainActor). Exposes var tracks: [Track]. Holds private let db: DatabaseService. Uses GRDB ValueObservation (updateQuery()), so tracks auto-refreshes whenever the DB changes — there is NO loadTracks() and none is needed after an edit.
  • TrackTableView (Music/Views/TrackTableView.swift): NSViewRepresentable. Props: tracks, playingTrackId, sortColumn, sortAscending, onSort, onDoubleClick, contextMenuConfig, onReorder, scrollToPlayingTrigger. allowsMultipleSelection = false (line 57) and there is no selection binding — selection lives in NSTableView.selectedRowIndexes. The Coordinator (NSMenuDelegate) builds the menu in menuNeedsUpdate(_:) off tableView.clickedRow and maps rows via its own tracks array. @objc handlers (addToPlaylist etc.) follow the pattern: guard clickedRow, read config, call closure.
  • TrackContextMenuConfig (Music/Models/TrackContextMenuConfig.swift): nonisolated struct with playlists, lastUsedPlaylistName, selectedPlaylist, onAddToPlaylist, onAddToLastPlaylist?, onRemoveFromPlaylist?.
  • TrackContextMenuModifier (Music/Views/TrackContextMenuModifier.swift): struct … ViewModifier whose body(content:) adds content.contextMenu { if let track, let config { … } }; plus extension View { func trackContextMenu(track:config:) }. Used by PlayerControlsView (.trackContextMenu(track: currentTrack, config: contextMenuConfig)).
  • ContentView (Music/ContentView.swift — note: under Music/, not Music/Views/): has a computed private var trackContextMenuConfig: TrackContextMenuConfig that delegates to the playlist view model; passes it into TrackTableView(... contextMenuConfig: trackContextMenuConfig ...). displayedTracks returns library.tracks. Already holds var library: LibraryViewModel and var db: DatabaseService. Uses .sheet(isPresented:) and .sheet(item:) patterns.
  • Sheet UI pattern (Music/Views/SmartPlaylistBuilderSheet.swift): struct with var onSave/onCancel closures, @State form fields set in a custom init, a canSave computed flag, body is a VStack(alignment:.leading, spacing:16) with .padding(20).frame(width: 540), and an HStack { Spacer(); Button("Cancel", action: onCancel); Button("Save"){…}.disabled(!canSave).keyboardShortcut(.defaultAction) }.
  • Sandbox (CRITICAL): Music/Music.entitlements has com.apple.security.files.user-selected.read-only = true and com.apple.security.files.bookmarks.app-scope = true — i.e. the app currently has READ-ONLY access to the user's music folder. Music/MusicApp.swift lets the user pick a folder via NSOpenPanel (line ~175), saves a security-scoped bookmark (url.bookmarkData(options: .withSecurityScope), key "musicFolderBookmark"), and resolves it at launch with url.startAccessingSecurityScopedResource(). File-tag writeback is impossible until the entitlement is changed to read-write AND a read-write bookmark is obtained (Task 1).
  • SPM: GRDB is integrated as a remote SPM package (packageReferences in Music.xcodeproj/project.pbxproj, lines ~234, ~658-685). ID3TagEditor must be added the same way (Task 2).
  • Tests: MusicTests/ uses Swift Testing (import Testing, @testable import Music, struct SomeTests { @Test func name() throws { … } }, assertions via #expect(...) and try #require(...)), constructs try DatabaseService(inMemory: true), and uses Track.fixture(...). (Do NOT use XCTest.) Async tests are @Test func name() async throws. To reach bundled fixtures from a struct suite, use a token class: private final class BundleToken {} then Bundle(for: BundleToken.self).

Commands used throughout

  • Build: xcodebuild build -scheme Music -destination 'platform=macOS,arch=arm64'
  • Test one class: xcodebuild test -scheme Music -destination 'platform=macOS,arch=arm64' -only-testing:MusicTests/<ClassName>
  • If -scheme Music is wrong, list with xcodebuild -list -project Music.xcodeproj and use the app scheme.
  • A "failing test" in Swift/Xcode is often a build failure (symbol not found). That counts as red.

Task 1: Enable read-write file access and prove a write works

Files:

  • Modify: Music/Music.entitlements

  • Verify: Music/MusicApp.swift (bookmark save/resolve), no code change required unless re-grant needed

  • Step 1: Flip the user-selected entitlement to read-write

In Music/Music.entitlements, replace:

	<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.pbxprojXCRemoteSwiftPackageReference, 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.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:

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

    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 fileHash matches), 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 .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).