Phase 1: Anmeldung, Bibliothek, Detailseiten, Hybrid-Player

Die Datenschicht:
- plex.tv-Anmeldung über das PIN-Verfahren, Token im Schlüsselbund
- Serversuche und Verbindungsauswahl nach Rang (lokal vor direkt vor
  Vermittlung), mit Abgleich der Server-Kennung gegen veraltete
  plex.direct-Adressen
- PlexServer als einzige Stelle, an der die App Endpunkte kennt:
  Mediatheken, Hubs, seitenweises Blättern mit Filtern, Metadaten,
  Staffeln/Folgen, Suche

Die Oberfläche:
- Startseite mit Aufmacher und den Reihen vom Server
- Mediathek als Raster oder Liste, mit Filter- und Sortierblatt
- Detailseiten für Filme, Folgen, Serien und Staffeln inkl. Besetzung,
  Fassungsauswahl und aufklappbaren technischen Angaben
- Suche mit gemerkten Anfragen
- Vollbildplayer mit eigenem Chrome

Der Player:
- AVPlayerEngine und VLCPlayerEngine hinter einem gemeinsamen Protokoll
- VLCKit liegt in einem eigenen Package (FlixyVLC), damit `swift test`
  in FlixyKit nicht das 920-MB-Binärpaket mitzieht

