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-smart-playlist-c...

1143 lines
40 KiB

# Smart Playlist Conditions 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:** Extend smart playlists to support structured metadata-based conditions (e.g. artist = "Alicia Keys", year > 2015) with a new condition builder sheet, while keeping the existing FTS search-based smart playlists working unchanged.
**Architecture:** Add a nullable `conditions: [SmartPlaylistCondition]?` column (stored as JSON TEXT) to the `smart_playlists` table via a new DB migration. When `conditions` is nil the existing FTS path runs; when non-nil a SQL WHERE clause is generated from the conditions. A new `SmartPlaylistBuilderSheet` view handles create and edit, wired into the app menu and playlist context menu.
**Tech Stack:** Swift, SwiftUI, GRDB 7.10.0, Swift Testing
---
## File Map
| Action | File | Purpose |
|--------|------|---------|
| Create | `Music/Models/SmartPlaylistCondition.swift` | TrackField, ConditionOperator, ConditionValue, SmartPlaylistCondition |
| Modify | `Music/Models/SmartPlaylist.swift` | Add `conditions` property, update fixture |
| Modify | `Music/Services/DatabaseService.swift` | Migration v5, buildWhereClause, fetchTracks(conditions:), createSmartPlaylist(name:conditions:), updateSmartPlaylistConditions |
| Modify | `Music/ViewModels/PlaylistViewModel.swift` | Branch observeSmartPlaylistTracks, add createSmartPlaylist(name:conditions:), updateSmartPlaylistConditions |
| Create | `Music/Views/SmartPlaylistBuilderSheet.swift` | Condition builder sheet UI + ConditionRowView |
| Modify | `Music/Views/PlaylistBarView.swift` | Add onEditConditions callback, update context menu |
| Modify | `Music/ContentView.swift` | Add showSmartPlaylistBuilder binding, sheet, onEditConditions wiring |
| Modify | `Music/MusicApp.swift` | Add showSmartPlaylistBuilder state + "New Smart Playlist…" menu item |
| Modify | `MusicTests/SmartPlaylistTests.swift` | Tests for conditions model, query evaluation, JSON round-trip |
---
## Task 1: SmartPlaylistCondition Model
**Files:**
- Create: `Music/Models/SmartPlaylistCondition.swift`
- Test: `MusicTests/SmartPlaylistTests.swift`
- [ ] **Step 1: Write the failing test**
Add to `MusicTests/SmartPlaylistTests.swift`:
```swift
// Encodes and decodes a SmartPlaylistCondition to/from JSON,
// verifying that all fields survive the round-trip.
@Test func conditionCodableRoundTrip() throws {
let condition = SmartPlaylistCondition(
field: .artist,
op: .equals,
value: .string("Miles Davis")
)
let data = try JSONEncoder().encode(condition)
let decoded = try JSONDecoder().decode(SmartPlaylistCondition.self, from: data)
#expect(decoded.field == .artist)
#expect(decoded.op == .equals)
if case .string(let s) = decoded.value {
#expect(s == "Miles Davis")
} else {
Issue.record("Expected string value")
}
}
// Encodes and decodes an array of conditions with mixed value types.
@Test func conditionsArrayCodableRoundTrip() throws {
let conditions: [SmartPlaylistCondition] = [
SmartPlaylistCondition(field: .artist, op: .startsWith, value: .string("Miles")),
SmartPlaylistCondition(field: .year, op: .greaterThan, value: .int(1960)),
SmartPlaylistCondition(field: .dateAdded, op: .lessThan, value: .date(Date(timeIntervalSince1970: 0)))
]
let data = try JSONEncoder().encode(conditions)
let decoded = try JSONDecoder().decode([SmartPlaylistCondition].self, from: data)
#expect(decoded.count == 3)
#expect(decoded[0].field == .artist)
#expect(decoded[1].op == .greaterThan)
if case .int(let y) = decoded[1].value { #expect(y == 1960) } else { Issue.record("Expected int") }
if case .date(let d) = decoded[2].value { #expect(d == Date(timeIntervalSince1970: 0)) } else { Issue.record("Expected date") }
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `xcodebuild test -scheme Music -only-testing:MusicTests/SmartPlaylistTests/conditionCodableRoundTrip 2>&1 | grep -E "error:|FAILED|PASSED" | head -20`
Expected: compile error — `SmartPlaylistCondition` not defined.
- [ ] **Step 3: Create `Music/Models/SmartPlaylistCondition.swift`**
```swift
import Foundation
// Classifies a track field for operator and UI purposes.
enum FieldType {
case string, int, double, date
}
// Represents a track column that can be filtered on.
// Raw value matches the SQLite column name in the "tracks" table.
enum TrackField: String, Codable, CaseIterable, Identifiable, Sendable {
case title, artist, albumArtist, album, genre, composer, fileFormat
case year, bpm, rating, playCount, trackNumber, discNumber, bitrate, sampleRate
case fileSize, duration
case dateAdded, dateModified, lastPlayedAt
var id: String { rawValue }
var displayName: String {
switch self {
case .title: return "Title"
case .artist: return "Artist"
case .albumArtist: return "Album Artist"
case .album: return "Album"
case .genre: return "Genre"
case .composer: return "Composer"
case .fileFormat: return "File Format"
case .year: return "Year"
case .bpm: return "BPM"
case .rating: return "Rating"
case .playCount: return "Play Count"
case .trackNumber: return "Track Number"
case .discNumber: return "Disc Number"
case .bitrate: return "Bitrate"
case .sampleRate: return "Sample Rate"
case .fileSize: return "File Size"
case .duration: return "Duration"
case .dateAdded: return "Date Added"
case .dateModified: return "Date Modified"
case .lastPlayedAt: return "Last Played"
}
}
var fieldType: FieldType {
switch self {
case .title, .artist, .albumArtist, .album, .genre, .composer, .fileFormat:
return .string
case .year, .bpm, .rating, .playCount, .trackNumber, .discNumber, .bitrate, .sampleRate, .fileSize:
return .int
case .duration:
return .double
case .dateAdded, .dateModified, .lastPlayedAt:
return .date
}
}
var validOperators: [ConditionOperator] {
switch fieldType {
case .string: return [.equals, .startsWith]
case .int, .double, .date: return [.equals, .greaterThan, .lessThan]
}
}
var defaultValue: ConditionValue {
switch fieldType {
case .string: return .string("")
case .int: return .int(0)
case .double: return .double(0)
case .date: return .date(Date())
}
}
}
enum ConditionOperator: String, Codable, Identifiable, Sendable {
case equals
case startsWith
case greaterThan
case lessThan
var id: String { rawValue }
var displayName: String {
switch self {
case .equals: return "is"
case .startsWith: return "starts with"
case .greaterThan: return "is greater than"
case .lessThan: return "is less than"
}
}
}
// Tagged union storing the actual filter value with its type.
// Uses custom Codable to survive JSON round-trips cleanly.
enum ConditionValue: Equatable, Sendable {
case string(String)
case int(Int)
case double(Double)
case date(Date)
var isEmpty: Bool {
if case .string(let s) = self {
return s.trimmingCharacters(in: .whitespaces).isEmpty
}
return false
}
}
extension ConditionValue: Codable {
private enum CodingKeys: String, CodingKey { case type, value }
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .string(let s):
try container.encode("string", forKey: .type)
try container.encode(s, forKey: .value)
case .int(let i):
try container.encode("int", forKey: .type)
try container.encode(i, forKey: .value)
case .double(let d):
try container.encode("double", forKey: .type)
try container.encode(d, forKey: .value)
case .date(let date):
try container.encode("date", forKey: .type)
try container.encode(date.timeIntervalSince1970, forKey: .value)
}
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(String.self, forKey: .type)
switch type {
case "string":
self = .string(try container.decode(String.self, forKey: .value))
case "int":
self = .int(try container.decode(Int.self, forKey: .value))
case "double":
self = .double(try container.decode(Double.self, forKey: .value))
case "date":
self = .date(Date(timeIntervalSince1970: try container.decode(Double.self, forKey: .value)))
default:
throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "Unknown type: \(type)")
}
}
}
nonisolated struct SmartPlaylistCondition: Codable, Equatable, Sendable {
var field: TrackField
var op: ConditionOperator
var value: ConditionValue
var isEmpty: Bool { value.isEmpty }
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `xcodebuild test -scheme Music -only-testing:MusicTests/SmartPlaylistTests/conditionCodableRoundTrip -only-testing:MusicTests/SmartPlaylistTests/conditionsArrayCodableRoundTrip 2>&1 | grep -E "error:|FAILED|PASSED|Test.*passed|Test.*failed"`
Expected: both tests PASSED.
- [ ] **Step 5: Commit**
```bash
git add Music/Models/SmartPlaylistCondition.swift MusicTests/SmartPlaylistTests.swift
git commit -m "feat: add SmartPlaylistCondition model with Codable types"
```
---
## Task 2: Extend SmartPlaylist Model
**Files:**
- Modify: `Music/Models/SmartPlaylist.swift`
- Modify: `MusicTests/SmartPlaylistTests.swift`
- [ ] **Step 1: Write the failing test**
Add to `MusicTests/SmartPlaylistTests.swift`:
```swift
// Creates a SmartPlaylist fixture with conditions and verifies the conditions
// field is preserved and the isSmartPlaylist flag is true.
@Test func smartPlaylistWithConditions() throws {
let conditions = [SmartPlaylistCondition(field: .artist, op: .equals, value: .string("Miles Davis"))]
let sp = SmartPlaylist.fixture(conditions: conditions)
#expect(sp.conditions?.count == 1)
#expect(sp.conditions?[0].field == .artist)
#expect(sp.isSmartPlaylist == true)
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `xcodebuild test -scheme Music -only-testing:MusicTests/SmartPlaylistTests/smartPlaylistWithConditions 2>&1 | grep -E "error:|FAILED|PASSED" | head -10`
Expected: compile error — fixture doesn't accept `conditions` parameter.
- [ ] **Step 3: Modify `Music/Models/SmartPlaylist.swift`**
Add `conditions` property to the struct and update the fixture. Replace the entire file content:
```swift
import Foundation
import GRDB
nonisolated struct SmartPlaylist: Codable, Identifiable, Equatable, Hashable, Sendable {
var id: Int64?
var name: String
var searchQuery: String
var createdAt: Date
var conditions: [SmartPlaylistCondition]?
}
nonisolated extension SmartPlaylist: FetchableRecord, MutablePersistableRecord {
static let databaseTableName = "smart_playlists"
mutating func didInsert(_ inserted: InsertionSuccess) {
id = inserted.rowID
}
}
extension SmartPlaylist: PlaylistRepresentable {
var isSmartPlaylist: Bool { true }
}
#if DEBUG
extension SmartPlaylist {
static func fixture(
id: Int64? = nil,
name: String = "Test Smart Playlist",
searchQuery: String = "test query",
createdAt: Date = Date(),
conditions: [SmartPlaylistCondition]? = nil
) -> SmartPlaylist {
SmartPlaylist(
id: id, name: name, searchQuery: searchQuery,
createdAt: createdAt, conditions: conditions
)
}
}
#endif
```
> **Note on GRDB Codable synthesis:** GRDB 7 automatically encodes/decodes `[SmartPlaylistCondition]?` as a JSON TEXT column named "conditions" — no custom `init(row:)` or `encode(to:)` needed. Existing rows where the column is NULL decode as `nil`.
- [ ] **Step 4: Update the memberwise init call in DatabaseService**
In `Music/Services/DatabaseService.swift`, find the `createSmartPlaylist(name:searchQuery:)` method (line ~447) and update the init call:
Old:
```swift
var smartPlaylist = SmartPlaylist(
id: nil, name: name, searchQuery: searchQuery, createdAt: Date()
)
```
New:
```swift
var smartPlaylist = SmartPlaylist(
id: nil, name: name, searchQuery: searchQuery, createdAt: Date(), conditions: nil
)
```
- [ ] **Step 5: Update the memberwise init call in SmartPlaylistTests**
In `MusicTests/SmartPlaylistTests.swift`, find `SmartPlaylistTests.smartPlaylistProperties()` (line ~8) and update:
Old:
```swift
let sp = SmartPlaylist(
id: nil,
name: "Miles Davis",
searchQuery: "miles davis",
createdAt: Date()
)
```
New:
```swift
let sp = SmartPlaylist(
id: nil,
name: "Miles Davis",
searchQuery: "miles davis",
createdAt: Date(),
conditions: nil
)
```
- [ ] **Step 6: Run all SmartPlaylistTests to verify they pass**
Run: `xcodebuild test -scheme Music -only-testing:MusicTests/SmartPlaylistTests 2>&1 | grep -E "error:|FAILED|PASSED|Test.*passed|Test.*failed"`
Expected: all tests PASSED.
- [ ] **Step 7: Commit**
```bash
git add Music/Models/SmartPlaylist.swift Music/Services/DatabaseService.swift MusicTests/SmartPlaylistTests.swift
git commit -m "feat: add conditions field to SmartPlaylist model"
```
---
## Task 3: DB Migration + Query Evaluation
**Files:**
- Modify: `Music/Services/DatabaseService.swift`
- Test: `MusicTests/SmartPlaylistTests.swift`
- [ ] **Step 1: Write failing tests**
Add to `MusicTests/SmartPlaylistTests.swift`:
```swift
// Creates an in-memory DB and verifies existing smart playlists (conditions = nil)
// still load correctly after the v5 migration adds the conditions column.
@Test func existingFTSPlaylistSurvivesMigration() throws {
// Step 1: Create DB (migration runs automatically, including v5)
// Step 2: Create a FTS smart playlist using the old searchQuery path
// Step 3: Fetch it back and verify conditions is nil
let db = try DatabaseService(inMemory: true)
let sp = try db.createSmartPlaylist(name: "Jazz", searchQuery: "jazz")
let all = try db.fetchSmartPlaylists()
#expect(all.count == 1)
#expect(all[0].searchQuery == "jazz")
#expect(all[0].conditions == nil)
_ = sp
}
// Inserts tracks and verifies that fetchTracks(conditions:) with an equals
// condition on artist returns only the matching track.
@Test func fetchTracksWithEqualsCondition() throws {
// Step 1: Insert two tracks with different artists
// Step 2: Fetch with artist equals "Miles Davis"
// Step 3: Verify only one track is returned
let db = try DatabaseService(inMemory: true)
var t1 = Track.fixture(fileURL: "/a.mp3", title: "Kind of Blue", artist: "Miles Davis")
var t2 = Track.fixture(fileURL: "/b.mp3", title: "Hotel California", artist: "Eagles")
try db.insert(&t1)
try db.insert(&t2)
let conditions = [SmartPlaylistCondition(field: .artist, op: .equals, value: .string("Miles Davis"))]
let results = try db.fetchTracks(conditions: conditions, sortColumn: "title", ascending: true)
#expect(results.count == 1)
#expect(results[0].artist == "Miles Davis")
}
// Verifies that equals is case-insensitive for string fields.
@Test func fetchTracksEqualsIsCaseInsensitive() throws {
// Step 1: Insert a track with mixed-case artist
// Step 2: Fetch using lowercase artist value
// Step 3: Verify it matches
let db = try DatabaseService(inMemory: true)
var t = Track.fixture(fileURL: "/a.mp3", artist: "Miles Davis")
try db.insert(&t)
let conditions = [SmartPlaylistCondition(field: .artist, op: .equals, value: .string("miles davis"))]
let results = try db.fetchTracks(conditions: conditions, sortColumn: "title", ascending: true)
#expect(results.count == 1)
}
// Verifies that startsWith matches tracks whose artist begins with the given prefix,
// regardless of case.
@Test func fetchTracksWithStartsWithCondition() throws {
// Step 1: Insert tracks — one starting with "Miles", one not
// Step 2: Fetch with artist startsWith "miles"
// Step 3: Verify only the Miles Davis track is returned
let db = try DatabaseService(inMemory: true)
var t1 = Track.fixture(fileURL: "/a.mp3", artist: "Miles Davis")
var t2 = Track.fixture(fileURL: "/b.mp3", artist: "Eagles")
try db.insert(&t1)
try db.insert(&t2)
let conditions = [SmartPlaylistCondition(field: .artist, op: .startsWith, value: .string("miles"))]
let results = try db.fetchTracks(conditions: conditions, sortColumn: "title", ascending: true)
#expect(results.count == 1)
#expect(results[0].artist == "Miles Davis")
}
// Verifies that greaterThan on an integer field returns tracks where the value
// strictly exceeds the condition value.
@Test func fetchTracksWithGreaterThanCondition() throws {
// Step 1: Insert tracks with years 1990, 2010, 2020
// Step 2: Fetch with year > 2000
// Step 3: Verify 2010 and 2020 are returned; 1990 is not
let db = try DatabaseService(inMemory: true)
var t1 = Track.fixture(fileURL: "/a.mp3", year: 1990)
var t2 = Track.fixture(fileURL: "/b.mp3", year: 2010)
var t3 = Track.fixture(fileURL: "/c.mp3", year: 2020)
try db.insert(&t1)
try db.insert(&t2)
try db.insert(&t3)
let conditions = [SmartPlaylistCondition(field: .year, op: .greaterThan, value: .int(2000))]
let results = try db.fetchTracks(conditions: conditions, sortColumn: "title", ascending: true)
#expect(results.count == 2)
#expect(results.allSatisfy { ($0.year ?? 0) > 2000 })
}
// Verifies that multiple AND conditions filter correctly — only tracks matching
// all conditions are returned.
@Test func fetchTracksWithMultipleAndConditions() throws {
// Step 1: Insert three tracks: two Miles Davis (years 1959, 1970), one Eagles (1975)
// Step 2: Fetch with artist = "Miles Davis" AND year > 1960
// Step 3: Verify only the 1970 Miles Davis track is returned
let db = try DatabaseService(inMemory: true)
var t1 = Track.fixture(fileURL: "/a.mp3", title: "Kind of Blue", artist: "Miles Davis", year: 1959)
var t2 = Track.fixture(fileURL: "/b.mp3", title: "Bitches Brew", artist: "Miles Davis", year: 1970)
var t3 = Track.fixture(fileURL: "/c.mp3", title: "Hotel California", artist: "Eagles", year: 1975)
try db.insert(&t1)
try db.insert(&t2)
try db.insert(&t3)
let conditions = [
SmartPlaylistCondition(field: .artist, op: .equals, value: .string("Miles Davis")),
SmartPlaylistCondition(field: .year, op: .greaterThan, value: .int(1960))
]
let results = try db.fetchTracks(conditions: conditions, sortColumn: "title", ascending: true)
#expect(results.count == 1)
#expect(results[0].title == "Bitches Brew")
}
// Creates a smart playlist with structured conditions, fetches it back, and
// verifies the conditions survive the JSON round-trip through the database.
@Test func createSmartPlaylistWithConditionsPersists() throws {
// Step 1: Create DB and build a conditions-based smart playlist
// Step 2: Fetch all smart playlists
// Step 3: Verify conditions, field, operator, and value round-tripped correctly
let db = try DatabaseService(inMemory: true)
let conditions = [
SmartPlaylistCondition(field: .artist, op: .equals, value: .string("Miles Davis")),
SmartPlaylistCondition(field: .year, op: .greaterThan, value: .int(1960))
]
_ = try db.createSmartPlaylist(name: "Late Miles", conditions: conditions)
let all = try db.fetchSmartPlaylists()
#expect(all.count == 1)
#expect(all[0].conditions?.count == 2)
#expect(all[0].conditions?[0].field == .artist)
#expect(all[0].conditions?[1].op == .greaterThan)
if case .int(let y) = all[0].conditions?[1].value {
#expect(y == 1960)
} else {
Issue.record("Expected int value")
}
}
// Updates the conditions of a structured smart playlist and verifies the new
// conditions are persisted.
@Test func updateSmartPlaylistConditions() throws {
// Step 1: Create a conditions-based playlist
// Step 2: Update its conditions to a different set
// Step 3: Fetch and verify the updated conditions
let db = try DatabaseService(inMemory: true)
let sp = try db.createSmartPlaylist(
name: "Test",
conditions: [SmartPlaylistCondition(field: .artist, op: .equals, value: .string("Eagles"))]
)
let newConditions = [SmartPlaylistCondition(field: .genre, op: .startsWith, value: .string("Jazz"))]
try db.updateSmartPlaylistConditions(id: sp.id!, conditions: newConditions)
let all = try db.fetchSmartPlaylists()
#expect(all[0].conditions?.count == 1)
#expect(all[0].conditions?[0].field == .genre)
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `xcodebuild test -scheme Music -only-testing:MusicTests/SmartPlaylistTests/fetchTracksWithEqualsCondition 2>&1 | grep -E "error:|FAILED|PASSED" | head -10`
Expected: compile error — `fetchTracks(conditions:)` not defined.
- [ ] **Step 3: Add migration v5 and query methods to `Music/Services/DatabaseService.swift`**
**3a — Add migration v5** after the `"v4-drop-artworkData"` migration (before `try migrator.migrate(db)`):
```swift
migrator.registerMigration("v5-add-smart-playlist-conditions") { db in
try db.alter(table: "smart_playlists") { t in
t.add(column: "conditions", .text)
}
}
```
**3b — Add `buildWhereClause` private method** to `DatabaseService` (place in the `// MARK: - Smart Playlists` section):
```swift
private func buildWhereClause(_ conditions: [SmartPlaylistCondition]) -> (sql: String, arguments: StatementArguments) {
guard !conditions.isEmpty else { return ("", StatementArguments()) }
var fragments: [String] = []
var args: [DatabaseValueConvertible?] = []
for condition in conditions {
let col = condition.field.rawValue
switch (condition.op, condition.value) {
case (.equals, .string(let s)):
fragments.append("LOWER(\(col)) = LOWER(?)")
args.append(s)
case (.startsWith, .string(let s)):
fragments.append("LOWER(\(col)) LIKE LOWER(?) || '%'")
args.append(s)
case (.equals, .int(let i)):
fragments.append("\(col) = ?"); args.append(i)
case (.greaterThan, .int(let i)):
fragments.append("\(col) > ?"); args.append(i)
case (.lessThan, .int(let i)):
fragments.append("\(col) < ?"); args.append(i)
case (.equals, .double(let d)):
fragments.append("\(col) = ?"); args.append(d)
case (.greaterThan, .double(let d)):
fragments.append("\(col) > ?"); args.append(d)
case (.lessThan, .double(let d)):
fragments.append("\(col) < ?"); args.append(d)
case (.equals, .date(let date)):
fragments.append("\(col) = ?"); args.append(date)
case (.greaterThan, .date(let date)):
fragments.append("\(col) > ?"); args.append(date)
case (.lessThan, .date(let date)):
fragments.append("\(col) < ?"); args.append(date)
default:
break
}
}
return (fragments.joined(separator: " AND "), StatementArguments(args))
}
```
**3c — Add public `fetchTracks(conditions:)` methods** after the existing `fetchTracks(db:search:sortColumn:ascending:)` method:
```swift
func fetchTracks(conditions: [SmartPlaylistCondition], sortColumn: String, ascending: Bool) throws -> [Track] {
try dbPool.read { db in
try self.fetchTracks(db: db, conditions: conditions, sortColumn: sortColumn, ascending: ascending)
}
}
func fetchTracks(db: Database, conditions: [SmartPlaylistCondition], sortColumn: String, ascending: Bool) throws -> [Track] {
let col = Self.validSortColumns.contains(sortColumn) ? sortColumn : "title"
let order = ascending ? "ASC" : "DESC"
let (whereSQL, args) = buildWhereClause(conditions)
if whereSQL.isEmpty {
return try Track.fetchAll(db, sql: "SELECT * FROM tracks ORDER BY \(col) COLLATE NOCASE \(order)")
}
return try Track.fetchAll(
db,
sql: "SELECT * FROM tracks WHERE \(whereSQL) ORDER BY \(col) COLLATE NOCASE \(order)",
arguments: args
)
}
```
**3d — Add `createSmartPlaylist(name:conditions:)` overload** after the existing `createSmartPlaylist(name:searchQuery:)` method:
```swift
func createSmartPlaylist(name: String, conditions: [SmartPlaylistCondition]) throws -> SmartPlaylist {
try dbPool.write { db in
var smartPlaylist = SmartPlaylist(
id: nil, name: name, searchQuery: "", createdAt: Date(), conditions: conditions
)
try smartPlaylist.insert(db)
return smartPlaylist
}
}
```
**3e — Add `updateSmartPlaylistConditions(id:conditions:)`** after `updateSmartPlaylistQuery`:
```swift
func updateSmartPlaylistConditions(id: Int64, conditions: [SmartPlaylistCondition]) throws {
let json: String?
if let data = try? JSONEncoder().encode(conditions) {
json = String(data: data, encoding: .utf8)
} else {
json = nil
}
try dbPool.write { db in
try db.execute(
sql: "UPDATE smart_playlists SET conditions = ? WHERE id = ?",
arguments: [json, id]
)
}
}
```
- [ ] **Step 4: Run all new tests**
Run: `xcodebuild test -scheme Music -only-testing:MusicTests/SmartPlaylistTests 2>&1 | grep -E "error:|FAILED|PASSED|Test.*passed|Test.*failed"`
Expected: all tests PASSED.
- [ ] **Step 5: Commit**
```bash
git add Music/Services/DatabaseService.swift MusicTests/SmartPlaylistTests.swift
git commit -m "feat: add migration v5 and structured condition query support to DatabaseService"
```
---
## Task 4: PlaylistViewModel — Branch on Conditions
**Files:**
- Modify: `Music/ViewModels/PlaylistViewModel.swift`
No separate test — the DB-level tests in Task 3 cover the query logic; the ViewModel wiring is validated by running the app in Task 7.
- [ ] **Step 1: Rename and update `observeSmartPlaylistTracks`**
Replace the existing `observeSmartPlaylistTracks(searchQuery:)` private method with a new signature that takes the full `SmartPlaylist`:
Old signature: `private func observeSmartPlaylistTracks(searchQuery: String)`
New implementation (replace the entire method):
```swift
private func observeSmartPlaylistTracks(for smartPlaylist: SmartPlaylist) {
tracksCancellable?.cancel()
let col = sortColumn
let asc = sortAscending
if let conditions = smartPlaylist.conditions {
let observation = ValueObservation.tracking { [db] dbAccess in
try db.fetchTracks(db: dbAccess, conditions: conditions, sortColumn: col, ascending: asc)
}
tracksCancellable = observation.start(
in: db.dbPool,
onError: { error in print("Smart playlist tracks observation error: \(error)") },
onChange: { [weak self] tracks in self?.playlistTracks = tracks }
)
} else {
let searchQuery = smartPlaylist.searchQuery
let observation = ValueObservation.tracking { [db] dbAccess in
try db.fetchTracks(db: dbAccess, search: searchQuery, sortColumn: col, ascending: asc)
}
tracksCancellable = observation.start(
in: db.dbPool,
onError: { error in print("Smart playlist tracks observation error: \(error)") },
onChange: { [weak self] tracks in self?.playlistTracks = tracks }
)
}
}
```
- [ ] **Step 2: Update all call sites of `observeSmartPlaylistTracks`**
**In `selectItem`**, change:
```swift
} else if let smart = item as? SmartPlaylist {
observeSmartPlaylistTracks(searchQuery: smart.searchQuery)
}
```
to:
```swift
} else if let smart = item as? SmartPlaylist {
observeSmartPlaylistTracks(for: smart)
}
```
**In `updateSmartPlaylistQuery`**, change:
```swift
if selectedSmartPlaylist?.id == id {
observeSmartPlaylistTracks(searchQuery: query)
}
```
to:
```swift
if selectedSmartPlaylist?.id == id {
var updated = smartPlaylist
updated.searchQuery = query
updated.conditions = nil
observeSmartPlaylistTracks(for: updated)
}
```
**In `sort`**, change:
```swift
if let smart = selectedSmartPlaylist {
observeSmartPlaylistTracks(searchQuery: smart.searchQuery)
}
```
to:
```swift
if let smart = selectedSmartPlaylist {
observeSmartPlaylistTracks(for: smart)
}
```
- [ ] **Step 3: Add `createSmartPlaylist(name:conditions:)` to PlaylistViewModel**
Add after the existing `createSmartPlaylist(searchQuery:)` method:
```swift
func createSmartPlaylist(name: String, conditions: [SmartPlaylistCondition]) throws {
_ = try db.createSmartPlaylist(name: name, conditions: conditions)
}
```
- [ ] **Step 4: Add `updateSmartPlaylistConditions(_:to:)` to PlaylistViewModel**
Add after `updateSmartPlaylistQuery`:
```swift
func updateSmartPlaylistConditions(_ smartPlaylist: SmartPlaylist, to conditions: [SmartPlaylistCondition]) throws {
guard let id = smartPlaylist.id else { return }
try db.updateSmartPlaylistConditions(id: id, conditions: conditions)
if selectedSmartPlaylist?.id == id {
var updated = smartPlaylist
updated.conditions = conditions
observeSmartPlaylistTracks(for: updated)
}
}
```
- [ ] **Step 5: Verify the project still builds**
Run: `xcodebuild build -scheme Music 2>&1 | grep -E "error:|BUILD SUCCEEDED|BUILD FAILED"`
Expected: `BUILD SUCCEEDED`
- [ ] **Step 6: Commit**
```bash
git add Music/ViewModels/PlaylistViewModel.swift
git commit -m "feat: branch PlaylistViewModel on conditions for structured smart playlist observation"
```
---
## Task 5: SmartPlaylistBuilderSheet UI
**Files:**
- Create: `Music/Views/SmartPlaylistBuilderSheet.swift`
- [ ] **Step 1: Create `Music/Views/SmartPlaylistBuilderSheet.swift`**
```swift
import SwiftUI
struct SmartPlaylistBuilderSheet: View {
var editingPlaylist: SmartPlaylist?
var onSave: (String, [SmartPlaylistCondition]) -> Void
var onCancel: () -> Void
@State private var name: String
@State private var conditions: [SmartPlaylistCondition]
init(
editingPlaylist: SmartPlaylist? = nil,
onSave: @escaping (String, [SmartPlaylistCondition]) -> Void,
onCancel: @escaping () -> Void
) {
self.editingPlaylist = editingPlaylist
self.onSave = onSave
self.onCancel = onCancel
let defaultCondition = SmartPlaylistCondition(field: .artist, op: .equals, value: .string(""))
_name = State(initialValue: editingPlaylist?.name ?? "")
_conditions = State(initialValue: editingPlaylist?.conditions ?? [defaultCondition])
}
private var canSave: Bool {
!name.trimmingCharacters(in: .whitespaces).isEmpty &&
conditions.allSatisfy { !$0.isEmpty }
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text(editingPlaylist == nil ? "New Smart Playlist" : "Edit Smart Playlist")
.font(.headline)
VStack(alignment: .leading, spacing: 4) {
Text("Name")
.font(.caption)
.foregroundStyle(.secondary)
TextField("Playlist name", text: $name)
.textFieldStyle(.roundedBorder)
}
VStack(alignment: .leading, spacing: 6) {
Text("Conditions (all must match)")
.font(.caption)
.foregroundStyle(.secondary)
ForEach(conditions.indices, id: \.self) { index in
ConditionRowView(
condition: $conditions[index],
canRemove: conditions.count > 1,
onRemove: { conditions.remove(at: index) }
)
}
Button("+ Add Condition") {
conditions.append(SmartPlaylistCondition(field: .artist, op: .equals, value: .string("")))
}
.buttonStyle(.plain)
.foregroundStyle(.accentColor)
.font(.system(size: 12))
}
Divider()
HStack {
Spacer()
Button("Cancel", action: onCancel)
Button("Save") {
onSave(name.trimmingCharacters(in: .whitespaces), conditions)
}
.disabled(!canSave)
.keyboardShortcut(.defaultAction)
}
}
.padding(20)
.frame(width: 540)
}
}
private struct ConditionRowView: View {
@Binding var condition: SmartPlaylistCondition
var canRemove: Bool
var onRemove: () -> Void
var body: some View {
HStack(spacing: 8) {
Picker("", selection: $condition.field) {
ForEach(TrackField.allCases) { field in
Text(field.displayName).tag(field)
}
}
.labelsHidden()
.frame(maxWidth: 130)
.onChange(of: condition.field) { _, newField in
condition.op = newField.validOperators[0]
condition.value = newField.defaultValue
}
Picker("", selection: $condition.op) {
ForEach(condition.field.validOperators) { op in
Text(op.displayName).tag(op)
}
}
.labelsHidden()
.frame(maxWidth: 130)
valueField
Button(action: onRemove) {
Image(systemName: "minus.circle.fill")
.foregroundStyle(canRemove ? .secondary : .secondary.opacity(0.3))
}
.buttonStyle(.plain)
.disabled(!canRemove)
}
}
@ViewBuilder
private var valueField: some View {
switch condition.field.fieldType {
case .string:
TextField("Value", text: Binding(
get: { if case .string(let s) = condition.value { return s } else { return "" } },
set: { condition.value = .string($0) }
))
.textFieldStyle(.roundedBorder)
case .int:
TextField("Value", text: Binding(
get: { if case .int(let i) = condition.value { return String(i) } else { return "0" } },
set: { condition.value = .int(Int($0) ?? 0) }
))
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 100)
case .double:
TextField("Value", text: Binding(
get: { if case .double(let d) = condition.value { return String(d) } else { return "0" } },
set: { condition.value = .double(Double($0) ?? 0) }
))
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 100)
case .date:
DatePicker("", selection: Binding(
get: { if case .date(let d) = condition.value { return d } else { return Date() } },
set: { condition.value = .date($0) }
), displayedComponents: .date)
.labelsHidden()
}
}
}
```
- [ ] **Step 2: Verify the project builds**
Run: `xcodebuild build -scheme Music 2>&1 | grep -E "error:|BUILD SUCCEEDED|BUILD FAILED"`
Expected: `BUILD SUCCEEDED`
- [ ] **Step 3: Commit**
```bash
git add Music/Views/SmartPlaylistBuilderSheet.swift
git commit -m "feat: add SmartPlaylistBuilderSheet with ConditionRowView"
```
---
## Task 6: PlaylistBarView — Context Menu Update
**Files:**
- Modify: `Music/Views/PlaylistBarView.swift`
- [ ] **Step 1: Add `onEditConditions` callback and update the context menu**
In `Music/Views/PlaylistBarView.swift`, add the new callback property after `onEditQuery`:
```swift
var onEditConditions: (SmartPlaylist) -> Void
```
Then update the context menu block (currently lines 38-46):
Old:
```swift
.contextMenu {
if !isRemoteMode {
Button("Rename...") { onRename(item) }
if let smart = item as? SmartPlaylist {
Button("Edit Search Query...") { onEditQuery(smart) }
}
Button("Delete") { onDelete(item) }
}
}
```
New:
```swift
.contextMenu {
if !isRemoteMode {
Button("Rename...") { onRename(item) }
if let smart = item as? SmartPlaylist {
if smart.conditions != nil {
Button("Edit...") { onEditConditions(smart) }
} else {
Button("Edit Search Query...") { onEditQuery(smart) }
}
}
Button("Delete") { onDelete(item) }
}
}
```
- [ ] **Step 2: Verify the project builds (PlaylistBarView call site in ContentView will fail — expected)**
Run: `xcodebuild build -scheme Music 2>&1 | grep -E "error:|BUILD SUCCEEDED|BUILD FAILED"`
Expected: BUILD FAILED with a missing `onEditConditions` argument error in `ContentView.swift` — this is the compile error that Task 7 will fix.
- [ ] **Step 3: Do NOT commit yet — commit together with Task 7**
---
## Task 7: ContentView + MusicApp Wiring
**Files:**
- Modify: `Music/ContentView.swift`
- Modify: `Music/MusicApp.swift`
- [ ] **Step 1: Add binding and sheet states to ContentView**
In `Music/ContentView.swift`, add a new `@Binding` parameter for the smart playlist builder alongside the existing `showNewPlaylistAlert` binding:
Add after `@Binding var showNewPlaylistAlert: Bool`:
```swift
@Binding var showSmartPlaylistBuilder: Bool
```
Add to the `@State` block (after `showEditQueryAlert`):
```swift
@State private var smartPlaylistBuilderEditing: SmartPlaylist?
```
- [ ] **Step 2: Pass `onEditConditions` to PlaylistBarView in ContentView**
Find the `PlaylistBarView(...)` call in `ContentView.body` and add the missing `onEditConditions` argument after `onEditQuery`:
```swift
onEditConditions: { smart in
smartPlaylistBuilderEditing = smart
}
```
- [ ] **Step 3: Add sheets to ContentView**
Add two `.sheet` modifiers after the existing `.alert` modifiers at the bottom of `ContentView.body`:
```swift
.sheet(isPresented: $showSmartPlaylistBuilder) {
SmartPlaylistBuilderSheet(
editingPlaylist: nil,
onSave: { name, conditions in
try? playlist.createSmartPlaylist(name: name, conditions: conditions)
showSmartPlaylistBuilder = false
},
onCancel: { showSmartPlaylistBuilder = false }
)
}
.sheet(item: $smartPlaylistBuilderEditing) { smart in
SmartPlaylistBuilderSheet(
editingPlaylist: smart,
onSave: { name, conditions in
if name != smart.name {
try? playlist.renameSmartPlaylist(smart, to: name)
}
try? playlist.updateSmartPlaylistConditions(smart, to: conditions)
smartPlaylistBuilderEditing = nil
},
onCancel: { smartPlaylistBuilderEditing = nil }
)
}
```
- [ ] **Step 4: Add `showSmartPlaylistBuilder` state and menu item to MusicApp**
In `Music/MusicApp.swift`:
Add state after `showNewPlaylistAlert`:
```swift
@State private var showSmartPlaylistBuilder = false
```
Pass it to ContentView — in the `ContentView(...)` initializer call, add after `showNewPlaylistAlert: $showNewPlaylistAlert`:
```swift
showSmartPlaylistBuilder: $showSmartPlaylistBuilder,
```
Add menu item after `"New Playlist..."` button in the `.commands` block:
```swift
Button("New Smart Playlist...") {
showSmartPlaylistBuilder = true
}
.keyboardShortcut("n", modifiers: [.command, .shift])
.disabled(remoteClient.connectionState.isConnected)
```
- [ ] **Step 5: Verify the project builds**
Run: `xcodebuild build -scheme Music 2>&1 | grep -E "error:|BUILD SUCCEEDED|BUILD FAILED"`
Expected: `BUILD SUCCEEDED`
- [ ] **Step 6: Run all tests**
Run: `xcodebuild test -scheme Music 2>&1 | grep -E "error:|FAILED|PASSED|Test.*passed|Test.*failed" | tail -20`
Expected: all tests PASSED.
- [ ] **Step 7: Commit**
```bash
git add Music/Views/PlaylistBarView.swift Music/ContentView.swift Music/MusicApp.swift
git commit -m "feat: wire SmartPlaylistBuilderSheet into menu and context menu"
```