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)
|
||||
|
||||
@@ -216,8 +216,8 @@ $(BUILD_DIR)/configuration-check: Checks/configuration.swift $(CONFIG_LIBRARY) M
|
||||
check-config: $(BUILD_DIR)/configuration-check
|
||||
$(BUILD_DIR)/configuration-check
|
||||
|
||||
$(BUILD_DIR)/youtube-resolver-check: Checks/youtube-resolver.swift Sources/App/YouTubeResolver.swift Makefile | $(BUILD_DIR) check-dependencies
|
||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/youtube-resolver.swift Sources/App/YouTubeResolver.swift -framework Foundation -o $@
|
||||
$(BUILD_DIR)/youtube-resolver-check: Checks/youtube-resolver.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift Sources/App/StreamResolver.swift Makefile | $(BUILD_DIR) check-dependencies
|
||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/youtube-resolver.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift Sources/App/StreamResolver.swift -framework Foundation -o $@
|
||||
|
||||
check-youtube: $(BUILD_DIR)/youtube-resolver-check $(BUNDLE_STAMP)
|
||||
$(BUILD_DIR)/youtube-resolver-check "$(abspath $(APP))"
|
||||
@@ -360,6 +360,7 @@ check-cast: $(BUILD_DIR)/cast-session-check $(BUILD_DIR)/cast-media-check $(BUIL
|
||||
$(BUILD_DIR)/cast-media-check --remux-work
|
||||
$(BUILD_DIR)/cast-media-check --codec-work
|
||||
$(BUILD_DIR)/cast-session-check --playback $(BUILD_DIR)/cast-playback-check $(BUILD_DIR)/fixtures/cache.mp4
|
||||
python3 Checks/automatic-quality.py $(BUILD_DIR)/cast-playback-check $(BUILD_DIR)/fixtures "$(APP)" --cast-session $(BUILD_DIR)/cast-session-check
|
||||
|
||||
$(BUILD_DIR)/app-startup-check.dylib: Checks/app-startup.swift $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies
|
||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -emit-library -DPLAYER_CHECK -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h Checks/app-startup.swift $(SWIFT_SOURCES) $(CONFIG_FLAGS) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@
|
||||
|
||||
@@ -28,6 +28,7 @@ The app icon uses layered glass screens and the amber, ember, cream and dark-bro
|
||||
- **Stream queues:** integrations can supply an ordered yt-dlp/Miruro queue with `--new-instance --queue-file /absolute/queue.json`, or append without interrupting the running player using `--enqueue --queue-file /absolute/queue.json` (also for a single item). Enqueue starts playback if the player is empty or not running. The same sidebar supports play, reorder, remove, previous/next and automatic advance. Unresolved titles, including the selected row, load serially at lower priority without waiting for playback loading, buffering or quality extraction. YouTube uses a title-only oEmbed request; other sites use yt-dlp metadata and Miruro uses optional episode metadata. Arrival still depends on the site/network; failed lookups retain the supplied label, and full playback metadata takes precedence. Playback sources still resolve fresh when selected or revisited; local-file batches still use natural filename order. yt-dlp items use the original page URL for resume and version-1 progress reports; recognized YouTube video URLs still canonicalize.
|
||||
- **Seeking:** click or drag the progress track or thumb to seek. The thumb centers on the cursor, clamped to the track endpoints. While mouse-seeking, the segment under the thumb smoothly thickens and returns to normal on release. Hovering a named segment fades its title in above the segment's center. Reduce Motion disables these transitions.
|
||||
- **YouTube chapters:** when yt-dlp provides usable chapters, small rounded gaps mark their boundaries in the playback track. Boundaries use the current player's duration; missing or malformed chapter data leaves ordinary playback unchanged. Chapters do not add opening/ending skip buttons. Availability varies, and description-derived timestamps may differ from YouTube's displayed chapters.
|
||||
- **YouTube subtitles:** the existing subtitle menu offers manual WebVTT tracks and original-language automatic captions (marked **auto-generated**), up to 32 tracks. Captions default **Off** and fetch only when selected. A track—or Off—survives manual and automatic quality changes; queue changes reset to Off. Caption errors do not stop video playback. Machine-translated automatic tracks and live captions are not offered; captions requiring additional YouTube access may be unavailable.
|
||||
- **Sponsored ranges:** optional [SponsorBlock](https://sponsor.ajay.app/) sponsor/skip ranges paint the existing track crimson without adding chapter boundaries or changing chapter gaps. Hover shows **Sponsor** alongside any chapter title, centered over the sponsored range itself even when it crosses chapter boundaries; accessibility also identifies sponsored ranges. Inside a range, **Skip Sponsor** uses the opening/ending button without a timeout or countdown fill. Clicking seeks to the range's end; automatic skipping is a separate opt-in.
|
||||
- **Miruro metadata:** titles and opening/ending intervals load independently of playback when available. Small rounded gaps mark interval boundaries in the slider. Inside a segment, **Skip Opening** or **Skip Ending** appears at the top-right below the titlebar for up to ten seconds or until the segment ends; clicking seeks to its end. A light background fill grows as the offer expires, with a short entrance/exit transition; Reduce Motion uses stepped countdown updates without the transition. Ignored offers stay dismissed until the media is closed or replaced. Missing/invalid metadata leaves playback usable, and timings may differ between cuts. Automatic skipping is configurable below.
|
||||
- **Resume:** videos resume their saved position; finished videos restart from the beginning. Live and audio-only media do not save progress.
|
||||
@@ -57,7 +58,7 @@ Open media, select the **Cast** icon or **Playback → Cast to Device**, and cho
|
||||
|
||||
Playback controls and queue navigation work on the receiver. **Play on This Mac** returns to local playback at the receiver's position. Keep the Mac connected: it relays the media and prevents idle system sleep during playback, while allowing display sleep when casting. Closing the lid or explicitly sleeping the Mac can interrupt playback.
|
||||
|
||||
Cast output is limited to 1080p/30 fps; frame stepping and stream quality changes are local-only. Use Cast only on a trusted network. AirPlay is not supported.
|
||||
Cast output is limited to 1080p/30 fps; frame stepping and stream quality changes are local-only. YouTube captions are also local-only: their selector is disabled while casting, no caption tracks are fetched for the receiver, and returning to this Mac restores the local selection. Use Cast only on a trusted network. AirPlay is not supported.
|
||||
|
||||
## Command line
|
||||
|
||||
@@ -164,7 +165,7 @@ Native playback supports the returned proxy configuration and HLS query/segment-
|
||||
## Limitations
|
||||
|
||||
- SDR output only; HDR is not supported.
|
||||
- No embedded or YouTube captions, manual audio-track selection, rotation or deinterlacing. External WebVTT subtitles are supported for resolved streams.
|
||||
- No embedded captions, manual audio-track selection, rotation or deinterlacing. External WebVTT subtitles are supported for resolved streams and YouTube videos.
|
||||
- yt-dlp support does not guarantee playback: extraction must yield HTTP(S)/HLS media without download-only fragment assembly. Audio-only results with explicit codec metadata are unsupported. Private, age-restricted, region-blocked or sign-in-required videos may fail; browser cookies are not used. Rebuild and reinstall for bundled extractor updates.
|
||||
- YouTube extraction uses IPv4 to avoid anti-bot failures on affected IPv6 routes. This does not bypass account or regional restrictions; other video sites retain their default network routing.
|
||||
- Seeking can be slow on long-GOP or unindexed media. Live streams cannot be sought, and expired stream URLs are not automatically refreshed.
|
||||
@@ -177,6 +178,7 @@ Install FFmpeg for test fixtures (`brew install ffmpeg`), then run `make check`.
|
||||
For the focused native compact-queue smoke (transparent, noninteractive app window), run `TEREBI_CHECK_COMPACT_QUEUE_ONLY=1 make check-startup`. `make check-window check-config` covers dropdown geometry, resizing, metadata aspect changes, restoration and configurable queue shortcuts without displaying windows.
|
||||
|
||||
`make check-sponsorblock` exercises the bounded native lookup and duration validation with local fixtures; `.build/sponsorblock-check --live` performs read-only public API probes. `make check-resolved` includes persistent manual sponsor skips, native sponsor/opening/ending autoskip, deliberate relative/slider seek protection, paused/late metadata, saved preferences, opt-in/failure isolation, same-video cancellation, direct/saved/queue/enqueue launches, quality changes and Miruro separation. The native slider regression (`.build/cast-playback-check --slider-regression`, after building that target) checks crimson painting across chapter gaps, sponsor-centered hover anchors, buffering, active emphasis and seeking.
|
||||
`make check-youtube check-subtitles check-resolved check-adaptive` covers caption metadata filtering, native HTTP fetching, Off/On, seeking, stable manual/auto selection through quality changes, queue resets and optional failures. `make check-cast` also checks YouTube caption isolation during Cast handoff and local selection restoration; it uses a simulated TLS receiver, not physical Chromecast hardware.
|
||||
`make check-zoko check-reporting check-cli check-stream check-progress` covers provider admission/codec/error categories, bounded resources, scoped CDN policy, isolated resume and report identity without launching a GUI. Optional `make check-zoko-live` resolves current AniList 1 episode 1, loads VTT, and uses the **actual native player** while paused to decode/step video, seek/decode at 600 seconds, restore position and assert version-2 AniList progress. It creates no windows and does not start audio playback, but production Core Audio still initializes an output device. No tokenized addresses are printed or retained after the check. This is not a full-episode, audio-output, pixel/subtitle-rendering or UI smoke test, nor proof of availability for other catalog entries. Full `make check` still launches GUI/audio checks.
|
||||
|
||||
`make check-source` exercises the public parser/result contract, error redaction, shared manifest validation, total deadline/cancellation and actual CLI rejection paths without GUI/audio or native-player linking. Optional `make check-source-live` runs the **actual CLI**, copied outside its app bundle with an intentionally invalid configuration and isolated state directories: Cowboy Bebop, Black Clover episodes 1/170 and Black Clover Season 2 episode 1 must resolve through their exact MAL mappings, while Cowboy Bebop episode 27 remains `unavailable`, with no state writes. These source-only probes fetch no segments and are distinct from `check-zoko-live`'s native playback checks. `.build/zoko-check --live-black-clover` exercises native decoding, ten-minute seek, subtitles, resume and original AniList progress identity for Black Clover.
|
||||
|
||||
@@ -7,6 +7,8 @@ struct ResolvedSubtitle: Decodable, Sendable {
|
||||
let language: String
|
||||
let label: String
|
||||
let isDefault: Bool
|
||||
// Extractor identity survives expiring URLs and distinguishes manual from automatic captions.
|
||||
var selectionID: String? = nil
|
||||
private let headerFields: [String: String]
|
||||
var proxy: MiruroProxy? = nil
|
||||
var headers: String {
|
||||
@@ -15,7 +17,7 @@ struct ResolvedSubtitle: Decodable, Sendable {
|
||||
fileprivate var validatedHeaders: [String: String] { headerFields }
|
||||
|
||||
init(url: String, format: String, language: String, label: String, isDefault: Bool,
|
||||
headers: [String: String], proxy: MiruroProxy? = nil) {
|
||||
headers: [String: String], proxy: MiruroProxy? = nil, selectionID: String? = nil) {
|
||||
self.url = url
|
||||
self.format = format
|
||||
self.language = language
|
||||
@@ -23,6 +25,7 @@ struct ResolvedSubtitle: Decodable, Sendable {
|
||||
self.isDefault = isDefault
|
||||
self.headerFields = headers
|
||||
self.proxy = proxy
|
||||
self.selectionID = selectionID
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case url, format, language, label
|
||||
|
||||
@@ -201,6 +201,10 @@ final class PlayerModel: ObservableObject {
|
||||
@Published private(set) var subtitleError: String?
|
||||
private var subtitleDocument: WebVTTDocument?
|
||||
private var subtitleTask: Task<Void, Never>?
|
||||
var canSelectSubtitle: Bool {
|
||||
if case .youtube? = currentMedia, castDevice != nil { return false }
|
||||
return !subtitleTracks.isEmpty
|
||||
}
|
||||
@Published var seekPosition = 0.0
|
||||
@Published var isSeeking = false
|
||||
@Published private(set) var volume = 1.0
|
||||
@@ -380,7 +384,9 @@ final class PlayerModel: ObservableObject {
|
||||
guard canCast, let source = currentMedia, let handle else { return }
|
||||
cancelStreamResolution()
|
||||
commandError = nil
|
||||
casting.start(to: device, source: source, title: fileName ?? "Terebi", subtitle: selectedSubtitle,
|
||||
// YouTube captions remain local-only: never prefetch optional tracks during Cast handoff.
|
||||
let subtitle: Int? = if case .youtube = source { nil } else { selectedSubtitle }
|
||||
casting.start(to: device, source: source, title: fileName ?? "Terebi", subtitle: subtitle,
|
||||
handle: handle, snapshot: state, playing: playing ?? effectivePlaying,
|
||||
volume: volume, audibleVolume: lastAudibleVolume)
|
||||
revealControls()
|
||||
@@ -404,7 +410,7 @@ final class PlayerModel: ObservableObject {
|
||||
state = projection.state
|
||||
volume = projection.volume
|
||||
if volume > 0 { lastAudibleVolume = volume }
|
||||
selectedSubtitle = projection.subtitle
|
||||
if case .resolved? = currentMedia { selectedSubtitle = projection.subtitle }
|
||||
if !isSeeking && !state.seek_pending { seekPosition = state.position }
|
||||
if let handle { _ = player_report_remote_state(handle, &state) }
|
||||
saveProgress()
|
||||
@@ -597,6 +603,11 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
private func resolveStream(_ stream: StreamURL, change: MediaChange) {
|
||||
replaceMetadata(for: nil)
|
||||
if !change.isQuality {
|
||||
if castDevice != nil { stopCasting(resume: false) }
|
||||
selectSubtitle(nil)
|
||||
subtitleTracks = []
|
||||
}
|
||||
switch stream {
|
||||
case .youtube(let url): resolveYouTube(url, height: nil, change: change)
|
||||
case .miruro(let mediaID, let episode): resolveMiruro(mediaID: mediaID, episode: episode, change: change)
|
||||
@@ -620,6 +631,7 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
func selectSubtitle(_ index: Int?) {
|
||||
if casting.active {
|
||||
guard canSelectSubtitle else { return }
|
||||
if !casting.setSubtitle(index) { commandError = "Could not change Cast subtitles." }
|
||||
return
|
||||
}
|
||||
@@ -1103,16 +1115,22 @@ final class PlayerModel: ObservableObject {
|
||||
if let fileName, !source.url.isFileURL, let id = queue.currentID {
|
||||
queue.updateLabel(fileName, for: id)
|
||||
}
|
||||
let previousSubtitleLanguage = selectedSubtitle.flatMap {
|
||||
subtitleTracks.indices.contains($0) ? subtitleTracks[$0].language : nil
|
||||
let previousSubtitle = selectedSubtitle.flatMap {
|
||||
subtitleTracks.indices.contains($0) ? subtitleTracks[$0] : nil
|
||||
}
|
||||
selectSubtitle(nil)
|
||||
if case .resolved(let resolved, _) = source {
|
||||
subtitleTracks = resolved.subtitles
|
||||
let defaultIndex = subtitleTracks.firstIndex(where: \.isDefault)
|
||||
selectSubtitle(change.isQuality ? previousSubtitleLanguage.flatMap { language in
|
||||
subtitleTracks.firstIndex { $0.language == language } ?? defaultIndex
|
||||
selectSubtitle(change.isQuality ? previousSubtitle.flatMap { previous in
|
||||
subtitleTracks.firstIndex { $0.language == previous.language } ?? defaultIndex
|
||||
} : defaultIndex)
|
||||
} else if case .youtube(let video, _) = source {
|
||||
subtitleTracks = video.subtitles
|
||||
// Off and a missing track both stay Off; never substitute manual/auto by language.
|
||||
if change.preservesPosition, let id = previousSubtitle?.selectionID {
|
||||
selectSubtitle(subtitleTracks.firstIndex { $0.selectionID == id })
|
||||
}
|
||||
} else {
|
||||
subtitleTracks = []
|
||||
}
|
||||
@@ -2987,6 +3005,7 @@ private struct CaptionSelector: NSViewRepresentable {
|
||||
required init?(coder: NSCoder) { nil }
|
||||
|
||||
func updateTracks() {
|
||||
isEnabled = model.canSelectSubtitle
|
||||
guard menu == nil || displayedSelection != model.selectedSubtitle ||
|
||||
displayedTracks.count != model.subtitleTracks.count ||
|
||||
zip(displayedTracks, model.subtitleTracks).contains(where: {
|
||||
@@ -3013,7 +3032,6 @@ private struct CaptionSelector: NSViewRepresentable {
|
||||
}
|
||||
menu = tracks
|
||||
(cell as? NSPopUpButtonCell)?.menuItem = captionItem
|
||||
isEnabled = !model.subtitleTracks.isEmpty
|
||||
}
|
||||
|
||||
@objc private func selectTrack(_ item: NSMenuItem) {
|
||||
@@ -3457,7 +3475,7 @@ private struct PlayerContent: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(model.subtitleTracks.isEmpty)
|
||||
.disabled(!model.canSelectSubtitle)
|
||||
Divider()
|
||||
}
|
||||
PlayerActionButton(model: model, action: model.bufferInfoAction,
|
||||
|
||||
@@ -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