iOS 1.0 RC 2: Plex streams under upload caps, shuffle and album radio stations, Recently Added list, photo covers, crossfade

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-11-06 08:40:37 -08:00
1 parent c0e33b5e3e
commit 2fb8040388
17 files changed
+412 -74

No files matched your search

+2
View File
@@ -440,6 +440,8 @@ final class JellyfinClient: MediaSource, @unchecked Sendable {
URL(string: base + "/Audio/\(partKey)/stream?static=true&api_key=" + urlEncode(server.token))
}
func streamUrl(_ ratingKey: String, partKey: String) async -> URL? { partUrl(partKey) }
/// True if [url] answers as a Jellyfin server.
func probe() async -> Bool {
guard let u = URL(string: base + "/System/Info/Public") else { return false }
+2
View File
@@ -57,6 +57,8 @@ protocol MediaSource: AnyObject, Sendable {
func timeline(_ track: Track, state: String, positionMs: Int64) async
func artUrl(_ thumb: String?, px: Int) -> URL?
func partUrl(_ partKey: String) -> URL?
/// The URL to stream a track's file from, after any setup the server needs for playback.
func streamUrl(_ ratingKey: String, partKey: String) async -> URL?
/// Music libraries on the server, for the picker in Settings.
func musicSections() async throws -> [MusicSection]
}
+23 -12
View File
@@ -141,18 +141,6 @@ 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))
@@ -265,6 +253,7 @@ final class PlexClient: @unchecked Sendable {
// ---- URLs -----------------------------------------------------------------------
func artUrl(_ thumb: String?, px: Int) -> URL? {
if let thumb, thumb.hasPrefix(StemPlaylists.localPrefix) { return StemPlaylists.localArt(thumb) }
guard let thumb, !thumb.isEmpty, let r = route(thumb) else { return nil }
let (s, k) = r
return s.artUrl(k, px: px)
@@ -276,6 +265,26 @@ final class PlexClient: @unchecked Sendable {
return s.partUrl(k)
}
/// [track]'s file, set up for playback on its server (see PlexServer.streamUrl).
func streamUrl(_ track: Track) async -> URL? {
guard let part = track.partKey, let r = route(part) else { return nil }
let (s, k) = r
let rk = route(track.ratingKey)?.1 ?? track.ratingKey
return await s.streamUrl(rk, partKey: k)
}
/// Blocking form of [streamUrl] for worker threads.
func streamUrlNow(_ track: Track) -> URL? {
let box = UrlBox()
let sem = DispatchSemaphore(value: 0)
Task {
box.url = await streamUrl(track)
sem.signal()
}
sem.wait()
return box.url
}
// ---- sign-in helpers --------------------------------------------------------------
/// A Plex client for plex.tv calls and for a server that isn't saved yet.
@@ -333,3 +342,5 @@ final class PlexClient: @unchecked Sendable {
func jellyfin(_ s: MediaServer) -> JellyfinClient { JellyfinClient(http: http, settings: settings, server: s) }
}
private final class UrlBox: @unchecked Sendable { var url: URL? }
+26
View File
@@ -317,6 +317,32 @@ final class PlexServer: MediaSource, @unchecked Sendable {
URL(string: base + partKey + "?X-Plex-Token=" + urlEncode(token))
}
/// The part's URL the way Plexamp plays it: first a playback decision for this session, then
/// the file fetched under that session and client. A bare part request has no bitrate, so Plex
/// reads it as unlimited ("Bandwidth exceeded: 2147483647 kbps") and answers 500 whenever
/// Remote Access has an upload limit set.
func streamUrl(_ ratingKey: String, partKey: String) async -> URL? {
let session = UUID().uuidString.lowercased()
if signedIn, let u = try? url("/music/:/transcode/universal/decision", [
("path", "/library/metadata/\(ratingKey)"), ("mediaIndex", 0), ("partIndex", 0),
("protocol", "http"), ("directPlay", 1), ("directStream", 1), ("directStreamAudio", 1),
("hasMDE", 1), ("session", session), ("X-Plex-Session-Identifier", session),
]) {
var req = request(u)
req.setValue(session, forHTTPHeaderField: "X-Plex-Session-Identifier")
req.timeoutInterval = 8
// Best effort: the part URL below still carries the session if this fails.
if let (_, resp) = try? await http.session.data(for: req), let code = (resp as? HTTPURLResponse)?.statusCode, code >= 300 {
Log.shared.error("plex", "stream decision for \(ratingKey): HTTP \(code)")
}
}
guard let u = URL(string: base + partKey) else { return nil }
return u.with([
("X-Plex-Token", token), ("X-Plex-Client-Identifier", settings.clientId), ("X-Plex-Product", Self.product),
("X-Plex-Platform", "iOS"), ("X-Plex-Device", model), ("X-Plex-Session-Identifier", session), ("session", session),
])
}
// ---- plex.tv sign-in ------------------------------------------------------------
func createPin() async throws -> PlexPin {