Phase 2: Transcoding, adaptive Bitrate, Spuren, Marker, Sync
Der Decider brauchte keine Änderung — nur eine andere Richtlinie. Genau dafür war er gebaut. Transcoding - TranscodeRequest baut die Parameter für /video/:/transcode/universal. directStream=1 lässt Plex nur umpacken, wenn der Bildcodec passt, statt pauschal neu zu kodieren. - Vor dem Start wird der Server gefragt (decision); sagt er nein, steht der Grund im Klartext da statt eines schwarzen Bildes. - Beim Verlassen wird der Transcoder angehalten. Ohne dieses Signal rechnet der Server minutenlang für niemanden weiter. - Die adaptive Bitrate kommt mit HLS: AVPlayer wechselt sie selbst. Qualitätseinstellungen - Getrennt für WLAN und Mobilfunk, fünf spürbare Stufen. Voreinstellung: zuhause Original, unterwegs 4 Mbit. - NetworkConditions erkennt Mobilfunk über isExpensive — das deckt auch den Hotspot ab, der technisch WLAN ist und trotzdem Volumen kostet. Spuren und Marken - Ton- und Untertitelwahl im Player, bevorzugte Sprache in den Einstellungen, nachsichtiger Sprachvergleich (de/deu/ger/de-DE). - Intro- und Abspann-Marken mit Überspringen-Knopf, optional automatisch. Bereits übersprungene Marken werden gemerkt, sonst käme man nach einem Rücksprung nie ins Intro zurück. - Nächste Folge inkl. Staffelwechsel; Specials in Staffel 0 werden nicht vorgezogen. Fortschritt - Alle zehn Sekunden an den Server, plus Schlussmeldung beim Verlassen. Erst die nimmt den Titel aus „wird gerade abgespielt" heraus. - markWatched/markUnwatched/rate/refreshLibrary stehen bereit. 86 Tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import Observation
|
||||
|
||||
/// Beobachtet, worüber das Gerät gerade am Netz hängt.
|
||||
///
|
||||
/// Die Unterscheidung WLAN/Mobilfunk entscheidet, welche Bitratengrenze gilt.
|
||||
/// `isExpensive` statt einer Abfrage auf den Schnittstellentyp: der Wert deckt
|
||||
/// auch den Fall ab, dass das iPhone am persönlichen Hotspot eines anderen
|
||||
/// Geräts hängt — technisch WLAN, in der Abrechnung Mobilfunk.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class NetworkConditions {
|
||||
public private(set) var isReachable = true
|
||||
/// Mobilfunk oder Hotspot — alles, was Datenvolumen kostet.
|
||||
public private(set) var isExpensive = false
|
||||
/// Der Sparmodus für Daten ist an. Dann sollte die App von sich aus
|
||||
/// sparsam sein, auch im WLAN.
|
||||
public private(set) var isConstrained = false
|
||||
|
||||
private let monitor = NWPathMonitor()
|
||||
|
||||
public init() {
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
isReachable = path.status == .satisfied
|
||||
isExpensive = path.isExpensive
|
||||
isConstrained = path.isConstrained
|
||||
}
|
||||
}
|
||||
monitor.start(queue: DispatchQueue(label: "flixy.network"))
|
||||
}
|
||||
|
||||
deinit {
|
||||
monitor.cancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
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) })
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,17 @@ import Foundation
|
||||
/// Die eine Stelle, an der die App mit ihrem Server spricht. Die Views kennen
|
||||
/// weder Pfade noch Abfrageparameter — sie rufen Methoden auf.
|
||||
public actor PlexServer {
|
||||
public let resource: PlexResource
|
||||
public let connection: PlexConnection
|
||||
fileprivate let client: PlexHTTPClient
|
||||
private let token: String
|
||||
// `nonisolated`, weil beide unveränderlich und Sendable sind: die
|
||||
// Oberfläche muss für „welche Verbindung läuft gerade" nicht auf den
|
||||
// Actor warten. Über Modulgrenzen hinweg leitet der Compiler das nicht
|
||||
// von selbst her.
|
||||
public nonisolated let resource: PlexResource
|
||||
public nonisolated let connection: PlexConnection
|
||||
/// Modulweit sichtbar, weil die Endpunkte auf mehrere Dateien verteilt
|
||||
/// sind — Blättern hier, Wiedergabe in PlexServer+Playback. Nach außen
|
||||
/// bleibt er verborgen: die App soll Methoden aufrufen, keine Pfade bauen.
|
||||
let client: PlexHTTPClient
|
||||
nonisolated let token: String
|
||||
|
||||
public init?(
|
||||
resource: PlexResource,
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
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?
|
||||
|
||||
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
|
||||
) {
|
||||
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
|
||||
}
|
||||
|
||||
var queryItems: [URLQueryItem] {
|
||||
var items = [
|
||||
URLQueryItem(name: "path", value: "/library/metadata/\(ratingKey)"),
|
||||
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 Strom. 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.
|
||||
///
|
||||
/// Code 1000 heißt „in Ordnung". Alles andere ist eine Absage — meist
|
||||
/// fehlende Berechtigungen oder ein überlasteter Transcoder.
|
||||
public var isAllowed: Bool {
|
||||
generalDecisionCode == nil || generalDecisionCode == 1000
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import PlexKit
|
||||
|
||||
/// Die Suche nach der nächsten Folge.
|
||||
///
|
||||
/// Plex kennt dafür keinen Endpunkt — der Weg führt über die Geschwister, und
|
||||
/// am Staffelende über die nächste Staffel. Genau dort bricht es ohne diese
|
||||
/// Logik ab, und zwar an der Stelle, an der man es am wenigsten gebrauchen
|
||||
/// kann: mitten in einer Serie.
|
||||
@Suite("Nächste Folge")
|
||||
struct NextEpisodeTests {
|
||||
|
||||
private func episode(
|
||||
ratingKey: String,
|
||||
index: Int,
|
||||
season: Int,
|
||||
seasonKey: String,
|
||||
showKey: String = "show-1"
|
||||
) -> String {
|
||||
"""
|
||||
{
|
||||
"ratingKey": "\(ratingKey)", "type": "episode", "title": "Folge \(index)",
|
||||
"index": \(index), "parentIndex": \(season),
|
||||
"parentRatingKey": "\(seasonKey)", "grandparentRatingKey": "\(showKey)",
|
||||
"grandparentTitle": "Eine Serie"
|
||||
}
|
||||
"""
|
||||
}
|
||||
|
||||
private func container(_ items: [String]) -> String {
|
||||
"""
|
||||
{"MediaContainer":{"size":\(items.count),"Metadata":[\(items.joined(separator: ","))]}}
|
||||
"""
|
||||
}
|
||||
|
||||
private func season(ratingKey: String, index: Int) -> String {
|
||||
"""
|
||||
{"ratingKey":"\(ratingKey)","type":"season","title":"Staffel \(index)","index":\(index)}
|
||||
"""
|
||||
}
|
||||
|
||||
private func server(routes: [String: String]) -> PlexServer {
|
||||
PlexServer(
|
||||
resource: PlexResource(
|
||||
name: "Test",
|
||||
clientIdentifier: "srv",
|
||||
accessToken: "token",
|
||||
connections: [PlexConnection(uri: "http://server:32400", local: true)]
|
||||
),
|
||||
connection: PlexConnection(uri: "http://server:32400", local: true),
|
||||
identity: .test,
|
||||
transport: FakeTransport.routes(routes)
|
||||
)!
|
||||
}
|
||||
|
||||
private func current(index: Int, season: Int = 1, seasonKey: String = "season-1") throws -> PlexItem {
|
||||
let data = Data(container([episode(ratingKey: "e\(index)", index: index, season: season, seasonKey: seasonKey)]).utf8)
|
||||
let decoded = try PlexResponse.decode(PlexMediaContainer<PlexItem>.self, from: data)
|
||||
return try #require(decoded.content.first)
|
||||
}
|
||||
|
||||
@Test("Innerhalb einer Staffel kommt die nächsthöhere Folge")
|
||||
func nextWithinSeason() async throws {
|
||||
let server = server(routes: [
|
||||
"/library/metadata/season-1/children": container([
|
||||
episode(ratingKey: "e1", index: 1, season: 1, seasonKey: "season-1"),
|
||||
episode(ratingKey: "e2", index: 2, season: 1, seasonKey: "season-1"),
|
||||
episode(ratingKey: "e3", index: 3, season: 1, seasonKey: "season-1"),
|
||||
]),
|
||||
])
|
||||
|
||||
let next = try await server.nextEpisode(after: try current(index: 1))
|
||||
#expect(next?.ratingKey == "e2")
|
||||
}
|
||||
|
||||
@Test("Lücken in der Nummerierung überspringen nichts Falsches")
|
||||
func handlesGapsInNumbering() async throws {
|
||||
// Fehlt eine Folge auf der Platte, darf nicht die Folge mit der
|
||||
// höchsten Nummer kommen, sondern die nächsterreichbare.
|
||||
let server = server(routes: [
|
||||
"/library/metadata/season-1/children": container([
|
||||
episode(ratingKey: "e1", index: 1, season: 1, seasonKey: "season-1"),
|
||||
episode(ratingKey: "e5", index: 5, season: 1, seasonKey: "season-1"),
|
||||
episode(ratingKey: "e9", index: 9, season: 1, seasonKey: "season-1"),
|
||||
]),
|
||||
])
|
||||
|
||||
let next = try await server.nextEpisode(after: try current(index: 1))
|
||||
#expect(next?.ratingKey == "e5")
|
||||
}
|
||||
|
||||
@Test("Am Staffelende geht es in der nächsten Staffel weiter")
|
||||
func rollsOverToNextSeason() async throws {
|
||||
let server = server(routes: [
|
||||
"/library/metadata/season-1/children": container([
|
||||
episode(ratingKey: "e1", index: 1, season: 1, seasonKey: "season-1"),
|
||||
episode(ratingKey: "e2", index: 2, season: 1, seasonKey: "season-1"),
|
||||
]),
|
||||
"/library/metadata/show-1/children": container([
|
||||
season(ratingKey: "season-1", index: 1),
|
||||
season(ratingKey: "season-2", index: 2),
|
||||
]),
|
||||
"/library/metadata/season-2/children": container([
|
||||
episode(ratingKey: "s2e1", index: 1, season: 2, seasonKey: "season-2"),
|
||||
episode(ratingKey: "s2e2", index: 2, season: 2, seasonKey: "season-2"),
|
||||
]),
|
||||
])
|
||||
|
||||
let next = try await server.nextEpisode(after: try current(index: 2))
|
||||
#expect(next?.ratingKey == "s2e1")
|
||||
}
|
||||
|
||||
@Test("Nach der letzten Folge der letzten Staffel kommt nichts mehr")
|
||||
func stopsAtTheEnd() async throws {
|
||||
let server = server(routes: [
|
||||
"/library/metadata/season-1/children": container([
|
||||
episode(ratingKey: "e1", index: 1, season: 1, seasonKey: "season-1"),
|
||||
]),
|
||||
"/library/metadata/show-1/children": container([season(ratingKey: "season-1", index: 1)]),
|
||||
])
|
||||
|
||||
let next = try await server.nextEpisode(after: try current(index: 1))
|
||||
#expect(next == nil)
|
||||
}
|
||||
|
||||
@Test("Ein Film hat keine nächste Folge")
|
||||
func moviesHaveNoNext() async throws {
|
||||
let data = Data(#"{"MediaContainer":{"Metadata":[{"ratingKey":"m1","type":"movie","title":"Ein Film"}]}}"#.utf8)
|
||||
let movie = try #require(try PlexResponse.decode(PlexMediaContainer<PlexItem>.self, from: data).content.first)
|
||||
|
||||
let next = try await server(routes: [:]).nextEpisode(after: movie)
|
||||
#expect(next == nil)
|
||||
}
|
||||
|
||||
@Test("Staffel 0 mit Specials wird nicht vorgezogen")
|
||||
func specialsDoNotJumpAhead() async throws {
|
||||
// Plex führt Specials als Staffel 0. Sie stehen in der Liste vor der
|
||||
// ersten regulären Staffel — würde einfach die erste genommen, landete
|
||||
// man nach dem Staffelfinale bei einem Weihnachtsspecial.
|
||||
let server = server(routes: [
|
||||
"/library/metadata/season-1/children": container([
|
||||
episode(ratingKey: "e1", index: 1, season: 1, seasonKey: "season-1"),
|
||||
]),
|
||||
"/library/metadata/show-1/children": container([
|
||||
season(ratingKey: "season-0", index: 0),
|
||||
season(ratingKey: "season-1", index: 1),
|
||||
season(ratingKey: "season-2", index: 2),
|
||||
]),
|
||||
"/library/metadata/season-2/children": container([
|
||||
episode(ratingKey: "s2e1", index: 1, season: 2, seasonKey: "season-2"),
|
||||
]),
|
||||
])
|
||||
|
||||
let next = try await server.nextEpisode(after: try current(index: 1))
|
||||
#expect(next?.ratingKey == "s2e1")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import PlexKit
|
||||
|
||||
@Suite("Transcode-Anfrage")
|
||||
struct TranscodeRequestTests {
|
||||
private func value(_ request: TranscodeRequest, _ name: String) -> String? {
|
||||
request.queryItems.first { $0.name == name }?.value
|
||||
}
|
||||
|
||||
@Test("Der Pfad zeigt auf den Titel, nicht auf die Datei")
|
||||
func pathPointsAtMetadata() {
|
||||
// Plex erwartet hier die Metadaten-Adresse. Gibt man den Dateipfad an,
|
||||
// antwortet der Transcoder mit 404.
|
||||
let request = TranscodeRequest(ratingKey: "45231", isLocal: true)
|
||||
#expect(value(request, "path") == "/library/metadata/45231")
|
||||
}
|
||||
|
||||
@Test("Umpacken ist erlaubt, DirectPlay ausdrücklich nicht")
|
||||
func allowsDirectStreamButNotDirectPlay() {
|
||||
// directStream=1 lässt Plex nur den Container wechseln, wenn der
|
||||
// Bildcodec passt — das spart die Neukodierung. directPlay=0, weil
|
||||
// diese Frage der PlaybackDecider bereits entschieden hat.
|
||||
let request = TranscodeRequest(ratingKey: "1", isLocal: true)
|
||||
#expect(value(request, "directStream") == "1")
|
||||
#expect(value(request, "directPlay") == "0")
|
||||
}
|
||||
|
||||
@Test("Ohne fastSeek müsste der Transcoder beim Springen von vorn beginnen")
|
||||
func enablesFastSeek() {
|
||||
#expect(value(TranscodeRequest(ratingKey: "1", isLocal: true), "fastSeek") == "1")
|
||||
}
|
||||
|
||||
@Test("Die Bitratengrenze bringt eine passende Qualitätsstufe mit")
|
||||
func bitrateCarriesQuality() {
|
||||
// Ohne videoQuality ignoriert Plex die Grenze bei manchen Fassungen.
|
||||
let request = TranscodeRequest(ratingKey: "1", maximumBitrate: 4_000, isLocal: false)
|
||||
#expect(value(request, "maxVideoBitrate") == "4000")
|
||||
#expect(value(request, "videoQuality") != nil)
|
||||
}
|
||||
|
||||
@Test("Höhere Bitraten ergeben höhere Qualitätsstufen")
|
||||
func qualityRisesWithBitrate() {
|
||||
func quality(_ bitrate: Int) -> Int {
|
||||
Int(value(TranscodeRequest(ratingKey: "1", maximumBitrate: bitrate, isLocal: true), "videoQuality") ?? "0")!
|
||||
}
|
||||
#expect(quality(2_000) < quality(8_000))
|
||||
#expect(quality(8_000) < quality(20_000))
|
||||
#expect(quality(20_000) <= 100)
|
||||
}
|
||||
|
||||
@Test("Ohne Grenze steht keine Bitrate in der Anfrage")
|
||||
func noBitrateWhenUnlimited() {
|
||||
// Ein leerer Wert ist für Plex nicht dasselbe wie ein fehlender — er
|
||||
// würde als Grenze von null gelesen.
|
||||
let request = TranscodeRequest(ratingKey: "1", isLocal: true)
|
||||
#expect(value(request, "maxVideoBitrate") == nil)
|
||||
#expect(value(request, "videoQuality") == nil)
|
||||
}
|
||||
|
||||
@Test("Lokal und außer Haus werden unterschieden")
|
||||
func distinguishesLocation() {
|
||||
#expect(value(TranscodeRequest(ratingKey: "1", isLocal: true), "location") == "lan")
|
||||
#expect(value(TranscodeRequest(ratingKey: "1", isLocal: false), "location") == "wan")
|
||||
}
|
||||
|
||||
@Test("Die Sitzungskennung steht in beiden Feldern")
|
||||
func sessionAppearsTwice() {
|
||||
// Plex erwartet sie sowohl als `session` als auch als
|
||||
// `X-Plex-Session-Identifier`. Fehlt eines, lässt sich der Transcoder
|
||||
// später nicht gezielt anhalten — und er rechnet minutenlang weiter.
|
||||
let request = TranscodeRequest(ratingKey: "1", isLocal: true, sessionIdentifier: "sitzung-1")
|
||||
#expect(value(request, "session") == "sitzung-1")
|
||||
#expect(value(request, "X-Plex-Session-Identifier") == "sitzung-1")
|
||||
}
|
||||
|
||||
@Test("Untertitel werden nur eingebrannt, wenn eine Spur gewählt ist")
|
||||
func subtitlesOnlyWhenRequested() {
|
||||
let without = TranscodeRequest(ratingKey: "1", isLocal: true)
|
||||
#expect(value(without, "subtitles") == nil)
|
||||
|
||||
let with = TranscodeRequest(ratingKey: "1", isLocal: true, subtitleStreamID: 310_024)
|
||||
#expect(value(with, "subtitles") == "burn")
|
||||
#expect(value(with, "subtitleStreamID") == "310024")
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("Dauer in Plex-Einheiten")
|
||||
struct DurationConversionTests {
|
||||
@Test("Millisekunden werden ganzzahlig gerechnet")
|
||||
func millisecondsAreWholeNumbers() {
|
||||
#expect(Duration.seconds(90).milliseconds == 90_000)
|
||||
#expect(Duration.milliseconds(4_892_000).milliseconds == 4_892_000)
|
||||
#expect(Duration.zero.milliseconds == 0)
|
||||
}
|
||||
|
||||
@Test("Sekunden als Gleitkommazahl")
|
||||
func secondsAsDouble() {
|
||||
#expect(abs(Duration.milliseconds(1_500).seconds - 1.5) < 0.0001)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user