Flixy heißt ab hier Halo
Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit, damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind. 712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25 Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit `HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`, `JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben. `docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben hiesse, die Vergangenheit zu glätten. **Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis: `com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container; Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen. **Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter `…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet. Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht statt umbenannt — sie steht ohnehin nicht im Repo. Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist ein Handgriff ausserhalb des Projekts. 368 Tests grün. iOS und tvOS bauen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2fcf6821c8
commit
4b9a1e7b67
@@ -0,0 +1,443 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import MediaKit
|
||||
@testable import JellyfinKit
|
||||
|
||||
/// Was die App dem Server zum Abspielen schickt — und was sie daraus macht.
|
||||
///
|
||||
/// Adressen und Anfragekörper entstehen ohne Netz und lassen sich deshalb
|
||||
/// genau prüfen. Genau hier lagen bei Plex die Fehler, die am teuersten waren:
|
||||
/// ein Wert, der nicht kodiert wurde, ein Feld, das der Server anders nennt.
|
||||
@Suite("Wiedergabe bei Jellyfin")
|
||||
struct PlaybackTests {
|
||||
|
||||
private func backend(_ transport: RecordingTransport) -> JellyfinBackend {
|
||||
let client = JellyfinClient(
|
||||
baseURL: URL(string: "http://server:8096")!,
|
||||
identity: JellyfinIdentity(
|
||||
client: "Halo", device: "Testgerät", deviceID: "gerät-1", version: "1"
|
||||
),
|
||||
token: "tok",
|
||||
transport: transport
|
||||
)
|
||||
return JellyfinBackend(
|
||||
client: client,
|
||||
account: JellyfinAccount(
|
||||
userID: "u1", userName: "Test", token: "tok", isAdministrator: false
|
||||
),
|
||||
serverName: "Attrappe"
|
||||
)
|
||||
}
|
||||
|
||||
private var file: MediaFile {
|
||||
MediaFile(id: "src-1", key: "src-1", container: "mp4")
|
||||
}
|
||||
|
||||
@Test("Unverändert: die Datei selbst, mit Token in der Adresse")
|
||||
func buildsDirectPlayURL() async throws {
|
||||
let target = try await backend(RecordingTransport())
|
||||
.preparePlayback(PlaybackIntent(itemID: "film-1", versionIndex: 0, file: file))
|
||||
|
||||
let url = try #require(URLComponents(url: target.url, resolvingAgainstBaseURL: false))
|
||||
#expect(url.path == "/Videos/film-1/stream")
|
||||
// Der Systemplayer schickt keine Kopfzeilen mit. Steht der Token nicht
|
||||
// in der Adresse, antwortet der Server mit „nicht angemeldet".
|
||||
#expect(url.queryItems?.contains(URLQueryItem(name: "ApiKey", value: "tok")) == true)
|
||||
#expect(url.queryItems?.contains(URLQueryItem(name: "static", value: "true")) == true)
|
||||
#expect(url.queryItems?.contains(URLQueryItem(name: "mediaSourceId", value: "src-1")) == true)
|
||||
// Auch ohne Umrechnung eine Sitzung: sonst ordnet Jellyfin den
|
||||
// Fortschritt keiner Wiedergabe zu.
|
||||
#expect(target.sessionID != nil)
|
||||
#expect(target.versionID == "src-1")
|
||||
}
|
||||
|
||||
@Test("Umgerechnet: die Adresse kommt vom Server")
|
||||
func usesServerTranscodingURL() async throws {
|
||||
let transport = RecordingTransport(response: """
|
||||
{"MediaSources": [{"Id": "src-1", "TranscodingUrl": "/Videos/film-1/main.m3u8?api_key=tok"}],
|
||||
"PlaySessionId": "ps-7"}
|
||||
""")
|
||||
|
||||
let target = try await backend(transport).preparePlayback(PlaybackIntent(
|
||||
itemID: "film-1",
|
||||
versionIndex: 0,
|
||||
file: file,
|
||||
transcode: TranscodeSettings(maximumBitrate: 4_000, resolution: "1280x720")
|
||||
))
|
||||
|
||||
// **`master.m3u8`, nicht `main.m3u8`.** Der Pfad des Servers bleibt,
|
||||
// nur die Liste wechselt: `main` führt die Schnipsel einer Stufe,
|
||||
// `master` die Spuren. Nur in der zweiten darf `#EXT-X-MEDIA` stehen
|
||||
// — die Zeile, mit der eine Untertitelspur angekündigt wird. Dieser
|
||||
// Test hielt vorher das Gegenteil fest und war damit mitschuldig,
|
||||
// dass nie ein Untertitel erschien.
|
||||
#expect(target.url.absoluteString.hasPrefix("http://server:8096/Videos/film-1/master.m3u8"))
|
||||
#expect(target.sessionID == "ps-7")
|
||||
|
||||
// Die Bitrate versteht Jellyfin in Bit, nicht in Kilobit. Eine Null zu
|
||||
// wenig, und aus vier Megabit werden vier Kilobit.
|
||||
let asked = try #require(transport.lastRequest)
|
||||
let query = try #require(URLComponents(url: asked.url!, resolvingAgainstBaseURL: false)?.queryItems)
|
||||
#expect(query.contains(URLQueryItem(name: "maxStreamingBitrate", value: "4000000")))
|
||||
#expect(query.contains(URLQueryItem(name: "enableDirectPlay", value: "false")))
|
||||
#expect(asked.httpMethod == "POST")
|
||||
}
|
||||
|
||||
@Test("Lehnt der Server ab, steht der Grund im Fehler")
|
||||
func refusesWithoutTranscodingURL() async throws {
|
||||
let transport = RecordingTransport(response: """
|
||||
{"MediaSources": [{"Id": "src-1"}], "PlaySessionId": "ps-7"}
|
||||
""")
|
||||
|
||||
await #expect(throws: MediaBackendError.self) {
|
||||
try await backend(transport).preparePlayback(PlaybackIntent(
|
||||
itemID: "film-1",
|
||||
versionIndex: 0,
|
||||
file: file,
|
||||
transcode: TranscodeSettings(maximumBitrate: nil, resolution: nil)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Die Bildhöhe ersetzt eine vorhandene Angabe, statt daneben zu stehen")
|
||||
func replacesExistingHeight() {
|
||||
let url = URL(string: "http://s/main.m3u8?maxHeight=1080&videoCodec=h264")!
|
||||
|
||||
let limited = JellyfinBackend.limiting(height: "1280x720", in: url)
|
||||
let query = URLComponents(url: limited, resolvingAgainstBaseURL: false)?.queryItems ?? []
|
||||
|
||||
// Bei zwei gleichnamigen Werten gilt beim Server der erste — und das
|
||||
// wäre dann gerade nicht der gewählte.
|
||||
#expect(query.filter { $0.name.lowercased() == "maxheight" }.count == 1)
|
||||
#expect(query.contains(URLQueryItem(name: "maxHeight", value: "720")))
|
||||
#expect(query.contains(URLQueryItem(name: "videoCodec", value: "h264")))
|
||||
}
|
||||
|
||||
@Test("Ohne gewählte Auflösung bleibt die Adresse, wie sie ist")
|
||||
func leavesURLAloneWithoutResolution() {
|
||||
let url = URL(string: "http://s/main.m3u8?videoCodec=h264")!
|
||||
|
||||
#expect(JellyfinBackend.limiting(height: nil, in: url) == url)
|
||||
}
|
||||
|
||||
@Test("Der Fortschritt geht in Ticks und mit allen Kennungen hinaus")
|
||||
func reportsProgressInTicks() async throws {
|
||||
let transport = RecordingTransport()
|
||||
|
||||
await backend(transport).reportProgress(PlaybackReport(
|
||||
itemID: "film-1",
|
||||
sessionID: "ps-7",
|
||||
versionID: "src-1",
|
||||
state: .playing,
|
||||
time: .seconds(90),
|
||||
duration: .seconds(600)
|
||||
))
|
||||
|
||||
let asked = try #require(transport.lastRequest)
|
||||
#expect(asked.url?.path == "/Sessions/Playing/Progress")
|
||||
|
||||
let body = try #require(asked.httpBody)
|
||||
let fields = try #require(
|
||||
try JSONSerialization.jsonObject(with: body) as? [String: Any]
|
||||
)
|
||||
// 90 Sekunden sind 900.000.000 Ticks. Zehntausend je Millisekunde.
|
||||
#expect(fields["PositionTicks"] as? Int == 900_000_000)
|
||||
#expect(fields["PlaySessionId"] as? String == "ps-7")
|
||||
#expect(fields["MediaSourceId"] as? String == "src-1")
|
||||
#expect(fields["IsPaused"] as? Bool == false)
|
||||
}
|
||||
|
||||
@Test("Verkleinert herunterladen ergibt eine einzelne Datei")
|
||||
func buildsCompressedDownloadURL() throws {
|
||||
let url = try #require(backend(RecordingTransport()).compressedDownloadURL(
|
||||
itemID: "film-1",
|
||||
versionIndex: 0,
|
||||
maximumBitrate: 4_000,
|
||||
resolution: "1280x720"
|
||||
))
|
||||
|
||||
// `.mp4`, nicht `.m3u8`: eine Abspielliste mit hunderten Schnipseln
|
||||
// lässt sich nicht als Datei ablegen.
|
||||
#expect(url.path == "/Videos/film-1/stream.mp4")
|
||||
|
||||
let query = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems)
|
||||
// In Bit, nicht in Kilobit — eine Null zu wenig, und aus vier Megabit
|
||||
// werden vier Kilobit.
|
||||
#expect(query.contains(URLQueryItem(name: "videoBitRate", value: "4000000")))
|
||||
#expect(query.contains(URLQueryItem(name: "maxHeight", value: "720")))
|
||||
#expect(query.contains(URLQueryItem(name: "ApiKey", value: "tok")))
|
||||
}
|
||||
|
||||
@Test("Der erste Bericht legt die Sitzung an")
|
||||
func reportsStartSeparately() async throws {
|
||||
let transport = RecordingTransport()
|
||||
|
||||
await backend(transport).reportProgress(PlaybackReport(
|
||||
itemID: "film-1", state: .started, time: .zero, duration: .seconds(600)
|
||||
))
|
||||
|
||||
// Ohne diese Meldung erscheint der Titel nirgends als „läuft gerade",
|
||||
// und der Server kennt die Sitzung nicht, unter der alles Weitere
|
||||
// gemeldet wird.
|
||||
#expect(transport.lastRequest?.url?.path == "/Sessions/Playing")
|
||||
}
|
||||
|
||||
@Test("Der letzte Bericht geht an einen eigenen Endpunkt")
|
||||
func reportsStopSeparately() async throws {
|
||||
let transport = RecordingTransport()
|
||||
|
||||
await backend(transport).reportProgress(PlaybackReport(
|
||||
itemID: "film-1", state: .stopped, time: .seconds(5), duration: .seconds(600)
|
||||
))
|
||||
|
||||
// „Gestoppt" als Fortschritt zu melden nähme den Titel nicht aus
|
||||
// „wird gerade abgespielt" heraus.
|
||||
#expect(transport.lastRequest?.url?.path == "/Sessions/Playing/Stopped")
|
||||
}
|
||||
|
||||
@Test("Abräumen nennt Gerät und Sitzung")
|
||||
func endsEncodingWithDeviceAndSession() async throws {
|
||||
let transport = RecordingTransport()
|
||||
|
||||
await backend(transport).endPlayback(sessionID: "ps-7")
|
||||
|
||||
let asked = try #require(transport.lastRequest)
|
||||
#expect(asked.httpMethod == "DELETE")
|
||||
#expect(asked.url?.path == "/Videos/ActiveEncodings")
|
||||
let query = try #require(URLComponents(url: asked.url!, resolvingAgainstBaseURL: false)?.queryItems)
|
||||
// Über die Sitzung allein hält Jellyfin nichts an.
|
||||
#expect(query.contains(URLQueryItem(name: "deviceId", value: "gerät-1")))
|
||||
#expect(query.contains(URLQueryItem(name: "playSessionId", value: "ps-7")))
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Transport, der nichts verschickt und alles behält.
|
||||
private final class RecordingTransport: JellyfinTransport, @unchecked Sendable {
|
||||
private let response: String
|
||||
private let lock = NSLock()
|
||||
private var recorded: URLRequest?
|
||||
|
||||
init(response: String = "{}") {
|
||||
self.response = response
|
||||
}
|
||||
|
||||
var lastRequest: URLRequest? {
|
||||
lock.withLock { recorded }
|
||||
}
|
||||
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
lock.withLock { recorded = request }
|
||||
return (
|
||||
Data(response.utf8),
|
||||
HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("Tonspur ausdrücklich bestellen")
|
||||
struct AudioStreamIndexTests {
|
||||
private func backend(_ transport: RecordingTransport) -> JellyfinBackend {
|
||||
let client = JellyfinClient(
|
||||
baseURL: URL(string: "http://server:8096")!,
|
||||
identity: JellyfinIdentity(
|
||||
client: "Halo", device: "Testgerät", deviceID: "gerät-1", version: "1"
|
||||
),
|
||||
token: "tok",
|
||||
transport: transport
|
||||
)
|
||||
return JellyfinBackend(
|
||||
client: client,
|
||||
account: JellyfinAccount(
|
||||
userID: "u1", userName: "Test", token: "tok", isAdministrator: false
|
||||
),
|
||||
serverName: "Attrappe"
|
||||
)
|
||||
}
|
||||
|
||||
private func intent(audio: Int?) -> PlaybackIntent {
|
||||
PlaybackIntent(
|
||||
itemID: "abc",
|
||||
versionIndex: 0,
|
||||
file: MediaFile(id: "src", key: "src", itemID: "abc"),
|
||||
transcode: TranscodeSettings(maximumBitrate: 8_000, resolution: nil),
|
||||
audioStreamIndex: audio
|
||||
)
|
||||
}
|
||||
|
||||
private func query(_ transport: RecordingTransport) throws -> [URLQueryItem] {
|
||||
let asked = try #require(transport.lastRequest)
|
||||
return URLComponents(url: asked.url!, resolvingAgainstBaseURL: false)?.queryItems ?? []
|
||||
}
|
||||
|
||||
/// Ohne diese Angabe wählt der Server — und bei einem umgerechneten
|
||||
/// Stream kommt danach nur noch **eine** Tonspur an. Dann ist im Player
|
||||
/// nichts mehr umzustellen: die Wahl muss in der Aushandlung stattfinden
|
||||
/// oder gar nicht.
|
||||
@Test("Die Spurnummer steht in der Anfrage")
|
||||
func indexIsSent() async throws {
|
||||
let transport = RecordingTransport()
|
||||
_ = try? await backend(transport).preparePlayback(intent(audio: 3))
|
||||
|
||||
let werte = try query(transport)
|
||||
#expect(werte.contains(URLQueryItem(name: "audioStreamIndex", value: "3")))
|
||||
}
|
||||
|
||||
@Test("Ohne Wunsch steht sie nicht da")
|
||||
func indexIsOmitted() async throws {
|
||||
let transport = RecordingTransport()
|
||||
_ = try? await backend(transport).preparePlayback(intent(audio: nil))
|
||||
|
||||
let werte = try query(transport)
|
||||
#expect(!werte.contains { $0.name == "audioStreamIndex" })
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("Untertitel ausdrücklich bestellen")
|
||||
struct SubtitleStreamIndexTests {
|
||||
private func backend(_ transport: RecordingTransport) -> JellyfinBackend {
|
||||
JellyfinBackend(
|
||||
client: JellyfinClient(
|
||||
baseURL: URL(string: "http://server:8096")!,
|
||||
identity: JellyfinIdentity(
|
||||
client: "Halo", device: "Testgerät", deviceID: "gerät-1", version: "1"
|
||||
),
|
||||
token: "tok",
|
||||
transport: transport
|
||||
),
|
||||
account: JellyfinAccount(
|
||||
userID: "u1", userName: "Test", token: "tok", isAdministrator: false
|
||||
),
|
||||
serverName: "Attrappe"
|
||||
)
|
||||
}
|
||||
|
||||
private func intent(subtitle: Int?) -> PlaybackIntent {
|
||||
PlaybackIntent(
|
||||
itemID: "abc",
|
||||
versionIndex: 0,
|
||||
file: MediaFile(id: "src", key: "src", itemID: "abc"),
|
||||
transcode: TranscodeSettings(maximumBitrate: 8_000, resolution: nil),
|
||||
subtitleStreamIndex: subtitle
|
||||
)
|
||||
}
|
||||
|
||||
private func query(_ transport: RecordingTransport) throws -> [URLQueryItem] {
|
||||
let asked = try #require(transport.lastRequest)
|
||||
return URLComponents(url: asked.url!, resolvingAgainstBaseURL: false)?.queryItems ?? []
|
||||
}
|
||||
|
||||
@Test("Die Nummer steht in der Anfrage")
|
||||
func indexIsSent() async throws {
|
||||
let transport = RecordingTransport()
|
||||
_ = try? await backend(transport).preparePlayback(intent(subtitle: 4))
|
||||
#expect(try query(transport).contains(URLQueryItem(name: "subtitleStreamIndex", value: "4")))
|
||||
}
|
||||
|
||||
/// **Der Unterschied, der zählt.** Ohne Angabe sucht der Server selbst
|
||||
/// einen aus; „keine" muss man ihm ausdrücklich sagen, sonst steht
|
||||
/// plötzlich ein Untertitel im Bild, den niemand bestellt hat.
|
||||
@Test("Minus eins heisst ausdrücklich keine")
|
||||
func minusOneMeansNone() async throws {
|
||||
let transport = RecordingTransport()
|
||||
_ = try? await backend(transport).preparePlayback(intent(subtitle: -1))
|
||||
#expect(try query(transport).contains(URLQueryItem(name: "subtitleStreamIndex", value: "-1")))
|
||||
}
|
||||
|
||||
@Test("Ohne Angabe steht sie nicht da")
|
||||
func indexIsOmitted() async throws {
|
||||
let transport = RecordingTransport()
|
||||
_ = try? await backend(transport).preparePlayback(intent(subtitle: nil))
|
||||
#expect(!(try query(transport)).contains { $0.name == "subtitleStreamIndex" })
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("Die Master-Liste, auch ohne Leiter")
|
||||
struct MasterPlaylistTests {
|
||||
private func backend(_ transport: RecordingTransport) -> JellyfinBackend {
|
||||
JellyfinBackend(
|
||||
client: JellyfinClient(
|
||||
baseURL: URL(string: "http://server:8096")!,
|
||||
identity: JellyfinIdentity(
|
||||
client: "Halo", device: "Testgerät", deviceID: "gerät-1", version: "1"
|
||||
),
|
||||
token: "tok",
|
||||
transport: transport
|
||||
),
|
||||
account: JellyfinAccount(
|
||||
userID: "u1", userName: "Test", token: "tok", isAdministrator: false
|
||||
),
|
||||
serverName: "Attrappe"
|
||||
)
|
||||
}
|
||||
|
||||
private func transport() -> RecordingTransport {
|
||||
RecordingTransport(response: """
|
||||
{"MediaSources": [{"Id": "src-1", "TranscodingUrl": "/Videos/film-1/main.m3u8?api_key=tok"}],
|
||||
"PlaySessionId": "ps-7"}
|
||||
""")
|
||||
}
|
||||
|
||||
private func target(remux: Bool, adaptive: Bool) async throws -> URL {
|
||||
try await backend(transport()).preparePlayback(PlaybackIntent(
|
||||
itemID: "film-1",
|
||||
versionIndex: 0,
|
||||
file: MediaFile(id: "src-1", key: "src-1", container: "mkv"),
|
||||
transcode: TranscodeSettings(
|
||||
maximumBitrate: nil,
|
||||
resolution: nil,
|
||||
prefersAdaptive: adaptive,
|
||||
prefersRemux: remux
|
||||
)
|
||||
)).url
|
||||
}
|
||||
|
||||
/// **Der Fehler, wegen dem nie ein Untertitel erschien.** `main.m3u8` ist
|
||||
/// eine Liste von Schnipseln; `#EXT-X-MEDIA` — die Zeile, mit der ein
|
||||
/// Server eine Untertitelspur ankündigt — darf nach RFC 8216 nur in einer
|
||||
/// Master-Liste stehen. Auf `main.m3u8` kann es strukturell keine
|
||||
/// Untertitel geben. Und ein Matroska geht immer über das Umpacken.
|
||||
@Test("Umgepackt kommt die Master-Liste")
|
||||
func remuxGetsMaster() async throws {
|
||||
let adresse = try await target(remux: true, adaptive: false)
|
||||
#expect(adresse.lastPathComponent == "master.m3u8")
|
||||
}
|
||||
|
||||
/// Die Leiter ist eine **andere** Frage als die Master-Liste. Beim
|
||||
/// Umpacken gibt es nur eine Fassung — sie zu bestellen wäre sinnlos.
|
||||
@Test("Umgepackt aber keine Leiter")
|
||||
func remuxHasNoLadder() async throws {
|
||||
let adresse = try await target(remux: true, adaptive: true)
|
||||
let werte = URLComponents(url: adresse, resolvingAgainstBaseURL: false)?.queryItems ?? []
|
||||
|
||||
#expect(adresse.lastPathComponent == "master.m3u8")
|
||||
#expect(!werte.contains { $0.name.lowercased() == "enableadaptivebitratestreaming" })
|
||||
}
|
||||
|
||||
/// Auch eine von Hand gewählte Stufe braucht die Master-Liste — sonst
|
||||
/// verliert man die Untertitel, sobald man die Qualität einstellt.
|
||||
@Test("Eine feste Stufe bekommt sie auch")
|
||||
func fixedRungGetsMaster() async throws {
|
||||
let adresse = try await target(remux: false, adaptive: false)
|
||||
let werte = URLComponents(url: adresse, resolvingAgainstBaseURL: false)?.queryItems ?? []
|
||||
|
||||
#expect(adresse.lastPathComponent == "master.m3u8")
|
||||
#expect(!werte.contains { $0.name.lowercased() == "enableadaptivebitratestreaming" })
|
||||
}
|
||||
|
||||
@Test("Bei automatisch kommt zusätzlich die Leiter")
|
||||
func automaticGetsLadder() async throws {
|
||||
let adresse = try await target(remux: false, adaptive: true)
|
||||
let werte = URLComponents(url: adresse, resolvingAgainstBaseURL: false)?.queryItems ?? []
|
||||
|
||||
#expect(adresse.lastPathComponent == "master.m3u8")
|
||||
#expect(werte.contains(URLQueryItem(name: "enableAdaptiveBitrateStreaming", value: "true")))
|
||||
}
|
||||
|
||||
/// Heisst die Adresse schon so, bleibt sie — und wird nicht doppelt
|
||||
/// umgeschrieben.
|
||||
@Test("Was schon Master heisst, bleibt es")
|
||||
func masterStaysMaster() {
|
||||
let schon = URL(string: "http://s/Videos/x/master.m3u8?api_key=t")!
|
||||
#expect(JellyfinBackend.masterPlaylist(schon) == schon)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user