146 lines
6.3 KiB
Swift
146 lines
6.3 KiB
Swift
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) }
|
|
}
|
|
}
|
|
}
|