import Foundation import Observation /// One song in a stem playlist, saved with the mix it plays with. struct StemPlaylistItem: Codable, Hashable, Identifiable { var uid = UUID().uuidString var track: Track var mix: [String: StemSetting] var id: String { uid } } struct StemPlaylist: Codable, Hashable, Identifiable { var id = UUID().uuidString var title: String var items: [StemPlaylistItem] = [] var created = Date() var updated = Date() /// Cover picked by the user; nil follows the first song. var artThumb: String? var tracks: [Track] { items.map(\.track) } var mixes: [[String: StemSetting]] { items.map(\.mix) } var thumb: String? { artThumb ?? items.lazy.compactMap(\.track.thumb).first } var durationMs: Int64 { items.reduce(0) { $0 + $1.track.durationMs } } } /// Playlists that live on this device only: each song keeps its own stem mix (instrumentals, a cappellas…). /// Plex playlists can't hold a mix, so these are separate. @MainActor @Observable final class StemPlaylists { private(set) var lists: [StemPlaylist] = [] @ObservationIgnored private let file: URL /// Every stem a song can have, for naming saved mixes without loading the song. nonisolated static let allStems = ["vocals", "drums", "bass", "guitar", "piano", "other"] init(dir: URL) { file = dir.appendingPathComponent("stem-playlists.json") lists = (try? JSONDecoder().decode([StemPlaylist].self, from: Data(contentsOf: file))) ?? [] } func list(_ id: String) -> StemPlaylist? { lists.first { $0.id == id } } @discardableResult func create(_ title: String, tracks: [Track] = [], mix: [String: StemSetting] = [:]) -> StemPlaylist { let pl = StemPlaylist(title: title, items: tracks.map { StemPlaylistItem(track: $0, mix: MixPresets.normalized(mix)) }) lists.insert(pl, at: 0) save() return pl } func add(_ id: String, tracks: [Track], mix: [String: StemSetting]) { edit(id) { $0.items += tracks.map { StemPlaylistItem(track: $0, mix: MixPresets.normalized(mix)) } } } func setArt(_ id: String, _ thumb: String?) { edit(id) { $0.artThumb = thumb } } /// Re-reads every song from the library so renamed, retagged or re-added songs match again. /// Keeps each item's uid and mix, and leaves songs the library can't find as they are. /// Returns true when anything changed. @discardableResult func refresh(albumTracks: (String) async throws -> [Track], search: ((String) async throws -> [Track])? = nil) async -> Bool { var fresh: [String: [Track]] = [:] for key in Set(lists.flatMap { $0.items.compactMap(\.track.albumKey) }) { if let t = try? await albumTracks(key) { fresh[key] = t } } var found: [String: [Track]] = [:] func norm(_ s: String) -> String { s.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: nil).trimmingCharacters(in: .whitespaces) } func pick(_ old: Track, _ pool: [Track]) -> Track? { pool.first { $0.ratingKey == old.ratingKey } ?? old.partFile.flatMap { f in pool.first { $0.partFile == f } } ?? pool.first { norm($0.title) == norm(old.title) && norm($0.artist) == norm(old.artist) && (old.album.isEmpty || norm($0.album) == norm(old.album)) } ?? pool.first { norm($0.title) == norm(old.title) && norm($0.artist) == norm(old.artist) } } var changed = false, missed = 0 for li in lists.indices { for ii in lists[li].items.indices { let old = lists[li].items[ii].track var new = old.albumKey.flatMap { fresh[$0] }.flatMap { pick(old, $0) } if new == nil, let search { if found[old.title] == nil { found[old.title] = (try? await search(old.title)) ?? [] } new = pick(old, found[old.title] ?? []) } guard var new else { missed += 1; continue } if new.stemKey == nil { new.stemKey = old.stemKey } if new != old { lists[li].items[ii].track = new; changed = true } } } print("[stemplaylists] refresh: \(fresh.count) albums, changed=\(changed), unmatched=\(missed)") if changed { save() } return changed } func setItems(_ id: String, _ items: [StemPlaylistItem]) { edit(id) { $0.items = items } } func remove(_ id: String, item: String) { edit(id) { $0.items.removeAll { $0.uid == item } } } func setMix(_ id: String, item: String, _ mix: [String: StemSetting]) { edit(id) { pl in if let i = pl.items.firstIndex(where: { $0.uid == item }) { pl.items[i].mix = MixPresets.normalized(mix) } } } func rename(_ id: String, _ title: String) { edit(id) { $0.title = title } } func delete(_ id: String) { lists.removeAll { $0.id == id } save() } /// "Instrumental", "A Cappella"… or "Custom Mix" for a hand-made one. static func label(_ mix: [String: StemSetting]) -> String { if MixPresets.normalized(mix).isEmpty { return MixPresets.full.label } if MixPresets.current(allStems, mix) == MixPresets.karaoke { return "Instrumental" } return MixPresets.current(allStems, mix)?.label ?? "Custom Mix" } /// Brings in playlists from an export: new ids are added, ids already here are replaced when the /// import was updated more recently. Returns how many lists were added or replaced. @discardableResult func merge(_ incoming: [StemPlaylist]) -> Int { var n = 0 for pl in incoming { if let i = lists.firstIndex(where: { $0.id == pl.id }) { guard pl.updated > lists[i].updated else { continue } lists[i] = pl } else { lists.append(pl) } n += 1 } if n > 0 { save() } return n } private func edit(_ id: String, _ change: (inout StemPlaylist) -> Void) { guard let i = lists.firstIndex(where: { $0.id == id }) else { return } change(&lists[i]) lists[i].updated = Date() save() } private func save() { let all = lists, file = file DispatchQueue.global(qos: .utility).async { if let data = try? JSONEncoder().encode(all) { try? data.write(to: file, options: .atomic) } } } }