import Foundation /// Die Anfrage an den Transcoder von Plex. /// /// Baut die Parameter für `/video/:/transcode/universal/…`. Plex entscheidet /// daraufhin selbst, wie viel es umrechnen muss: passt der Bildcodec, aber /// nicht der Container, wird nur umgepackt („direct stream"); passt nichts, /// wird neu kodiert. Das ist deutlich schonender für den Server, als pauschal /// alles neu zu rechnen — deshalb steht `directStream` auf 1. public struct TranscodeRequest: Sendable, Equatable { public let ratingKey: String public let mediaIndex: Int public let partIndex: Int /// Obergrenze in Kilobit pro Sekunde. public let maximumBitrate: Int? /// Zielauflösung als `"1920x1080"`. `nil` behält die des Originals. public let resolution: String? /// Ob der Server im selben Netz steht. Plex wählt danach andere /// Voreinstellungen für Puffergrößen. public let isLocal: Bool /// Kennung dieser Wiedergabe. Über sie lässt sich der Transcoder später /// wieder anhalten — ohne sie rechnet der Server weiter, auch wenn längst /// niemand mehr zusieht. public let sessionIdentifier: String /// Welche Untertitelspur eingebrannt werden soll. public let subtitleStreamID: Int? public let audioStreamID: Int? /// Eine andere Adresse als die der Mediathek. /// /// Live-TV liegt nicht unter `/library/metadata`, sondern beim /// Programmanbieter. Der Transcoder nimmt beides — aber nur, wenn man /// ihm die richtige Adresse nennt. public let pathOverride: String? public init( ratingKey: String, mediaIndex: Int = 0, partIndex: Int = 0, maximumBitrate: Int? = nil, resolution: String? = nil, isLocal: Bool, sessionIdentifier: String = UUID().uuidString, subtitleStreamID: Int? = nil, audioStreamID: Int? = nil, pathOverride: String? = nil ) { self.ratingKey = ratingKey self.mediaIndex = mediaIndex self.partIndex = partIndex self.maximumBitrate = maximumBitrate self.resolution = resolution self.isLocal = isLocal self.sessionIdentifier = sessionIdentifier self.subtitleStreamID = subtitleStreamID self.audioStreamID = audioStreamID self.pathOverride = pathOverride } /// Die Adresse, die umgerechnet werden soll — so, wie sie in die Anfrage /// gehört: **bereits kodiert**. /// /// Hier lagen beide naheliegenden Wege daneben. Unverändert durch /// `URLComponents` geschickt, wird sie ein zweites Mal kodiert: aus `%3A` /// wird `%253A`. Vorher entpackt, steht `plex://episode/…` im Klartext in /// der Anfrage, und daran verschluckt sich der Server — er antwortet mit /// einer nackten „400 Bad Request"-Seite, ohne Plex-Meldung. /// /// Richtig ist: genau einmal kodiert. Darum kümmert sich `transcodeURL`, /// indem es diesen Wert unangetastet einsetzt. var percentEncodedPath: String { pathOverride ?? "/library/metadata/\(ratingKey)" } var queryItems: [URLQueryItem] { var items = [ URLQueryItem(name: "path", value: percentEncodedPath), // „Ich verstehe die Entscheidungsantwort." Plex' eigener Client // schickt das bei jeder Wiedergabe mit. Ohne diese Angabe lehnt // der Server eine Live-Sitzung mit einer nackten 400 ab. URLQueryItem(name: "hasMDE", value: "1"), URLQueryItem(name: "mediaIndex", value: String(mediaIndex)), URLQueryItem(name: "partIndex", value: String(partIndex)), URLQueryItem(name: "protocol", value: "hls"), // Ohne fastSeek muss der Transcoder beim Springen von vorn // beginnen — bei einem Zweistünder dauert das ewig. URLQueryItem(name: "fastSeek", value: "1"), // 0 heißt: wir fragen hier ausdrücklich nach einem Stream. Ob // DirectPlay möglich wäre, hat der PlaybackDecider schon geprüft. URLQueryItem(name: "directPlay", value: "0"), // 1 erlaubt das bloße Umpacken, wenn der Bildcodec passt. Das // spart dem Server die Neukodierung. URLQueryItem(name: "directStream", value: "1"), URLQueryItem(name: "copyts", value: "1"), URLQueryItem(name: "location", value: isLocal ? "lan" : "wan"), URLQueryItem(name: "session", value: sessionIdentifier), URLQueryItem(name: "X-Plex-Session-Identifier", value: sessionIdentifier), ] if let maximumBitrate { items.append(URLQueryItem(name: "maxVideoBitrate", value: String(maximumBitrate))) // `videoQuality` skaliert mit: ohne sie ignoriert Plex die // Bitratengrenze bei manchen Fassungen. items.append(URLQueryItem(name: "videoQuality", value: String(quality(for: maximumBitrate)))) } if let resolution { items.append(URLQueryItem(name: "videoResolution", value: resolution)) } if let subtitleStreamID { items.append(URLQueryItem(name: "subtitles", value: "burn")) items.append(URLQueryItem(name: "subtitleStreamID", value: String(subtitleStreamID))) items.append(URLQueryItem(name: "subtitleSize", value: "100")) } if let audioStreamID { items.append(URLQueryItem(name: "audioStreamID", value: String(audioStreamID))) } return items } /// Übersetzt eine Bitrate in Plex' Qualitätsskala von 10 bis 100. /// /// Die Werte stammen aus der Staffelung, die Plex in seinen eigenen Apps /// anbietet. Zwischen den Stufen wird aufgerundet — lieber etwas zu gut /// als sichtbar zu schlecht. private func quality(for bitrate: Int) -> Int { switch bitrate { case ..<1_000: 20 case ..<2_000: 40 case ..<4_000: 60 case ..<8_000: 75 case ..<12_000: 90 default: 100 } } } /// Was der Server zur geplanten Wiedergabe sagt. /// /// Wird vor dem Start abgefragt. Der Wert liegt weniger in der Entscheidung /// selbst — die trifft der `PlaybackDecider` — als im Fehlerfall: sagt Plex /// hier schon nein, erfährt man den Grund im Klartext, statt später vor einem /// schwarzen Bild zu sitzen. public struct TranscodeDecision: Sendable, Decodable { public let generalDecisionCode: Int? public let generalDecisionText: String? public let transcodeDecisionCode: Int? public let transcodeDecisionText: String? public let mediaDecisionCode: Int? /// Ob der Server die Wiedergabe grundsätzlich zulässt. /// /// `1000` heißt „direkt abspielbar", `1001` „direkt nicht, umrechnen /// schon", `2000` „Umwandlung in Ordnung". Das sind alles **Zusagen** — /// erst ab `3000` wird abgelehnt, etwa bei fehlender Berechtigung oder /// überlastetem Transcoder. /// /// Vorher galt nur `1000` als Zusage. Damit wurde ausgerechnet der Fall /// blockiert, den die App selbst angefordert hatte: wer im Player eine /// Stufe wählt, will umgerechnet bekommen, und der Server antwortete /// „direkte Wiedergabe nicht verfügbar; Umwandlung ok" — eine Zusage, die /// hier als Absage ankam. Es spielte dann gar nichts. public var isAllowed: Bool { guard let generalDecisionCode else { return true } return generalDecisionCode < Self.firstRefusalCode } /// Ab hier lehnt Plex ab. static let firstRefusalCode = 3_000 public var explanation: String? { generalDecisionText ?? transcodeDecisionText } private enum OuterKeys: String, CodingKey { case mediaContainer = "MediaContainer" } private enum InnerKeys: String, CodingKey { case generalDecisionCode, generalDecisionText case transcodeDecisionCode, transcodeDecisionText case mediaDecisionCode } public init(from decoder: any Decoder) throws { let outer = try decoder.container(keyedBy: OuterKeys.self) let inner = try outer.nestedContainer(keyedBy: InnerKeys.self, forKey: .mediaContainer) self.generalDecisionCode = try inner.decodeIfPresent(Int.self, forKey: .generalDecisionCode) self.generalDecisionText = try inner.decodeIfPresent(String.self, forKey: .generalDecisionText) self.transcodeDecisionCode = try inner.decodeIfPresent(Int.self, forKey: .transcodeDecisionCode) self.transcodeDecisionText = try inner.decodeIfPresent(String.self, forKey: .transcodeDecisionText) self.mediaDecisionCode = try inner.decodeIfPresent(Int.self, forKey: .mediaDecisionCode) } }