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