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
+101 -1
View File
@@ -124,6 +124,7 @@ struct ResolvedPlaybackCheck {
if !input.hasPrefix("https://") {
try await transportProtocol(source: source)
model.shutdown()
try await youtubeSubtitleLifecycle(source: source)
try await automaticSegmentSkips(file: file)
try await providerLifecycle(source: source)
try await dubOnlySelection(source: source)
@@ -137,6 +138,103 @@ struct ResolvedPlaybackCheck {
}
}
static func youtubeSubtitleLifecycle(source: ResolvedSource) async throws {
let suite = "terebi.youtube-subtitle-check." + UUID().uuidString
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
var resolution = 0
let model = PlayerModel(defaults: defaults, youtubeResolver: { url, height in
resolution += 1
let manual = ResolvedSubtitle(url: source.subtitles[0].url + "?revision=\(resolution)",
format: "vtt", language: "en", label: "English", isDefault: false,
headers: ["X-Resource": "subtitle-private"], selectionID: "youtube:manual:en")
let automatic = ResolvedSubtitle(url: manual.url + "&auto=1", format: "vtt",
language: "en", label: "English (auto-generated)", isDefault: false,
headers: ["X-Resource": "subtitle-private"], selectionID: "youtube:auto:en")
let denied = ResolvedSubtitle(url: source.subtitles[0].url.replacingOccurrences(of: "english.jpeg", with: "denied.vtt"),
format: "vtt", language: "fr", label: "Unavailable", isDefault: false,
headers: ["X-Resource": "subtitle-private"], selectionID: "youtube:manual:fr")
let malformed = ResolvedSubtitle(url: source.url, format: "vtt", language: "de",
label: "Malformed", isDefault: false, headers: ["X-Resource": "stream-private"],
selectionID: "youtube:manual:de")
return YouTubeVideo(title: "YouTube caption fixture", video: .init(url: source.url, headers: source.headers),
audio: nil, duration: 0, isLive: false, webpageURL: url, height: height ?? 1080,
availableHeights: [1080, 720], chapters: [],
subtitles: resolution >= 4 ? [manual, denied, malformed] :
(resolution.isMultiple(of: 2) ? [automatic, manual, denied, malformed] : [manual, automatic, denied, malformed]))
}, youtubeTitleResolver: { _ in nil })
defer { model.shutdown() }
model.setVolume(0)
let url = URL(string: "https://www.youtube.com/watch?v=AAAAAAAAAAA")!
model.openStream(url.absoluteString)
try await until("YouTube playback starts with optional captions Off", model: model) {
model.canChangeYouTubeQuality && model.state.position > 0.2
}
require(model.subtitleTracks.count == 4 && model.selectedSubtitle == nil &&
model.subtitleText.isEmpty && model.subtitleError == nil, "YouTube defaults Off")
require(model.setPlayback(playing: false), "pause caption fixture")
try await until("caption fixture pause acknowledged", model: model) { model.state.status == PLAYER_PAUSED }
model.selectSubtitle(1)
try await until("native caption HTTP loader renders automatic English", model: model) {
model.subtitleText == "First automatic cue"
}
require(model.seekRelative(2.5 - model.state.position), "forward caption seek accepted")
try await until("forward seek uses acknowledged caption position", model: model) {
!model.state.seek_pending && model.subtitleText == "Second automatic cue"
}
model.setYouTubeQuality(720)
try await until("quality preserves auto identity despite reordered tracks and refreshed URLs", model: model) {
model.canChangeYouTubeQuality && model.youtubeHeight == 720 && model.selectedSubtitle == 0 &&
model.subtitleText == "Second automatic cue" && model.state.status == PLAYER_PAUSED
}
require(model.seekRelative(0.5 - model.state.position), "backward caption seek accepted")
try await until("backward seek restores earlier caption", model: model) {
!model.state.seek_pending && model.subtitleText == "First automatic cue"
}
model.selectSubtitle(nil)
model.setYouTubeQuality(1080)
try await until("manual quality preserves explicit Off", model: model) {
model.canChangeYouTubeQuality && model.youtubeHeight == 1080
}
require(model.selectedSubtitle == nil && model.subtitleText.isEmpty, "quality must not re-enable captions")
model.selectSubtitle(1)
try await until("automatic track can be selected again", model: model) { model.subtitleText == "First automatic cue" }
model.setYouTubeQuality(720)
try await until("missing automatic track switches Off, not to manual English", model: model) {
model.canChangeYouTubeQuality && model.youtubeHeight == 720
}
require(model.selectedSubtitle == nil && model.subtitleText.isEmpty, "missing identity has no language fallback")
require(model.setPlayback(playing: true), "resume before optional caption failures")
for index in [1, 2] {
let position = model.state.position
model.selectSubtitle(index)
try await until("inaccessible/malformed captions fail while video keeps advancing", model: model) {
model.subtitleError != nil && model.state.status == PLAYER_PLAYING && model.state.position > position + 0.1
}
require(model.commandError == nil && model.state.has_video && model.state.has_audio &&
model.canSeek, "caption failures retain usable video")
}
require(model.setPlayback(playing: false), "pause after optional caption failures")
try await until("video still pauses normally after caption errors", model: model) { model.state.status == PLAYER_PAUSED }
require(model.seekRelative(0.5 - model.state.position), "video still seeks normally after caption errors")
try await until("post-error seek acknowledged", model: model) { !model.state.seek_pending && abs(model.state.position - 0.5) < 0.1 }
model.selectSubtitle(0)
try await until("valid captions recover without reopening video", model: model) {
model.subtitleText == "First English cue" && model.subtitleError == nil
}
try await model.addStreamToQueue("https://www.youtube.com/watch?v=BBBBBBBBBBB")
model.selectQueueItem(model.queue.items.last!.id)
require(model.subtitleTracks.isEmpty && model.selectedSubtitle == nil &&
model.subtitleText.isEmpty && model.subtitleError == nil, "queue change immediately clears old captions")
try await until("next queued video starts with fresh tracks Off", model: model) {
!model.isBusy && model.state.status == PLAYER_PLAYING && !model.subtitleTracks.isEmpty
}
require(model.selectedSubtitle == nil && model.subtitleText.isEmpty, "queue selection never inherits prior captions")
model.closeFile()
require(model.subtitleTracks.isEmpty && model.subtitleError == nil, "close clears caption state")
print("PASS: YouTube native VTT fetch, Off/On, seeks, stable auto/manual quality identity, missing tracks, optional failures and queue reset")
}
static func zokoIntervalLifecycle(source: ResolvedSource) async throws {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("terebi.zoko-interval-check." + UUID().uuidString)
@@ -398,7 +496,9 @@ struct ResolvedPlaybackCheck {
}
require(held.count == 2, "quality replacement coalesces the in-flight current-video lookup")
held[1].resume(returning: fixtureSponsors)
try await until("pending metadata survives quality replacement", model: model) { model.sponsoredRanges == [0.5...1.5] }
try await until("pending metadata survives quality replacement and the next quality choice is available", model: model) {
model.sponsoredRanges == [0.5...1.5] && model.canChangeYouTubeQuality
}
model.setYouTubeQuality(1080)
try await until("completed metadata survives quality replacement", model: model) {
!model.isBusy && model.youtubeHeight == 1080 && model.sponsoredRanges == [0.5...1.5]