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