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

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

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 onEditConditions callback 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 onEditConditions to 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 showSmartPlaylistBuilder state 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"