448 lines
15 KiB
Swift
448 lines
15 KiB
Swift
import Foundation
|
|
|
|
// ---- lenient JSON ----------------------------------------------------------------
|
|
|
|
/// Plex is loose with types (numbers as strings, "0"/"1" booleans); never fail a whole page over it.
|
|
struct AnyKey: CodingKey {
|
|
var stringValue: String
|
|
var intValue: Int? { nil }
|
|
init(_ s: String) { stringValue = s }
|
|
init?(stringValue: String) { self.stringValue = stringValue }
|
|
init?(intValue: Int) { nil }
|
|
}
|
|
|
|
extension KeyedDecodingContainer where Key == AnyKey {
|
|
func str(_ k: String) -> String? {
|
|
let key = AnyKey(k)
|
|
if let s = try? decodeIfPresent(String.self, forKey: key) { return s }
|
|
if let i = try? decodeIfPresent(Int64.self, forKey: key) { return String(i) }
|
|
if let d = try? decodeIfPresent(Double.self, forKey: key) { return String(d) }
|
|
return nil
|
|
}
|
|
|
|
func int64(_ k: String) -> Int64? {
|
|
let key = AnyKey(k)
|
|
if let i = try? decodeIfPresent(Int64.self, forKey: key) { return i }
|
|
if let d = try? decodeIfPresent(Double.self, forKey: key) { return Int64(d) }
|
|
if let s = try? decodeIfPresent(String.self, forKey: key) { return Int64(s) ?? Double(s).map { Int64($0) } }
|
|
return nil
|
|
}
|
|
|
|
func int(_ k: String) -> Int? { int64(k).map { Int(clamping: $0) } }
|
|
|
|
func double(_ k: String) -> Double? {
|
|
let key = AnyKey(k)
|
|
if let d = try? decodeIfPresent(Double.self, forKey: key) { return d }
|
|
if let s = try? decodeIfPresent(String.self, forKey: key) { return Double(s) }
|
|
return nil
|
|
}
|
|
|
|
func bool(_ k: String) -> Bool? {
|
|
let key = AnyKey(k)
|
|
if let b = try? decodeIfPresent(Bool.self, forKey: key) { return b }
|
|
if let i = try? decodeIfPresent(Int.self, forKey: key) { return i != 0 }
|
|
if let s = try? decodeIfPresent(String.self, forKey: key) { return s == "1" || s == "true" }
|
|
return nil
|
|
}
|
|
|
|
func list<T: Decodable>(_ k: String) -> [T] {
|
|
(try? decodeIfPresent([Lenient<T>].self, forKey: AnyKey(k)))?.compactMap(\.value) ?? []
|
|
}
|
|
}
|
|
|
|
/// Drops a single bad array element instead of the array.
|
|
struct Lenient<T: Decodable>: Decodable {
|
|
let value: T?
|
|
init(from decoder: Decoder) throws { value = try? T(from: decoder) }
|
|
}
|
|
|
|
// ---- wire format (Accept: application/json) --------------------------------------
|
|
|
|
struct PlexResponse: Decodable {
|
|
let container: MediaContainer
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: AnyKey.self)
|
|
container = (try? c.decodeIfPresent(MediaContainer.self, forKey: AnyKey("MediaContainer"))) ?? MediaContainer()
|
|
}
|
|
}
|
|
|
|
struct MediaContainer: Decodable {
|
|
var size = 0
|
|
var totalSize: Int?
|
|
var machineIdentifier: String?
|
|
var metadata: [PlexItem] = []
|
|
var directories: [PlexDirectory] = []
|
|
var hubs: [PlexHub] = []
|
|
var levels: [PlexLevel] = []
|
|
|
|
init() {}
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: AnyKey.self)
|
|
size = c.int("size") ?? 0
|
|
totalSize = c.int("totalSize")
|
|
machineIdentifier = c.str("machineIdentifier")
|
|
metadata = c.list("Metadata")
|
|
directories = c.list("Directory")
|
|
hubs = c.list("Hub")
|
|
levels = c.list("Level")
|
|
}
|
|
}
|
|
|
|
struct PlexItem: Decodable {
|
|
var ratingKey = ""
|
|
var key = ""
|
|
var type = ""
|
|
var title = ""
|
|
var titleSort: String?
|
|
var originalTitle: String?
|
|
var parentTitle: String?
|
|
var grandparentTitle: String?
|
|
var parentRatingKey: String?
|
|
var grandparentRatingKey: String?
|
|
var index: Int?
|
|
var parentIndex: Int?
|
|
var duration: Int64?
|
|
var thumb: String?
|
|
var parentThumb: String?
|
|
var grandparentThumb: String?
|
|
var composite: String?
|
|
var year: Int?
|
|
var parentYear: Int?
|
|
/// Release date, "1975-11-14", when Plex knows the day.
|
|
var originallyAvailableAt: String?
|
|
var addedAt: Int64?
|
|
var leafCount: Int?
|
|
var playlistItemID: Int64?
|
|
var playlistType: String?
|
|
/// Boolean in current servers, "0"/"1" in some older ones.
|
|
var smart: Bool?
|
|
var media: [PlexMedia] = []
|
|
var genres: [PlexTag] = []
|
|
|
|
init() {}
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: AnyKey.self)
|
|
ratingKey = c.str("ratingKey") ?? ""
|
|
key = c.str("key") ?? ""
|
|
type = c.str("type") ?? ""
|
|
title = c.str("title") ?? ""
|
|
titleSort = c.str("titleSort")
|
|
originalTitle = c.str("originalTitle")
|
|
parentTitle = c.str("parentTitle")
|
|
grandparentTitle = c.str("grandparentTitle")
|
|
parentRatingKey = c.str("parentRatingKey")
|
|
grandparentRatingKey = c.str("grandparentRatingKey")
|
|
index = c.int("index")
|
|
parentIndex = c.int("parentIndex")
|
|
duration = c.int64("duration")
|
|
thumb = c.str("thumb")
|
|
parentThumb = c.str("parentThumb")
|
|
grandparentThumb = c.str("grandparentThumb")
|
|
composite = c.str("composite")
|
|
year = c.int("year")
|
|
parentYear = c.int("parentYear")
|
|
originallyAvailableAt = c.str("originallyAvailableAt")
|
|
addedAt = c.int64("addedAt")
|
|
leafCount = c.int("leafCount")
|
|
playlistItemID = c.int64("playlistItemID")
|
|
playlistType = c.str("playlistType")
|
|
smart = c.bool("smart")
|
|
media = c.list("Media")
|
|
genres = c.list("Genre")
|
|
}
|
|
}
|
|
|
|
struct PlexMedia: Decodable {
|
|
var id: Int64 = 0
|
|
var duration: Int64?
|
|
var bitrate: Int?
|
|
var audioCodec: String?
|
|
var container: String?
|
|
var parts: [PlexPart] = []
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: AnyKey.self)
|
|
id = c.int64("id") ?? 0
|
|
duration = c.int64("duration")
|
|
bitrate = c.int("bitrate")
|
|
audioCodec = c.str("audioCodec")
|
|
container = c.str("container")
|
|
parts = c.list("Part")
|
|
}
|
|
}
|
|
|
|
struct PlexPart: Decodable {
|
|
var id: Int64 = 0
|
|
var key = ""
|
|
var duration: Int64?
|
|
var file: String?
|
|
var size: Int64?
|
|
var container: String?
|
|
var streams: [PlexStream] = []
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: AnyKey.self)
|
|
id = c.int64("id") ?? 0
|
|
key = c.str("key") ?? ""
|
|
duration = c.int64("duration")
|
|
file = c.str("file")
|
|
size = c.int64("size")
|
|
container = c.str("container")
|
|
streams = c.list("Stream")
|
|
}
|
|
}
|
|
|
|
struct PlexStream: Decodable {
|
|
var id: Int64 = 0
|
|
var streamType = 0
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: AnyKey.self)
|
|
id = c.int64("id") ?? 0
|
|
streamType = c.int("streamType") ?? 0
|
|
}
|
|
}
|
|
|
|
struct PlexTag: Decodable {
|
|
var tag = ""
|
|
init(from decoder: Decoder) throws {
|
|
tag = (try decoder.container(keyedBy: AnyKey.self)).str("tag") ?? ""
|
|
}
|
|
}
|
|
|
|
struct PlexDirectory: Decodable {
|
|
var key = ""
|
|
var type = ""
|
|
var title = ""
|
|
var locations: [PlexLocation] = []
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: AnyKey.self)
|
|
key = c.str("key") ?? ""
|
|
type = c.str("type") ?? ""
|
|
title = c.str("title") ?? ""
|
|
locations = c.list("Location")
|
|
}
|
|
}
|
|
|
|
struct PlexLocation: Decodable {
|
|
var path = ""
|
|
init(from decoder: Decoder) throws {
|
|
path = (try decoder.container(keyedBy: AnyKey.self)).str("path") ?? ""
|
|
}
|
|
}
|
|
|
|
struct PlexHub: Decodable {
|
|
var type = ""
|
|
var hubIdentifier = ""
|
|
var title = ""
|
|
var metadata: [PlexItem] = []
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: AnyKey.self)
|
|
type = c.str("type") ?? ""
|
|
hubIdentifier = c.str("hubIdentifier") ?? ""
|
|
title = c.str("title") ?? ""
|
|
metadata = c.list("Metadata")
|
|
}
|
|
}
|
|
|
|
struct PlexLevel: Decodable {
|
|
var v: Float = 0
|
|
init(from decoder: Decoder) throws {
|
|
v = Float((try decoder.container(keyedBy: AnyKey.self)).double("v") ?? 0)
|
|
}
|
|
}
|
|
|
|
struct PlexPin: Decodable {
|
|
var id: Int64 = 0
|
|
var code = ""
|
|
var authToken: String?
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: AnyKey.self)
|
|
id = c.int64("id") ?? 0
|
|
code = c.str("code") ?? ""
|
|
authToken = c.str("authToken")
|
|
}
|
|
}
|
|
|
|
/// One server from plex.tv/api/v2/resources, with every address it can be reached at.
|
|
struct PlexResource: Decodable {
|
|
struct Connection {
|
|
var uri = ""
|
|
var local = false
|
|
var relay = false
|
|
}
|
|
var name = ""
|
|
var clientIdentifier = ""
|
|
var accessToken = ""
|
|
var provides = ""
|
|
var connections: [Connection] = []
|
|
var isServer: Bool { provides.split(separator: ",").contains("server") }
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: AnyKey.self)
|
|
name = c.str("name") ?? ""
|
|
clientIdentifier = c.str("clientIdentifier") ?? ""
|
|
accessToken = c.str("accessToken") ?? ""
|
|
provides = c.str("provides") ?? ""
|
|
connections = (try? c.decodeIfPresent([Lenient<PlexConn>].self, forKey: AnyKey("connections")))?
|
|
.compactMap(\.value).map { Connection(uri: $0.uri, local: $0.local, relay: $0.relay) } ?? []
|
|
}
|
|
}
|
|
|
|
private struct PlexConn: Decodable {
|
|
var uri = ""
|
|
var local = false
|
|
var relay = false
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: AnyKey.self)
|
|
uri = c.str("uri") ?? ""
|
|
local = c.bool("local") ?? false
|
|
relay = c.bool("relay") ?? false
|
|
}
|
|
}
|
|
|
|
struct PlexUser: Decodable {
|
|
var username: String?
|
|
var title: String?
|
|
var email: String?
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: AnyKey.self)
|
|
username = c.str("username")
|
|
title = c.str("title")
|
|
email = c.str("email")
|
|
}
|
|
}
|
|
|
|
// ---- app models ------------------------------------------------------------------
|
|
|
|
struct Track: Codable, Hashable, Identifiable {
|
|
var ratingKey: String
|
|
var title: String
|
|
var artist: String
|
|
var album: String
|
|
var albumKey: String?
|
|
var artistKey: String?
|
|
var index: Int?
|
|
var disc: Int?
|
|
var durationMs: Int64 = 0
|
|
var thumb: String?
|
|
var partKey: String?
|
|
var partId: Int64?
|
|
var partFile: String?
|
|
var partSize: Int64?
|
|
var codec: String?
|
|
var year: Int?
|
|
var playlistItemId: Int64?
|
|
/// Set for tracks known only from the stems index (no Plex match): play this stem folder.
|
|
var stemKey: String?
|
|
|
|
var id: String { playlistItemId.map { "\(ratingKey)#\($0)" } ?? ratingKey }
|
|
|
|
init(ratingKey: String, title: String, artist: String, album: String, albumKey: String? = nil,
|
|
artistKey: String? = nil, index: Int? = nil, disc: Int? = nil, durationMs: Int64 = 0,
|
|
thumb: String? = nil, partKey: String? = nil, partId: Int64? = nil, partFile: String? = nil,
|
|
partSize: Int64? = nil, codec: String? = nil, year: Int? = nil, playlistItemId: Int64? = nil,
|
|
stemKey: String? = nil) {
|
|
self.ratingKey = ratingKey; self.title = title; self.artist = artist; self.album = album
|
|
self.albumKey = albumKey; self.artistKey = artistKey; self.index = index; self.disc = disc
|
|
self.durationMs = durationMs; self.thumb = thumb; self.partKey = partKey; self.partId = partId
|
|
self.partFile = partFile; self.partSize = partSize; self.codec = codec; self.year = year
|
|
self.playlistItemId = playlistItemId; self.stemKey = stemKey
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: AnyKey.self)
|
|
self.init(ratingKey: c.str("ratingKey") ?? "", title: c.str("title") ?? "", artist: c.str("artist") ?? "",
|
|
album: c.str("album") ?? "", albumKey: c.str("albumKey"), artistKey: c.str("artistKey"),
|
|
index: c.int("index"), disc: c.int("disc"), durationMs: c.int64("durationMs") ?? 0,
|
|
thumb: c.str("thumb"), partKey: c.str("partKey"), partId: c.int64("partId"),
|
|
partFile: c.str("partFile"), partSize: c.int64("partSize"), codec: c.str("codec"),
|
|
year: c.int("year"), playlistItemId: c.int64("playlistItemId"), stemKey: c.str("stemKey"))
|
|
}
|
|
}
|
|
|
|
struct Album: Codable, Hashable, Identifiable {
|
|
var ratingKey: String
|
|
var title: String
|
|
var artist: String
|
|
var artistKey: String?
|
|
var year: Int?
|
|
var thumb: String?
|
|
var trackCount: Int?
|
|
var genre: String?
|
|
/// "1975-11-14" when Plex knows the release day.
|
|
var released: String?
|
|
var id: String { ratingKey }
|
|
}
|
|
|
|
struct Artist: Codable, Hashable, Identifiable {
|
|
var ratingKey: String
|
|
var title: String
|
|
var sortTitle: String
|
|
var thumb: String?
|
|
var id: String { ratingKey }
|
|
}
|
|
|
|
struct Playlist: Codable, Hashable, Identifiable {
|
|
var ratingKey: String
|
|
var title: String
|
|
var thumb: String?
|
|
var trackCount = 0
|
|
var durationMs: Int64 = 0
|
|
var smart = false
|
|
var id: String { ratingKey }
|
|
}
|
|
|
|
struct MusicSection: Hashable, Identifiable {
|
|
var key: String
|
|
var title: String
|
|
var locations: [String]
|
|
var id: String { key }
|
|
}
|
|
|
|
struct SearchResults {
|
|
var artists: [Artist] = []
|
|
var albums: [Album] = []
|
|
var tracks: [Track] = []
|
|
var playlists: [Playlist] = []
|
|
var isEmpty: Bool { artists.isEmpty && albums.isEmpty && tracks.isEmpty && playlists.isEmpty }
|
|
}
|
|
|
|
extension PlexItem {
|
|
func toTrack() -> Track {
|
|
let m = media.first
|
|
let part = m?.parts.first
|
|
let orig = originalTitle.flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 }
|
|
return Track(
|
|
ratingKey: ratingKey,
|
|
title: title,
|
|
artist: orig ?? grandparentTitle ?? "Unknown Artist",
|
|
album: parentTitle ?? "",
|
|
albumKey: parentRatingKey,
|
|
artistKey: grandparentRatingKey,
|
|
index: index,
|
|
disc: parentIndex,
|
|
durationMs: duration ?? m?.duration ?? part?.duration ?? 0,
|
|
thumb: thumb ?? parentThumb ?? grandparentThumb,
|
|
partKey: part?.key,
|
|
partId: part?.id,
|
|
partFile: part?.file,
|
|
partSize: part?.size,
|
|
codec: m?.audioCodec ?? part?.container,
|
|
year: parentYear ?? year,
|
|
playlistItemId: playlistItemID
|
|
)
|
|
}
|
|
|
|
func toAlbum() -> Album {
|
|
Album(ratingKey: ratingKey, title: title, artist: parentTitle ?? "Unknown Artist", artistKey: parentRatingKey,
|
|
year: year, thumb: thumb, trackCount: leafCount, genre: genres.first?.tag,
|
|
released: originallyAvailableAt.flatMap { $0.count >= 10 ? $0 : nil })
|
|
}
|
|
|
|
func toArtist() -> Artist { Artist(ratingKey: ratingKey, title: title, sortTitle: titleSort ?? title, thumb: thumb) }
|
|
|
|
func toPlaylist() -> Playlist {
|
|
Playlist(ratingKey: ratingKey, title: title, thumb: thumb ?? composite, trackCount: leafCount ?? 0,
|
|
durationMs: duration ?? 0, smart: smart ?? false)
|
|
}
|
|
}
|