import Foundation import UIKit /// One Plex Media Server, plus the plex.tv calls. Library reads go through [http], whose cache /// answers from disk when the server can't be reached (see AppGraph). final class PlexServer: MediaSource, @unchecked Sendable { static let product = "Stemify" private static let page = 1000 private let http: Http private let settings: Settings let server: MediaServer private var machine: String private let model: String private let osVersion: String init(http: Http, settings: Settings, server: MediaServer) { self.http = http self.settings = settings self.server = server machine = server.machineId var sys = utsname() uname(&sys) let machine = withUnsafeBytes(of: &sys.machine) { String(decoding: $0.prefix { $0 != 0 }, as: UTF8.self) } model = machine.isEmpty ? "iPhone" : machine osVersion = ProcessInfo.processInfo.operatingSystemVersionString .replacingOccurrences(of: "Version ", with: "").components(separatedBy: " ").first ?? "" } var signedIn: Bool { server.signedIn } private var base: String { server.base } private var token: String { server.token } /// The chosen music libraries; listings pool them, in the order they were picked. private var sections: [String] { server.sections } private func request(_ url: URL, withToken: Bool = true, method: String = "GET") -> URLRequest { var r = URLRequest(url: url) r.httpMethod = method r.setValue("application/json", forHTTPHeaderField: "Accept") r.setValue(Self.product, forHTTPHeaderField: "X-Plex-Product") r.setValue("1.0", forHTTPHeaderField: "X-Plex-Version") r.setValue(settings.clientId, forHTTPHeaderField: "X-Plex-Client-Identifier") r.setValue("iOS", forHTTPHeaderField: "X-Plex-Platform") r.setValue(osVersion, forHTTPHeaderField: "X-Plex-Platform-Version") r.setValue(model, forHTTPHeaderField: "X-Plex-Device") r.setValue("\(Self.product) (\(model))", forHTTPHeaderField: "X-Plex-Device-Name") if withToken, !token.isEmpty { r.setValue(token, forHTTPHeaderField: "X-Plex-Token") } return r } private func url(_ path: String, _ params: [(String, Any?)] = []) throws -> URL { guard let u = URL(string: base + path) else { throw PlainError("Bad Plex address") } return u.with(params) } private func decode(_ data: Data) throws -> MediaContainer { try JSONDecoder().decode(PlexResponse.self, from: data).container } private func get(_ path: String, _ params: [(String, Any?)] = [], start: Int? = nil, size: Int? = nil) async throws -> MediaContainer { guard signedIn else { throw NotSignedIn() } var req = request(try url(path, params)) if let start { req.setValue(String(start), forHTTPHeaderField: "X-Plex-Container-Start") } if let size { req.setValue(String(size), forHTTPHeaderField: "X-Plex-Container-Size") } let body = try await http.data(req) if body.allSatisfy({ $0 == 0x20 || $0 == 0x0A || $0 == 0x0D || $0 == 0x09 }) { return MediaContainer() } return try decode(body) } @discardableResult private func send(_ method: String, _ path: String, _ params: [(String, Any?)] = []) async throws -> MediaContainer { guard signedIn else { throw NotSignedIn() } var req = request(try url(path, params), method: method) if method != "DELETE" { req.httpBody = Data() } let body = try await http.data(req) let text = String(decoding: body, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) guard text.hasPrefix("{") else { return MediaContainer() } return (try? decode(body)) ?? MediaContainer() } /// Pages through a section listing; Plex caps a single response on big libraries. private func all(_ path: String, _ params: [(String, Any?)] = []) async throws -> [PlexItem] { var out: [PlexItem] = [] var start = 0 while true { let page = try await get(path, params, start: start, size: Self.page) out += page.metadata start += page.metadata.count let total = page.totalSize ?? page.size if page.metadata.isEmpty || start >= total { break } } return out } /// Every section's listing, one after another. private func pooled(_ params: [(String, Any?)]) async throws -> [PlexItem] { var out: [PlexItem] = [] for sec in sections { out += try await all("/library/sections/\(sec)/all", params) } return out } /// A page of a listing that runs on through each section in turn. private func pooledPage(_ params: [(String, Any?)], start: Int, size: Int) async throws -> ([PlexItem], Int) { if sections.count == 1 { let c = try await get("/library/sections/\(sections[0])/all", params, start: start, size: size) return (c.metadata, c.totalSize ?? c.size) } var out: [PlexItem] = [] var offset = 0, total = 0 for sec in sections { let n = try await get("/library/sections/\(sec)/all", params, start: 0, size: 0) let count = n.totalSize ?? n.size let from = max(start + out.count - offset, 0) if out.count < size, from < count { out += try await get("/library/sections/\(sec)/all", params, start: from, size: size - out.count).metadata } offset += count; total += count } return (out, total) } // ---- server ------------------------------------------------------------------- func machineId() async throws -> String { let body = try await http.data(request(try url("/identity"), withToken: false)) guard let id = try decode(body).machineIdentifier else { throw PlainError("Not a Plex server") } return id } func musicSections() async throws -> [MusicSection] { try await get("/library/sections").directories .filter { $0.type == "artist" } .map { MusicSection(key: $0.key, title: $0.title, locations: $0.locations.map(\.path)) } } // ---- library ------------------------------------------------------------------ func artists(sort: String = "titleSort") async throws -> [Artist] { try await pooled([("type", 8), ("sort", sort)]).map { $0.toArtist() } } func albums(sort: String = "titleSort") async throws -> [Album] { try await pooled([("type", 9), ("sort", sort)]).map { $0.toAlbum() } } func recentlyAdded(count: Int = 20) async throws -> [Album] { var out: [Album] = [] for sec in sections { out += try await get("/library/sections/\(sec)/all", [("type", 9), ("sort", "addedAt:desc")], start: 0, size: count) .metadata.map { $0.toAlbum() } } return out } /// Albums played most recently first. func recentlyPlayed(count: Int = 20) async throws -> [Album] { var out: [Album] = [] for sec in sections { out += try await get("/library/sections/\(sec)/all", [("type", 9), ("sort", "lastViewedAt:desc"), ("viewCount>>", 0)], start: 0, size: count) .metadata.map { $0.toAlbum() } } return out } /// Songs added most recently first. func recentlyAddedTracks(count: Int = 20) async throws -> [Track] { var out: [Track] = [] for sec in sections { out += try await get("/library/sections/\(sec)/all", [("type", 10), ("sort", "addedAt:desc")], start: 0, size: count) .metadata.map { $0.toTrack() } } return out } func randomAlbums(_ count: Int) async throws -> [Album] { var out: [Album] = [] for sec in sections { out += try await get("/library/sections/\(sec)/all", [("type", 9), ("sort", "random")], start: 0, size: count) .metadata.map { $0.toAlbum() } } return Array(out.shuffled().prefix(count)) } /// One page of every track. Returns the page and the library's track count. func tracksPage(start: Int, size: Int, sort: String = "titleSort") async throws -> ([Track], Int) { let (page, total) = try await pooledPage([("type", 10), ("sort", sort)], start: start, size: size) return (page.map { $0.toTrack() }, total) } /// Random tracks from the whole library, for shuffling it without loading every page. func randomTracks(_ count: Int) async throws -> [Track] { var out: [Track] = [] for sec in sections { out += try await get("/library/sections/\(sec)/all", [("type", 10), ("sort", "random")], start: 0, size: count / max(sections.count, 1) + 1) .metadata.map { $0.toTrack() } } return Array(out.shuffled().prefix(count)) } func album(_ key: String) async throws -> Album? { try await get("/library/metadata/\(key)").metadata.first?.toAlbum() } func albumTracks(_ key: String) async throws -> [Track] { try await get("/library/metadata/\(key)/children").metadata.map { $0.toTrack() } } // Not /library/metadata//children: that leaves out compilations, singles and live // albums (an artist with only "The Best of Kansas" came back empty). func artistAlbums(_ key: String) async throws -> [Album] { try await pooled([("type", 9), ("artist.id", key)]).map { $0.toAlbum() } } func artistTracks(_ key: String) async throws -> [Track] { try await get("/library/metadata/\(key)/allLeaves").metadata.map { $0.toTrack() } } func track(_ key: String) async throws -> PlexItem? { try await get("/library/metadata/\(key)").metadata.first } /// Title search inside the music section; used to give stem-only entries Plex metadata. func searchTracks(_ title: String) async throws -> [Track] { var out: [Track] = [] for sec in sections { out += try await get("/library/sections/\(sec)/search", [("type", 10), ("query", title)]).metadata.map { $0.toTrack() } } return out } /// Plex's own loudness analysis (what Plexamp draws its waveform from), in dB. func loudness(_ trackKey: String, samples: Int) async throws -> [Float]? { guard let streamId = try await track(trackKey)?.media.first?.parts.first?.streams.first(where: { $0.streamType == 2 })?.id else { return nil } let levels: [PlexLevel] do { levels = try await get("/library/streams/\(streamId)/levels", [("subsample", samples)]).levels } catch is HttpError { return nil // not analysed yet } return levels.isEmpty ? nil : levels.map(\.v) } func search(_ query: String) async throws -> SearchResults { let hubs = try await get("/hubs/search", [("query", query), ("limit", 12), ("sectionId", sections.count == 1 ? sections[0] : nil)]).hubs func hub(_ type: String) -> [PlexItem] { hubs.first { $0.type == type }?.metadata ?? [] } return SearchResults( artists: hub("artist").map { $0.toArtist() }, albums: hub("album").map { $0.toAlbum() }, tracks: hub("track").map { $0.toTrack() }, playlists: hub("playlist").filter { $0.playlistType == "audio" }.map { $0.toPlaylist() } ) } // ---- playlists ---------------------------------------------------------------- func playlists(sort: String = "titleSort") async throws -> [Playlist] { try await get("/playlists", [("playlistType", "audio"), ("sort", sort)]).metadata.map { $0.toPlaylist() } } func playlistTracks(_ key: String) async throws -> [Track] { try await all("/playlists/\(key)/items").map { $0.toTrack() } } private func libraryUri(_ keys: [String]) async throws -> String { if machine.isEmpty { machine = try await machineId() } return "server://\(machine)/com.plexapp.plugins.library/library/metadata/\(keys.joined(separator: ","))" } func createPlaylist(_ title: String, trackKeys: [String]) async throws -> Playlist? { let uri = try await libraryUri(trackKeys) return try await send("POST", "/playlists", [("type", "audio"), ("title", title), ("smart", 0), ("uri", uri)]) .metadata.first?.toPlaylist() } func addToPlaylist(_ key: String, trackKeys: [String]) async throws { let uri = try await libraryUri(trackKeys) try await send("PUT", "/playlists/\(key)/items", [("uri", uri)]) } func removeFromPlaylist(_ key: String, playlistItemId: Int64) async throws { try await send("DELETE", "/playlists/\(key)/items/\(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 { try await send("PUT", "/playlists/\(key)/items/\(playlistItemId)/move", [("after", afterItemId)]) } func renamePlaylist(_ key: String, title: String) async throws { try await send("PUT", "/playlists/\(key)", [("title", title)]) } func deletePlaylist(_ key: String) async throws { try await send("DELETE", "/playlists/\(key)") } // ---- playback reporting --------------------------------------------------------- func scrobble(_ trackKey: String) async { _ = try? await get("/:/scrobble", [("key", trackKey), ("identifier", "com.plexapp.plugins.library")]) } func timeline(_ track: Track, state: String, positionMs: Int64) async { _ = try? await get("/:/timeline", [ ("ratingKey", track.ratingKey), ("key", "/library/metadata/\(track.ratingKey)"), ("state", state), ("time", positionMs), ("duration", track.durationMs), ("hasMDE", 1), ]) } // ---- URLs ----------------------------------------------------------------------- func artUrl(_ thumb: String?, px: Int) -> URL? { guard let thumb, !thumb.isEmpty, signedIn, let u = URL(string: base + "/photo/:/transcode") else { return nil } return u.with([("width", px), ("height", px), ("minSize", 1), ("upscale", 1), ("url", thumb), ("X-Plex-Token", token)]) } func partUrl(_ partKey: String) -> URL? { URL(string: base + partKey + "?X-Plex-Token=" + urlEncode(token)) } /// The part's URL the way Plexamp plays it: first a playback decision for this session, then /// the file fetched under that session and client. A bare part request has no bitrate, so Plex /// reads it as unlimited ("Bandwidth exceeded: 2147483647 kbps") and answers 500 whenever /// Remote Access has an upload limit set. func streamUrl(_ ratingKey: String, partKey: String) async -> URL? { let session = UUID().uuidString.lowercased() if signedIn, let u = try? url("/music/:/transcode/universal/decision", [ ("path", "/library/metadata/\(ratingKey)"), ("mediaIndex", 0), ("partIndex", 0), ("protocol", "http"), ("directPlay", 1), ("directStream", 1), ("directStreamAudio", 1), ("hasMDE", 1), ("session", session), ("X-Plex-Session-Identifier", session), ]) { var req = request(u) req.setValue(session, forHTTPHeaderField: "X-Plex-Session-Identifier") req.timeoutInterval = 8 // Best effort: the part URL below still carries the session if this fails. if let (_, resp) = try? await http.session.data(for: req), let code = (resp as? HTTPURLResponse)?.statusCode, code >= 300 { Log.shared.error("plex", "stream decision for \(ratingKey): HTTP \(code)") } } guard let u = URL(string: base + partKey) else { return nil } return u.with([ ("X-Plex-Token", token), ("X-Plex-Client-Identifier", settings.clientId), ("X-Plex-Product", Self.product), ("X-Plex-Platform", "iOS"), ("X-Plex-Device", model), ("X-Plex-Session-Identifier", session), ("session", session), ]) } // ---- plex.tv sign-in ------------------------------------------------------------ func createPin() async throws -> PlexPin { var req = request(URL(string: "https://plex.tv/api/v2/pins?strong=false")!, withToken: false, method: "POST") req.httpBody = Data() return try JSONDecoder().decode(PlexPin.self, from: try await http.data(req)) } /// A strong PIN, completed in the browser at [authUrl] (same flow as the web UI). func createStrongPin() async throws -> PlexPin { var req = request(URL(string: "https://plex.tv/api/v2/pins?strong=true")!, withToken: false, method: "POST") req.httpBody = Data() return try JSONDecoder().decode(PlexPin.self, from: try await http.data(req)) } func authUrl(code: String) -> URL? { var c = URLComponents(string: "https://app.plex.tv/auth") c?.percentEncodedFragment = "?clientID=\(urlEncode(settings.clientId))&code=\(urlEncode(code))&context%5Bdevice%5D%5Bproduct%5D=\(urlEncode(Self.product))" return c?.url } /// Every server this plex.tv account can reach, owned or shared. func resources(token: String) async throws -> [PlexResource] { var req = request(URL(string: "https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1")!, withToken: false) req.setValue(token, forHTTPHeaderField: "X-Plex-Token") let list = try JSONDecoder().decode([Lenient].self, from: try await http.data(req)) return list.compactMap(\.value).filter(\.isServer) } /// The first address that answers /identity as the expected server, local ones first, relay last. func reach(_ r: PlexResource, userToken: String) async -> String? { let tok = r.accessToken.isEmpty ? userToken : r.accessToken let ordered = r.connections.filter { !$0.uri.isEmpty }.sorted { rank($0) < rank($1) } for c in ordered { let base = c.uri.trimmingCharacters(in: CharacterSet(charactersIn: "/")) guard let u = URL(string: base + "/identity") else { continue } var req = request(u, withToken: false) req.setValue(tok, forHTTPHeaderField: "X-Plex-Token") req.timeoutInterval = 3.5 guard let body = try? await http.data(req), let id = try? decode(body).machineIdentifier, id == r.clientIdentifier || r.clientIdentifier.isEmpty else { continue } return base } return nil } private func rank(_ c: PlexResource.Connection) -> Int { c.relay ? 2 : (c.local ? 0 : 1) } func checkPin(_ id: Int64) async throws -> PlexPin { let req = request(URL(string: "https://plex.tv/api/v2/pins/\(id)")!, withToken: false) return try JSONDecoder().decode(PlexPin.self, from: try await http.data(req)) } func user(token: String) async throws -> PlexUser { var req = request(URL(string: "https://plex.tv/api/v2/user")!, withToken: false) req.setValue(token, forHTTPHeaderField: "X-Plex-Token") return try JSONDecoder().decode(PlexUser.self, from: try await http.data(req)) } /// True if [url] answers as a Plex server with the given token. func probe(_ url: String, token: String) async -> Bool { let trimmed = url.trimmingCharacters(in: CharacterSet(charactersIn: "/")) guard let u = URL(string: trimmed + "/library/sections") else { return false } var req = request(u, withToken: false) req.setValue(token, forHTTPHeaderField: "X-Plex-Token") return await http.ok(req) } }