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:
Guido
2026-09-11 16:04:40 +02:00
co-authored by Claude Opus 5
commit 1ed64bf166
54 changed files with 3584 additions and 0 deletions
@@ -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)
}
}