feat: support YouTube VTT subtitle selection
This commit is contained in:
@@ -68,7 +68,8 @@ struct AutomaticQualityPlaybackChecks {
|
||||
until(model, "compact mode remains usable across empty playback polls while resolving") {
|
||||
model.isCompact && !model.compactWindow.isTransitioning
|
||||
}
|
||||
require(window.level == .floating && window.frame.width < normalFrame.width,
|
||||
require(model.compactWindow.presentationWindow!.level == .floating &&
|
||||
model.compactWindow.presentationWindow!.frame.width < normalFrame.width,
|
||||
"unresolved video has a usable compact window")
|
||||
model.handleKey(compactKey)
|
||||
until(model, "compact shortcut restores while YouTube resolution is still pending") {
|
||||
@@ -86,7 +87,8 @@ struct AutomaticQualityPlaybackChecks {
|
||||
require(model.isCompact, "loading completion preserves the requested compact mode")
|
||||
until(model, "decoded metadata updates compact geometry") {
|
||||
!model.compactWindow.isTransitioning &&
|
||||
abs(window.frame.width / window.frame.height - model.state.video_aspect_ratio) < 0.01
|
||||
abs(model.compactWindow.presentationWindow!.frame.width / model.compactWindow.presentationWindow!.frame.height -
|
||||
model.state.video_aspect_ratio) < 0.01
|
||||
}
|
||||
model.handleKey(compactKey)
|
||||
until(model, "compact shortcut restores during network buffering") {
|
||||
@@ -100,6 +102,12 @@ struct AutomaticQualityPlaybackChecks {
|
||||
polled(model)
|
||||
require(model.youtubeHeight == 1080 && !model.resolvingStream,
|
||||
"disabled automatic quality leaves a stalled manual/default stream alone")
|
||||
let captionCountURL = URL(string: ProcessInfo.processInfo.environment["TEREBI_CAPTION_COUNT"]!)!
|
||||
require(try! String(contentsOf: captionCountURL, encoding: .utf8) == "0",
|
||||
"Off never requests captions during extraction or playback")
|
||||
require(model.selectedSubtitle == nil && model.subtitleTracks.count == 1, "YouTube captions default Off")
|
||||
model.selectSubtitle(0)
|
||||
until(model, "selected original-auto VTT loads through native HTTP") { model.subtitleText == "Automatic caption" }
|
||||
model.setAutomaticYouTubeQuality(true)
|
||||
polled(model)
|
||||
let position = model.state.position
|
||||
@@ -110,11 +118,16 @@ struct AutomaticQualityPlaybackChecks {
|
||||
}
|
||||
require(model.state.position >= position,
|
||||
"automatic quality reopening does not reset acknowledged progress")
|
||||
until(model, "automatic downshift restores the selected caption") { model.subtitleText == "Automatic caption" }
|
||||
require(model.selectedSubtitle == 0, "automatic downshift retains caption identity")
|
||||
polled(model)
|
||||
clock.now = 2063
|
||||
until(model, "sustained health automatically restores actual 1080p media") {
|
||||
model.youtubeHeight == 1080 && model.canTogglePlayback && model.state.status == PLAYER_PLAYING && !model.state.seek_pending
|
||||
}
|
||||
until(model, "automatic upshift restores the selected caption") { model.subtitleText == "Automatic caption" }
|
||||
require(model.selectedSubtitle == 0, "automatic upshift retains caption identity")
|
||||
model.selectSubtitle(nil)
|
||||
model.setYouTubeQuality(model.youtubeHeight)
|
||||
require(!model.automaticYouTubeQuality && !model.isBusy && model.state.status == PLAYER_PLAYING,
|
||||
"reselecting the current manual resolution disables auto without disrupting playback")
|
||||
@@ -134,6 +147,7 @@ struct AutomaticQualityPlaybackChecks {
|
||||
model.youtubeHeight == 720 && model.canChangeYouTubeQuality && model.state.status == PLAYER_PLAYING &&
|
||||
!model.state.seek_pending && (model.bufferedRange.map { $0.upperBound - model.state.position >= 20 } ?? false)
|
||||
}
|
||||
require(model.selectedSubtitle == nil && model.subtitleText.isEmpty, "manual changes preserve Off after automatic changes")
|
||||
let selectedTitle = model.fileName
|
||||
let selectedQuality = model.youtubeQuality
|
||||
mark("gate-1080")
|
||||
|
||||
@@ -7,16 +7,45 @@ from pathlib import Path
|
||||
import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from streaming import StreamingServer
|
||||
|
||||
|
||||
class CaptionStreamingServer(StreamingServer):
|
||||
def __init__(self, fixtures):
|
||||
super().__init__(fixtures)
|
||||
self.caption_requests = 0
|
||||
|
||||
def _get(self, request):
|
||||
path = urlsplit(request.path).path
|
||||
if path == "/caption-count":
|
||||
self._reply(request, 200, str(self.caption_requests).encode())
|
||||
return
|
||||
if path == "/captions.vtt":
|
||||
if request.headers.get("X-Subtitle") != "caption-private":
|
||||
self._reply(request, 403, b"Missing caption credentials")
|
||||
return
|
||||
self.caption_requests += 1
|
||||
self._reply(request, 200, b"WEBVTT\n\n00:00.000 --> 02:00.000\nAutomatic caption\n", "text/vtt")
|
||||
return
|
||||
super()._get(request)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("binary", type=Path)
|
||||
parser.add_argument("fixtures", type=Path)
|
||||
parser.add_argument("app", type=Path)
|
||||
parser.add_argument("--cast-session", type=Path)
|
||||
args = parser.parse_args()
|
||||
if args.cast_session:
|
||||
with CaptionStreamingServer(args.fixtures) as server:
|
||||
result = subprocess.run([str(args.cast_session.resolve()), "--youtube-subtitles",
|
||||
str(args.binary.resolve()), str(args.fixtures.resolve() / "cache.mp4")],
|
||||
env=dict(os.environ, TEREBI_CAST_YOUTUBE_SOURCE=server.base_url), timeout=150, check=True)
|
||||
assert server.caption_requests == 2, "only initial selection and local restoration fetch captions"
|
||||
return result.returncode
|
||||
macOS = args.app.resolve() / "Contents/MacOS"
|
||||
extractor = str(macOS / "yt-dlp")
|
||||
runtime = macOS / "deno"
|
||||
@@ -25,7 +54,7 @@ def main():
|
||||
required = ("quality-1080.mp4", "quality-720.mp4")
|
||||
if any(not (args.fixtures / name).is_file() for name in required):
|
||||
parser.error("Missing real quality playback fixtures")
|
||||
with StreamingServer(args.fixtures) as server, tempfile.TemporaryDirectory(prefix="terebi-quality-check-") as directory:
|
||||
with CaptionStreamingServer(args.fixtures) as server, tempfile.TemporaryDirectory(prefix="terebi-quality-check-") as directory:
|
||||
root = Path(directory)
|
||||
(root / "deno").symlink_to(runtime)
|
||||
formats = []
|
||||
@@ -37,7 +66,9 @@ def main():
|
||||
"http_headers": {"X-Terebi-Fixture": "combined"}})
|
||||
info = {"id": "aqz-KE-bpKQ", "title": "Automatic quality playback fixture", "duration": 120,
|
||||
"is_live": False, "webpage_url": "https://www.youtube.com/watch?v=aqz-KE-bpKQ",
|
||||
"extractor": "youtube", "extractor_key": "Youtube", "formats": formats}
|
||||
"extractor": "youtube", "extractor_key": "Youtube", "formats": formats,
|
||||
"automatic_captions": {"en-orig": [{"ext": "vtt", "name": "English (Original)",
|
||||
"url": server.base_url + "/captions.vtt", "http_headers": {"X-Subtitle": "caption-private"}}]}}
|
||||
metadata = root / "info.json"
|
||||
metadata.write_text(json.dumps(info))
|
||||
wrapper = root / "yt-dlp"
|
||||
@@ -67,7 +98,7 @@ def main():
|
||||
+ ' "${args[@]:0:${#args[@]}-2}"\n')
|
||||
wrapper.chmod(0o755)
|
||||
environment = dict(os.environ, PATH=str(root) + os.pathsep + os.environ.get("PATH", ""),
|
||||
TEREBI_QUALITY_CONTROL=str(control))
|
||||
TEREBI_QUALITY_CONTROL=str(control), TEREBI_CAPTION_COUNT=server.base_url + "/caption-count")
|
||||
try:
|
||||
result = subprocess.run([str(args.binary.resolve())], env=environment, timeout=90, check=False)
|
||||
except subprocess.TimeoutExpired:
|
||||
|
||||
@@ -211,7 +211,19 @@ struct CastPlaybackCheck {
|
||||
let suite = "terebi.cast-playback-check." + UUID().uuidString
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
let model = PlayerModel(defaults: defaults, skipClock: { 100 })
|
||||
let youtubeSource = ProcessInfo.processInfo.environment["TEREBI_CAST_YOUTUBE_SOURCE"]
|
||||
let identity = youtubeSource == nil ? source : URL(string: "https://www.youtube.com/watch?v=AAAAAAAAAAA")!
|
||||
let model = PlayerModel(defaults: defaults, youtubeResolver: { url, _ in
|
||||
let base = youtubeSource!
|
||||
return YouTubeVideo(title: "YouTube Cast captions", video: .init(url: base + "/media/cache.mp4",
|
||||
headers: "X-Terebi-Fixture: combined\r\n"), audio: nil, duration: 120, isLive: false,
|
||||
webpageURL: url, height: 1080, availableHeights: [1080], chapters: [],
|
||||
subtitles: [
|
||||
.init(url: base + "/captions.vtt", format: "vtt", language: "en", label: "English",
|
||||
isDefault: false, headers: ["X-Subtitle": "caption-private"], selectionID: "youtube:manual:en"),
|
||||
.init(url: base + "/unavailable.vtt", format: "vtt", language: "fr", label: "Unavailable",
|
||||
isDefault: false, headers: [:], selectionID: "youtube:manual:fr")])
|
||||
}, youtubeTitleResolver: { _ in nil }, skipClock: { 100 })
|
||||
defer { model.shutdown() }
|
||||
if CommandLine.arguments.count == 5 {
|
||||
if CommandLine.arguments[3] == "queue" {
|
||||
@@ -223,7 +235,7 @@ struct CastPlaybackCheck {
|
||||
return
|
||||
}
|
||||
model.setVolume(0)
|
||||
model.open(source)
|
||||
model.open(identity)
|
||||
try await until(model, "local source plays") { model.state.status == PLAYER_PLAYING && model.canSeek }
|
||||
requirePlaybackPower(system: true, display: true, "local video prevents system and display sleep")
|
||||
require(model.setPlayback(playing: false), "local pause accepted")
|
||||
@@ -232,6 +244,10 @@ struct CastPlaybackCheck {
|
||||
require(model.seekRelative(9.25 - model.state.position), "local restoration position accepted")
|
||||
require(!model.canCast, "Cast cannot transfer an unacknowledged local seek")
|
||||
try await until(model, "local position acknowledged") { !model.state.seek_pending && abs(model.state.position - 9.25) < 0.15 }
|
||||
if youtubeSource != nil {
|
||||
model.selectSubtitle(0)
|
||||
try await until(model, "local YouTube caption selected before handoff") { model.subtitleText == "Automatic caption" }
|
||||
}
|
||||
model.cast(to: CastDevice(id: "loopback", name: "Loopback TV", host: "127.0.0.1", port: port))
|
||||
requirePlaybackPower(system: true, display: false, "pending Cast handoff protects the relay")
|
||||
try await until(model, "receiver load acknowledges paused handoff") {
|
||||
@@ -240,6 +256,12 @@ struct CastPlaybackCheck {
|
||||
requirePlaybackPower(system: false, display: false, "paused Cast permits sleep")
|
||||
require(abs(model.state.position - 9.25) < 0.15, "Cast starts at acknowledged local position")
|
||||
require(!model.canStepFrame && model.bufferedRange == nil, "local decoder-only controls are not exposed while casting")
|
||||
if youtubeSource != nil {
|
||||
require(!model.canSelectSubtitle && model.selectedSubtitle == 0 && model.subtitleText.isEmpty,
|
||||
"YouTube Cast disables local captions without losing the local choice")
|
||||
model.selectSubtitle(1)
|
||||
require(model.commandError == nil && model.selectedSubtitle == 0, "unsupported Cast captions cannot interrupt playback")
|
||||
}
|
||||
require(model.setPlayback(playing: true), "remote play accepted")
|
||||
try await until(model, "receiver play acknowledged") { model.state.status == PLAYER_PLAYING && model.playbackIntent == nil }
|
||||
requirePlaybackPower(system: true, display: false, "playing Cast protects the relay but permits display sleep")
|
||||
@@ -266,7 +288,12 @@ struct CastPlaybackCheck {
|
||||
abs(model.state.position - 24.25) < 0.15 && !model.isBusy
|
||||
}
|
||||
requirePlaybackPower(system: false, display: false, "paused local restoration permits sleep")
|
||||
require(abs((PlaybackProgress(defaults: defaults).position(for: source) ?? 0) - 24.25) < 0.15,
|
||||
if youtubeSource != nil {
|
||||
try await until(model, "return to Mac restores local YouTube caption selection") {
|
||||
model.canSelectSubtitle && model.selectedSubtitle == 0 && model.subtitleText == "Automatic caption"
|
||||
}
|
||||
}
|
||||
require(abs((PlaybackProgress(defaults: defaults).position(for: identity) ?? 0) - 24.25) < 0.15,
|
||||
"remote history is not overwritten by the abandoned local position")
|
||||
require(model.volume == 0, "local volume survives receiver volume changes")
|
||||
require(model.setPlayback(playing: true), "resume local playback before a failed Cast connection")
|
||||
|
||||
@@ -879,6 +879,12 @@ struct CastSessionCheck {
|
||||
try await until("Disconnect STOP reaches receiver") { peer.commands.contains { $0.0 == "STOP" } }
|
||||
let load = peer.commands.first { $0.0 == "LOAD" }!.1
|
||||
require(load["currentTime"] as? Double == 9.25 && load["autoplay"] as? Bool == false, "Acknowledged paused handoff")
|
||||
if ProcessInfo.processInfo.environment["TEREBI_CAST_YOUTUBE_SOURCE"] != nil {
|
||||
let media = load["media"] as! [String: Any]
|
||||
require((media["tracks"] as? [Any] ?? []).isEmpty &&
|
||||
(load["activeTrackIds"] as? [Any] ?? []).isEmpty,
|
||||
"YouTube Cast LOAD contains no captions or invalid local track selection")
|
||||
}
|
||||
require(peer.relayVerified, "Receiver fetches real relay HLS and media bytes")
|
||||
}
|
||||
|
||||
@@ -1034,6 +1040,11 @@ struct CastSessionCheck {
|
||||
print("PASS: latest slider intent, stale volume feedback, authoritative acknowledgement, external TV changes, timeout")
|
||||
return
|
||||
}
|
||||
if CommandLine.arguments.count == 4, CommandLine.arguments[1] == "--youtube-subtitles" {
|
||||
try await playback(identity: identity, executable: CommandLine.arguments[2], file: CommandLine.arguments[3])
|
||||
print("PASS: YouTube Cast omits optional captions and restores the local choice on return")
|
||||
return
|
||||
}
|
||||
if CommandLine.arguments.count == 4, CommandLine.arguments[1] == "--playback" {
|
||||
try await playback(identity: identity, executable: CommandLine.arguments[2], file: CommandLine.arguments[3])
|
||||
print("PASS: native TLS receiver fetches relay and acknowledges PlayerModel handoff/control/disconnect")
|
||||
|
||||
@@ -70,7 +70,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
captions = path.startswith("/captions/")
|
||||
if alternate or captions:
|
||||
path = "/" + path.split("/", 2)[2]
|
||||
expected = "alternate-private" if alternate else "subtitle-private" if captions or path == "/english.jpeg" else "stream-private"
|
||||
expected = "alternate-private" if alternate else "subtitle-private" if captions or path in ("/english.jpeg", "/denied.vtt") else "stream-private"
|
||||
if self.headers.get("X-Resource") != expected:
|
||||
errors.append("wrong resource headers: " + path)
|
||||
self.send_error(403)
|
||||
@@ -90,6 +90,11 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
elif path == "/english.jpeg":
|
||||
data = (b'WEBVTT\n\n00:00.000 --> 00:10.000\nAlternate English cue\n' if alternate else
|
||||
b'WEBVTT\n\n00:00.000 --> 00:02.000\nFirst English cue\n\n00:02.000 --> 00:04.000\nSecond English cue\n')
|
||||
if parse_qs(urlsplit(self.path).query).get("auto") == ["1"]:
|
||||
data = data.replace(b"First English cue", b"First automatic cue").replace(b"Second English cue", b"Second automatic cue")
|
||||
elif path == "/denied.vtt":
|
||||
self.send_error(403)
|
||||
return
|
||||
elif path.startswith("/resolved-vod-") and path.endswith(".ts"):
|
||||
data = (fixtures / path[1:]).read_bytes()
|
||||
else:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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