`FlaroServerKit` steht neben `JellyfinKit`, nicht darin — genau auf dem Steckplatz, den Plex geräumt hat. Dass `MediaBackend` zwei Server zugleich trägt, war schon einmal bewiesen; die Naht hat gehalten, und keine einzige Ansicht musste dafür neu geschrieben werden. `ServerKind` ist zurück. Es wurde beim Entfernen von Plex gestrichen, weil ein Aufzählungstyp mit einem Fall ein umständliches Bool ist. Jetzt gibt es wieder zwei, und es ist wieder eine Unterscheidung — einer zur Zeit, nicht beide nebeneinander. **Flaro steht oben auf dem Anmeldebildschirm.** Nicht aus Eitelkeit: dort bringt ein Download alle Tonspuren mit und der Fortschritt wird ab der ersten Sekunde gemerkt. Wer Jellyfin benutzt, sucht gezielt danach — wer neu anfängt, soll nicht in der schwächeren Hälfte landen, weil ihr Knopf auffälliger war. Am Fernseher steht bei beiden der sechsstellige Code voran. --- **Der Zugang geht als Cookie, nicht in der Adresse.** Jellyfin hängt sein Token an jede Bildadresse, weil eine Bildansicht keine Kopfzeilen schickt; es steht damit in jedem Zugriffsprotokoll und jedem weitergegebenen Link. Flaro tut das nicht, und deshalb legt der Client sein Token selbst im gemeinsamen Keksspeicher ab — auch beim Wiederaufnehmen aus dem Schlüsselbund, wo gar keine Anmeldung stattfindet und der Server es nie setzen könnte. `URLSession` schickt es von allein mit; **AVFoundation nicht**. Der Systemplayer bekommt die Kekse jetzt ausdrücklich. Serverneutral: wo keine liegen, ist die Liste leer. **`BackendCapability.allTracksDownload`** statt einer Spurwahl, die ins Leere führt. Die Frage „welche Tonspur?" gibt es nur, weil Jellyfins Transcoder eine je Sitzung abbildet. Bei Flaro führen beide Antworten zum selben Ergebnis — also wird nicht gefragt, und das Umpacken mit einer einzigen Spur entfällt gleich mit. **Die Adressraterei wandert nach `MediaKit`.** Sechzig Zeilen sorgfältig begründeter Reihenfolge — IP-Adressen sprechen selten HTTPS, Domänennamen fast immer — unterscheiden sich zwischen beiden Servern nur im Standardport. Zweimal hingeschrieben wäre die eine Fassung beim nächsten Sonderfall nachgezogen worden und die andere nicht. Jellyfins Prüfungen laufen unverändert gegen die verschobene Fassung; das ist der Beweis. **Nebenbei zwei Altlasten:** `player.jellyfinPending` war unerreichbar — der Player zeigt diese Meldung nur, wenn gar kein Server da ist, dann aber nie die mit Jellyfin. Und `signIn.jellyfin.userName` hiess „Benutzername"; der Schlüssel heisst jetzt so neutral wie der Text. --- **Der Fehler, den erst der Simulator zeigte:** „Stelle wird wiederhergestellt", 0:00, für immer. Muss der Server umpacken, antwortet er auf den Download mit 202 und fängt an. AVPlayer bekommt darauf kein Video, sondern eine leere Antwort — und wartet, unbegrenzt, ohne Fehler. Die Weboberfläche fragt in einer Schleife nach; `preparePlayback` tat es nicht. Jetzt wartet es, mit Obergrenze und einer Begründung, wenn sie reisst. Mit Prüfung, die zwei Runden erzwingt. 413 Tests grün. Im Simulator gegen den echten Server nachgewiesen: Adresse eintippen, Server gefunden, angemeldet, Mediathek mit Plakaten, Neustart ohne erneute Anmeldung — und eine MKV abgespielt, die der Server dafür erst umpacken musste. Danach stand auf dem Server: Matrix, 2,0 s von 2,0 s, gesehen, ein Durchlauf. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
151 lines
6.1 KiB
Swift
151 lines
6.1 KiB
Swift
import Foundation
|
|
import Testing
|
|
import MediaKit
|
|
@testable import FlaroServerKit
|
|
|
|
/// Adressen und Zugang — alles, was ohne Netz entsteht.
|
|
@Suite("Flaro: Adressen und Zugang")
|
|
struct AdressenTests {
|
|
private func backend(_ transport: Attrappe, keksglas: Keksglas? = nil) -> FlaroBackend {
|
|
FlaroBackend(
|
|
client: FlaroClient(
|
|
baseURL: Proben.adresse, identity: Proben.kennung, token: "geheim-123",
|
|
transport: transport, cookieJar: keksglas),
|
|
account: FlaroAccount(
|
|
baseURL: Proben.adresse, serverName: "Wohnzimmer",
|
|
userID: "u1", userName: "guido", isAdministrator: true))
|
|
}
|
|
|
|
/// **Kein Token in der Adresse — das ist der Unterschied zu Jellyfin.**
|
|
///
|
|
/// Dort steckt der Zugang als `?ApiKey=` in jeder Bildadresse, weil eine
|
|
/// Bildansicht keine Kopfzeilen schickt. Er steht damit in jedem
|
|
/// Zugriffsprotokoll, in jedem Verlauf und in jedem weitergegebenen Link.
|
|
/// Flaro geht über ein Cookie.
|
|
@Test("Bildadressen tragen keinen Zugang")
|
|
func bildAdresseOhneToken() {
|
|
let url = backend(Attrappe(rumpf: "{}")).rawImageURL(path: "abc123")
|
|
|
|
#expect(url?.path == "/api/v1/images/abc123")
|
|
#expect(url?.query == nil)
|
|
#expect(url?.absoluteString.contains("geheim") == false)
|
|
}
|
|
|
|
@Test("Ein Zugang wird als Cookie abgelegt")
|
|
func tokenWirdZuKeks() async {
|
|
let glas = Keksglas()
|
|
_ = FlaroClient(
|
|
baseURL: Proben.adresse, identity: Proben.kennung, token: "geheim-123",
|
|
transport: Attrappe(rumpf: "{}"), cookieJar: glas)
|
|
|
|
let keks = glas.kekse.first
|
|
#expect(keks?.name == "flaro_session")
|
|
#expect(keks?.value == "geheim-123")
|
|
#expect(keks?.path == "/")
|
|
#expect(keks?.domain.hasSuffix("nas") == true)
|
|
}
|
|
|
|
/// **Nach einem Neustart meldet sich niemand neu an.**
|
|
///
|
|
/// Die App hat dann nur das Token aus dem Schlüsselbund. Käme das Cookie
|
|
/// allein aus der Antwort des Servers, wäre keines da — und jedes Plakat
|
|
/// bliebe grau, obwohl der Zugang gilt.
|
|
@Test("Auch ein wiederaufgenommenes Token legt das Cookie an")
|
|
func keksAuchOhneAnmeldung() async throws {
|
|
let glas = Keksglas()
|
|
let transport = Attrappe([
|
|
("/api/v1/auth/me", 200, #"{"id":"u1","userName":"guido","isAdministrator":false}"#),
|
|
("/api/v1/system/info", 200, Proben.info),
|
|
])
|
|
|
|
_ = try await FlaroSignIn.restore(
|
|
baseURL: Proben.adresse, token: "geheim-123",
|
|
identity: Proben.kennung, transport: transport, cookieJar: glas)
|
|
|
|
#expect(glas.kekse.contains { $0.name == "flaro_session" && $0.value == "geheim-123" })
|
|
}
|
|
|
|
@Test("Die Download-Adresse zeigt auf die Datei, nicht auf den Titel")
|
|
func downloadAdresse() {
|
|
let url = backend(Attrappe(rumpf: "{}")).downloadURL(
|
|
for: MediaFile(id: "datei-1", itemID: "film-1"))
|
|
|
|
#expect(url?.path == "/api/v1/files/datei-1/download")
|
|
}
|
|
|
|
/// **Der Vertrag fragt nach einer Tonspur, Flaro braucht keine.**
|
|
///
|
|
/// `remuxDownloadURL` gibt es nur, weil Jellyfins Transcoder genau eine
|
|
/// Tonspur je Sitzung abbildet. Hier wäre eine Adresse mit Spurnummer
|
|
/// eine Lüge über das, was herauskommt — also gibt es keine, und die
|
|
/// Oberfläche erkennt an der Fähigkeit, dass sie nicht fragen muss.
|
|
@Test("Kein Umpacken mit Spurwahl — stattdessen die Fähigkeit")
|
|
func alleSpuren() {
|
|
let b = backend(Attrappe(rumpf: "{}"))
|
|
|
|
#expect(b.capabilities.contains(.allTracksDownload))
|
|
#expect(b.remuxDownloadURL(
|
|
itemID: "film-1", versionIndex: 0,
|
|
audioStreamIndex: 1, subtitleStreamIndex: nil) == nil)
|
|
}
|
|
|
|
/// Zwei Server dürfen „Medien" heissen; derselbe darf umbenannt werden.
|
|
@Test("Die Kennung hängt an der Adresse, nicht am Namen")
|
|
func kennung() {
|
|
#expect(backend(Attrappe(rumpf: "{}")).identity == "flaro:http://nas:8080")
|
|
}
|
|
|
|
@Test("Anmelden schickt Gerätename und Kennung mit")
|
|
func anmeldung() async throws {
|
|
let transport = Attrappe([
|
|
("/api/v1/system/info", 200, Proben.info),
|
|
("/api/v1/auth/login", 200, Proben.anmeldung),
|
|
])
|
|
|
|
let (token, konto, _) = try await FlaroSignIn.signIn(
|
|
baseURL: Proben.adresse, userName: "guido", password: "geheim",
|
|
identity: Proben.kennung, transport: transport, cookieJar: Keksglas())
|
|
|
|
let rumpf = try #require(transport.rumpf(von: "/api/v1/auth/login"))
|
|
|
|
#expect(token == "geheim-123")
|
|
#expect(konto.userName == "guido")
|
|
#expect(konto.isAdministrator)
|
|
#expect(konto.serverName == "Wohnzimmer")
|
|
#expect(rumpf["deviceName"] as? String == "Prüfgerät")
|
|
#expect(rumpf["deviceId"] as? String == "geraet-1")
|
|
}
|
|
|
|
/// **Ein frischer Server ist kein falsches Passwort.**
|
|
@Test("Ein nicht eingerichteter Server sagt das")
|
|
func nochNichtEingerichtet() async throws {
|
|
let transport = Attrappe(rumpf: #"{"version":"0.1.0","serverName":null,"needsSetup":true}"#)
|
|
|
|
let gruss = try await FlaroSignIn.greet(
|
|
baseURL: Proben.adresse, identity: Proben.kennung, transport: transport)
|
|
|
|
#expect(gruss.needsSetup)
|
|
}
|
|
|
|
/// **Flaro begründet seine Absagen, und der Satz zählt mehr als die Zahl.**
|
|
@Test("Eine Absage mit Begründung kommt als Begründung an")
|
|
func begruendeteAbsage() async {
|
|
let transport = Attrappe(rumpf: #"{"detail":"Für diese Bibliothek läuft bereits ein Scan."}"#, status: 409)
|
|
let b = backend(transport)
|
|
|
|
await #expect(throws: MediaBackendError.refused(
|
|
reason: "Für diese Bibliothek läuft bereits ein Scan.")) {
|
|
try await b.refreshLibrary(MediaLibrary(id: "b1", title: "Filme", kind: .movie))
|
|
}
|
|
}
|
|
|
|
@Test("Ein abgelaufener Zugang führt zur Abmeldung, nicht zu einer Meldung")
|
|
func abgelaufen() async {
|
|
let b = backend(Attrappe(rumpf: "", status: 401))
|
|
|
|
await #expect(throws: MediaBackendError.unauthorized) {
|
|
_ = try await b.libraries()
|
|
}
|
|
}
|
|
}
|