Stemify iOS 1.1 and WebUI updates, repo restructure into desktop/ios/server/web
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
894dd5a695
commit
df5d847169
157 files changed
+3935
-742
No files matched your search
@@ -0,0 +1,448 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
/// A Jellyfin item, read leniently like PlexItem.
|
||||
struct JItem: Decodable {
|
||||
var id = ""
|
||||
var name = ""
|
||||
var type = ""
|
||||
var sortName: String?
|
||||
var album: String?
|
||||
var albumId: String?
|
||||
var albumArtist: String?
|
||||
var albumArtistId: String?
|
||||
var artistId: String?
|
||||
var artist: String?
|
||||
var index: Int?
|
||||
var disc: Int?
|
||||
var ticks: Int64?
|
||||
var year: Int?
|
||||
var premiere: String?
|
||||
var path: String?
|
||||
var size: Int64?
|
||||
var container: String?
|
||||
var primaryTag: String?
|
||||
var albumPrimaryTag: String?
|
||||
var childCount: Int?
|
||||
var genre: String?
|
||||
var mediaType: String?
|
||||
var playlistItemId: String?
|
||||
var collectionType: String?
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: AnyKey.self)
|
||||
id = c.str("Id") ?? ""
|
||||
name = c.str("Name") ?? ""
|
||||
type = c.str("Type") ?? ""
|
||||
sortName = c.str("SortName")
|
||||
album = c.str("Album")
|
||||
albumId = c.str("AlbumId")
|
||||
albumArtist = c.str("AlbumArtist")
|
||||
let albumArtists: [JRef] = c.list("AlbumArtists")
|
||||
let artists: [JRef] = c.list("ArtistItems")
|
||||
albumArtistId = albumArtists.first?.id
|
||||
artistId = artists.first?.id ?? albumArtistId
|
||||
artist = artists.first?.name
|
||||
index = c.int("IndexNumber")
|
||||
disc = c.int("ParentIndexNumber")
|
||||
ticks = c.int64("RunTimeTicks")
|
||||
year = c.int("ProductionYear")
|
||||
premiere = c.str("PremiereDate")
|
||||
path = c.str("Path")
|
||||
let sources: [JSource] = c.list("MediaSources")
|
||||
size = sources.first?.size
|
||||
container = sources.first?.container ?? c.str("Container")
|
||||
if path == nil { path = sources.first?.path }
|
||||
primaryTag = (try? c.decodeIfPresent([String: String].self, forKey: AnyKey("ImageTags")))?["Primary"]
|
||||
albumPrimaryTag = c.str("AlbumPrimaryImageTag")
|
||||
childCount = c.int("ChildCount") ?? c.int("SongCount")
|
||||
genre = (try? c.decodeIfPresent([String].self, forKey: AnyKey("Genres")))?.first
|
||||
mediaType = c.str("MediaType")
|
||||
playlistItemId = c.str("PlaylistItemId")
|
||||
collectionType = c.str("CollectionType")
|
||||
}
|
||||
|
||||
var durationMs: Int64 { (ticks ?? 0) / 10_000 }
|
||||
var thumb: String? { primaryTag != nil ? id : albumPrimaryTag != nil ? albumId : nil }
|
||||
|
||||
func toTrack(_ position: Int? = nil) -> Track {
|
||||
Track(ratingKey: id, title: name, artist: artist ?? albumArtist ?? "Unknown Artist", album: album ?? "",
|
||||
albumKey: albumId, artistKey: albumArtistId ?? artistId, index: index, disc: disc, durationMs: durationMs,
|
||||
thumb: thumb, partKey: id, partId: stableId(id), partFile: path,
|
||||
partSize: size, codec: container, year: year, playlistItemId: position.map(Int64.init))
|
||||
}
|
||||
|
||||
func toAlbum() -> Album {
|
||||
Album(ratingKey: id, title: name, artist: albumArtist ?? "Unknown Artist", artistKey: albumArtistId, year: year,
|
||||
thumb: thumb, trackCount: childCount, genre: genre, released: premiere.map { String($0.prefix(10)) })
|
||||
}
|
||||
|
||||
func toArtist() -> Artist { Artist(ratingKey: id, title: name, sortTitle: sortName ?? name, thumb: thumb) }
|
||||
|
||||
func toPlaylist() -> Playlist {
|
||||
Playlist(ratingKey: id, title: name, thumb: thumb, trackCount: childCount ?? 0, durationMs: durationMs)
|
||||
}
|
||||
}
|
||||
|
||||
/// FNV-1a, so the cache key for a Jellyfin file is the same on every launch.
|
||||
private func stableId(_ s: String) -> Int64 {
|
||||
var h: UInt64 = 0xcbf29ce484222325
|
||||
for b in s.utf8 { h = (h ^ UInt64(b)) &* 0x100000001b3 }
|
||||
return Int64(bitPattern: h & 0x7FFF_FFFF_FFFF_FFFF)
|
||||
}
|
||||
|
||||
private struct JRef: Decodable {
|
||||
var id = ""
|
||||
var name = ""
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: AnyKey.self)
|
||||
id = c.str("Id") ?? ""
|
||||
name = c.str("Name") ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
private struct JSource: Decodable {
|
||||
var size: Int64?
|
||||
var container: String?
|
||||
var path: String?
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: AnyKey.self)
|
||||
size = c.int64("Size")
|
||||
container = c.str("Container")
|
||||
path = c.str("Path")
|
||||
}
|
||||
}
|
||||
|
||||
private struct JList: Decodable {
|
||||
var items: [JItem] = []
|
||||
var total = 0
|
||||
init(from decoder: Decoder) throws {
|
||||
if let arr = try? [Lenient<JItem>](from: decoder) {
|
||||
items = arr.compactMap(\.value)
|
||||
total = items.count
|
||||
return
|
||||
}
|
||||
let c = try decoder.container(keyedBy: AnyKey.self)
|
||||
items = c.list("Items")
|
||||
total = c.int("TotalRecordCount") ?? items.count
|
||||
}
|
||||
}
|
||||
|
||||
private struct JFolder: Decodable {
|
||||
var id = ""
|
||||
var locations: [String] = []
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: AnyKey.self)
|
||||
id = c.str("ItemId") ?? ""
|
||||
locations = (try? c.decodeIfPresent([String].self, forKey: AnyKey("Locations"))) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
struct JellyfinLogin {
|
||||
var token: String
|
||||
var userId: String
|
||||
var user: String
|
||||
var serverId: String
|
||||
}
|
||||
|
||||
/// One Jellyfin server. Tracks keep the file Path so stem matching works the same as on Plex.
|
||||
final class JellyfinClient: MediaSource, @unchecked Sendable {
|
||||
private static let page = 1000
|
||||
private static let fields = "Path,MediaSources,SortName,Genres,PremiereDate,ChildCount,ProductionYear"
|
||||
|
||||
private let http: Http
|
||||
private let settings: Settings
|
||||
let server: MediaServer
|
||||
|
||||
init(http: Http, settings: Settings, server: MediaServer) {
|
||||
self.http = http
|
||||
self.settings = settings
|
||||
self.server = server
|
||||
}
|
||||
|
||||
private var base: String { server.base }
|
||||
private var uid: String { server.userId }
|
||||
|
||||
private func request(_ url: URL, method: String = "GET", token: String? = nil) -> URLRequest {
|
||||
var r = URLRequest(url: url)
|
||||
r.httpMethod = method
|
||||
r.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
let t = token ?? server.token
|
||||
var auth = "MediaBrowser Client=\"\(PlexClient.product)\", Device=\"iPhone\", DeviceId=\"\(settings.clientId)\", Version=\"1.1\""
|
||||
if !t.isEmpty { auth += ", Token=\"\(t)\"" }
|
||||
r.setValue(auth, forHTTPHeaderField: "Authorization")
|
||||
return r
|
||||
}
|
||||
|
||||
private func url(_ path: String, _ params: [(String, Any?)] = []) throws -> URL {
|
||||
guard let u = URL(string: base + path) else { throw PlainError("Bad Jellyfin address") }
|
||||
return u.with(params)
|
||||
}
|
||||
|
||||
private func get(_ path: String, _ params: [(String, Any?)] = []) async throws -> JList {
|
||||
guard server.signedIn else { throw NotSignedIn() }
|
||||
return try JSONDecoder().decode(JList.self, from: try await http.data(request(try url(path, params))))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func send(_ method: String, _ path: String, _ params: [(String, Any?)] = [], body: [String: Any]? = nil) async throws -> Data {
|
||||
guard server.signedIn else { throw NotSignedIn() }
|
||||
var req = request(try url(path, params), method: method)
|
||||
if let body {
|
||||
req.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
} else if method != "DELETE" { req.httpBody = Data() }
|
||||
return try await http.data(req)
|
||||
}
|
||||
|
||||
/// Plex sort keys ("addedAt:desc", "artist.titleSort,year") to Jellyfin's SortBy and SortOrder.
|
||||
private func sortParams(_ sort: String, tracks: Bool = false) -> [(String, Any?)] {
|
||||
let desc = sort.contains(":desc")
|
||||
let first = sort.split(separator: ",").first.map { String($0).replacingOccurrences(of: ":desc", with: "") } ?? ""
|
||||
let by: String = switch first {
|
||||
case "addedAt", "updatedAt": "DateCreated"
|
||||
case "lastViewedAt": "DatePlayed"
|
||||
case "viewCount": "PlayCount"
|
||||
case "userRating": "CommunityRating"
|
||||
case "year": "ProductionYear,SortName"
|
||||
case "originallyAvailableAt": "PremiereDate,SortName"
|
||||
case "duration": "Runtime"
|
||||
case "random": "Random"
|
||||
case "artist.titleSort": tracks ? "AlbumArtist,Album,ParentIndexNumber,IndexNumber" : "AlbumArtist,ProductionYear"
|
||||
case "album.titleSort": "Album,ParentIndexNumber,IndexNumber"
|
||||
default: "SortName"
|
||||
}
|
||||
return [("SortBy", by), ("SortOrder", desc ? "Descending" : "Ascending")]
|
||||
}
|
||||
|
||||
private func items(_ type: String, _ params: [(String, Any?)], parent: String? = nil, start: Int? = nil, limit: Int? = nil) async throws -> JList {
|
||||
try await get("/Items", [("userId", uid), ("IncludeItemTypes", type), ("Recursive", "true"), ("Fields", Self.fields),
|
||||
("ParentId", parent), ("StartIndex", start), ("Limit", limit)] + params)
|
||||
}
|
||||
|
||||
/// Every chosen library's items, paged.
|
||||
private func pooled(_ type: String, _ params: [(String, Any?)]) async throws -> [JItem] {
|
||||
var out: [JItem] = []
|
||||
for sec in server.sections {
|
||||
var start = 0
|
||||
while true {
|
||||
let page = try await items(type, params, parent: sec, start: start, limit: Self.page)
|
||||
out += page.items
|
||||
start += page.items.count
|
||||
if page.items.isEmpty || start >= page.total { break }
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private func first(_ type: String, _ params: [(String, Any?)], count: Int) async throws -> [JItem] {
|
||||
var out: [JItem] = []
|
||||
for sec in server.sections { out += try await items(type, params, parent: sec, limit: count).items }
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- sign-in --------------------------------------------------------------------
|
||||
|
||||
func login(user: String, password: String) async throws -> JellyfinLogin {
|
||||
var req = request(try url("/Users/AuthenticateByName"), method: "POST", token: "")
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = try JSONSerialization.data(withJSONObject: ["Username": user, "Pw": password])
|
||||
let data = try await http.data(req)
|
||||
guard let o = try JSONSerialization.jsonObject(with: data) as? [String: Any], let t = o["AccessToken"] as? String,
|
||||
let u = o["User"] as? [String: Any], let id = u["Id"] as? String
|
||||
else { throw PlainError("Jellyfin didn't accept that sign-in") }
|
||||
return JellyfinLogin(token: t, userId: id, user: u["Name"] as? String ?? user, serverId: o["ServerId"] as? String ?? "")
|
||||
}
|
||||
|
||||
/// An API key from the dashboard has no user, so take the first user (admins only see that list).
|
||||
func loginWithKey(_ key: String) async throws -> JellyfinLogin {
|
||||
let data = try await http.data(request(try url("/Users"), token: key))
|
||||
guard let list = try JSONSerialization.jsonObject(with: data) as? [[String: Any]], let u = list.first, let id = u["Id"] as? String
|
||||
else { throw PlainError("That API key didn't work on this server") }
|
||||
return JellyfinLogin(token: key, userId: id, user: u["Name"] as? String ?? "Jellyfin user", serverId: u["ServerId"] as? String ?? "")
|
||||
}
|
||||
|
||||
func musicSections() async throws -> [MusicSection] {
|
||||
let all: [JItem]
|
||||
do { all = try await get("/UserViews", [("userId", uid)]).items } catch { all = try await get("/Users/\(uid)/Views").items }
|
||||
let views = all.filter { $0.collectionType == "music" }
|
||||
// Folder paths need an admin; without them the picker just shows names.
|
||||
var paths: [String: [String]] = [:]
|
||||
if let data = try? await http.data(request(try url("/Library/VirtualFolders"))),
|
||||
let folders = try? JSONDecoder().decode([Lenient<JFolder>].self, from: data) {
|
||||
for f in folders.compactMap(\.value) { paths[f.id] = f.locations }
|
||||
}
|
||||
return views.map { MusicSection(key: $0.id, title: $0.name, locations: paths[$0.id] ?? []) }
|
||||
}
|
||||
|
||||
// ---- library ----------------------------------------------------------------------
|
||||
|
||||
func artists(sort: String) async throws -> [Artist] {
|
||||
var out: [Artist] = []
|
||||
for sec in server.sections {
|
||||
out += try await get("/Artists/AlbumArtists", [("userId", uid), ("ParentId", sec), ("Fields", "SortName")] + sortParams(sort))
|
||||
.items.map { $0.toArtist() }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func albums(sort: String) async throws -> [Album] {
|
||||
try await pooled("MusicAlbum", sortParams(sort)).map { $0.toAlbum() }
|
||||
}
|
||||
|
||||
func recentlyAdded(count: Int) async throws -> [Album] {
|
||||
try await first("MusicAlbum", sortParams("addedAt:desc"), count: count).map { $0.toAlbum() }
|
||||
}
|
||||
|
||||
/// Albums of the songs played last, newest first.
|
||||
func recentlyPlayed(count: Int) async throws -> [Album] {
|
||||
let played = try await first("Audio", sortParams("lastViewedAt:desc") + [("Filters", "IsPlayed")], count: count * 4)
|
||||
var seen = Set<String>()
|
||||
let ids = played.compactMap(\.albumId).filter { seen.insert($0).inserted }.prefix(count)
|
||||
guard !ids.isEmpty else { return [] }
|
||||
let list = try await get("/Items", [("userId", uid), ("Ids", ids.joined(separator: ",")), ("Fields", Self.fields)]).items
|
||||
return ids.compactMap { id in list.first { $0.id == id }?.toAlbum() }
|
||||
}
|
||||
|
||||
func recentlyAddedTracks(count: Int) async throws -> [Track] {
|
||||
try await first("Audio", sortParams("addedAt:desc"), count: count).map { $0.toTrack() }
|
||||
}
|
||||
|
||||
func randomAlbums(_ count: Int) async throws -> [Album] {
|
||||
Array(try await first("MusicAlbum", sortParams("random"), count: count).map { $0.toAlbum() }.shuffled().prefix(count))
|
||||
}
|
||||
|
||||
func tracksPage(start: Int, size: Int, sort: String) async throws -> ([Track], Int) {
|
||||
var out: [Track] = []
|
||||
var offset = 0, total = 0
|
||||
for sec in server.sections {
|
||||
let count = try await items("Audio", [], parent: sec, limit: 0).total
|
||||
let from = max(start + out.count - offset, 0)
|
||||
if out.count < size, from < count {
|
||||
out += try await items("Audio", sortParams(sort, tracks: true), parent: sec, start: from, limit: size - out.count).items.map { $0.toTrack() }
|
||||
}
|
||||
offset += count; total += count
|
||||
}
|
||||
return (out, total)
|
||||
}
|
||||
|
||||
func randomTracks(_ count: Int) async throws -> [Track] {
|
||||
let each = count / max(server.sections.count, 1) + 1
|
||||
return Array(try await first("Audio", sortParams("random"), count: each).map { $0.toTrack() }.shuffled().prefix(count))
|
||||
}
|
||||
|
||||
func album(_ key: String) async throws -> Album? {
|
||||
try await get("/Items", [("userId", uid), ("Ids", key), ("Fields", Self.fields)]).items.first?.toAlbum()
|
||||
}
|
||||
|
||||
func albumTracks(_ key: String) async throws -> [Track] {
|
||||
try await items("Audio", [("SortBy", "ParentIndexNumber,IndexNumber,SortName")], parent: key).items.map { $0.toTrack() }
|
||||
}
|
||||
|
||||
func artistAlbums(_ key: String) async throws -> [Album] {
|
||||
try await items("MusicAlbum", [("AlbumArtistIds", key), ("SortBy", "ProductionYear,SortName")]).items.map { $0.toAlbum() }
|
||||
}
|
||||
|
||||
func artistTracks(_ key: String) async throws -> [Track] {
|
||||
try await items("Audio", [("ArtistIds", key), ("SortBy", "Album,ParentIndexNumber,IndexNumber")]).items.map { $0.toTrack() }
|
||||
}
|
||||
|
||||
func searchTracks(_ title: String) async throws -> [Track] {
|
||||
try await items("Audio", [("searchTerm", title), ("Limit", 50)]).items.map { $0.toTrack() }
|
||||
}
|
||||
|
||||
func loudness(_ trackKey: String, samples: Int) async throws -> [Float]? { nil }
|
||||
|
||||
func search(_ query: String) async throws -> SearchResults {
|
||||
async let ar = get("/Artists", [("userId", uid), ("searchTerm", query), ("Limit", 12)])
|
||||
async let al = items("MusicAlbum", [("searchTerm", query)], limit: 12)
|
||||
async let tr = items("Audio", [("searchTerm", query)], limit: 12)
|
||||
async let pl = items("Playlist", [("searchTerm", query)], limit: 12)
|
||||
return SearchResults(artists: try await ar.items.map { $0.toArtist() }, albums: try await al.items.map { $0.toAlbum() },
|
||||
tracks: try await tr.items.map { $0.toTrack() },
|
||||
playlists: try await pl.items.filter { $0.mediaType != "Video" }.map { $0.toPlaylist() })
|
||||
}
|
||||
|
||||
// ---- playlists ----------------------------------------------------------------------
|
||||
|
||||
func playlists(sort: String) async throws -> [Playlist] {
|
||||
try await items("Playlist", sortParams(sort)).items.filter { $0.mediaType != "Video" }.map { $0.toPlaylist() }
|
||||
}
|
||||
|
||||
private func entries(_ key: String) async throws -> [JItem] {
|
||||
try await get("/Playlists/\(key)/Items", [("userId", uid), ("Fields", Self.fields)]).items
|
||||
}
|
||||
|
||||
/// Jellyfin's entry ids are strings, so a track's playlistItemId is its position.
|
||||
func playlistTracks(_ key: String) async throws -> [Track] {
|
||||
try await entries(key).enumerated().map { $1.toTrack($0) }
|
||||
}
|
||||
|
||||
func createPlaylist(_ title: String, trackKeys: [String]) async throws -> Playlist? {
|
||||
let data = try await send("POST", "/Playlists", body: ["Name": title, "Ids": trackKeys, "UserId": uid, "MediaType": "Audio"])
|
||||
guard let o = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let id = o["Id"] as? String else { return nil }
|
||||
return Playlist(ratingKey: id, title: title, trackCount: trackKeys.count)
|
||||
}
|
||||
|
||||
func addToPlaylist(_ key: String, trackKeys: [String]) async throws {
|
||||
try await send("POST", "/Playlists/\(key)/Items", [("ids", trackKeys.joined(separator: ",")), ("userId", uid)])
|
||||
}
|
||||
|
||||
private func entryId(_ key: String, _ position: Int64) async throws -> String {
|
||||
let list = try await entries(key)
|
||||
guard position >= 0, position < list.count, let id = list[Int(position)].playlistItemId else { throw PlainError("That song moved; refresh the playlist") }
|
||||
return id
|
||||
}
|
||||
|
||||
func removeFromPlaylist(_ key: String, playlistItemId: Int64) async throws {
|
||||
let id = try await entryId(key, playlistItemId)
|
||||
try await send("DELETE", "/Playlists/\(key)/Items", [("entryIds", id)])
|
||||
}
|
||||
|
||||
func movePlaylistItem(_ key: String, playlistItemId: Int64, afterItemId: Int64?) async throws {
|
||||
let id = try await entryId(key, playlistItemId)
|
||||
// The new index counts the list without the moved song.
|
||||
let to = afterItemId.map { $0 < playlistItemId ? $0 + 1 : $0 } ?? 0
|
||||
try await send("POST", "/Playlists/\(key)/Items/\(id)/Move/\(to)")
|
||||
}
|
||||
|
||||
func renamePlaylist(_ key: String, title: String) async throws {
|
||||
try await send("POST", "/Playlists/\(key)", body: ["Name": title])
|
||||
}
|
||||
|
||||
func deletePlaylist(_ key: String) async throws {
|
||||
try await send("DELETE", "/Items/\(key)")
|
||||
}
|
||||
|
||||
// ---- playback reporting ----------------------------------------------------------------
|
||||
|
||||
func scrobble(_ trackKey: String) async {
|
||||
if (try? await send("POST", "/UserPlayedItems/\(trackKey)", [("userId", uid)])) == nil {
|
||||
_ = try? await send("POST", "/Users/\(uid)/PlayedItems/\(trackKey)")
|
||||
}
|
||||
}
|
||||
|
||||
func timeline(_ track: Track, state: String, positionMs: Int64) async {
|
||||
let path = state == "stopped" ? "/Sessions/Playing/Stopped" : "/Sessions/Playing/Progress"
|
||||
_ = try? await send("POST", path, body: ["ItemId": track.ratingKey, "PositionTicks": positionMs * 10_000,
|
||||
"IsPaused": state == "paused", "CanSeek": true])
|
||||
}
|
||||
|
||||
// ---- URLs ---------------------------------------------------------------------------
|
||||
|
||||
func artUrl(_ thumb: String?, px: Int) -> URL? {
|
||||
guard let thumb, !thumb.isEmpty, server.signedIn, let u = URL(string: base + "/Items/\(thumb)/Images/Primary") else { return nil }
|
||||
return u.with([("fillWidth", px), ("fillHeight", px), ("quality", 90), ("api_key", server.token)])
|
||||
}
|
||||
|
||||
/// The original file, untouched, like Plex's part URL.
|
||||
func partUrl(_ partKey: String) -> URL? {
|
||||
URL(string: base + "/Audio/\(partKey)/stream?static=true&api_key=" + urlEncode(server.token))
|
||||
}
|
||||
|
||||
/// True if [url] answers as a Jellyfin server.
|
||||
func probe() async -> Bool {
|
||||
guard let u = URL(string: base + "/System/Info/Public") else { return false }
|
||||
return await http.ok(request(u, token: ""))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import Foundation
|
||||
|
||||
/// One music server the app reads from. The first one ever added has an empty [id], so its
|
||||
/// keys stay bare and everything saved before multi-server support (downloads, mixes, stem
|
||||
/// playlists) still matches. Every other server's keys carry "<id>~" in front.
|
||||
struct MediaServer: Codable, Hashable, Identifiable {
|
||||
enum Kind: String, Codable { case plex, jellyfin }
|
||||
|
||||
var id = ""
|
||||
var kind = Kind.plex
|
||||
var name = ""
|
||||
var url = ""
|
||||
var token = ""
|
||||
var user = ""
|
||||
/// Jellyfin user id; Plex leaves it empty.
|
||||
var userId = ""
|
||||
var machineId = ""
|
||||
var sections: [String] = []
|
||||
var sectionTitles = ""
|
||||
|
||||
var signedIn: Bool { !token.isEmpty }
|
||||
var ready: Bool { signedIn && !sections.isEmpty }
|
||||
var base: String { url.trimmingCharacters(in: CharacterSet(charactersIn: "/")) }
|
||||
var label: String { name.isEmpty ? (kind == .plex ? "Plex" : "Jellyfin") : name }
|
||||
var kindLabel: String { kind == .plex ? "Plex" : "Jellyfin" }
|
||||
|
||||
static func newId() -> String { "s" + String(UUID().uuidString.prefix(6)).lowercased() }
|
||||
}
|
||||
|
||||
/// What a single server can do. [PlexClient] pools these and tags their keys.
|
||||
protocol MediaSource: AnyObject, Sendable {
|
||||
var server: MediaServer { get }
|
||||
func artists(sort: String) async throws -> [Artist]
|
||||
func albums(sort: String) async throws -> [Album]
|
||||
func recentlyAdded(count: Int) async throws -> [Album]
|
||||
func recentlyPlayed(count: Int) async throws -> [Album]
|
||||
func recentlyAddedTracks(count: Int) async throws -> [Track]
|
||||
func randomAlbums(_ count: Int) async throws -> [Album]
|
||||
func tracksPage(start: Int, size: Int, sort: String) async throws -> ([Track], Int)
|
||||
func randomTracks(_ count: Int) async throws -> [Track]
|
||||
func album(_ key: String) async throws -> Album?
|
||||
func albumTracks(_ key: String) async throws -> [Track]
|
||||
func artistAlbums(_ key: String) async throws -> [Album]
|
||||
func artistTracks(_ key: String) async throws -> [Track]
|
||||
func searchTracks(_ title: String) async throws -> [Track]
|
||||
func loudness(_ trackKey: String, samples: Int) async throws -> [Float]?
|
||||
func search(_ query: String) async throws -> SearchResults
|
||||
func playlists(sort: String) async throws -> [Playlist]
|
||||
func playlistTracks(_ key: String) async throws -> [Track]
|
||||
func createPlaylist(_ title: String, trackKeys: [String]) async throws -> Playlist?
|
||||
func addToPlaylist(_ key: String, trackKeys: [String]) async throws
|
||||
func removeFromPlaylist(_ key: String, playlistItemId: Int64) async throws
|
||||
func movePlaylistItem(_ key: String, playlistItemId: Int64, afterItemId: Int64?) async throws
|
||||
func renamePlaylist(_ key: String, title: String) async throws
|
||||
func deletePlaylist(_ key: String) async throws
|
||||
func scrobble(_ trackKey: String) async
|
||||
func timeline(_ track: Track, state: String, positionMs: Int64) async
|
||||
func artUrl(_ thumb: String?, px: Int) -> URL?
|
||||
func partUrl(_ partKey: String) -> URL?
|
||||
/// Music libraries on the server, for the picker in Settings.
|
||||
func musicSections() async throws -> [MusicSection]
|
||||
}
|
||||
|
||||
// ---- key tagging -----------------------------------------------------------------
|
||||
|
||||
extension String {
|
||||
func tagged(_ id: String) -> String { id.isEmpty ? self : id + "~" + self }
|
||||
}
|
||||
|
||||
extension Optional where Wrapped == String {
|
||||
func tagged(_ id: String) -> String? { map { $0.tagged(id) } }
|
||||
}
|
||||
|
||||
extension Track {
|
||||
func tagged(_ id: String) -> Track {
|
||||
guard !id.isEmpty else { return self }
|
||||
var t = self
|
||||
t.ratingKey = ratingKey.tagged(id); t.albumKey = albumKey.tagged(id); t.artistKey = artistKey.tagged(id)
|
||||
t.thumb = thumb.tagged(id); t.partKey = partKey.tagged(id)
|
||||
return t
|
||||
}
|
||||
}
|
||||
|
||||
extension Album {
|
||||
func tagged(_ id: String) -> Album {
|
||||
guard !id.isEmpty else { return self }
|
||||
var a = self
|
||||
a.ratingKey = ratingKey.tagged(id); a.artistKey = artistKey.tagged(id); a.thumb = thumb.tagged(id)
|
||||
return a
|
||||
}
|
||||
}
|
||||
|
||||
extension Artist {
|
||||
func tagged(_ id: String) -> Artist {
|
||||
guard !id.isEmpty else { return self }
|
||||
var a = self
|
||||
a.ratingKey = ratingKey.tagged(id); a.thumb = thumb.tagged(id)
|
||||
return a
|
||||
}
|
||||
}
|
||||
|
||||
extension Playlist {
|
||||
func tagged(_ id: String) -> Playlist {
|
||||
guard !id.isEmpty else { return self }
|
||||
var p = self
|
||||
p.ratingKey = ratingKey.tagged(id); p.thumb = thumb.tagged(id)
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
extension SearchResults {
|
||||
func tagged(_ id: String) -> SearchResults {
|
||||
SearchResults(artists: artists.map { $0.tagged(id) }, albums: albums.map { $0.tagged(id) },
|
||||
tracks: tracks.map { $0.tagged(id) }, playlists: playlists.map { $0.tagged(id) })
|
||||
}
|
||||
}
|
||||
+160
-215
@@ -1,8 +1,7 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
struct NotSignedIn: LocalizedError {
|
||||
var errorDescription: String? { "Not signed in to Plex" }
|
||||
var errorDescription: String? { "Not signed in to a music server" }
|
||||
}
|
||||
|
||||
struct PlainError: LocalizedError {
|
||||
@@ -11,318 +10,264 @@ struct PlainError: LocalizedError {
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
|
||||
/// Plex Media Server and plex.tv calls. Library reads go through [http], whose cache
|
||||
/// answers from disk when the server can't be reached (see AppGraph).
|
||||
/// 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 static let page = 1000
|
||||
|
||||
private let http: Http
|
||||
private let settings: Settings
|
||||
private let model: String
|
||||
private let osVersion: String
|
||||
private let lock = NSLock()
|
||||
private var builtFrom = "\u{0}"
|
||||
private var built: [MediaSource] = []
|
||||
|
||||
init(http: Http, settings: Settings) {
|
||||
self.http = http
|
||||
self.settings = settings
|
||||
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 { !settings.plexToken.isEmpty }
|
||||
private var base: String { settings.plexUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")) }
|
||||
private var token: String { settings.plexToken }
|
||||
/// The chosen music libraries; listings pool them, in the order they were picked.
|
||||
private var sections: [String] { settings.musicSection.split(separator: ",").map(String.init) }
|
||||
func make(_ s: MediaServer) -> MediaSource {
|
||||
s.kind == .plex ? PlexServer(http: http, settings: settings, server: s) : JellyfinClient(http: http, settings: settings, server: s)
|
||||
}
|
||||
|
||||
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") }
|
||||
/// 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
|
||||
}
|
||||
|
||||
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 }
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
if results.allSatisfy({ $0 == nil }), let failure { throw failure }
|
||||
return results.compactMap { $0 }.flatMap { $0 }
|
||||
}
|
||||
|
||||
/// 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
|
||||
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 }
|
||||
}
|
||||
|
||||
/// 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)
|
||||
// ---- 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: [PlexItem] = []
|
||||
var out: [Track] = []
|
||||
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
|
||||
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 get("/library/sections/\(sec)/all", params, start: from, size: size - out.count).metadata
|
||||
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)
|
||||
}
|
||||
|
||||
// ---- 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
|
||||
}
|
||||
|
||||
/// 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))
|
||||
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? {
|
||||
try await get("/library/metadata/\(key)").metadata.first?.toAlbum()
|
||||
let (s, k) = try routed(key)
|
||||
return try await s.album(k)?.tagged(s.server.id)
|
||||
}
|
||||
|
||||
func albumTracks(_ key: String) async throws -> [Track] {
|
||||
try await get("/library/metadata/\(key)/children").metadata.map { $0.toTrack() }
|
||||
let (s, k) = try routed(key)
|
||||
return try await s.albumTracks(k).map { $0.tagged(s.server.id) }
|
||||
}
|
||||
|
||||
// Not /library/metadata/<artist>/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() }
|
||||
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] {
|
||||
try await get("/library/metadata/\(key)/allLeaves").metadata.map { $0.toTrack() }
|
||||
let (s, k) = try routed(key)
|
||||
return try await s.artistTracks(k).map { $0.tagged(s.server.id) }
|
||||
}
|
||||
|
||||
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.
|
||||
/// Title search on every server; used to give stem-only entries library 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
|
||||
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.
|
||||
/// 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]? {
|
||||
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)
|
||||
let (s, k) = try routed(trackKey)
|
||||
return try await s.loudness(k, samples: samples)
|
||||
}
|
||||
|
||||
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() }
|
||||
)
|
||||
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] {
|
||||
try await get("/playlists", [("playlistType", "audio"), ("sort", sort)]).metadata.map { $0.toPlaylist() }
|
||||
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] {
|
||||
try await all("/playlists/\(key)/items").map { $0.toTrack() }
|
||||
let (s, k) = try routed(key)
|
||||
return try await s.playlistTracks(k).map { $0.tagged(s.server.id) }
|
||||
}
|
||||
|
||||
private func libraryUri(_ keys: [String]) async throws -> String {
|
||||
var machine = settings.plexMachineId
|
||||
if machine.isEmpty {
|
||||
machine = try await machineId()
|
||||
settings.plexMachineId = machine
|
||||
}
|
||||
return "server://\(machine)/com.plexapp.plugins.library/library/metadata/\(keys.joined(separator: ","))"
|
||||
/// 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? {
|
||||
let uri = try await libraryUri(trackKeys)
|
||||
return try await send("POST", "/playlists", [("type", "audio"), ("title", title), ("smart", 0), ("uri", uri)])
|
||||
.metadata.first?.toPlaylist()
|
||||
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 uri = try await libraryUri(trackKeys)
|
||||
try await send("PUT", "/playlists/\(key)/items", [("uri", uri)])
|
||||
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 {
|
||||
try await send("DELETE", "/playlists/\(key)/items/\(playlistItemId)")
|
||||
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 {
|
||||
try await send("PUT", "/playlists/\(key)/items/\(playlistItemId)/move", [("after", afterItemId)])
|
||||
let (s, k) = try routed(key)
|
||||
try await s.movePlaylistItem(k, playlistItemId: playlistItemId, afterItemId: afterItemId)
|
||||
}
|
||||
|
||||
func renamePlaylist(_ key: String, title: String) async throws {
|
||||
try await send("PUT", "/playlists/\(key)", [("title", title)])
|
||||
let (s, k) = try routed(key)
|
||||
try await s.renamePlaylist(k, title: title)
|
||||
}
|
||||
|
||||
func deletePlaylist(_ key: String) async throws {
|
||||
try await send("DELETE", "/playlists/\(key)")
|
||||
let (s, k) = try routed(key)
|
||||
try await s.deletePlaylist(k)
|
||||
}
|
||||
|
||||
// ---- playback reporting ---------------------------------------------------------
|
||||
|
||||
func scrobble(_ trackKey: String) async {
|
||||
_ = try? await get("/:/scrobble", [("key", trackKey), ("identifier", "com.plexapp.plugins.library")])
|
||||
guard let r = route(trackKey) else { return }
|
||||
let (s, k) = r
|
||||
await s.scrobble(k)
|
||||
}
|
||||
|
||||
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),
|
||||
])
|
||||
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? {
|
||||
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)])
|
||||
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? {
|
||||
URL(string: base + partKey + "?X-Plex-Token=" + urlEncode(token))
|
||||
guard let r = route(partKey) else { return nil }
|
||||
let (s, k) = r
|
||||
return s.partUrl(k)
|
||||
}
|
||||
|
||||
// ---- plex.tv sign-in ------------------------------------------------------------
|
||||
// ---- sign-in helpers --------------------------------------------------------------
|
||||
|
||||
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 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)) }
|
||||
|
||||
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)
|
||||
}
|
||||
func jellyfin(_ s: MediaServer) -> JellyfinClient { JellyfinClient(http: http, settings: settings, server: s) }
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
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/<artist>/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))
|
||||
}
|
||||
|
||||
// ---- 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))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user