Drei Gruende, und der erste erklaert das ganze Aergernis. AVPlayer besteht nach einem Sprung darauf, erst seinen eigenen Puffer zu fuellen. Der stand auf sechzig Sekunden — richtig, solange er selbst vorladen sollte. Mit einem Speicher davor ist es das Gegenteil: springt man mitten in den Vorrat, liegt dahinter vielleicht noch eine halbe Minute, zu wenig fuer seine sechzig, und er wartet auf den Server, obwohl alles da ist. Der Abstand liegt jetzt bei uns; seiner darf klein sein. Der Vorrat holte ausserdem weiter Schnipsel hinter der Abspielstelle, wenn der Player vorausgesprungen war — und der eine, auf den gerade gewartet wurde, kam als letzter. Er macht jetzt dort weiter, wo gesprungen wurde. Und dieselbe Liste noch einmal zu holen setzte den Vorrat zurueck. Der Player holt sie staendig: nach einem Sprung, beim Stufenwechsel, von sich aus. Zurueckgesetzt wird nur noch bei einem echten Wechsel der Quelle. Dazu ein latenter Fehler, der beim Umpacken ein Schwarzbild gegeben haette: in EXT-X-MAP steckt die Adresse des Kopfstuecks einer fragmentierten MP4, ohne das kein einziger Schnipsel zu dekodieren ist. Sie blieb stehen und zeigte damit relativ auf den lokalen Server. Der alte Test hielt genau dieses Verhalten fest — er hat mitgeaendert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
164 lines
6.5 KiB
Swift
164 lines
6.5 KiB
Swift
import Testing
|
|
import Foundation
|
|
@testable import FlixyPlayback
|
|
|
|
@Suite("Abspielliste lesen und umschreiben")
|
|
struct MediaPlaylistTests {
|
|
|
|
private let medien = """
|
|
#EXTM3U
|
|
#EXT-X-VERSION:7
|
|
#EXT-X-TARGETDURATION:6
|
|
#EXT-X-MEDIA-SEQUENCE:0
|
|
#EXT-X-MAP:URI="hls1/main/-1.mp4"
|
|
#EXTINF:6.000000,
|
|
hls1/main/0.mp4
|
|
#EXTINF:6.000000,
|
|
hls1/main/1.mp4
|
|
#EXTINF:5.400000,
|
|
hls1/main/2.mp4
|
|
#EXT-X-ENDLIST
|
|
"""
|
|
|
|
private let stufen = """
|
|
#EXTM3U
|
|
#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1920x1080
|
|
main.m3u8?level=0
|
|
#EXT-X-STREAM-INF:BANDWIDTH=3000000,RESOLUTION=1280x720
|
|
main.m3u8?level=1
|
|
"""
|
|
|
|
@Test("Die Schnipsel kommen in der Reihenfolge der Liste")
|
|
func readsSegmentsInOrder() {
|
|
let liste = MediaPlaylist(medien)
|
|
#expect(liste.segments == ["hls1/main/0.mp4", "hls1/main/1.mp4", "hls1/main/2.mp4"])
|
|
#expect(!liste.isMaster)
|
|
}
|
|
|
|
/// Die beiden Listenarten sehen sich ähnlich und meinen Verschiedenes.
|
|
/// Wer sie verwechselt, lädt Abspiellisten vor statt Bild.
|
|
@Test("Eine Liste von Stufen ist keine Liste von Schnipseln")
|
|
func tellsMasterFromMedia() {
|
|
#expect(MediaPlaylist(stufen).isMaster)
|
|
#expect(MediaPlaylist(stufen).segments.count == 2)
|
|
#expect(!MediaPlaylist(medien).isMaster)
|
|
}
|
|
|
|
/// **Die teuerste Stelle.** Eine Liste, die stellenweise am Speicher
|
|
/// vorbeizeigt, ergibt einen Film, der nur manchmal hakt — und so etwas
|
|
/// findet man im laufenden Bild nicht.
|
|
@Test("Umgeschrieben werden Adressen, sonst nichts")
|
|
func rewritesOnlyReferences() {
|
|
let neu = MediaPlaylist(medien).rewritingReferences { "http://127.0.0.1:1234/s/\($0)" }
|
|
|
|
#expect(neu.contains("http://127.0.0.1:1234/s/hls1/main/0.mp4"))
|
|
#expect(neu.contains("http://127.0.0.1:1234/s/hls1/main/2.mp4"))
|
|
// Anweisungen bleiben, wie sie waren.
|
|
#expect(neu.contains("#EXT-X-TARGETDURATION:6"))
|
|
// **Mit einer Ausnahme**, und die hat mich ein Schwarzbild gekostet:
|
|
// in `EXT-X-MAP` steckt eine Adresse, und die muss mit. Siehe unten.
|
|
#expect(neu.contains("#EXT-X-MAP:URI=\"http://127.0.0.1:1234/s/hls1/main/-1.mp4\""))
|
|
#expect(neu.contains("#EXT-X-ENDLIST"))
|
|
#expect(neu.hasPrefix("#EXTM3U"))
|
|
}
|
|
|
|
/// Ohne das Kopfstück ist kein einziger Schnipsel einer fragmentierten
|
|
/// MP4 zu dekodieren. Bliebe die Adresse stehen, zeigte sie relativ auf
|
|
/// den lokalen Server — schwarzes Bild, ohne dass an den Schnipseln
|
|
/// irgendetwas falsch wäre.
|
|
@Test("Das Kopfstück wandert mit")
|
|
func rewritesInitSegment() {
|
|
let neu = MediaPlaylist(medien).rewritingReferences { "/s/\($0)" }
|
|
#expect(neu.contains("#EXT-X-MAP:URI=\"/s/hls1/main/-1.mp4\""))
|
|
}
|
|
|
|
@Test("Eine Anweisung ohne Adresse bleibt unberührt")
|
|
func leavesOtherTagsAlone() {
|
|
let neu = MediaPlaylist(medien).rewritingReferences { "/s/\($0)" }
|
|
#expect(neu.contains("#EXT-X-VERSION:7"))
|
|
#expect(neu.contains("#EXTINF:6.000000,"))
|
|
}
|
|
|
|
@Test("Die Zeilenzahl bleibt gleich")
|
|
func keepsStructure() {
|
|
let neu = MediaPlaylist(medien).rewritingReferences { $0 }
|
|
#expect(neu.components(separatedBy: "\n").count == medien.components(separatedBy: "\n").count)
|
|
}
|
|
|
|
@Test("Eine vollständige Liste ist als solche zu erkennen")
|
|
func knowsWhenComplete() {
|
|
#expect(MediaPlaylist(medien).isComplete)
|
|
// Ohne Schlussmarke: ein laufender Sender, die Liste wächst noch.
|
|
#expect(!MediaPlaylist(medien.replacingOccurrences(of: "#EXT-X-ENDLIST", with: "")).isComplete)
|
|
}
|
|
|
|
@Test("Die Schnipseldauer wird gelesen")
|
|
func readsTargetDuration() {
|
|
#expect(MediaPlaylist(medien).targetDuration == .seconds(6))
|
|
#expect(MediaPlaylist(stufen).targetDuration == nil)
|
|
}
|
|
|
|
@Test("Eine leere Eingabe ergibt eine leere Liste, keinen Absturz")
|
|
func emptyIsEmpty() {
|
|
#expect(MediaPlaylist("").segments.isEmpty)
|
|
#expect(MediaPlaylist("").rewritingReferences { $0 } == "")
|
|
}
|
|
}
|
|
|
|
@Suite("Adressen für den lokalen Speicher")
|
|
struct LocalStreamServerPathTests {
|
|
/// Der Fehler, an dem der erste Anlauf gescheitert ist: eine Liste von
|
|
/// Stufen wurde durchgereicht, ihre **relativen** Adressen zeigten danach
|
|
/// auf den lokalen Server — und dort gibt es sie nicht.
|
|
@Test("Eine Stufe wird vollständig adressiert, nicht relativ")
|
|
func levelPathIsAbsolute() {
|
|
let pfad = LocalStreamServer.localPlaylistPath(
|
|
for: "http://server:8096/videos/x/main.m3u8?level=1&api_key=t"
|
|
)
|
|
let teile = URLComponents(string: pfad)
|
|
#expect(teile?.path == "/level.m3u8")
|
|
#expect(
|
|
teile?.queryItems?.first { $0.name == "u" }?.value
|
|
== "http://server:8096/videos/x/main.m3u8?level=1&api_key=t"
|
|
)
|
|
}
|
|
|
|
/// Die Endung muss stehen bleiben: AVPlayer entscheidet am Namen, ob er
|
|
/// eine Abspielliste oder Bild erwartet.
|
|
@Test("Die Endung bleibt eine Abspielliste")
|
|
func levelPathKeepsExtension() {
|
|
#expect(LocalStreamServer.localPlaylistPath(for: "http://s/a.m3u8").hasPrefix("/level.m3u8"))
|
|
}
|
|
|
|
/// Schrägstriche und Punkte im ursprünglichen Pfad dürfen nichts
|
|
/// anrichten — deshalb wandert er in die Abfrage, nicht in den Pfad.
|
|
@Test("Der Schnipselpfad überlebt Schrägstriche")
|
|
func segmentPathSurvivesSlashes() {
|
|
let pfad = LocalStreamServer.localPath(for: "hls1/main/12.mp4?runtimeTicks=0")
|
|
let teile = URLComponents(string: pfad)
|
|
#expect(teile?.path == "/s/")
|
|
#expect(teile?.queryItems?.first { $0.name == "p" }?.value == "hls1/main/12.mp4?runtimeTicks=0")
|
|
}
|
|
|
|
/// Eine Stufenliste, durch den Umschreiber gedreht, muss auf den lokalen
|
|
/// Server zeigen — und zwar jede Zeile.
|
|
@Test("Jede Stufe zeigt danach auf uns")
|
|
func everyLevelIsRewritten() {
|
|
let stufen = """
|
|
#EXTM3U
|
|
#EXT-X-STREAM-INF:BANDWIDTH=8000000
|
|
main.m3u8?level=0
|
|
#EXT-X-STREAM-INF:BANDWIDTH=3000000
|
|
main.m3u8?level=1
|
|
"""
|
|
let basis = URL(string: "http://server:8096/videos/x/master.m3u8?api_key=t")!
|
|
let neu = MediaPlaylist(stufen).rewritingReferences { eintrag in
|
|
let absolut = URL(string: eintrag, relativeTo: basis)?.absoluteString ?? eintrag
|
|
return LocalStreamServer.localPlaylistPath(for: absolut)
|
|
}
|
|
|
|
#expect(neu.components(separatedBy: "/level.m3u8").count == 3)
|
|
#expect(!neu.contains("\nmain.m3u8"))
|
|
}
|
|
}
|