Add iOS app with CarPlay and Siri
SwiftUI port of the Android app under ios/, with CarPlay browse and Now Playing, Siri play-media intents, and a build-ipa.sh script that makes an unsigned IPA for sideloading. README gets an iOS section and the vibecoded disclaimer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
45bc0eefe0
commit
fdf9e970b2
45 files changed
+9822
No files matched your search
@@ -0,0 +1,284 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
struct NotSignedIn: LocalizedError {
|
||||
var errorDescription: String? { "Not signed in to Plex" }
|
||||
}
|
||||
|
||||
struct PlainError: LocalizedError {
|
||||
let message: String
|
||||
init(_ m: String) { message = m }
|
||||
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).
|
||||
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
|
||||
|
||||
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 }
|
||||
private var section: String { settings.musicSection }
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// ---- 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() async throws -> [Artist] {
|
||||
try await all("/library/sections/\(section)/all", [("type", 8), ("sort", "titleSort")]).map { $0.toArtist() }
|
||||
}
|
||||
|
||||
func albums(sort: String = "titleSort") async throws -> [Album] {
|
||||
try await all("/library/sections/\(section)/all", [("type", 9), ("sort", sort)]).map { $0.toAlbum() }
|
||||
}
|
||||
|
||||
func recentlyAdded(count: Int = 20) async throws -> [Album] {
|
||||
try await get("/library/sections/\(section)/all", [("type", 9), ("sort", "addedAt:desc")], start: 0, size: count)
|
||||
.metadata.map { $0.toAlbum() }
|
||||
}
|
||||
|
||||
/// One page of every track, sorted by title. Returns the page and the library's track count.
|
||||
func tracksPage(start: Int, size: Int) async throws -> ([Track], Int) {
|
||||
let c = try await get("/library/sections/\(section)/all", [("type", 10), ("sort", "titleSort")], start: start, size: size)
|
||||
return (c.metadata.map { $0.toTrack() }, c.totalSize ?? c.size)
|
||||
}
|
||||
|
||||
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 all("/library/sections/\(section)/all", [("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] {
|
||||
try await get("/library/sections/\(section)/search", [("type", 10), ("query", title)]).metadata.map { $0.toTrack() }
|
||||
}
|
||||
|
||||
/// 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", section)]).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() async throws -> [Playlist] {
|
||||
try await get("/playlists", [("playlistType", "audio")]).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 {
|
||||
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: ","))"
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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: composite ?? thumb, trackCount: leafCount ?? 0,
|
||||
durationMs: duration ?? 0, smart: smart ?? false)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user