Files
Flaro/Packages/FlixyKit/Tests/PlexKitTests/NextEpisodeTests.swift
T
GuidoandClaude Opus 5 55bcfe2fbf 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>
2026-09-11 17:24:17 +02:00

159 lines
6.5 KiB
Swift

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")
}
}