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:
// 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
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
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:
// 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:
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 custominit(row:)orencode(to:)needed. Existing rows where the column is NULL decode asnil.
- 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:
var smartPlaylist = SmartPlaylist(
id: nil, name: name, searchQuery: searchQuery, createdAt: Date()
)
New:
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:
let sp = SmartPlaylist(
id: nil,
name: "Miles Davis",
searchQuery: "miles davis",
createdAt: Date()
)
New:
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
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:
// 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)):
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):
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:
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:
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:
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
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):
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:
} else if let smart = item as? SmartPlaylist {
observeSmartPlaylistTracks(searchQuery: smart.searchQuery)
}
to:
} else if let smart = item as? SmartPlaylist {
observeSmartPlaylistTracks(for: smart)
}
In updateSmartPlaylistQuery, change:
if selectedSmartPlaylist?.id == id {
observeSmartPlaylistTracks(searchQuery: query)
}
to:
if selectedSmartPlaylist?.id == id {
var updated = smartPlaylist
updated.searchQuery = query
updated.conditions = nil
observeSmartPlaylistTracks(for: updated)
}
In sort, change:
if let smart = selectedSmartPlaylist {
observeSmartPlaylistTracks(searchQuery: smart.searchQuery)
}
to:
if let smart = selectedSmartPlaylist {
observeSmartPlaylistTracks(for: smart)
}
- Step 3: Add
createSmartPlaylist(name:conditions:)to PlaylistViewModel
Add after the existing createSmartPlaylist(searchQuery:) method:
func createSmartPlaylist(name: String, conditions: [SmartPlaylistCondition]) throws {
_ = try db.createSmartPlaylist(name: name, conditions: conditions)
}
- Step 4: Add
updateSmartPlaylistConditions(_:to:)to PlaylistViewModel
Add after updateSmartPlaylistQuery:
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
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
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
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
onEditConditionscallback and update the context menu
In Music/Views/PlaylistBarView.swift, add the new callback property after onEditQuery:
var onEditConditions: (SmartPlaylist) -> Void
Then update the context menu block (currently lines 38-46):
Old:
.contextMenu {
if !isRemoteMode {
Button("Rename...") { onRename(item) }
if let smart = item as? SmartPlaylist {
Button("Edit Search Query...") { onEditQuery(smart) }
}
Button("Delete") { onDelete(item) }
}
}
New:
.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:
@Binding var showSmartPlaylistBuilder: Bool
Add to the @State block (after showEditQueryAlert):
@State private var smartPlaylistBuilderEditing: SmartPlaylist?
- Step 2: Pass
onEditConditionsto PlaylistBarView in ContentView
Find the PlaylistBarView(...) call in ContentView.body and add the missing onEditConditions argument after onEditQuery:
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:
.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
showSmartPlaylistBuilderstate and menu item to MusicApp
In Music/MusicApp.swift:
Add state after showNewPlaylistAlert:
@State private var showSmartPlaylistBuilder = false
Pass it to ContentView — in the ContentView(...) initializer call, add after showNewPlaylistAlert: $showNewPlaylistAlert:
showSmartPlaylistBuilder: $showSmartPlaylistBuilder,
Add menu item after "New Playlist..." button in the .commands block:
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
git add Music/Views/PlaylistBarView.swift Music/ContentView.swift Music/MusicApp.swift
git commit -m "feat: wire SmartPlaylistBuilderSheet into menu and context menu"