Phase 0: Projektgerüst, Design-Tokens und Navigation
Legt das Fundament für den Plex-Client an: - XcodeGen-Projekt nach dem Muster von Onyx (com.scarriffleservices, strict concurrency, Projektdatei nicht eingecheckt) - FlixyKit mit vier Modulen: PlexKit, FlixyDesign, FlixyPlayback, FlixyDownloads. macOS steht als Plattform mit drin, damit die Logik-Tests ohne Simulator laufen. - FlixyDesign: dunkle Farb-Tokens, Schriftskala, Poster- und Querformat-Kacheln, Aufmacher-Bild. Die Akzentfarbe fließt über die Umgebung und wird per WCAG-Rechnung lesbar gehalten. - PlexKit: Kopfzeilen-Identität, Transport-Protokoll, Fehlerarten und die Medienmodelle samt nachsichtigem Decoder. - FlixyPlayback: VideoPlayerEngine-Protokoll und PlaybackDecider — die eine Funktion, an der Phase 2 das Transcoding andockt. - Ausfahrbare Seitenleiste auf dem iPhone, Split-View auf dem iPad. - 45 Tests, alle grün. VLCKit 4.0.0-a24 ist vorab geprüft: baut über SPM unter Swift 6 mit strict concurrency. Die iOS-Slice ist mit 239 MB allerdings gewichtig, deshalb bekommt sie in Phase 1 ein eigenes Target. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
import Testing
|
||||
@testable import FlixyDesign
|
||||
|
||||
@Suite("Kontrastkorrektur")
|
||||
struct ContrastTests {
|
||||
private let canvas = FlixyColor.canvas
|
||||
|
||||
@Test("Schwarz zu Weiß ergibt das Maximum von 21:1")
|
||||
func extremesProduceMaximumRatio() {
|
||||
let ratio = Contrast.ratio(RGB(hex: 0x000000), RGB(hex: 0xFFFFFF))
|
||||
#expect(abs(ratio - 21) < 0.01)
|
||||
}
|
||||
|
||||
@Test("Gleiche Farben ergeben 1:1")
|
||||
func identicalColorsProduceMinimumRatio() {
|
||||
#expect(abs(Contrast.ratio(canvas, canvas) - 1) < 0.0001)
|
||||
}
|
||||
|
||||
@Test("Farben mit genug Kontrast bleiben unangetastet")
|
||||
func sufficientColorsAreLeftAlone() {
|
||||
// Wichtig, weil die kuratierte Palette sonst beim Zeichnen verfälscht
|
||||
// würde — die Kachel in den Einstellungen und die Farbe in der App
|
||||
// müssen übereinstimmen.
|
||||
let amber = RGB(hex: 0xFFB224)
|
||||
#expect(Contrast.legible(amber, on: canvas) == amber)
|
||||
}
|
||||
|
||||
@Test(
|
||||
"Zu dunkle Farben werden bis zur Lesbarkeit angehoben",
|
||||
arguments: [0x00008B, 0x1A1A2E, 0x2D0A31, 0x003300, 0x4A0000, 0x111111]
|
||||
)
|
||||
func darkColorsAreLifted(hex: Int) {
|
||||
let original = RGB(hex: UInt32(hex))
|
||||
let corrected = Contrast.legible(original, on: canvas)
|
||||
|
||||
#expect(Contrast.ratio(original, canvas) < Contrast.minimumForText)
|
||||
#expect(Contrast.ratio(corrected, canvas) >= Contrast.minimumForText)
|
||||
}
|
||||
|
||||
@Test("Die Korrektur hebt nur so weit an wie nötig")
|
||||
func correctionIsMinimal() {
|
||||
// Ein Sprung auf Weiß wäre die einfache Lösung, würde aber jede
|
||||
// dunkle Wunschfarbe in dieselbe Farbe verwandeln.
|
||||
let corrected = Contrast.legible(RGB(hex: 0x00008B), on: canvas)
|
||||
#expect(Contrast.ratio(corrected, canvas) < Contrast.minimumForText + 0.5)
|
||||
#expect(corrected != RGB(hex: 0xFFFFFF))
|
||||
}
|
||||
|
||||
@Test("Der Farbton überlebt die Korrektur")
|
||||
func correctionPreservesHue() {
|
||||
let darkBlue = RGB(hex: 0x00008B)
|
||||
let corrected = Contrast.legible(darkBlue, on: canvas)
|
||||
// Blau muss blau bleiben. Ein Abstand von 0,02 im Farbkreis sind rund
|
||||
// sieben Grad — für das Auge dieselbe Farbe.
|
||||
#expect(abs(HSB(corrected).hue - HSB(darkBlue).hue) < 0.02)
|
||||
}
|
||||
|
||||
@Test("Selbst Schwarz wird lesbar gemacht")
|
||||
func blackIsHandled() {
|
||||
// Schwarz hat keinen Farbton, an dem sich festhalten ließe — hier
|
||||
// muss die Sättigungsstufe greifen, sonst käme die Suche nie an.
|
||||
let corrected = Contrast.legible(RGB(hex: 0x000000), on: canvas)
|
||||
#expect(Contrast.ratio(corrected, canvas) >= Contrast.minimumForText)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("Akzentpalette")
|
||||
struct AccentPaletteTests {
|
||||
@Test("Jede kuratierte Farbe ist ohne Korrektur lesbar")
|
||||
func curatedColorsAreLegibleAsIs() {
|
||||
// Das ist der eigentliche Zweck des Kuratierens. Fällt eine neue Farbe
|
||||
// hier durch, gehört sie nicht in die Palette — nicht in die
|
||||
// Korrektur.
|
||||
for option in AccentOption.palette {
|
||||
let ratio = Contrast.ratio(option.color, FlixyColor.canvas)
|
||||
#expect(
|
||||
ratio >= Contrast.minimumForText,
|
||||
"\(option.id) erreicht nur \(String(format: "%.2f", ratio)):1"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Die Kennungen sind eindeutig")
|
||||
func identifiersAreUnique() {
|
||||
// Sie dienen als Schlüssel in den Voreinstellungen; ein Duplikat
|
||||
// hieße, dass eine Auswahl still eine andere überschreibt.
|
||||
let identifiers = AccentOption.palette.map(\.id)
|
||||
#expect(Set(identifiers).count == identifiers.count)
|
||||
}
|
||||
|
||||
@Test("Keine kuratierte Farbe nimmt die Kennung der eigenen Farbe ein")
|
||||
func paletteDoesNotCollideWithCustom() {
|
||||
#expect(!AccentOption.palette.contains { $0.id == AccentOption.customIdentifier })
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("Farbwerte")
|
||||
struct RGBTests {
|
||||
@Test(
|
||||
"Hex-Eingaben werden gelesen",
|
||||
arguments: [
|
||||
("#1DB954", 0x1DB954),
|
||||
("1db954", 0x1DB954),
|
||||
(" #FFB224 ", 0xFFB224),
|
||||
// Kurzform: #ABC meint #AABBCC.
|
||||
("#ABC", 0xAABBCC),
|
||||
]
|
||||
)
|
||||
func validHexStringsParse(input: String, expected: Int) {
|
||||
#expect(RGB(string: input) == RGB(hex: UInt32(expected)))
|
||||
}
|
||||
|
||||
@Test(
|
||||
"Unbrauchbare Eingaben werden abgewiesen",
|
||||
arguments: ["", "#", "xyz", "#12345", "#GGGGGG", "1234567"]
|
||||
)
|
||||
func invalidHexStringsFail(input: String) {
|
||||
#expect(RGB(string: input) == nil)
|
||||
}
|
||||
|
||||
@Test("Hex hin und zurück ergibt denselben Wert")
|
||||
func hexRoundTrips() {
|
||||
for option in AccentOption.palette {
|
||||
#expect(RGB(string: option.color.hexString) == option.color)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import Testing
|
||||
@testable import FlixyDownloads
|
||||
|
||||
@Suite("Download-Zustandsautomat")
|
||||
struct DownloadStateTests {
|
||||
|
||||
@Test("Ein abgeschlossener Download bleibt abgeschlossen")
|
||||
func completedIsTerminal() {
|
||||
// Der wichtigste Übergang, den es NICHT geben darf: wäre er erlaubt,
|
||||
// könnte ein verspäteter Rückruf aus der Hintergrundsitzung eine
|
||||
// fertige Datei wieder in die Warteschlange schieben — und sie damit
|
||||
// ein zweites Mal herunterladen.
|
||||
for next in DownloadState.allCases {
|
||||
#expect(!DownloadState.completed.canTransition(to: next))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Gescheiterte Downloads dürfen zurück in die Warteschlange")
|
||||
func failedCanBeRetried() {
|
||||
#expect(DownloadState.failed.canTransition(to: .queued))
|
||||
}
|
||||
|
||||
@Test("Gescheiterte Downloads springen nicht direkt ins Übertragen")
|
||||
func failedCannotSkipTheQueue() {
|
||||
// Sonst umgeht der Wiederholen-Knopf die Prüfungen auf WLAN und
|
||||
// Speicherplatz, die beim Verlassen der Warteschlange stattfinden.
|
||||
#expect(!DownloadState.failed.canTransition(to: .downloading))
|
||||
}
|
||||
|
||||
@Test("Angehalten und laufend zählen beim Speicherplatz mit")
|
||||
func partialTransfersOccupyStorage() {
|
||||
// Beide haben bereits Daten auf der Platte. Zählte man sie nicht mit,
|
||||
// liefe das Gerät trotz gesetzter Grenze voll.
|
||||
#expect(DownloadState.downloading.occupiesStorage)
|
||||
#expect(DownloadState.paused.occupiesStorage)
|
||||
#expect(DownloadState.completed.occupiesStorage)
|
||||
#expect(!DownloadState.queued.occupiesStorage)
|
||||
#expect(!DownloadState.waiting.occupiesStorage)
|
||||
#expect(!DownloadState.failed.occupiesStorage)
|
||||
}
|
||||
|
||||
@Test("Nur wartende Zustände dürfen mit dem Übertragen beginnen")
|
||||
func onlyWaitingStatesAreTransferable() {
|
||||
#expect(DownloadState.queued.isTransferable)
|
||||
#expect(DownloadState.waiting.isTransferable)
|
||||
#expect(!DownloadState.downloading.isTransferable)
|
||||
#expect(!DownloadState.paused.isTransferable)
|
||||
#expect(!DownloadState.completed.isTransferable)
|
||||
}
|
||||
|
||||
@Test("Kein Zustand geht in sich selbst über")
|
||||
func noSelfTransitions() {
|
||||
// Ein Übergang auf sich selbst wäre ein stilles Nichts und würde
|
||||
// Zählerstände wie „Versuche" doppelt hochzählen.
|
||||
for state in DownloadState.allCases {
|
||||
#expect(!state.canTransition(to: state))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Jeder Zustand außer dem abgeschlossenen hat einen Ausgang")
|
||||
func everyStateCanProgress() {
|
||||
for state in DownloadState.allCases where state != .completed {
|
||||
let hasExit = DownloadState.allCases.contains { state.canTransition(to: $0) }
|
||||
#expect(hasExit, "\(state.rawValue) ist eine Sackgasse")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Die gespeicherten Namen bleiben stabil")
|
||||
func rawValuesAreStable() {
|
||||
// Sie landen in SwiftData. Ein umbenannter Fall würde beim nächsten
|
||||
// Start alle laufenden Downloads unlesbar machen.
|
||||
#expect(DownloadState.queued.rawValue == "queued")
|
||||
#expect(DownloadState.downloading.rawValue == "downloading")
|
||||
#expect(DownloadState.paused.rawValue == "paused")
|
||||
#expect(DownloadState.waiting.rawValue == "waiting")
|
||||
#expect(DownloadState.completed.rawValue == "completed")
|
||||
#expect(DownloadState.failed.rawValue == "failed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import Testing
|
||||
import PlexKit
|
||||
@testable import FlixyPlayback
|
||||
|
||||
/// Die Tabelle, die festhält, welche Datei auf welchem Weg landet.
|
||||
///
|
||||
/// Sie ist der Grund, warum `DeviceCapabilities` nicht beiläufig angefasst
|
||||
/// werden kann: jede Änderung dort schlägt hier sichtbar durch.
|
||||
@Suite("Wiedergabe-Entscheidung")
|
||||
struct PlaybackDeciderTests {
|
||||
|
||||
/// Baut eine Datei mit den angegebenen Eigenschaften.
|
||||
private func media(
|
||||
container: String,
|
||||
video: String,
|
||||
audio: String,
|
||||
bitrate: Int? = nil
|
||||
) -> (PlexMedia, PlexPart) {
|
||||
let streams = [
|
||||
PlexStream(id: 1, streamType: 1, codec: video),
|
||||
PlexStream(id: 2, streamType: 2, codec: audio, selected: true),
|
||||
]
|
||||
let part = PlexPart(id: 10, key: "/library/parts/10/1/file", container: container, streams: streams)
|
||||
let media = PlexMedia(
|
||||
id: 100,
|
||||
container: container,
|
||||
videoCodec: video,
|
||||
audioCodec: audio,
|
||||
bitrate: bitrate,
|
||||
parts: [part]
|
||||
)
|
||||
return (media, part)
|
||||
}
|
||||
|
||||
// MARK: - Was AVPlayer nativ kann
|
||||
|
||||
@Test(
|
||||
"Native Formate gehen an AVPlayer",
|
||||
arguments: [
|
||||
("mp4", "h264", "aac"),
|
||||
("mp4", "hevc", "eac3"),
|
||||
("mov", "h264", "ac3"),
|
||||
("m4v", "hevc", "alac"),
|
||||
// Groß-/Kleinschreibung darf keinen Unterschied machen — Plex ist
|
||||
// darin nicht einheitlich.
|
||||
("MP4", "H264", "AAC"),
|
||||
]
|
||||
)
|
||||
func nativeFormatsUseAVPlayer(container: String, video: String, audio: String) {
|
||||
let (media, part) = media(container: container, video: video, audio: audio)
|
||||
let decision = PlaybackDecider().decide(media: media, part: part, policy: .directPlayOnly)
|
||||
|
||||
#expect(decision.method == .directPlay(engine: .avPlayer))
|
||||
#expect(decision.rationale.contains(.nativelySupported))
|
||||
}
|
||||
|
||||
// MARK: - Was VLC übernehmen muss
|
||||
|
||||
@Test("Matroska geht an VLC, auch bei nativen Codecs")
|
||||
func matroskaFallsBackToVLC() {
|
||||
let (media, part) = media(container: "mkv", video: "h264", audio: "aac")
|
||||
let decision = PlaybackDecider().decide(media: media, part: part, policy: .directPlayOnly)
|
||||
|
||||
#expect(decision.method == .directPlay(engine: .vlc))
|
||||
#expect(decision.rationale.contains(.containerUnsupported("mkv")))
|
||||
}
|
||||
|
||||
@Test(
|
||||
"Nicht unterstützter Ton geht an VLC",
|
||||
arguments: ["dts", "dca", "truehd", "vorbis", "opus"]
|
||||
)
|
||||
func unsupportedAudioFallsBackToVLC(codec: String) {
|
||||
let (media, part) = media(container: "mp4", video: "h264", audio: codec)
|
||||
let decision = PlaybackDecider().decide(media: media, part: part, policy: .directPlayOnly)
|
||||
|
||||
#expect(decision.method == .directPlay(engine: .vlc))
|
||||
#expect(decision.rationale.contains(.audioCodecUnsupported(codec)))
|
||||
}
|
||||
|
||||
@Test(
|
||||
"Nicht unterstütztes Bild geht an VLC",
|
||||
arguments: ["av1", "vp9", "mpeg2video", "vc1", "wmv3"]
|
||||
)
|
||||
func unsupportedVideoFallsBackToVLC(codec: String) {
|
||||
let (media, part) = media(container: "mp4", video: codec, audio: "aac")
|
||||
let decision = PlaybackDecider().decide(media: media, part: part, policy: .directPlayOnly)
|
||||
|
||||
#expect(decision.method == .directPlay(engine: .vlc))
|
||||
#expect(decision.rationale.contains(.videoCodecUnsupported(codec)))
|
||||
}
|
||||
|
||||
@Test("Die gewählte Tonspur zählt, nicht die erste beste")
|
||||
func decisionFollowsSelectedAudioTrack() {
|
||||
// Deutsches AAC ist ausgewählt, englisches DTS liegt daneben. Würde
|
||||
// der Decider nur die erste Tonspur ansehen, ginge diese Datei
|
||||
// unnötig an VLC — und damit ohne Bild-in-Bild.
|
||||
let streams = [
|
||||
PlexStream(id: 1, streamType: 1, codec: "h264"),
|
||||
PlexStream(id: 2, streamType: 2, codec: "dts", language: "English"),
|
||||
PlexStream(id: 3, streamType: 2, codec: "aac", language: "Deutsch", selected: true),
|
||||
]
|
||||
let part = PlexPart(id: 10, key: "/p", container: "mp4", streams: streams)
|
||||
let media = PlexMedia(id: 100, container: "mp4", videoCodec: "h264", audioCodec: "dts", parts: [part])
|
||||
|
||||
let decision = PlaybackDecider().decide(media: media, part: part, policy: .directPlayOnly)
|
||||
#expect(decision.method == .directPlay(engine: .avPlayer))
|
||||
}
|
||||
|
||||
@Test("Unanalysierte Dateien gehen an den toleranteren Motor")
|
||||
func unknownStreamsFallBackToVLC() {
|
||||
let part = PlexPart(id: 10, key: "/p", container: "mp4", streams: [])
|
||||
let media = PlexMedia(id: 100, container: "mp4", parts: [part])
|
||||
|
||||
let decision = PlaybackDecider().decide(media: media, part: part, policy: .directPlayOnly)
|
||||
#expect(decision.method == .directPlay(engine: .vlc))
|
||||
#expect(decision.rationale.contains(.streamsUnknown))
|
||||
}
|
||||
|
||||
// MARK: - Die Bandbreitengrenze
|
||||
|
||||
@Test("Über der Grenze wird umgerechnet, sobald das erlaubt ist")
|
||||
func exceedingBitrateTranscodesWhenAllowed() {
|
||||
let (media, part) = media(container: "mp4", video: "h264", audio: "aac", bitrate: 80_000)
|
||||
let policy = PlaybackPolicy(allowsTranscoding: true, maximumBitrate: 8_000)
|
||||
|
||||
let decision = PlaybackDecider().decide(media: media, part: part, policy: policy)
|
||||
|
||||
#expect(decision.method == .transcode(maximumBitrate: 8_000))
|
||||
#expect(decision.rationale.contains(.bitrateExceedsLimit(mediaBitrate: 80_000, limit: 8_000)))
|
||||
}
|
||||
|
||||
@Test("Ohne Transcoder wird es trotzdem versucht — und der Grund vermerkt")
|
||||
func exceedingBitrateStillPlaysInPhaseOne() {
|
||||
// Das ist die Lage in Phase 1. Abbrechen wäre falsch: im WLAN läuft
|
||||
// auch ein 80-Mbit-Remux, die Grenze ist nur eine Vorsichtsmaßnahme.
|
||||
let (media, part) = media(container: "mp4", video: "h264", audio: "aac", bitrate: 80_000)
|
||||
let policy = PlaybackPolicy(allowsTranscoding: false, maximumBitrate: 8_000)
|
||||
|
||||
let decision = PlaybackDecider().decide(media: media, part: part, policy: policy)
|
||||
|
||||
#expect(decision.isDirectPlay)
|
||||
#expect(decision.rationale.contains(.transcodingUnavailable))
|
||||
}
|
||||
|
||||
@Test("Unter der Grenze bleibt alles beim Direktabspielen")
|
||||
func withinBitrateLimitPlaysDirectly() {
|
||||
let (media, part) = media(container: "mp4", video: "h264", audio: "aac", bitrate: 4_000)
|
||||
let policy = PlaybackPolicy(allowsTranscoding: true, maximumBitrate: 8_000)
|
||||
|
||||
let decision = PlaybackDecider().decide(media: media, part: part, policy: policy)
|
||||
#expect(decision.method == .directPlay(engine: .avPlayer))
|
||||
}
|
||||
|
||||
@Test("Ohne VLC übernimmt der Transcoder")
|
||||
func withoutFallbackEngineTranscodingTakesOver() {
|
||||
let (media, part) = media(container: "mkv", video: "h264", audio: "dts")
|
||||
let policy = PlaybackPolicy(allowsTranscoding: true, allowsFallbackEngine: false)
|
||||
|
||||
let decision = PlaybackDecider().decide(media: media, part: part, policy: policy)
|
||||
#expect(decision.method == .transcode(maximumBitrate: nil))
|
||||
}
|
||||
|
||||
@Test("Der Transcode-Weg läuft über AVPlayer")
|
||||
func transcodingUsesAVPlayer() {
|
||||
// HLS ist genau die Kost, für die AVPlayer gebaut ist — inklusive
|
||||
// automatischem Bitratenwechsel. Deshalb darf VLC hier nicht greifen.
|
||||
let (media, part) = media(container: "mkv", video: "h264", audio: "dts")
|
||||
let policy = PlaybackPolicy(allowsTranscoding: true, allowsFallbackEngine: false)
|
||||
|
||||
let decision = PlaybackDecider().decide(media: media, part: part, policy: policy)
|
||||
#expect(decision.engine == .avPlayer)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
# Antwort-Vorlagen
|
||||
|
||||
Diese Dateien bilden das Antwortformat von Plex nach. Sie sind **von Hand
|
||||
geschrieben**, nicht von einem laufenden Server mitgeschnitten — es stand bei
|
||||
der Entstehung keiner zur Verfügung.
|
||||
|
||||
Sie decken das Format ab, nicht die Wirklichkeit eines gewachsenen Bestands.
|
||||
Sobald ein Server erreichbar ist, gehören sie durch echte Mitschnitte ersetzt:
|
||||
|
||||
curl -H "Accept: application/json" \
|
||||
"http://<server>:32400/library/sections/1/all?X-Plex-Token=<token>" \
|
||||
> movies-section.json
|
||||
|
||||
Erfahrungsgemäß tauchen dabei Fälle auf, die man sich nicht ausdenkt — fehlende
|
||||
`Media`-Blöcke bei noch nicht analysierten Dateien, Titel ohne `year`, Folgen
|
||||
ohne `grandparentThumb`. Genau dafür sind die Vorlagen da.
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"MediaContainer": {
|
||||
"size": 2,
|
||||
"title1": "Serien",
|
||||
"title2": "Staffel 1",
|
||||
"Metadata": [
|
||||
{
|
||||
"ratingKey": "77801",
|
||||
"key": "/library/metadata/77801",
|
||||
"type": "episode",
|
||||
"title": "Die erste Folge",
|
||||
"index": 1,
|
||||
"parentIndex": 1,
|
||||
"parentTitle": "Staffel 1",
|
||||
"parentRatingKey": "77800",
|
||||
"grandparentTitle": "Eine Serie",
|
||||
"grandparentRatingKey": "77700",
|
||||
"grandparentThumb": "/library/metadata/77700/thumb/1699000000",
|
||||
"duration": 2760000,
|
||||
"addedAt": 1699000000,
|
||||
"Media": [
|
||||
{
|
||||
"id": 90001,
|
||||
"container": "mkv",
|
||||
"Part": [
|
||||
{ "id": 95001, "key": "/library/parts/95001/1699000000/file.mkv", "container": "mkv" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"ratingKey": "77802",
|
||||
"key": "/library/metadata/77802",
|
||||
"type": "episode",
|
||||
"title": "Die zweite Folge",
|
||||
"index": 2,
|
||||
"parentIndex": 1,
|
||||
"grandparentTitle": "Eine Serie",
|
||||
"duration": 2820000,
|
||||
"addedAt": 1699000100
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"MediaContainer": {
|
||||
"size": 1,
|
||||
"totalSize": 1,
|
||||
"offset": 0,
|
||||
"title1": "Filme",
|
||||
"title2": "Zuletzt hinzugefügt",
|
||||
"Metadata": [
|
||||
{
|
||||
"ratingKey": "45231",
|
||||
"key": "/library/metadata/45231",
|
||||
"guid": "plex://movie/5d776b59ad5437001f79c36d",
|
||||
"type": "movie",
|
||||
"title": "Blade Runner 2049",
|
||||
"titleSort": "Blade Runner 2049",
|
||||
"summary": "Dreißig Jahre nach den Ereignissen des ersten Films.",
|
||||
"year": 2017,
|
||||
"duration": 9784000,
|
||||
"contentRating": "de/16",
|
||||
"rating": 8.0,
|
||||
"audienceRating": 8.1,
|
||||
"thumb": "/library/metadata/45231/thumb/1699887654",
|
||||
"art": "/library/metadata/45231/art/1699887654",
|
||||
"viewCount": 1,
|
||||
"viewOffset": 4892000,
|
||||
"lastViewedAt": 1699900000,
|
||||
"addedAt": 1699887654,
|
||||
"Media": [
|
||||
{
|
||||
"id": 88121,
|
||||
"container": "mkv",
|
||||
"videoCodec": "hevc",
|
||||
"audioCodec": "truehd",
|
||||
"videoResolution": "4k",
|
||||
"videoProfile": "main 10",
|
||||
"bitrate": 58420,
|
||||
"width": 3840,
|
||||
"height": 2160,
|
||||
"audioChannels": 8,
|
||||
"duration": 9784000,
|
||||
"Part": [
|
||||
{
|
||||
"id": 91002,
|
||||
"key": "/library/parts/91002/1699887654/file.mkv",
|
||||
"container": "mkv",
|
||||
"size": 71453298112,
|
||||
"duration": 9784000,
|
||||
"file": "/media/Filme/Blade Runner 2049 (2017)/Blade Runner 2049 2160p.mkv",
|
||||
"Stream": [
|
||||
{ "id": 310021, "streamType": 1, "codec": "hevc", "bitDepth": 10 },
|
||||
{ "id": 310022, "streamType": 2, "codec": "truehd", "language": "Deutsch", "languageCode": "deu", "channels": 8, "displayTitle": "Deutsch (TrueHD 7.1)", "selected": true },
|
||||
{ "id": 310023, "streamType": 2, "codec": "eac3", "language": "English", "languageCode": "eng", "channels": 6, "displayTitle": "English (EAC3 5.1)" },
|
||||
{ "id": 310024, "streamType": 3, "codec": "subrip", "language": "Deutsch", "languageCode": "deu", "key": "/library/streams/310024", "displayTitle": "Deutsch (SRT)" },
|
||||
{ "id": 310025, "streamType": 3, "codec": "pgs", "language": "English", "languageCode": "eng", "forced": true, "displayTitle": "English (Forced)" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 88122,
|
||||
"container": "mp4",
|
||||
"videoCodec": "h264",
|
||||
"audioCodec": "aac",
|
||||
"videoResolution": "1080",
|
||||
"bitrate": 8240,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"audioChannels": 2,
|
||||
"duration": 9784000,
|
||||
"Part": [
|
||||
{
|
||||
"id": 91003,
|
||||
"key": "/library/parts/91003/1699887655/file.mp4",
|
||||
"container": "mp4",
|
||||
"size": 10083446784,
|
||||
"duration": 9784000,
|
||||
"file": "/media/Filme/Blade Runner 2049 (2017)/Blade Runner 2049 1080p.mp4",
|
||||
"Stream": [
|
||||
{ "id": 310031, "streamType": 1, "codec": "h264" },
|
||||
{ "id": 310032, "streamType": 2, "codec": "aac", "language": "Deutsch", "languageCode": "deu", "channels": 2, "selected": true }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import PlexKit
|
||||
|
||||
/// Lädt eine Antwort-Vorlage aus dem Test-Bundle.
|
||||
private func fixture(_ name: String) throws -> Data {
|
||||
let url = try #require(
|
||||
Bundle.module.url(forResource: "Fixtures/\(name)", withExtension: "json"),
|
||||
"Vorlage \(name).json nicht im Bundle — steht sie in Package.swift unter resources?"
|
||||
)
|
||||
return try Data(contentsOf: url)
|
||||
}
|
||||
|
||||
@Suite("Antworten lesen")
|
||||
struct PlexDecodingTests {
|
||||
|
||||
@Test("Ein Film mit zwei Fassungen wird vollständig gelesen")
|
||||
func decodesMovieWithTwoVersions() throws {
|
||||
let container = try PlexResponse.decode(
|
||||
PlexMediaContainer<PlexItem>.self,
|
||||
from: try fixture("movie-with-two-versions")
|
||||
)
|
||||
|
||||
let movie = try #require(container.content.first)
|
||||
#expect(movie.ratingKey == "45231")
|
||||
#expect(movie.kind == .movie)
|
||||
#expect(movie.title == "Blade Runner 2049")
|
||||
#expect(movie.year == 2017)
|
||||
#expect(movie.hasMultipleVersions)
|
||||
#expect(movie.media.count == 2)
|
||||
}
|
||||
|
||||
@Test("Die Fassung mit der höchsten Bitrate wird vorgewählt")
|
||||
func prefersHighestBitrateVersion() throws {
|
||||
let container = try PlexResponse.decode(
|
||||
PlexMediaContainer<PlexItem>.self,
|
||||
from: try fixture("movie-with-two-versions")
|
||||
)
|
||||
let movie = try #require(container.content.first)
|
||||
let preferred = try #require(movie.preferredMedia)
|
||||
|
||||
#expect(preferred.videoResolution == "4k")
|
||||
#expect(preferred.bitrate == 58420)
|
||||
}
|
||||
|
||||
@Test("Der Fortschritt wird als Anteil berechnet")
|
||||
func computesWatchProgress() throws {
|
||||
let container = try PlexResponse.decode(
|
||||
PlexMediaContainer<PlexItem>.self,
|
||||
from: try fixture("movie-with-two-versions")
|
||||
)
|
||||
let movie = try #require(container.content.first)
|
||||
let progress = try #require(movie.watchProgress)
|
||||
|
||||
// 4 892 000 von 9 784 000 Millisekunden — genau die Hälfte.
|
||||
#expect(abs(progress - 0.5) < 0.001)
|
||||
}
|
||||
|
||||
@Test("Spuren werden nach Art getrennt")
|
||||
func separatesStreamsByKind() throws {
|
||||
let container = try PlexResponse.decode(
|
||||
PlexMediaContainer<PlexItem>.self,
|
||||
from: try fixture("movie-with-two-versions")
|
||||
)
|
||||
let part = try #require(container.content.first?.preferredMedia?.parts.first)
|
||||
|
||||
#expect(part.videoStream?.codec == "hevc")
|
||||
#expect(part.audioStreams.count == 2)
|
||||
#expect(part.subtitleStreams.count == 2)
|
||||
#expect(part.selectedAudioStream?.language == "Deutsch")
|
||||
}
|
||||
|
||||
@Test("Untertitel als eigene Datei werden erkannt")
|
||||
func detectsExternalSubtitles() throws {
|
||||
let container = try PlexResponse.decode(
|
||||
PlexMediaContainer<PlexItem>.self,
|
||||
from: try fixture("movie-with-two-versions")
|
||||
)
|
||||
let part = try #require(container.content.first?.preferredMedia?.parts.first)
|
||||
let subtitles = part.subtitleStreams
|
||||
|
||||
// Die SRT-Spur liegt als Datei daneben und lässt sich deshalb auch bei
|
||||
// DirectPlay dazuschalten; die PGS-Spur steckt im Container.
|
||||
#expect(subtitles.first { $0.codec == "subrip" }?.isExternal == true)
|
||||
#expect(subtitles.first { $0.codec == "pgs" }?.isExternal == false)
|
||||
#expect(subtitles.first { $0.codec == "pgs" }?.isForced == true)
|
||||
}
|
||||
|
||||
@Test("Folgen ohne analysierte Datei brechen das Lesen nicht ab")
|
||||
func toleratesUnanalyzedEpisodes() throws {
|
||||
// Der wichtigste Fall aus der Praxis: Plex liefert Einträge aus, bevor
|
||||
// es die Dateien durchgesehen hat. Ein nicht-optionales Feld würde
|
||||
// hier die ganze Staffel unlesbar machen statt nur eine Angabe.
|
||||
let container = try PlexResponse.decode(
|
||||
PlexMediaContainer<PlexItem>.self,
|
||||
from: try fixture("episodes-unanalyzed")
|
||||
)
|
||||
|
||||
#expect(container.content.count == 2)
|
||||
|
||||
let first = try #require(container.content.first)
|
||||
#expect(first.media.first?.parts.first?.streams.isEmpty == true)
|
||||
|
||||
// Die zweite Folge hat gar keinen `Media`-Block.
|
||||
let second = try #require(container.content.last)
|
||||
#expect(second.media.isEmpty)
|
||||
#expect(second.title == "Die zweite Folge")
|
||||
#expect(second.index == 2)
|
||||
}
|
||||
|
||||
@Test("Serienzugehörigkeit von Folgen wird gelesen")
|
||||
func decodesEpisodeHierarchy() throws {
|
||||
let container = try PlexResponse.decode(
|
||||
PlexMediaContainer<PlexItem>.self,
|
||||
from: try fixture("episodes-unanalyzed")
|
||||
)
|
||||
let episode = try #require(container.content.first)
|
||||
|
||||
#expect(episode.kind == .episode)
|
||||
#expect(episode.index == 1)
|
||||
#expect(episode.parentIndex == 1)
|
||||
#expect(episode.grandparentTitle == "Eine Serie")
|
||||
#expect(episode.grandparentRatingKey == "77700")
|
||||
}
|
||||
|
||||
@Test("Ungesehene Titel haben keinen Fortschritt")
|
||||
func unwatchedItemsHaveNoProgress() throws {
|
||||
let container = try PlexResponse.decode(
|
||||
PlexMediaContainer<PlexItem>.self,
|
||||
from: try fixture("episodes-unanalyzed")
|
||||
)
|
||||
// Ohne diese Unterscheidung bekäme jede ungesehene Kachel einen
|
||||
// Fortschrittsbalken der Breite null — sichtbarer Strich, keine Aussage.
|
||||
#expect(container.content.allSatisfy { $0.watchProgress == nil })
|
||||
#expect(container.content.allSatisfy { !$0.isWatched })
|
||||
}
|
||||
|
||||
@Test("Kaputte Antworten melden, woran es lag")
|
||||
func decodingErrorsCarryDetail() {
|
||||
let garbage = Data(#"{"MediaContainer":{"Metadata":[{"title":"ohne ratingKey"}]}}"#.utf8)
|
||||
|
||||
#expect(throws: PlexError.self) {
|
||||
try PlexResponse.decode(PlexMediaContainer<PlexItem>.self, from: garbage)
|
||||
}
|
||||
|
||||
do {
|
||||
_ = try PlexResponse.decode(PlexMediaContainer<PlexItem>.self, from: garbage)
|
||||
Issue.record("Hätte scheitern müssen")
|
||||
} catch let error as PlexError {
|
||||
guard case .decoding(let description) = error else {
|
||||
Issue.record("Falsche Fehlerart: \(error)")
|
||||
return
|
||||
}
|
||||
// Der Schlüsselpfad muss drinstehen — sonst ist beim Nachziehen
|
||||
// einer geänderten Plex-Version nicht zu erkennen, welches Feld
|
||||
// gewandert ist.
|
||||
#expect(description.contains("ratingKey"))
|
||||
} catch {
|
||||
Issue.record("Unerwarteter Fehler: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("HTTP-Status übersetzen")
|
||||
struct PlexResponseValidationTests {
|
||||
private func response(_ status: Int) -> HTTPURLResponse {
|
||||
HTTPURLResponse(
|
||||
url: URL(string: "http://server:32400/library/sections")!,
|
||||
statusCode: status,
|
||||
httpVersion: nil,
|
||||
headerFields: nil
|
||||
)!
|
||||
}
|
||||
|
||||
@Test("Erfolgsstatus geben die Daten durch", arguments: [200, 201, 204])
|
||||
func successPassesDataThrough(status: Int) throws {
|
||||
let payload = Data("inhalt".utf8)
|
||||
#expect(try PlexResponse.validate(data: payload, response: response(status)) == payload)
|
||||
}
|
||||
|
||||
@Test("401 und 403 führen zurück zur Anmeldung", arguments: [401, 403])
|
||||
func authenticationFailuresMapToUnauthorized(status: Int) {
|
||||
#expect(throws: PlexError.unauthorized) {
|
||||
try PlexResponse.validate(data: Data(), response: response(status))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("404 ist ein fehlender Eintrag")
|
||||
func notFoundMaps() {
|
||||
#expect(throws: PlexError.notFound) {
|
||||
try PlexResponse.validate(data: Data(), response: response(404))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Serverfehler dürfen wiederholt werden, Anfragefehler nicht")
|
||||
func retryabilityFollowsStatusClass() {
|
||||
#expect(PlexError.server(status: 503).isRetryable)
|
||||
#expect(PlexError.server(status: 500).isRetryable)
|
||||
#expect(!PlexError.server(status: 400).isRetryable)
|
||||
#expect(!PlexError.unauthorized.isRetryable)
|
||||
#expect(PlexError.transport(description: "Zeitüberschreitung").isRetryable)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user