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.
152 lines
5.0 KiB
152 lines
5.0 KiB
import Foundation
|
|
|
|
// Classifies a track field for operator and UI purposes.
|
|
enum FieldType: Sendable {
|
|
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, Hashable, 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, Hashable, Sendable {
|
|
var field: TrackField
|
|
var op: ConditionOperator
|
|
var value: ConditionValue
|
|
|
|
var isEmpty: Bool { value.isEmpty }
|
|
}
|
|
|