import Testing import Foundation @testable import FlaroPlayback @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 { adresse, _ in "http://127.0.0.1:1234/s/\(adresse)" } #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 { adresse, _ in "/s/\(adresse)" } #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 { adresse, _ in "/s/\(adresse)" } #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 { adresse, _ in adresse } #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 { adresse, _ in adresse } == "") } } @Suite("Nebenspuren in der Abspielliste") struct PlaylistRenditionTests { /// **Der Fehler, der mit „404" endete.** Untertitel stehen in einer /// eigenen Anweisung mit einer Adresse darin. Blieb sie stehen, zeigte /// sie relativ auf den lokalen Server — und dort gibt es sie nicht. Zu /// sehen war das erst, als jemand Untertitel einschaltete. private let mitUntertiteln = """ #EXTM3U #EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="Deutsch",URI="subtitles/0.m3u8" #EXT-X-STREAM-INF:BANDWIDTH=8000000,SUBTITLES="subs" main.m3u8?level=0 """ @Test("Die Untertitelspur wird mit umgeschrieben") func rewritesSubtitleRendition() { let neu = MediaPlaylist(mitUntertiteln).rewritingReferences { adresse, _ in "/um/\(adresse)" } #expect(neu.contains("URI=\"/um/subtitles/0.m3u8\"")) #expect(!neu.contains("URI=\"subtitles/0.m3u8\"")) } /// **Und sie darf nicht vorgeladen werden.** Sonst lüde der Vorrat /// Untertitel statt Bild: der Film bliebe stehen, während die Anzeige /// reichlich Vorrat meldet. @Test("Stufe und Nebenspur werden unterschieden") func tellsLevelFromRendition() { var arten: [String: MediaPlaylist.Reference] = [:] _ = MediaPlaylist(mitUntertiteln).rewritingReferences { adresse, art in arten[adresse] = art return adresse } #expect(arten["main.m3u8?level=0"] == .level) #expect(arten["subtitles/0.m3u8"] == .rendition) } @Test("Das Kopfstück gilt als Schnipsel, nicht als Liste") func mapIsASegment() { let liste = """ #EXTM3U #EXT-X-MAP:URI="hls1/main/-1.mp4" #EXTINF:6.0, hls1/main/0.mp4 """ var arten: [String: MediaPlaylist.Reference] = [:] _ = MediaPlaylist(liste).rewritingReferences { adresse, art in arten[adresse] = art return adresse } #expect(arten["hls1/main/-1.mp4"] == .map) #expect(arten["hls1/main/0.mp4"] == .segment) } } @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", prefetch: true ) 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", prefetch: true).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 == "u" }?.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, prefetch: true) } #expect(neu.components(separatedBy: "/level.m3u8").count == 3) #expect(!neu.contains("\nmain.m3u8")) } }