feat: support YouTube VTT subtitle selection
This commit is contained in:
@@ -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]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user