feat: support YouTube VTT subtitle selection

This commit is contained in:
2026-10-27 13:05:56 +01:00
parent a2c8cc8e4d
commit 0641859d95
12 changed files with 449 additions and 25 deletions
+107 -2
View File
@@ -23,6 +23,7 @@ struct YouTubeVideo: Sendable {
let height: Int?
let availableHeights: [Int]
let chapters: [YouTubeChapter]
var subtitles: [ResolvedSubtitle] = []
func chapterBoundaries(duration: Double) -> [Double] {
guard duration.isFinite, duration > 0 else { return [] }
@@ -157,13 +158,15 @@ enum YouTubeResolver {
let video = try stream(videoFormat, fallbackHeaders: metadata.http_headers)
let audio = try audioFormat.map { try stream($0, fallbackHeaders: metadata.http_headers) }
let title = metadata.title?.trimmingCharacters(in: .whitespacesAndNewlines)
let isLive = metadata.is_live == true || metadata.live_status == "is_live"
return YouTubeVideo(
title: title?.isEmpty == false ? title! : "Video",
video: video, audio: audio, duration: duration,
isLive: metadata.is_live == true || metadata.live_status == "is_live",
isLive: isLive,
webpageURL: try validateURL(webpageURL.absoluteString),
height: videoFormat.height, availableHeights: availableHeights,
chapters: metadata.chapters?.values ?? []
chapters: metadata.chapters?.values ?? [],
subtitles: isLive ? [] : subtitles(metadata)
)
}
@@ -297,6 +300,58 @@ enum YouTubeResolver {
return video
}
private static func subtitles(_ metadata: YouTubeMetadata) -> [ResolvedSubtitle] {
func track(_ variants: [YouTubeCaption], key: String, automatic: Bool) -> ResolvedSubtitle? {
let language = automatic && key.hasSuffix("-orig") ? String(key.dropLast(5)) : key
for variant in variants where variant.ext == "vtt" {
guard variant.transport == nil || ["http", "https"].contains(variant.transport!) else { continue }
if automatic,
URLComponents(string: variant.url)?.queryItems?.contains(where: { $0.name == "tlang" }) == true {
continue
}
if automatic && !key.hasSuffix("-orig") {
// Nontranslatable ASR may have no -orig alias. Never infer it from a label.
guard let url = URLComponents(string: variant.url),
let host = url.host, ["www.youtube.com", "youtube.com"].contains(host),
url.path == "/api/timedtext",
url.queryItems?.contains(where: { $0.name == "kind" && $0.value == "asr" }) == true,
url.queryItems?.contains(where: { $0.name == "lang" && $0.value == language }) == true else { continue }
}
let name = variant.name?.trimmingCharacters(in: .whitespacesAndNewlines)
let label = name?.isEmpty == false ? name! : language
let subtitle = ResolvedSubtitle(url: variant.url, format: "vtt", language: language,
label: automatic ? "\(label) (auto-generated)" : label, isDefault: false,
headers: variant.headers ?? metadata.http_headers ?? [:],
selectionID: "youtube:\(automatic ? "auto" : "manual"):\(language)")
if ResolvedSource.validSubtitles([subtitle]) { return subtitle }
}
return nil
}
let captions = metadata.automatic_captions?.values ?? [:]
// Explicit originals win over compatibility aliases, even when their signed URLs differ.
let autoKeys = captions.keys.sorted {
if $0.hasSuffix("-orig") != $1.hasSuffix("-orig") { return $0.hasSuffix("-orig") }
return $0 < $1
}
var originals: [ResolvedSubtitle] = []
var languages = Set<String>()
var urls = Set<String>()
for key in autoKeys {
guard let subtitle = track(captions[key]!, key: key, automatic: true),
languages.insert(subtitle.language).inserted,
urls.insert(subtitle.url).inserted else { continue }
originals.append(subtitle)
if originals.count == 32 { break }
}
var manual: [ResolvedSubtitle] = []
let supplied = metadata.subtitles?.values ?? [:]
for key in supplied.keys.sorted() {
if manual.count == 32 - originals.count { break }
if let subtitle = track(supplied[key]!, key: key, automatic: false) { manual.append(subtitle) }
}
return manual + originals
}
private static func stream(_ format: YouTubeMetadata, fallbackHeaders: [String: String]?) throws -> YouTubeStream {
guard format.protocol == nil || ["http", "https", "m3u8", "m3u8_native"].contains(format.protocol!) else {
throw YouTubeResolutionError(message: "yt-dlp selected a format that requires downloading and assembling fragments instead of native streaming.")
@@ -415,12 +470,62 @@ private struct YouTubeMetadata: Decodable {
let http_headers: [String: String]?
let requested_formats: [YouTubeMetadata]?
let chapters: YouTubeChapters?
let subtitles: YouTubeCaptions?
let automatic_captions: YouTubeCaptions?
// Generic extractors may omit codec hints; FFmpeg probes the actual streams.
var hasVideo: Bool { vcodec.map { !$0.isEmpty && $0 != "none" } ?? true }
var hasAudio: Bool { acodec.map { !$0.isEmpty && $0 != "none" } ?? true }
}
private struct YouTubeCaption: Decodable {
let ext: String
let url: String
let name: String?
let headers: [String: String]?
let transport: String?
private enum CodingKeys: String, CodingKey { case ext, url, name, http_headers, `protocol` }
init(from decoder: Decoder) throws {
let row = try decoder.container(keyedBy: CodingKeys.self)
ext = try row.decode(String.self, forKey: .ext)
url = try row.decode(String.self, forKey: .url)
name = try? row.decode(String.self, forKey: .name)
headers = try row.decodeIfPresent([String: String].self, forKey: .http_headers)
transport = try row.decodeIfPresent(String.self, forKey: .protocol)
}
}
private struct YouTubeCaptions: Decodable {
let values: [String: [YouTubeCaption]]
private struct LanguageKey: CodingKey {
let stringValue: String
var intValue: Int? { nil }
init?(stringValue: String) { self.stringValue = stringValue }
init?(intValue: Int) { return nil }
}
init(from decoder: Decoder) {
guard let languages = try? decoder.container(keyedBy: LanguageKey.self) else {
values = [:]
return
}
var captions: [String: [YouTubeCaption]] = [:]
for key in languages.allKeys {
guard var rows = try? languages.nestedUnkeyedContainer(forKey: key) else { continue }
var variants: [YouTubeCaption] = []
while !rows.isAtEnd {
guard let row = try? rows.superDecoder() else { break }
if let caption = try? YouTubeCaption(from: row) { variants.append(caption) }
}
captions[key.stringValue] = variants
}
values = captions
}
}
private struct YouTubeChapters: Decodable {
let values: [YouTubeChapter]