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) }
}