feat: add SmartPlaylistCondition model with Codable types

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat/music-streaming
Laurent 1 month ago
parent 6f07349a1e
commit bebd30974a
  1. 152
      Music/Models/SmartPlaylistCondition.swift
  2. 35
      MusicTests/SmartPlaylistTests.swift

@ -0,0 +1,152 @@
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 }
}

@ -76,4 +76,39 @@ struct SmartPlaylistTests {
#expect(results[0].title == "Bitches Brew")
#expect(results[1].title == "Kind of Blue")
}
// 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") }
}
}

Loading…
Cancel
Save