use std::time::Duration;
use crate::error::{AppError, AppResult};
const WATCH_PAGE_ORIGIN: &str = "https://www.youtube.com";
pub const DEFAULT_WATCH_PROBE_TIMEOUT_SECS: u64 = 20;
#[must_use]
pub fn watch_probe_timeout() -> Duration {
Duration::from_secs(crate::config::tuning_u64_in_range(
"net.watch_probe_timeout_secs",
DEFAULT_WATCH_PROBE_TIMEOUT_SECS,
1,
3_600,
))
}
#[derive(Debug, Clone)]
pub(super) struct WatchProbe {
pub(super) base: String,
pub(super) timeout: Duration,
}
impl WatchProbe {
pub(super) fn live() -> Self {
Self {
base: WATCH_PAGE_ORIGIN.to_string(),
timeout: watch_probe_timeout(),
}
}
pub(super) async fn watch_page(&self, video_id: &str) -> AppResult<String> {
let client = crate::net::session::chrome_client(self.timeout)?;
let url = format!("{}/watch?v={video_id}", self.base.trim_end_matches('/'));
client
.get(url)
.send()
.await
.map_err(AppError::Http)?
.error_for_status()
.map_err(AppError::Http)?
.text()
.await
.map_err(AppError::Http)
}
}
pub(super) fn published_languages(html: &str) -> Option<Vec<String>> {
use crate::parse::player_response::{available_languages, caption_tracks};
let tracks = caption_tracks(html).ok()?;
Some(available_languages(&tracks))
}
pub(super) fn classify_watch_page(html: &str, language: &str) -> Option<AppError> {
use crate::parse::player_response::{available_languages, caption_tracks, classify};
let tracks = caption_tracks(html).ok()?;
if tracks.is_empty() {
return Some(AppError::NoSubtitle(
crate::error::NoSubtitleReason::NotPublished,
));
}
match classify(&tracks, language) {
Err(err @ AppError::LanguageUnavailable { .. }) => Some(err),
Err(_) => None,
Ok(_) => {
if tracks
.iter()
.all(crate::parse::player_response::CaptionTrack::is_asr)
{
Some(AppError::CaptionsAsrOnly {
asr_languages: available_languages(&tracks),
})
} else {
None
}
}
}
}
pub(super) fn chain_never_reached_a_track(err: &AppError) -> bool {
matches!(
err,
AppError::RateLimited { .. } | AppError::CaptchaChallenge { .. }
)
}
#[cfg(test)]
mod watch_probe_tests {
use super::*;
use crate::error::AppResult;
use crate::error::NoSubtitleReason;
use crate::parse::player_response::test_pages::{
watch_page_with, ASR_ONLY_BLOCK, CAPTIONS_BLOCK, NO_CAPTIONS_BLOCK, PT_ONLY_BLOCK,
};
use crate::provider::chain::ledger::AttemptOutcome;
use crate::provider::{Format, Provider, ProviderChain, SubtitleInfo};
use async_trait::async_trait;
struct AlwaysUnavailable;
#[async_trait]
impl Provider for AlwaysUnavailable {
fn name(&self) -> &'static str {
"provider-test"
}
async fn fetch_subtitle(
&self,
_video_id: &str,
_language: &str,
_format: Format,
) -> AppResult<SubtitleInfo> {
Err(AppError::ProviderUnavailable {
provider: "provider-test",
})
}
async fn fetch_content(&self, _info: &SubtitleInfo) -> AppResult<Vec<u8>> {
Err(AppError::ProviderUnavailable {
provider: "provider-test",
})
}
}
struct AlwaysRateLimited;
#[async_trait]
impl Provider for AlwaysRateLimited {
fn name(&self) -> &'static str {
"provider-test"
}
async fn fetch_subtitle(
&self,
_video_id: &str,
_language: &str,
_format: Format,
) -> AppResult<SubtitleInfo> {
Err(AppError::RateLimited {
provider: "provider-test",
retry_after_secs: None,
})
}
async fn fetch_content(&self, _info: &SubtitleInfo) -> AppResult<Vec<u8>> {
Err(AppError::RateLimited {
provider: "provider-test",
retry_after_secs: None,
})
}
}
struct AlwaysChallenged;
#[async_trait]
impl Provider for AlwaysChallenged {
fn name(&self) -> &'static str {
"provider-test"
}
async fn fetch_subtitle(
&self,
_video_id: &str,
_language: &str,
_format: Format,
) -> AppResult<SubtitleInfo> {
Err(AppError::CaptchaChallenge {
provider: "provider-test",
kind: "checkbox-grid",
})
}
async fn fetch_content(&self, _info: &SubtitleInfo) -> AppResult<Vec<u8>> {
Err(AppError::CaptchaChallenge {
provider: "provider-test",
kind: "checkbox-grid",
})
}
}
#[tokio::test]
async fn a_challenged_chain_keeps_its_own_cause_over_the_asr_verdict() {
let server = watch_server(200, &watch_page_with(ASR_ONLY_BLOCK)).await;
let chain = ProviderChain::with_min_interval(
vec![Box::new(AlwaysChallenged)],
std::time::Duration::ZERO,
)
.with_watch_probe_base(server.uri());
let err = chain
.fetch_subtitle("Ze0i7zxpyrw", "pt", Format::Srt)
.await
.expect_err("every provider was challenged");
assert!(
matches!(err, AppError::CaptchaChallenge { kind, .. } if kind == "checkbox-grid"),
"the challenge must survive the probe, got {err:?}"
);
}
struct AlwaysDelivers;
#[async_trait]
impl Provider for AlwaysDelivers {
fn name(&self) -> &'static str {
"provider-test"
}
async fn fetch_subtitle(
&self,
video_id: &str,
language: &str,
format: Format,
) -> AppResult<SubtitleInfo> {
Ok(SubtitleInfo {
video_id: video_id.to_string(),
language: language.to_string(),
delivered_language: None,
format,
source_url: "https://example.invalid/track".to_string(),
byte_size: 0,
format_hint: crate::provider::SubtitleFormat::Srt,
provider: "provider-test",
})
}
async fn fetch_content(&self, _info: &SubtitleInfo) -> AppResult<Vec<u8>> {
Ok(b"1\n00:00:01,000 --> 00:00:02,000\nola\n".to_vec())
}
}
#[tokio::test]
async fn a_delivered_body_is_refused_when_the_page_lacks_the_requested_language() {
let server = watch_server(200, &watch_page_with(PT_ONLY_BLOCK)).await;
let chain = ProviderChain::with_min_interval(
vec![Box::new(AlwaysDelivers)],
std::time::Duration::ZERO,
)
.with_watch_probe_base(server.uri());
let err = chain
.fetch_subtitle("Ze0i7zxpyrw", "en", Format::Srt)
.await
.expect_err("the page publishes pt only, so en was never deliverable");
match err {
AppError::LanguageUnavailable { available } => {
assert_eq!(available, vec!["pt".to_string()]);
}
other => panic!("expected LanguageUnavailable, got {other:?}"),
}
}
#[tokio::test]
async fn the_refusal_records_the_watch_page_as_its_own_evidence() {
let server = watch_server(200, &watch_page_with(PT_ONLY_BLOCK)).await;
let chain = ProviderChain::with_min_interval(
vec![Box::new(AlwaysDelivers)],
std::time::Duration::ZERO,
)
.with_watch_probe_base(server.uri());
let (result, attempts) = chain
.fetch_subtitle_traced("Ze0i7zxpyrw", "en", Format::Srt)
.await;
assert!(
result.is_err(),
"en was not deliverable from a pt-only page"
);
let probe = attempts
.iter()
.find(|a| a.provider == super::super::WATCH_PAGE_SOURCE)
.expect("the source that decided the outcome must appear in attempts");
assert_eq!(probe.outcome, AttemptOutcome::LanguageUnavailable);
assert!(
attempts.iter().any(|a| a.provider == "provider-test"),
"the provider that delivered must still be recorded"
);
}
#[tokio::test]
async fn a_single_language_page_names_the_delivered_track() {
let server = watch_server(200, &watch_page_with(PT_ONLY_BLOCK)).await;
let chain = ProviderChain::with_min_interval(
vec![Box::new(AlwaysDelivers)],
std::time::Duration::ZERO,
)
.with_watch_probe_base(server.uri());
let (info, _body) = chain
.fetch_subtitle("Ze0i7zxpyrw", "pt", Format::Srt)
.await
.expect("pt is exactly what the page publishes");
assert_eq!(
info.delivered_language,
Some("pt".to_string()),
"the observation was available on the page and had to reach the envelope"
);
}
#[tokio::test]
async fn an_unreadable_page_leaves_a_successful_delivery_alone() {
let server = watch_server(500, "not a watch page").await;
let chain = ProviderChain::with_min_interval(
vec![Box::new(AlwaysDelivers)],
std::time::Duration::ZERO,
)
.with_watch_probe_base(server.uri());
let (info, _body) = chain
.fetch_subtitle("Ze0i7zxpyrw", "en", Format::Srt)
.await
.expect("an inconclusive probe cannot overrule a delivery");
assert!(
info.delivered_language.is_none(),
"an unknown answer stays declared instead of being guessed"
);
}
async fn watch_server(status: u16, body: &str) -> wiremock::MockServer {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/watch"))
.respond_with(wiremock::ResponseTemplate::new(status).set_body_string(body))
.mount(&server)
.await;
server
}
fn probing_chain(base: &str) -> ProviderChain {
ProviderChain::with_min_interval(
vec![Box::new(AlwaysUnavailable)],
std::time::Duration::ZERO,
)
.with_watch_probe_base(base)
}
#[test]
fn zero_tracks_classify_as_not_published() {
let page = watch_page_with(NO_CAPTIONS_BLOCK);
assert!(matches!(
classify_watch_page(&page, "pt"),
Some(AppError::NoSubtitle(NoSubtitleReason::NotPublished))
));
}
#[test]
fn tracks_without_the_requested_language_name_the_ones_that_exist() {
let page = watch_page_with(CAPTIONS_BLOCK);
match classify_watch_page(&page, "de") {
Some(AppError::LanguageUnavailable { available }) => {
assert_eq!(available, vec!["en", "pt"]);
}
other => panic!("expected LanguageUnavailable, got {other:?}"),
}
}
#[test]
fn an_all_asr_page_carrying_the_requested_language_is_the_new_kind() {
let page = watch_page_with(ASR_ONLY_BLOCK);
match classify_watch_page(&page, "pt") {
Some(err @ AppError::CaptionsAsrOnly { .. }) => {
assert_eq!(err.kind(), "captions_asr_unsupported_by_provider");
assert_eq!(err.exit_code(), crate::error::sysexits::EX_NOINPUT);
assert!(!err.retryable(), "the cause is stable");
let AppError::CaptionsAsrOnly { asr_languages } = err else {
unreachable!("matched above")
};
assert_eq!(asr_languages, vec!["en", "pt"]);
}
other => panic!("expected CaptionsAsrOnly, got {other:?}"),
}
}
#[test]
fn a_page_with_one_manual_track_explains_nothing_the_chain_did_not() {
let page = watch_page_with(CAPTIONS_BLOCK);
assert!(classify_watch_page(&page, "pt").is_none());
}
#[test]
fn an_unreadable_page_yields_no_verdict() {
assert!(classify_watch_page("<html>challenge</html>", "pt").is_none());
}
#[tokio::test]
async fn the_chain_returns_the_new_kind_for_an_asr_only_video() {
let server = watch_server(200, &watch_page_with(ASR_ONLY_BLOCK)).await;
let err = probing_chain(&server.uri())
.fetch_subtitle("Ze0i7zxpyrw", "pt", Format::Srt)
.await
.expect_err("every provider failed");
assert_eq!(err.kind(), "captions_asr_unsupported_by_provider");
}
#[tokio::test]
async fn a_rate_limited_chain_keeps_its_own_cause_over_the_asr_verdict() {
let server = watch_server(200, &watch_page_with(ASR_ONLY_BLOCK)).await;
let chain = ProviderChain::with_min_interval(
vec![Box::new(AlwaysRateLimited)],
std::time::Duration::ZERO,
)
.with_watch_probe_base(server.uri());
let err = chain
.fetch_subtitle("Ze0i7zxpyrw", "pt", Format::Srt)
.await
.expect_err("every provider failed");
assert!(
matches!(err, AppError::RateLimited { provider, .. } if provider == "provider-test"),
"the 429 must survive the probe, got {err:?}"
);
}
#[tokio::test]
async fn the_chain_reports_the_languages_the_video_does_publish() {
let server = watch_server(200, &watch_page_with(CAPTIONS_BLOCK)).await;
let err = probing_chain(&server.uri())
.fetch_subtitle("Ze0i7zxpyrw", "de", Format::Srt)
.await
.expect_err("every provider failed");
match err {
AppError::LanguageUnavailable { available } => {
assert_eq!(available, vec!["en", "pt"]);
}
other => panic!("expected LanguageUnavailable, got {other:?}"),
}
}
#[tokio::test]
async fn a_failed_probe_returns_the_original_chain_error() {
let server = watch_server(500, "upstream is angry").await;
let err = probing_chain(&server.uri())
.fetch_subtitle("Ze0i7zxpyrw", "pt", Format::Srt)
.await
.expect_err("every provider failed");
assert!(
matches!(err, AppError::ProviderUnavailable { provider } if provider == "provider-test"),
"got {err:?}"
);
}
#[tokio::test]
async fn a_chain_without_a_probe_keeps_its_own_error() {
let chain = ProviderChain::with_min_interval(
vec![Box::new(AlwaysUnavailable)],
std::time::Duration::ZERO,
);
let err = chain
.fetch_subtitle("Ze0i7zxpyrw", "pt", Format::Srt)
.await
.expect_err("every provider failed");
assert!(
matches!(err, AppError::ProviderUnavailable { provider } if provider == "provider-test"),
"got {err:?}"
);
}
}