Download-Regeln - Automatisches Löschen nach dem Ansehen, mit Frist statt sofort: wer die letzte Szene noch einmal sucht, soll sie nicht schon verloren haben. „Nie" ist die Voreinstellung. - „Halte N ungesehene Folgen offline": wartende und laufende Downloads zählen mit, sonst würde bei jedem Durchlauf nachgelegt, bis die ganze Serie auf dem Gerät liegt. - Beides als reine Rechnung ohne Zugriff auf Datenbank oder Dateien — bei einer Regel, die Filme löscht, ist Prüfbarkeit keine Formsache. 13 Tests. Qualitätswahl beim Download - transcodedDownloadURL über den Universal-Transcoder mit protocol=http statt über die umständliche Sync-API. NOCH NICHT AM SERVER GEPRÜFT: offen ist, ob Plex eine Content-Length mitschickt (sonst kein Fortschritt und kein Fortsetzen) und ob die Datei durchsuchbar ist. Steht so im Quelltext; Original bleibt Voreinstellung. Erscheinungsbild - Akzentfarbe: zehn kuratierte Kacheln plus freie Hex-Eingabe. Bei einer eigenen Farbe greift die Kontrastkorrektur und sagt es auch — sonst wirkt sie wie ein Fehler. - Vorschaufläche mit Knopf, Symbol und Fortschrittsbalken. Serveraktionen - Gesehen-Status umschalten und Sterne setzen über ein Menü auf der Detailseite. - Suchlauf der Mediathek aus der Werkzeugleiste. 115 Tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
245 lines
10 KiB
Swift
245 lines
10 KiB
Swift
import Foundation
|
|
|
|
extension PlexServer {
|
|
// MARK: - Transcoding
|
|
|
|
/// Die Adresse des HLS-Stroms für diese Wiedergabe.
|
|
///
|
|
/// HLS ist genau die Kost, für die AVPlayer gebaut ist: er wechselt die
|
|
/// Bitrate während der Wiedergabe von selbst, je nachdem was die Leitung
|
|
/// hergibt. Genau das ist die zugesagte automatische Qualitätsanpassung —
|
|
/// sie wird nicht selbst gerechnet, sondern kommt mit dem Format.
|
|
public nonisolated func transcodeURL(for request: TranscodeRequest) -> URL? {
|
|
guard let baseURL = connection.url,
|
|
var components = URLComponents(
|
|
url: baseURL.appending(path: "/video/:/transcode/universal/start.m3u8"),
|
|
resolvingAgainstBaseURL: false
|
|
)
|
|
else { return nil }
|
|
|
|
components.queryItems = request.queryItems + [
|
|
URLQueryItem(name: "X-Plex-Token", value: token),
|
|
] + clientProfileItems
|
|
return components.url
|
|
}
|
|
|
|
/// Fragt den Server, ob er diese Wiedergabe zulässt.
|
|
public func transcodeDecision(for request: TranscodeRequest) async throws(PlexError) -> TranscodeDecision {
|
|
try await client.send(
|
|
PlexRequest(
|
|
path: "/video/:/transcode/universal/decision",
|
|
query: request.queryItems + clientProfileItems
|
|
),
|
|
as: TranscodeDecision.self
|
|
)
|
|
}
|
|
|
|
/// Hält den Transcoder an.
|
|
///
|
|
/// Muss beim Verlassen des Players laufen. Ohne dieses Signal rechnet der
|
|
/// Server weiter, bis er von selbst merkt, dass niemand mehr abruft — das
|
|
/// dauert Minuten und kostet die ganze Zeit über Rechenleistung.
|
|
public func stopTranscode(sessionIdentifier: String) async {
|
|
_ = try? await client.send(
|
|
PlexRequest(
|
|
path: "/video/:/transcode/universal/stop",
|
|
query: [URLQueryItem(name: "session", value: sessionIdentifier)],
|
|
timeout: .seconds(5)
|
|
)
|
|
)
|
|
}
|
|
|
|
/// Was dieses Gerät abspielen kann, in der Schreibweise von Plex.
|
|
///
|
|
/// Der Server richtet seine Entscheidung danach aus. Die Angaben stammen
|
|
/// aus `DeviceCapabilities` — wären sie großzügiger als das, was iOS
|
|
/// wirklich kann, schickte Plex einen Strom, der hier stumm bliebe oder
|
|
/// schwarz.
|
|
private nonisolated var clientProfileItems: [URLQueryItem] {
|
|
[
|
|
URLQueryItem(
|
|
name: "X-Plex-Client-Profile-Extra",
|
|
value: [
|
|
// Was ohne Umrechnung durchgereicht werden darf.
|
|
"add-transcode-target(type=videoProfile&context=streaming&protocol=hls&container=mpegts&videoCodec=h264&audioCodec=aac)",
|
|
// DTS und TrueHD kann iOS nicht — für sie ausdrücklich
|
|
// eine Umrechnung nach AAC verlangen, statt zu hoffen,
|
|
// dass Plex es von selbst merkt.
|
|
"add-transcode-target-audio-codec(type=videoProfile&context=streaming&protocol=hls&audioCodec=aac)",
|
|
"add-limitation(scope=videoCodec&scopeName=h264&type=upperBound&name=video.level&value=52)",
|
|
].joined(separator: "+")
|
|
),
|
|
]
|
|
}
|
|
|
|
// MARK: - Fortschritt melden
|
|
|
|
/// Meldet die aktuelle Stelle an den Server.
|
|
///
|
|
/// Grundlage für das geräteübergreifende Weiterschauen: was hier
|
|
/// ankommt, sieht der Fernseher im Wohnzimmer. Fehler werden verschluckt —
|
|
/// eine Fehlermeldung mitten im Film wäre störender als ein verlorener
|
|
/// Fortschrittswert, und der nächste Bericht kommt in Sekunden.
|
|
public func reportProgress(
|
|
ratingKey: String,
|
|
state: PlaybackReportState,
|
|
time: Duration,
|
|
duration: Duration
|
|
) async {
|
|
_ = try? await client.send(
|
|
PlexRequest(
|
|
path: "/:/timeline",
|
|
query: [
|
|
URLQueryItem(name: "ratingKey", value: ratingKey),
|
|
URLQueryItem(name: "key", value: "/library/metadata/\(ratingKey)"),
|
|
URLQueryItem(name: "state", value: state.rawValue),
|
|
URLQueryItem(name: "time", value: String(time.milliseconds)),
|
|
URLQueryItem(name: "duration", value: String(duration.milliseconds)),
|
|
],
|
|
timeout: .seconds(10)
|
|
)
|
|
)
|
|
}
|
|
|
|
/// Markiert einen Titel als gesehen.
|
|
public func markWatched(ratingKey: String) async throws(PlexError) {
|
|
_ = try await client.send(scrobbleRequest(path: "/:/scrobble", ratingKey: ratingKey))
|
|
}
|
|
|
|
/// Nimmt die Markierung zurück.
|
|
public func markUnwatched(ratingKey: String) async throws(PlexError) {
|
|
_ = try await client.send(scrobbleRequest(path: "/:/unscrobble", ratingKey: ratingKey))
|
|
}
|
|
|
|
/// Setzt eine Bewertung. `rating` von 0 bis 10; 0 entfernt sie.
|
|
public func rate(ratingKey: String, rating: Int) async throws(PlexError) {
|
|
_ = try await client.send(
|
|
PlexRequest(path: "/:/rate", query: [
|
|
URLQueryItem(name: "key", value: ratingKey),
|
|
URLQueryItem(name: "identifier", value: "com.plexapp.plugins.library"),
|
|
URLQueryItem(name: "rating", value: String(max(0, min(10, rating)))),
|
|
])
|
|
)
|
|
}
|
|
|
|
/// Stößt einen Suchlauf in einer Mediathek an.
|
|
public func refreshLibrary(_ library: PlexLibrary) async throws(PlexError) {
|
|
_ = try await client.send(PlexRequest(path: "/library/sections/\(library.key)/refresh"))
|
|
}
|
|
|
|
private nonisolated func scrobbleRequest(path: String, ratingKey: String) -> PlexRequest {
|
|
PlexRequest(path: path, query: [
|
|
URLQueryItem(name: "key", value: ratingKey),
|
|
// Ohne diese Kennung antwortet Plex mit 400. Sie benennt die
|
|
// Bibliotheks-Erweiterung, an die sich die Meldung richtet.
|
|
URLQueryItem(name: "identifier", value: "com.plexapp.plugins.library"),
|
|
])
|
|
}
|
|
}
|
|
|
|
/// In welchem Zustand sich die Wiedergabe beim Melden befindet.
|
|
public enum PlaybackReportState: String, Sendable {
|
|
case playing
|
|
case paused
|
|
/// Beim Verlassen des Players. Erst dieser Wert lässt Plex den Eintrag aus
|
|
/// „wird gerade abgespielt" entfernen.
|
|
case stopped
|
|
case buffering
|
|
}
|
|
|
|
extension Duration {
|
|
/// Die Dauer in ganzen Millisekunden — die Einheit, in der Plex rechnet.
|
|
public var milliseconds: Int {
|
|
Int(components.seconds * 1000 + components.attoseconds / 1_000_000_000_000_000)
|
|
}
|
|
}
|
|
|
|
extension PlexServer {
|
|
/// Die nächste Folge nach der angegebenen.
|
|
///
|
|
/// Plex kennt keinen Endpunkt dafür. Der Weg führt über die Geschwister:
|
|
/// die Folgen derselben Staffel holen und die mit der nächsthöheren Nummer
|
|
/// nehmen. Ist die Staffel zu Ende, wird in der folgenden weitergesucht —
|
|
/// sonst bräche das Weiterschauen jedes Mal am Staffelende ab.
|
|
public func nextEpisode(after episode: PlexItem) async throws(PlexError) -> PlexItem? {
|
|
guard episode.kind == .episode,
|
|
let seasonKey = episode.parentRatingKey,
|
|
let currentIndex = episode.index
|
|
else { return nil }
|
|
|
|
let siblings = try await children(of: seasonKey)
|
|
if let next = siblings
|
|
.filter({ ($0.index ?? 0) > currentIndex })
|
|
.min(by: { ($0.index ?? 0) < ($1.index ?? 0) }) {
|
|
return next
|
|
}
|
|
|
|
// Staffelende: in der nächsten Staffel weitersuchen.
|
|
guard let showKey = episode.grandparentRatingKey,
|
|
let seasonNumber = episode.parentIndex
|
|
else { return nil }
|
|
|
|
let seasons = try await children(of: showKey)
|
|
guard let nextSeason = seasons
|
|
.filter({ ($0.index ?? 0) > seasonNumber })
|
|
.min(by: { ($0.index ?? 0) < ($1.index ?? 0) })
|
|
else { return nil }
|
|
|
|
return try await children(of: nextSeason.ratingKey)
|
|
.min(by: { ($0.index ?? 0) < ($1.index ?? 0) })
|
|
}
|
|
}
|
|
|
|
extension PlexServer {
|
|
/// Die Adresse für einen heruntergerechneten Download.
|
|
///
|
|
/// **Noch nicht an einem echten Server geprüft.** Der Weg nutzt den
|
|
/// Universal-Transcoder mit `protocol=http`, der einen fortlaufenden Strom
|
|
/// statt HLS liefert — anders als der offizielle Sync-Weg von Plex, der
|
|
/// umständlich und schlecht dokumentiert ist.
|
|
///
|
|
/// Zwei Dinge sind dabei offen und gehören am eigenen Server geprüft,
|
|
/// bevor die Einstellung angeboten wird:
|
|
/// 1. Ob Plex eine `Content-Length` mitschickt. Ohne sie gibt es keinen
|
|
/// Fortschrittsbalken und kein Fortsetzen nach dem Pausieren.
|
|
/// 2. Ob die entstehende Datei durchsuchbar ist. Ein Strom, der von vorn
|
|
/// gedacht ist, muss es nicht sein — dann ließe sich im Download nicht
|
|
/// springen.
|
|
///
|
|
/// Bis das geklärt ist, bleibt der Download in Originalqualität die
|
|
/// Voreinstellung.
|
|
public nonisolated func transcodedDownloadURL(
|
|
ratingKey: String,
|
|
mediaIndex: Int = 0,
|
|
maximumBitrate: Int,
|
|
resolution: String?
|
|
) -> URL? {
|
|
guard let baseURL = connection.url,
|
|
var components = URLComponents(
|
|
url: baseURL.appending(path: "/video/:/transcode/universal/start"),
|
|
resolvingAgainstBaseURL: false
|
|
)
|
|
else { return nil }
|
|
|
|
let request = TranscodeRequest(
|
|
ratingKey: ratingKey,
|
|
mediaIndex: mediaIndex,
|
|
maximumBitrate: maximumBitrate,
|
|
resolution: resolution,
|
|
// Ein Download ist kein Abspielen im Heimnetz: `wan` lässt Plex
|
|
// konservativer puffern, was für eine Datei egal ist.
|
|
isLocal: false
|
|
)
|
|
|
|
components.queryItems = request.queryItems
|
|
// Überschreibt `protocol=hls` aus der Anfrage: für eine Datei
|
|
// brauchen wir einen fortlaufenden Strom, keine Segmentliste.
|
|
.filter { $0.name != "protocol" && $0.name != "fastSeek" && $0.name != "copyts" }
|
|
+ [
|
|
URLQueryItem(name: "protocol", value: "http"),
|
|
URLQueryItem(name: "X-Plex-Token", value: token),
|
|
]
|
|
return components.url
|
|
}
|
|
}
|