iOS: stem playlist refresh and covers, download queue page, reorderable Library, whole-library radio

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Sonnet 5.5 committed 2026-10-28 15:49:09 -07:00
1 parent 7c8dfb54ca
commit a4c870251c
18 files changed
+514 -30

No files matched your search

+62
View File
@@ -141,6 +141,18 @@ final class PlexClient: @unchecked Sendable {
return (out, total)
}
/// Every track in the library, in pages, shuffled.
func allTracksShuffled() async throws -> [Track] {
let size = 2000
var (out, total) = try await tracksPage(start: 0, size: size)
while out.count < total {
let (page, _) = try await tracksPage(start: out.count, size: size)
if page.isEmpty { break }
out += page
}
return out.shuffled()
}
func randomTracks(_ count: Int) async throws -> [Track] {
let each = count / max(sources.count, 1) + 1
return Array(try await pool { s in try await s.randomTracks(each).map { $0.tagged(s.server.id) } }.shuffled().prefix(count))
@@ -269,5 +281,55 @@ final class PlexClient: @unchecked Sendable {
/// A Plex client for plex.tv calls and for a server that isn't saved yet.
func plexTv(_ url: String = "") -> PlexServer { PlexServer(http: http, settings: settings, server: MediaServer(url: url)) }
/// Signs in with a plex.tv token: finds every server on the account, saves each one that
/// answers (matched by machine id, so it never duplicates), and switches on all its music
/// libraries. Libraries already picked on a known server stay as they were.
/// Returns (servers saved, servers on the account that didn't answer).
@discardableResult
func syncAccount(token: String) async throws -> (saved: Int, unreachable: Int) {
let tv = plexTv()
let user = await {
guard let u = try? await tv.user(token: token) else { return "Plex user" }
return u.title ?? u.username ?? "Plex user"
}()
let found = try await tv.resources(token: token)
struct Hit { var res: PlexResource; var url: String; var secs: [MusicSection] }
let hits: [Hit] = await withTaskGroup(of: Hit?.self) { group in
for r in found {
group.addTask { [self] in
guard let url = await tv.reach(r, userToken: token) else { return nil }
let tok = r.accessToken.isEmpty ? token : r.accessToken
let probe = MediaServer(name: r.name, url: url, token: tok, machineId: r.clientIdentifier)
let secs = (try? await make(probe).musicSections()) ?? []
return Hit(res: r, url: url, secs: secs)
}
}
var out: [Hit] = []
for await h in group { if let h { out.append(h) } }
return out
}
let order = Dictionary(uniqueKeysWithValues: found.enumerated().map { ($1.clientIdentifier, $0) })
for h in hits.sorted(by: { (order[$0.res.clientIdentifier] ?? 0) < (order[$1.res.clientIdentifier] ?? 0) }) {
let known = settings.servers.first { $0.kind == .plex && $0.machineId == h.res.clientIdentifier && !$0.machineId.isEmpty }
?? settings.servers.first { $0.kind == .plex && $0.machineId.isEmpty && $0.base == h.url.trimmingCharacters(in: CharacterSet(charactersIn: "/")) }
var srv = known ?? MediaServer(id: settings.nextServerId(), kind: .plex)
srv.url = h.url
srv.token = h.res.accessToken.isEmpty ? token : h.res.accessToken
srv.machineId = h.res.clientIdentifier
srv.user = user
if srv.name.isEmpty || known == nil { srv.name = h.res.name }
if known != nil {
if !h.secs.isEmpty { srv.sections = srv.sections.filter { k in h.secs.contains { $0.key == k } } }
} else {
srv.sections = h.secs.map(\.key)
}
if !h.secs.isEmpty {
srv.sectionTitles = srv.sections.compactMap { k in h.secs.first { $0.key == k }?.title }.joined(separator: " + ")
}
settings.save(srv)
}
return (hits.count, found.count - hits.count)
}
func jellyfin(_ s: MediaServer) -> JellyfinClient { JellyfinClient(http: http, settings: settings, server: s) }
}
+36
View File
@@ -263,6 +263,42 @@ struct PlexPin: Decodable {
}
}
/// 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?
+40
View File
@@ -325,6 +325,46 @@ final class PlexServer: MediaSource, @unchecked Sendable {
return try JSONDecoder().decode(PlexPin.self, from: try await http.data(req))
}
/// A strong PIN, completed in the browser at [authUrl] (same flow as the web UI).
func createStrongPin() async throws -> PlexPin {
var req = request(URL(string: "https://plex.tv/api/v2/pins?strong=true")!, withToken: false, method: "POST")
req.httpBody = Data()
return try JSONDecoder().decode(PlexPin.self, from: try await http.data(req))
}
func authUrl(code: String) -> URL? {
var c = URLComponents(string: "https://app.plex.tv/auth")
c?.percentEncodedFragment = "?clientID=\(urlEncode(settings.clientId))&code=\(urlEncode(code))&context%5Bdevice%5D%5Bproduct%5D=\(urlEncode(Self.product))"
return c?.url
}
/// Every server this plex.tv account can reach, owned or shared.
func resources(token: String) async throws -> [PlexResource] {
var req = request(URL(string: "https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1")!, withToken: false)
req.setValue(token, forHTTPHeaderField: "X-Plex-Token")
let list = try JSONDecoder().decode([Lenient<PlexResource>].self, from: try await http.data(req))
return list.compactMap(\.value).filter(\.isServer)
}
/// The first address that answers /identity as the expected server, local ones first, relay last.
func reach(_ r: PlexResource, userToken: String) async -> String? {
let tok = r.accessToken.isEmpty ? userToken : r.accessToken
let ordered = r.connections.filter { !$0.uri.isEmpty }.sorted { rank($0) < rank($1) }
for c in ordered {
let base = c.uri.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
guard let u = URL(string: base + "/identity") else { continue }
var req = request(u, withToken: false)
req.setValue(tok, forHTTPHeaderField: "X-Plex-Token")
req.timeoutInterval = 3.5
guard let body = try? await http.data(req),
let id = try? decode(body).machineIdentifier, id == r.clientIdentifier || r.clientIdentifier.isEmpty else { continue }
return base
}
return nil
}
private func rank(_ c: PlexResource.Connection) -> Int { c.relay ? 2 : (c.local ? 0 : 1) }
func checkPin(_ id: Int64) async throws -> PlexPin {
let req = request(URL(string: "https://plex.tv/api/v2/pins/\(id)")!, withToken: false)
return try JSONDecoder().decode(PlexPin.self, from: try await http.data(req))