Am echten plex.tv geprüft: der Anmeldecode kommt an. Dabei fiel auf,
dass `strong=true` einen dreißigstelligen Code liefert, der sich bei
plex.tv/link nicht eintippen lässt — jetzt der kurze.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-09-11 16:46:28 +02:00
co-authored by Claude Opus 5
parent 1ed64bf166
commit 9a69df4635
44 changed files with 6311 additions and 138 deletions
@@ -0,0 +1,167 @@
import Foundation
import Testing
@testable import PlexKit
@Suite("Verbindungsauswahl")
struct ConnectionSelectorTests {
private let serverID = "server-abc-123"
private func identityResponse(_ machineIdentifier: String) -> String {
#"{"MediaContainer":{"machineIdentifier":"\#(machineIdentifier)","version":"1.42.0"}}"#
}
private func resource(connections: [PlexConnection]) -> PlexResource {
PlexResource(
name: "Wohnzimmer",
clientIdentifier: serverID,
accessToken: "server-token",
connections: connections
)
}
@Test("Der lokale Weg gewinnt, auch wenn die Vermittlung erreichbar ist")
func localWinsOverRelay() async {
// Der eigentliche Zweck der Rangordnung: die Vermittlung von Plex
// antwortet im Zweifel schneller, ist aber gedrosselt. Ein Rennen ohne
// Rang würde ausgerechnet den langsamsten Weg krönen.
let transport = FakeTransport.routes(["/identity": identityResponse(serverID)])
let selector = PlexConnectionSelector(identity: .test, transport: transport)
let chosen = await selector.selectConnection(for: resource(connections: [
PlexConnection(uri: "https://relay.plex.direct:443", relay: true),
PlexConnection(uri: "http://192.168.1.10:32400", local: true),
PlexConnection(uri: "https://1-2-3-4.abc.plex.direct:32400"),
]))
#expect(chosen?.uri == "http://192.168.1.10:32400")
}
@Test("Der direkte Weg von außen kommt vor der Vermittlung")
func directRemoteBeatsRelay() async {
let transport = FakeTransport.routes(["/identity": identityResponse(serverID)])
let selector = PlexConnectionSelector(identity: .test, transport: transport)
let chosen = await selector.selectConnection(for: resource(connections: [
PlexConnection(uri: "https://relay.plex.direct:443", relay: true),
PlexConnection(uri: "https://1-2-3-4.abc.plex.direct:32400"),
]))
#expect(chosen?.uri == "https://1-2-3-4.abc.plex.direct:32400")
}
@Test("Ein Server mit fremder Kennung wird abgelehnt")
func rejectsWrongServer() async {
// Eine veraltete plex.direct-Adresse kann auf einen fremden Server
// zeigen. Ohne Abgleich der Kennung zeigte die App fremde Mediatheken.
let transport = FakeTransport.routes(["/identity": identityResponse("ein-anderer-server")])
let selector = PlexConnectionSelector(identity: .test, transport: transport)
let chosen = await selector.selectConnection(for: resource(connections: [
PlexConnection(uri: "http://192.168.1.10:32400", local: true),
]))
#expect(chosen == nil)
}
@Test("Ohne erreichbaren Weg gibt es kein Ergebnis")
func noReachableConnection() async {
let selector = PlexConnectionSelector(identity: .test, transport: FakeTransport.unreachable())
let chosen = await selector.selectConnection(for: resource(connections: [
PlexConnection(uri: "http://192.168.1.10:32400", local: true),
PlexConnection(uri: "https://relay.plex.direct:443", relay: true),
]))
#expect(chosen == nil)
}
@Test("Ohne Server-Token wird gar nicht erst probiert")
func missingAccessTokenFailsFast() async {
// Bei geteilten Servern liefert plex.tv gelegentlich keinen Token —
// etwa wenn die Freigabe zurückgezogen wurde. Anfragen ohne ihn
// beantwortet der Server wortlos mit 401.
let selector = PlexConnectionSelector(identity: .test, transport: FakeTransport.unreachable())
let resource = PlexResource(
name: "Geteilt",
clientIdentifier: serverID,
accessToken: nil,
connections: [PlexConnection(uri: "http://192.168.1.10:32400", local: true)]
)
#expect(await selector.selectConnection(for: resource) == nil)
}
@Test("Die Rangordnung der Wege stimmt")
func tierOrdering() {
#expect(PlexConnection(uri: "x", local: true).tier == 0)
#expect(PlexConnection(uri: "x").tier == 1)
#expect(PlexConnection(uri: "x", relay: true).tier == 2)
}
}
@Suite("Serverliste")
struct ServerDirectoryTests {
private let resourcesJSON = """
[
{
"name": "Wohnzimmer",
"product": "Plex Media Server",
"clientIdentifier": "server-abc",
"provides": "server",
"owned": true,
"accessToken": "token-eigen",
"connections": [
{ "uri": "http://192.168.1.10:32400", "address": "192.168.1.10", "port": 32400, "local": true, "relay": false, "protocol": "http", "IPv6": false },
{ "uri": "https://1-2-3-4.abc.plex.direct:32400", "local": false, "relay": false, "protocol": "https" }
]
},
{
"name": "iPhone von Guido",
"clientIdentifier": "client-xyz",
"provides": "client,player",
"owned": true,
"connections": []
},
{
"name": "Server eines Freundes",
"clientIdentifier": "server-def",
"provides": "server",
"owned": false,
"accessToken": "token-geteilt",
"connections": [
{ "uri": "https://5-6-7-8.def.plex.direct:32400", "local": false, "relay": false }
]
}
]
"""
@Test("Nur Server erscheinen in der Liste, keine Abspielgeräte")
func filtersOutNonServers() async throws {
// Das Konto listet auch jedes iPhone und jeden Fernseher, der je
// angemeldet war. Ungefiltert stünden sie alle in der Serverauswahl.
let directory = PlexServerDirectory(
identity: .test,
token: "konto-token",
transport: FakeTransport.routes(["/api/v2/resources": resourcesJSON])
)
let servers = try await directory.servers()
#expect(servers.count == 2)
#expect(!servers.contains { $0.clientIdentifier == "client-xyz" })
}
@Test("Geteilte Server behalten ihren eigenen Token")
func sharedServersKeepTheirOwnToken() async throws {
let directory = PlexServerDirectory(
identity: .test,
token: "konto-token",
transport: FakeTransport.routes(["/api/v2/resources": resourcesJSON])
)
let servers = try await directory.servers()
let shared = try #require(servers.first { !$0.owned })
#expect(shared.accessToken == "token-geteilt")
#expect(shared.accessToken != "konto-token")
}
}
@@ -0,0 +1,100 @@
import Foundation
import Testing
@testable import PlexKit
@Suite("Mediatheks-Abfragen")
struct LibraryQueryTests {
private func value(_ items: [URLQueryItem], _ name: String) -> String? {
items.first { $0.name == name }?.value
}
@Test("Eine Serienmediathek liefert Serien, nicht einzelne Folgen")
func showLibraryRequestsShows() {
// Ohne `type=2` schüttet Plex alle Folgen einzeln aus — bei einer
// Serienmediathek sind das schnell zehntausend Einträge statt dreißig
// Serien.
let items = LibraryQuery().queryItems(libraryKind: .show)
#expect(value(items, "type") == "2")
}
@Test("Eine Filmmediathek fragt nach Filmen")
func movieLibraryRequestsMovies() {
#expect(value(LibraryQuery().queryItems(libraryKind: .movie), "type") == "1")
}
@Test("Unbekannte Mediathekenarten bekommen keine Artangabe")
func unknownKindOmitsType() {
#expect(value(LibraryQuery().queryItems(libraryKind: nil), "type") == nil)
}
@Test("Sortierung landet immer in der Abfrage")
func sortIsAlwaysPresent() {
for sort in LibraryQuery.Sort.allCases {
let items = LibraryQuery(sort: sort).queryItems(libraryKind: .movie)
#expect(value(items, "sort") == sort.rawValue)
}
}
@Test("Ohne Filter stehen keine Filterparameter in der Abfrage")
func unsetFiltersAreOmitted() {
// Ein leerer Parameter ist für Plex nicht dasselbe wie ein fehlender:
// `genre=` filtert auf ein Genre ohne Namen und liefert nichts.
let items = LibraryQuery().queryItems(libraryKind: .movie)
#expect(value(items, "genre") == nil)
#expect(value(items, "year") == nil)
#expect(value(items, "unwatched") == nil)
#expect(value(items, "resolution") == nil)
}
@Test("Gesetzte Filter landen vollständig in der Abfrage")
func setFiltersAppear() {
let query = LibraryQuery(
sort: .recentlyAdded,
unwatchedOnly: true,
genre: "18",
year: 2017,
resolution: "4k"
)
let items = query.queryItems(libraryKind: .movie)
#expect(value(items, "sort") == "addedAt:desc")
#expect(value(items, "unwatched") == "1")
#expect(value(items, "genre") == "18")
#expect(value(items, "year") == "2017")
#expect(value(items, "resolution") == "4k")
}
@Test("Der Filterknopf weiß, ob gefiltert wird")
func filteringFlagReflectsState() {
#expect(!LibraryQuery().isFiltering)
// Die Sortierung allein zählt nicht als Filter — sonst wäre der Knopf
// dauerhaft hervorgehoben.
#expect(!LibraryQuery(sort: .rating).isFiltering)
#expect(LibraryQuery(unwatchedOnly: true).isFiltering)
#expect(LibraryQuery(genre: "18").isFiltering)
}
}
@Suite("Seitenweises Nachladen")
struct PlexPageTests {
@Test("Eine volle Seite kündigt Nachschub an")
func fullPageHasMore() {
let page = PlexPage(items: Array(0..<60), offset: 0, totalSize: 250)
#expect(page.hasMore)
#expect(page.nextOffset == 60)
}
@Test("Die letzte Seite kündigt keinen Nachschub an")
func lastPageHasNoMore() {
// Genau die Bedingung, die sonst zu einer Endlosschleife führt: die
// Ansicht lädt nach, bekommt nichts, lädt wieder nach.
let page = PlexPage(items: Array(0..<10), offset: 240, totalSize: 250)
#expect(!page.hasMore)
}
@Test("Eine leere Mediathek verlangt kein Nachladen")
func emptyLibraryHasNoMore() {
let page = PlexPage(items: [Int](), offset: 0, totalSize: 0)
#expect(!page.hasMore)
}
}
@@ -0,0 +1,57 @@
import Foundation
@testable import PlexKit
/// Ein Transport, der vorbereitete Antworten liefert.
///
/// Statt `URLProtocol` zu unterwandern: das ist global und bringt bei parallel
/// laufenden Tests die Antworten durcheinander. Hier hat jeder Test seinen
/// eigenen Transport.
struct FakeTransport: PlexTransport {
let handler: @Sendable (URLRequest) async throws -> (Data, URLResponse)
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
try await handler(request)
}
/// Antwortet auf jeden Pfad, der als Schlüssel hinterlegt ist, und mit 404
/// auf alle übrigen.
static func routes(
_ routes: [String: String],
status: Int = 200
) -> FakeTransport {
FakeTransport { request in
let path = request.url?.path() ?? ""
guard let body = routes.first(where: { path.hasSuffix($0.key) })?.value else {
return (Data(), response(for: request, status: 404))
}
return (Data(body.utf8), response(for: request, status: status))
}
}
/// Verweigert jede Verbindung — für die Wege, die im Test nicht gewinnen
/// sollen.
static func unreachable() -> FakeTransport {
FakeTransport { _ in throw URLError(.cannotConnectToHost) }
}
static func response(for request: URLRequest, status: Int) -> HTTPURLResponse {
HTTPURLResponse(
url: request.url ?? URL(string: "http://example.invalid")!,
statusCode: status,
httpVersion: nil,
headerFields: nil
)!
}
}
extension PlexClientIdentity {
static let test = PlexClientIdentity(
product: "FlixyTests",
version: "0",
clientIdentifier: "test-client",
platform: "iOS",
platformVersion: "26.0",
device: "iPhone",
deviceName: "Testgerät"
)
}