import Foundation struct NotSignedIn: LocalizedError { var errorDescription: String? { "Not signed in to a music server" } } struct PlainError: LocalizedError { let message: String init(_ m: String) { message = m } var errorDescription: String? { message } } /// Every music server in Settings, pooled into one library. Listings run on each server and are /// joined in Settings order; anything keyed (an album, a track's file, a playlist) goes back to /// the server its key is tagged with (see MediaServer). final class PlexClient: @unchecked Sendable { static let product = "Stemify" private let http: Http private let settings: Settings private let lock = NSLock() private var builtFrom = "\u{0}" private var built: [MediaSource] = [] init(http: Http, settings: Settings) { self.http = http self.settings = settings } func make(_ s: MediaServer) -> MediaSource { s.kind == .plex ? PlexServer(http: http, settings: settings, server: s) : JellyfinClient(http: http, settings: settings, server: s) } /// Every signed-in server, rebuilt only when the saved list changes. var all: [MediaSource] { let json = settings.serversJson lock.lock() defer { lock.unlock() } if json != builtFrom { built = settings.servers.filter(\.signedIn).map(make) builtFrom = json } return built } /// Servers with at least one music library picked. private var sources: [MediaSource] { all.filter { $0.server.ready } } var signedIn: Bool { !all.isEmpty } /// The server a tagged key belongs to, and the key as that server knows it. private func route(_ key: String) -> (MediaSource, String)? { let list = all if let i = key.firstIndex(of: "~"), let s = list.first(where: { !$0.server.id.isEmpty && $0.server.id == key[.. (MediaSource, String) { guard let r = route(key) else { throw NotSignedIn() } return r } /// Runs [f] on every server at once and joins the results in Settings order. /// One server being down doesn't hide the others; it only fails when they all do. private func pool(_ f: @escaping @Sendable (MediaSource) async throws -> [T]) async throws -> [T] { let list = sources guard !list.isEmpty else { throw NotSignedIn() } if list.count == 1 { return try await f(list[0]) } var results = [[T]?](repeating: nil, count: list.count) var failure: Error? await withTaskGroup(of: (Int, Result<[T], Error>).self) { g in for (i, s) in list.enumerated() { g.addTask { do { return (i, .success(try await f(s))) } catch { return (i, .failure(error)) } } } for await (i, r) in g { switch r { case .success(let v): results[i] = v case .failure(let e): failure = e } } } if results.allSatisfy({ $0 == nil }), let failure { throw failure } return results.compactMap { $0 }.flatMap { $0 } } private var many: Bool { sources.count > 1 } /// Name sorts come back sorted per server; put the joined list back in order. private func byName(_ list: [T], _ sort: String, _ name: (T) -> String) -> [T] { guard many, sort.hasPrefix("titleSort") else { return list } let desc = sort.hasPrefix("titleSort:desc") return list.sorted { (name($0).localizedStandardCompare(name($1)) == .orderedAscending) != desc } } // ---- library ------------------------------------------------------------------ func artists(sort: String = "titleSort") async throws -> [Artist] { byName(try await pool { s in try await s.artists(sort: sort).map { $0.tagged(s.server.id) } }, sort, \.sortTitle) } func albums(sort: String = "titleSort") async throws -> [Album] { byName(try await pool { s in try await s.albums(sort: sort).map { $0.tagged(s.server.id) } }, sort, \.title) } func recentlyAdded(count: Int = 20) async throws -> [Album] { try await pool { s in try await s.recentlyAdded(count: count).map { $0.tagged(s.server.id) } } } func recentlyPlayed(count: Int = 20) async throws -> [Album] { try await pool { s in try await s.recentlyPlayed(count: count).map { $0.tagged(s.server.id) } } } func recentlyAddedTracks(count: Int = 20) async throws -> [Track] { try await pool { s in try await s.recentlyAddedTracks(count: count).map { $0.tagged(s.server.id) } } } func randomAlbums(_ count: Int) async throws -> [Album] { Array(try await pool { s in try await s.randomAlbums(count).map { $0.tagged(s.server.id) } }.shuffled().prefix(count)) } /// One page of every track, running on through each server in turn, and the total count. func tracksPage(start: Int, size: Int, sort: String = "titleSort") async throws -> ([Track], Int) { let list = sources guard !list.isEmpty else { throw NotSignedIn() } if list.count == 1 { let (page, total) = try await list[0].tracksPage(start: start, size: size, sort: sort) return (page.map { $0.tagged(list[0].server.id) }, total) } var out: [Track] = [] var offset = 0, total = 0 for s in list { let count = try await s.tracksPage(start: 0, size: 0, sort: sort).1 let from = max(start + out.count - offset, 0) if out.count < size, from < count { out += try await s.tracksPage(start: from, size: size - out.count, sort: sort).0.map { $0.tagged(s.server.id) } } offset += count; total += count } return (out, total) } func randomTracks(_ count: Int) async throws -> [Track] { let each = count / max(sources.count, 1) + 1 return Array(try await pool { s in try await s.randomTracks(each).map { $0.tagged(s.server.id) } }.shuffled().prefix(count)) } func album(_ key: String) async throws -> Album? { let (s, k) = try routed(key) return try await s.album(k)?.tagged(s.server.id) } func albumTracks(_ key: String) async throws -> [Track] { let (s, k) = try routed(key) return try await s.albumTracks(k).map { $0.tagged(s.server.id) } } func artistAlbums(_ key: String) async throws -> [Album] { let (s, k) = try routed(key) return try await s.artistAlbums(k).map { $0.tagged(s.server.id) } } func artistTracks(_ key: String) async throws -> [Track] { let (s, k) = try routed(key) return try await s.artistTracks(k).map { $0.tagged(s.server.id) } } /// Title search on every server; used to give stem-only entries library metadata. func searchTracks(_ title: String) async throws -> [Track] { try await pool { s in try await s.searchTracks(title).map { $0.tagged(s.server.id) } } } /// Plex's own loudness analysis (what Plexamp draws its waveform from), in dB. Jellyfin has none. func loudness(_ trackKey: String, samples: Int) async throws -> [Float]? { let (s, k) = try routed(trackKey) return try await s.loudness(k, samples: samples) } func search(_ query: String) async throws -> SearchResults { let parts = try await pool { s in [try await s.search(query).tagged(s.server.id)] } return SearchResults(artists: parts.flatMap(\.artists), albums: parts.flatMap(\.albums), tracks: parts.flatMap(\.tracks), playlists: parts.flatMap(\.playlists)) } // ---- playlists ---------------------------------------------------------------- func playlists(sort: String = "titleSort") async throws -> [Playlist] { byName(try await pool { s in try await s.playlists(sort: sort).map { $0.tagged(s.server.id) } }, sort, \.title) } func playlistTracks(_ key: String) async throws -> [Track] { let (s, k) = try routed(key) return try await s.playlistTracks(k).map { $0.tagged(s.server.id) } } /// The keys that live on [s], untagged. A playlist can't hold another server's songs. private func keys(_ trackKeys: [String], on s: MediaSource) -> [String] { trackKeys.compactMap { k in route(k).flatMap { $0.0 === s ? $0.1 : nil } } } /// Made on the server of the first song; songs from other servers are left out. func createPlaylist(_ title: String, trackKeys: [String]) async throws -> Playlist? { guard let first = trackKeys.first else { return nil } let (s, _) = try routed(first) return try await s.createPlaylist(title, trackKeys: keys(trackKeys, on: s))?.tagged(s.server.id) } func addToPlaylist(_ key: String, trackKeys: [String]) async throws { let (s, k) = try routed(key) let own = keys(trackKeys, on: s) guard !own.isEmpty else { throw PlainError("Those songs are on a different server than this playlist") } try await s.addToPlaylist(k, trackKeys: own) } func removeFromPlaylist(_ key: String, playlistItemId: Int64) async throws { let (s, k) = try routed(key) try await s.removeFromPlaylist(k, playlistItemId: playlistItemId) } /// Moves an item to just after [afterItemId], or to the top when it's nil. func movePlaylistItem(_ key: String, playlistItemId: Int64, afterItemId: Int64?) async throws { let (s, k) = try routed(key) try await s.movePlaylistItem(k, playlistItemId: playlistItemId, afterItemId: afterItemId) } func renamePlaylist(_ key: String, title: String) async throws { let (s, k) = try routed(key) try await s.renamePlaylist(k, title: title) } func deletePlaylist(_ key: String) async throws { let (s, k) = try routed(key) try await s.deletePlaylist(k) } // ---- playback reporting --------------------------------------------------------- func scrobble(_ trackKey: String) async { guard let r = route(trackKey) else { return } let (s, k) = r await s.scrobble(k) } func timeline(_ track: Track, state: String, positionMs: Int64) async { guard let r = route(track.ratingKey) else { return } let (s, k) = r var t = track t.ratingKey = k await s.timeline(t, state: state, positionMs: positionMs) } // ---- URLs ----------------------------------------------------------------------- func artUrl(_ thumb: String?, px: Int) -> URL? { if let thumb, thumb.hasPrefix(StemPlaylists.localPrefix) { return StemPlaylists.localArt(thumb) } guard let thumb, !thumb.isEmpty, let r = route(thumb) else { return nil } let (s, k) = r return s.artUrl(k, px: px) } func partUrl(_ partKey: String) -> URL? { guard let r = route(partKey) else { return nil } let (s, k) = r return s.partUrl(k) } /// [track]'s file, set up for playback on its server (see PlexServer.streamUrl). func streamUrl(_ track: Track) async -> URL? { guard let part = track.partKey, let r = route(part) else { return nil } let (s, k) = r let rk = route(track.ratingKey)?.1 ?? track.ratingKey return await s.streamUrl(rk, partKey: k) } /// Blocking form of [streamUrl] for worker threads. func streamUrlNow(_ track: Track) -> URL? { let box = UrlBox() let sem = DispatchSemaphore(value: 0) Task { box.url = await streamUrl(track) sem.signal() } sem.wait() return box.url } // ---- sign-in helpers -------------------------------------------------------------- /// A Plex client for plex.tv calls and for a server that isn't saved yet. func plexTv(_ url: String = "") -> PlexServer { PlexServer(http: http, settings: settings, server: MediaServer(url: url)) } /// Signs in with a plex.tv token: finds every server on the account, saves each one that /// answers (matched by machine id, so it never duplicates), and switches on all its music /// libraries. Libraries already picked on a known server stay as they were. /// Returns (servers saved, servers on the account that didn't answer). @discardableResult func syncAccount(token: String) async throws -> (saved: Int, unreachable: Int) { let tv = plexTv() let user = await { guard let u = try? await tv.user(token: token) else { return "Plex user" } return u.title ?? u.username ?? "Plex user" }() let found = try await tv.resources(token: token) struct Hit { var res: PlexResource; var url: String; var secs: [MusicSection] } let hits: [Hit] = await withTaskGroup(of: Hit?.self) { group in for r in found { group.addTask { [self] in guard let url = await tv.reach(r, userToken: token) else { return nil } let tok = r.accessToken.isEmpty ? token : r.accessToken let probe = MediaServer(name: r.name, url: url, token: tok, machineId: r.clientIdentifier) let secs = (try? await make(probe).musicSections()) ?? [] return Hit(res: r, url: url, secs: secs) } } var out: [Hit] = [] for await h in group { if let h { out.append(h) } } return out } let order = Dictionary(uniqueKeysWithValues: found.enumerated().map { ($1.clientIdentifier, $0) }) for h in hits.sorted(by: { (order[$0.res.clientIdentifier] ?? 0) < (order[$1.res.clientIdentifier] ?? 0) }) { let known = settings.servers.first { $0.kind == .plex && $0.machineId == h.res.clientIdentifier && !$0.machineId.isEmpty } ?? settings.servers.first { $0.kind == .plex && $0.machineId.isEmpty && $0.base == h.url.trimmingCharacters(in: CharacterSet(charactersIn: "/")) } var srv = known ?? MediaServer(id: settings.nextServerId(), kind: .plex) srv.url = h.url srv.token = h.res.accessToken.isEmpty ? token : h.res.accessToken srv.machineId = h.res.clientIdentifier srv.user = user if srv.name.isEmpty || known == nil { srv.name = h.res.name } if known != nil { if !h.secs.isEmpty { srv.sections = srv.sections.filter { k in h.secs.contains { $0.key == k } } } } else { srv.sections = h.secs.map(\.key) } if !h.secs.isEmpty { srv.sectionTitles = srv.sections.compactMap { k in h.secs.first { $0.key == k }?.title }.joined(separator: " + ") } settings.save(srv) } return (hits.count, found.count - hits.count) } func jellyfin(_ s: MediaServer) -> JellyfinClient { JellyfinClient(http: http, settings: settings, server: s) } } private final class UrlBox: @unchecked Sendable { var url: URL? }