feat: support YouTube VTT subtitle selection
This commit is contained in:
@@ -167,6 +167,112 @@ struct YouTubeResolverChecks {
|
||||
try require(try decode(emptyHeaders).video.headers.isEmpty, "absent headers serialize to an empty block")
|
||||
}
|
||||
|
||||
static func subtitles() throws {
|
||||
func vtt(_ url: String, name: Any = "English", headers: Any? = nil) -> [String: Any] {
|
||||
var row: [String: Any] = ["ext": "vtt", "url": url, "name": name]
|
||||
if let headers { row["http_headers"] = headers }
|
||||
return row
|
||||
}
|
||||
let original = "https://www.youtube.com/api/timedtext?lang=en&kind=asr&fmt=vtt"
|
||||
var object = try fixture(combinedJSON)
|
||||
try require(try decode(object).subtitles.isEmpty, "absent captions default to Off with no optional tracks")
|
||||
for payload in [NSNull(), [], true, 17, "captions"] as [Any] {
|
||||
object["subtitles"] = payload
|
||||
object["automatic_captions"] = payload
|
||||
try require(try decode(object).subtitles.isEmpty,
|
||||
"malformed optional caption containers never block playback")
|
||||
}
|
||||
object["subtitles"] = [
|
||||
"broken": true,
|
||||
"en": [NSNull(), 7, ["ext": false], ["ext": "vtt", "url": 9],
|
||||
["ext": "srt", "url": "https://subs.example/convert-me"],
|
||||
vtt("file:///unsafe.vtt"),
|
||||
vtt("https://subs.example/unsafe.vtt", headers: ["X-Unsafe": "bad\nvalue"]),
|
||||
vtt("https://subs.example/bad-headers.vtt", headers: ["X-Bad": 3]),
|
||||
vtt("https://subs.example/en.vtt?token=first", headers: ["X-Track": "manual"]),
|
||||
vtt("https://subs.example/duplicate.vtt")],
|
||||
"fr": [vtt("https://subs.example/fr.vtt", name: ["malformed"])],
|
||||
"srt-only": [["ext": "srt", "url": "https://subs.example/only.srt"]]
|
||||
] as [String: Any]
|
||||
object["automatic_captions"] = [
|
||||
"en-orig": [vtt(original, headers: ["X-Track": "auto"])],
|
||||
"en": [vtt(original)],
|
||||
"fr": [vtt(original + "&tlang=fr", name: "French")],
|
||||
"de": [vtt("https://subs.example/de.vtt", name: "German (Original)")],
|
||||
"bad-orig": [NSNull(), vtt("ftp://subs.example/bad.vtt")]
|
||||
] as [String: Any]
|
||||
let tracks = try decode(object).subtitles
|
||||
try require(tracks.count == 3 && tracks.map(\.language) == ["en", "fr", "en"],
|
||||
"manual VTT and one original auto survive malformed siblings; translations and aliases do not")
|
||||
try require(tracks.map(\.selectionID) == ["youtube:manual:en", "youtube:manual:fr", "youtube:auto:en"],
|
||||
"manual and auto identities depend on language metadata, not signed URLs or labels")
|
||||
try require(tracks[0].url == "https://subs.example/en.vtt?token=first"
|
||||
&& tracks[0].label == "English" && tracks[1].label == "fr"
|
||||
&& tracks[2].label == "English (auto-generated)",
|
||||
"one valid supplied VTT per language preserves name and query; malformed name falls back to language")
|
||||
try require(tracks.allSatisfy { !$0.isDefault } && ResolvedSource.validSubtitles(tracks),
|
||||
"optional captions preserve transport safeguards and never enable themselves")
|
||||
try require(tracks[0].headers == "X-Track: manual\r\n"
|
||||
&& tracks[1].headers == "User-Agent: combined-agent\r\n"
|
||||
&& tracks[2].headers == "X-Track: auto\r\n",
|
||||
"variant headers override the complete root map; absent variant headers inherit root")
|
||||
var changed = object
|
||||
changed["automatic_captions"] = ["en": [vtt(original + "&token=renewed", name: "Renamed")]]
|
||||
let alias = try decode(changed).subtitles.last!
|
||||
try require(alias.selectionID == tracks.last?.selectionID && alias.url != tracks.last?.url,
|
||||
"explicit untranslated ASR alias retains original identity after signed URL and label refresh")
|
||||
changed["automatic_captions"] = [
|
||||
"en-orig": [vtt(original)],
|
||||
"en": [vtt(original + "&token=alias")],
|
||||
"other-orig": [vtt(original)]
|
||||
]
|
||||
try require(try decode(changed).subtitles.filter { $0.selectionID?.hasPrefix("youtube:auto:") == true }.count == 1,
|
||||
"original auto aliases deduplicate by language and by URL")
|
||||
changed["automatic_captions"] = ["en-orig": [vtt(original)]]
|
||||
changed["subtitles"] = ["en": [vtt("https://subs.example/empty.vtt", headers: [:])]]
|
||||
try require(try decode(changed).subtitles.first?.headers == "",
|
||||
"an explicitly empty caption header map does not inherit root headers")
|
||||
|
||||
for unsafe in ["https://user:pass@subs.example/c.vtt", "https://subs.example/c.vtt#fragment",
|
||||
"https://subs.example:0/c.vtt", "https://subs.example/c vtt"] {
|
||||
changed["subtitles"] = ["en": [vtt(unsafe)]]
|
||||
try require(try decode(changed).subtitles.count == 1,
|
||||
"unsafe optional subtitle URL is skipped without discarding a good auto sibling")
|
||||
}
|
||||
var fragmented = vtt("https://subs.example/chunks")
|
||||
fragmented["protocol"] = "m3u8_native"
|
||||
changed["subtitles"] = ["en": [fragmented]]
|
||||
try require(try decode(changed).subtitles.count == 1,
|
||||
"caption playlists are not treated as immutable VTT documents")
|
||||
|
||||
var many: [String: Any] = [:]
|
||||
for index in 0..<40 {
|
||||
let key = String(format: "lang-%02d", index)
|
||||
many[key] = [vtt("https://subs.example/\(key).vtt", name: key)]
|
||||
}
|
||||
changed["subtitles"] = many
|
||||
let capped = try decode(changed).subtitles
|
||||
try require(capped.count == 32 && capped.last?.selectionID == "youtube:auto:en"
|
||||
&& capped.first?.language == "lang-00" && capped[30].language == "lang-30",
|
||||
"deterministic 32-track cap reserves original auto instead of crowding it out with manual languages")
|
||||
changed["automatic_captions"] = [:]
|
||||
try require(try decode(changed).subtitles.count == 32,
|
||||
"manual-only metadata uses the entire track allowance")
|
||||
changed["automatic_captions"] = ["en-orig": [vtt(original + "&tlang=fr")]]
|
||||
let translated = try decode(changed).subtitles
|
||||
try require(translated.count == 32
|
||||
&& translated.allSatisfy { $0.selectionID?.hasPrefix("youtube:manual:") == true },
|
||||
"translation query metadata is excluded even under an original-looking key")
|
||||
for (key, value) in [("is_live", true), ("live_status", "is_live")] as [(String, Any)] {
|
||||
var broadcast = object
|
||||
broadcast[key] = value
|
||||
try require(try decode(broadcast).subtitles.isEmpty,
|
||||
"live captions are disabled instead of freezing a partial live transcript")
|
||||
}
|
||||
changed["url"] = "file:///unsafe-video"
|
||||
try reject("optional caption tolerance does not weaken video validation") { _ = try decode(changed) }
|
||||
}
|
||||
|
||||
static func chapters() throws {
|
||||
var object = try fixture(combinedJSON)
|
||||
try require(try decode(object).chapters.isEmpty, "absent chapters remain playable")
|
||||
@@ -683,6 +789,7 @@ struct YouTubeResolverChecks {
|
||||
try videoURLs()
|
||||
try metadata()
|
||||
try chapters()
|
||||
try subtitles()
|
||||
try malformedMetadata()
|
||||
try qualities()
|
||||
try bundledHelpers(root: root)
|
||||
|
||||
Reference in New Issue
Block a user