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,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