347 lines
15 KiB
Swift
347 lines
15 KiB
Swift
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[..<i] }) {
|
|
return (s, String(key[key.index(after: i)...]))
|
|
}
|
|
return list.first { $0.server.id.isEmpty }.map { ($0, key) }
|
|
}
|
|
|
|
private func routed(_ key: String) throws -> (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<T: Sendable>(_ 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<T>(_ 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? }
|