mod chain;
pub mod decopy;
pub(crate) mod health;
pub mod noiz;
pub mod robots;
pub mod stealth;
pub use decopy::ProviderDecopy;
pub use noiz::ProviderNoiz;
pub(crate) use chain::http_failure;
pub use chain::{
per_host_concurrency, throttle_interval, watch_probe_timeout, AttemptOutcome, ProviderAttempt,
ProviderChain, ProviderOutcome, DEFAULT_PER_HOST_CONCURRENCY, DEFAULT_THROTTLE_INTERVAL_MS,
DEFAULT_WATCH_PROBE_TIMEOUT_SECS,
};
use async_trait::async_trait;
use fluent_langneg::negotiate::{negotiate_languages, NegotiationStrategy};
use serde::{Deserialize, Serialize};
use unic_langid::LanguageIdentifier;
use crate::error::{AppError, AppResult};
#[must_use]
pub fn is_offline() -> bool {
crate::config::tuning_bool_or("offline", false)
}
#[must_use]
pub fn prefers_asr() -> bool {
crate::config::tuning_bool_or("asr", false)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum Format {
Srt,
Txt,
}
impl Format {
pub fn as_str(&self) -> &'static str {
match self {
Format::Srt => "srt",
Format::Txt => "txt",
}
}
pub fn extension(&self) -> &'static str {
match self {
Format::Srt => "srt",
Format::Txt => "txt",
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct SubtitleInfo {
pub video_id: String,
pub language: String,
pub delivered_language: Option<String>,
pub format: Format,
pub source_url: String,
pub byte_size: usize,
pub format_hint: SubtitleFormat,
pub provider: &'static str,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum SubtitleFormat {
#[default]
Srt,
NoteeyTranscript,
}
impl SubtitleFormat {
pub fn as_str(&self) -> &'static str {
match self {
SubtitleFormat::Srt => "srt",
SubtitleFormat::NoteeyTranscript => "noteey-transcript",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct SubtitleTrack {
pub tag: String,
pub label: String,
pub auto_generated: bool,
pub format: Format,
}
impl SubtitleTrack {
#[must_use]
pub fn new(tag: impl Into<String>, format: Format) -> Self {
let tag = tag.into();
Self {
label: tag.clone(),
tag,
auto_generated: false,
format,
}
}
#[must_use]
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = label.into();
self
}
#[must_use]
pub fn with_auto_generated(mut self, auto_generated: bool) -> Self {
self.auto_generated = auto_generated;
self
}
}
pub fn negotiate_track(
requested: &LanguageIdentifier,
tracks: &[SubtitleTrack],
) -> AppResult<SubtitleTrack> {
if tracks.is_empty() {
return Err(AppError::NoSubtitle(
crate::error::NoSubtitleReason::LanguageUnavailable,
));
}
let requested_id = crate::i18n::negotiable(&requested.to_string())
.ok_or_else(|| AppError::LanguageParseError(requested.to_string()))?;
let parsed: Vec<(crate::i18n::NegotiableId, &SubtitleTrack)> = tracks
.iter()
.filter_map(|track| crate::i18n::negotiable(&track.tag).map(|id| (id, track)))
.collect();
let available: Vec<crate::i18n::NegotiableId> =
parsed.iter().map(|(id, _)| id.clone()).collect();
let english = crate::i18n::negotiable(crate::cli::LanguageArg::english().as_str())
.ok_or_else(|| AppError::Internal("english is not a negotiable tag".to_string()))?;
let matched = negotiate_languages(
&[requested_id],
&available,
Some(&english),
NegotiationStrategy::Lookup,
);
let winner = matched
.first()
.ok_or(AppError::NoSubtitle(
crate::error::NoSubtitleReason::LanguageUnavailable,
))?
.to_string();
let candidates: Vec<&SubtitleTrack> = parsed
.iter()
.filter(|(id, _)| id.to_string() == winner)
.map(|(_, track)| *track)
.collect();
pick_by_kind(&candidates, prefers_asr())
.cloned()
.ok_or(AppError::NoSubtitle(
crate::error::NoSubtitleReason::LanguageUnavailable,
))
}
fn pick_by_kind<'a>(
candidates: &[&'a SubtitleTrack],
wants_asr: bool,
) -> Option<&'a SubtitleTrack> {
candidates
.iter()
.find(|track| track.auto_generated == wants_asr)
.or_else(|| candidates.first())
.copied()
}
#[doc(alias = "Source")]
#[doc(alias = "Upstream")]
#[doc(alias = "Backend")]
#[doc(alias = "pluggable")]
#[doc(alias = "trait")]
#[doc(alias = "async trait")]
#[doc(alias = "subtitle source")]
#[doc(alias = "upstream")]
#[async_trait]
pub trait Provider: Send + Sync {
fn name(&self) -> &'static str;
async fn list_tracks(&self, _video_id: &str) -> AppResult<Vec<SubtitleTrack>> {
Ok(Vec::new())
}
async fn fetch_subtitle(
&self,
video_id: &str,
language: &str,
format: Format,
) -> AppResult<SubtitleInfo>;
async fn fetch_content(&self, info: &SubtitleInfo) -> AppResult<Vec<u8>>;
}
#[cfg(test)]
mod negotiation_tests {
use super::*;
use crate::cli::LanguageArg;
use crate::error::NoSubtitleReason;
fn track(tag: &str) -> SubtitleTrack {
SubtitleTrack::new(tag, Format::Txt)
}
fn negotiate(requested: &str, tags: &[&str]) -> AppResult<SubtitleTrack> {
let tracks: Vec<SubtitleTrack> = tags.iter().map(|t| track(t)).collect();
let requested = LanguageArg::parse(requested).expect("test tag parses");
negotiate_track(&requested.to_langid(), &tracks)
}
#[test]
fn exact_tag_wins() {
let hit = negotiate("pt-BR", &["en", "pt-BR", "es"]).expect("pt-BR is on the menu");
assert_eq!(hit.tag, "pt-BR");
}
#[test]
fn regional_request_reaches_the_bare_language() {
let hit = negotiate("pt-BR", &["en", "pt"]).expect("pt is close enough");
assert_eq!(hit.tag, "pt");
}
#[test]
fn bare_request_reaches_a_regional_track() {
let hit = negotiate("pt", &["en", "pt-BR"]).expect("pt-BR is close enough");
assert_eq!(hit.tag, "pt-BR");
}
#[test]
fn simplified_chinese_never_resolves_to_traditional() {
let hit = negotiate("zh-Hans", &["zh-Hant", "zh-Hans"]).expect("zh-Hans is on the menu");
assert_eq!(hit.tag, "zh-Hans");
let hit = negotiate("zh-Hant", &["zh-Hant", "zh-Hans"]).expect("zh-Hant is on the menu");
assert_eq!(hit.tag, "zh-Hant");
}
#[test]
fn english_is_the_fallback_when_the_request_misses() {
let hit = negotiate("ja", &["en", "pt-BR"]).expect("english backstops the miss");
assert_eq!(hit.tag, "en");
}
#[test]
fn a_miss_without_english_is_language_unavailable_not_not_published() {
let err = negotiate("ja", &["pt-BR", "es"]).expect_err("no japanese, no english");
assert!(
matches!(
err,
AppError::NoSubtitle(NoSubtitleReason::LanguageUnavailable)
),
"expected LanguageUnavailable, got {err:?}"
);
}
#[test]
fn the_kind_preference_decides_between_two_tracks_of_one_language() {
let manual = track("pt-BR");
let auto = SubtitleTrack::new("pt-BR", Format::Txt).with_auto_generated(true);
let menu = [&auto, &manual];
let chosen = pick_by_kind(&menu, false).expect("a track matches");
assert!(
!chosen.auto_generated,
"without --asr the human-authored track wins even when listed second"
);
let chosen = pick_by_kind(&menu, true).expect("a track matches");
assert!(
chosen.auto_generated,
"with --asr the machine-generated track wins"
);
}
#[test]
fn a_kind_that_is_absent_falls_back_instead_of_failing() {
let manual = track("pt-BR");
let only_manual = [&manual];
let chosen = pick_by_kind(&only_manual, true).expect("fallback serves the other kind");
assert!(
!chosen.auto_generated,
"--asr on a video with no ASR track must still deliver the manual one"
);
let auto = SubtitleTrack::new("pt-BR", Format::Txt).with_auto_generated(true);
let only_auto = [&auto];
let chosen = pick_by_kind(&only_auto, false).expect("fallback serves the other kind");
assert!(
chosen.auto_generated,
"without --asr an ASR-only video must still deliver its one track"
);
}
#[test]
fn no_candidate_yields_no_choice() {
let empty: [&SubtitleTrack; 0] = [];
assert!(pick_by_kind(&empty, false).is_none());
assert!(pick_by_kind(&empty, true).is_none());
}
#[test]
fn an_empty_menu_is_language_unavailable() {
let err = negotiate_track(&LanguageArg::english().to_langid(), &[])
.expect_err("nothing to negotiate against");
assert!(matches!(
err,
AppError::NoSubtitle(NoSubtitleReason::LanguageUnavailable)
));
}
#[test]
fn a_malformed_track_tag_does_not_hide_the_rest() {
let hit = negotiate("pt-BR", &["!!! not a tag", "pt-BR"]).expect("the good tag survives");
assert_eq!(hit.tag, "pt-BR");
}
#[test]
fn hebrew_negotiates_through_the_modern_code() {
let hit = negotiate("he", &["en", "he"]).expect("hebrew is on the menu");
assert_eq!(hit.tag, "he");
}
#[test]
fn track_builder_defaults_label_to_tag_and_is_not_auto_generated() {
let t = SubtitleTrack::new("pt-BR", Format::Txt);
assert_eq!(t.label, "pt-BR");
assert!(!t.auto_generated);
let t = t.with_label("Português (Brasil)").with_auto_generated(true);
assert_eq!(t.label, "Português (Brasil)");
assert!(t.auto_generated);
}
#[tokio::test]
async fn default_list_tracks_is_empty() {
struct Silent;
#[async_trait]
impl Provider for Silent {
fn name(&self) -> &'static str {
"silent"
}
async fn fetch_subtitle(
&self,
_video_id: &str,
_language: &str,
_format: Format,
) -> AppResult<SubtitleInfo> {
Err(AppError::ProviderUnavailable { provider: "silent" })
}
async fn fetch_content(&self, _info: &SubtitleInfo) -> AppResult<Vec<u8>> {
Err(AppError::ProviderUnavailable { provider: "silent" })
}
}
assert!(Silent
.list_tracks("dQw4w9WgXcQ")
.await
.expect("ok")
.is_empty());
}
}