use crate::cache;
use crate::cli::Cli;
use crate::commands::{
convert_format, extract_url_from_input, format_to_provider_format, language_to_str,
output_dry_run, output_error, output_error_traced, output_success, parse_video_id_from_url,
with_deadline, DeliveredLanguage,
};
use crate::error::AppResult;
use crate::provider::{ProviderAttempt, ProviderChain};
use std::process::ExitCode;
use std::sync::{Arc, Mutex};
use std::time::Instant;
use tracing::instrument;
#[instrument(skip(cli, chain), fields(video_id, language = %language_to_str(cli.lang)))]
pub async fn run(cli: &Cli, chain: &ProviderChain) -> AppResult<ExitCode> {
let started = Instant::now();
let target = match extract_url_from_input(cli).await {
Ok(t) => t,
Err(e) => {
output_error(cli, &e, None, None).await.ok();
return Ok(ExitCode::from(e.exit_code()));
}
};
tracing::info!(
target: "events",
event = "target_resolved",
target_resolved = %target.value,
target_source = target.source.as_str(),
);
let video_id = match parse_video_id_from_url(cli, &target.value) {
Ok(id) => id,
Err(e) => {
output_error(cli, &e, Some(&target), None).await.ok();
return Ok(ExitCode::from(e.exit_code()));
}
};
tracing::Span::current().record("video_id", tracing::field::display(&video_id));
let lang = language_to_str(cli.lang);
let format = format_to_provider_format(cli.format);
tracing::info!(target: "events", event = "started", video_id = %video_id, language = %lang);
if !cli.no_cache {
let path = cache::cache_path(
&video_id,
lang,
format.extension(),
cli.cache_ttl_duration(),
)?;
match cache::read_cache_with_hint(&path, cli.cache_ttl_duration()).await {
Ok(Some((bytes, format_hint))) => {
tracing::info!(target: "events", event = "cache_hit", video_id = %video_id);
if cli.dry_run {
tracing::info!(target: "events", event = "dry_run_cache_hit", video_id = %video_id);
output_dry_run(cli, &video_id, &target, false).await.ok();
return Ok(ExitCode::SUCCESS);
}
let duration_ms = started.elapsed().as_millis() as u64;
let converted = match convert_format(&bytes, cli.format, format_hint) {
Ok(s) => s,
Err(e) => {
output_error(cli, &e, Some(&target), Some(&video_id))
.await
.ok();
return Ok(ExitCode::from(e.exit_code()));
}
};
output_success(
cli,
"cache",
&video_id,
&target,
&converted,
"cache",
duration_ms,
&DeliveredLanguage::unknown(),
)
.await?;
tracing::info!(target: "events", event = "completed", video_id = %video_id, source = "cache");
return Ok(ExitCode::SUCCESS);
}
Ok(None) => {
tracing::debug!(target: "events", event = "cache_miss", video_id = %video_id);
}
Err(e) => {
tracing::warn!(target: "events", event = "cache_error", error = %e);
}
}
}
if cli.dry_run {
tracing::info!(target: "events", event = "dry_run_cache_miss", video_id = %video_id);
output_dry_run(cli, &video_id, &target, true).await.ok();
return Ok(ExitCode::SUCCESS);
}
let sink: Arc<Mutex<Vec<ProviderAttempt>>> = Arc::new(Mutex::new(Vec::new()));
let traced = with_deadline(cli, async {
Ok(chain
.fetch_subtitle_traced_into(&video_id, lang, format, &sink)
.await)
})
.await;
let (fetch_result, attempts) = match traced {
Ok(pair) => pair,
Err(e) => {
let recovered = std::mem::take(
&mut *sink
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner),
);
(Err(e), recovered)
}
};
let (info, content) = match fetch_result {
Ok(r) => r,
Err(e) => {
if !cli.json {
tracing::error!(target: "events", event = "failed", video_id = %video_id, error = %e);
}
output_error_traced(cli, &e, Some(&target), Some(&video_id), &attempts)
.await
.ok();
return Ok(ExitCode::from(e.exit_code()));
}
};
tracing::info!(target: "events", event = "fetched", video_id = %video_id, source = %info.source_url);
if !cli.no_cache {
if let Ok(path) = cache::cache_path(
&video_id,
lang,
format.extension(),
cli.cache_ttl_duration(),
) {
if let Err(e) = cache::write_cache_with_hint(&path, &content, info.format_hint).await {
tracing::warn!(target: "events", event = "cache_write_error", error = %e);
}
}
}
let converted = match convert_format(&content, cli.format, info.format_hint) {
Ok(s) => s,
Err(e) => {
output_error(cli, &e, Some(&target), Some(&video_id))
.await
.ok();
return Ok(ExitCode::from(e.exit_code()));
}
};
let duration_ms = started.elapsed().as_millis() as u64;
let source = info.source_url.clone();
let provider = info.provider;
let delivered_language = DeliveredLanguage::observed(&info);
output_success(
cli,
provider,
&video_id,
&target,
&converted,
&source,
duration_ms,
&delivered_language,
)
.await?;
tracing::info!(target: "events", event = "completed", video_id = %video_id, provider, duration_ms);
Ok(ExitCode::SUCCESS)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::Cli;
use crate::commands::{format_to_provider_format, format_to_str, language_to_str};
use crate::provider::Format;
use clap::Parser;
fn parse_cli(url: &str, dry_run: bool, json: bool) -> Cli {
let mut args = vec![
"youtube-legend-cli".to_string(),
url.to_string(),
"--format".to_string(),
"srt".to_string(),
];
if dry_run {
args.push("--dry-run".to_string());
}
if json {
args.push("--json".to_string());
}
Cli::parse_from(args)
}
#[test]
fn dry_run_envelope_shape_matches_contract() {
let cli = parse_cli("https://youtu.be/dQw4w9WgXcQ", true, true);
let payload = serde_json::json!({
"event": "dry_run_cache_miss",
"video_id": "dQw4w9WgXcQ",
"language": language_to_str(cli.lang),
"format": format_to_str(cli.format),
"would_fetch": true,
});
let json = payload.to_string();
assert!(
json.contains("\"event\":\"dry_run_cache_miss\""),
"envelope must carry the stable event field, got: {json}"
);
assert!(
json.contains("\"would_fetch\":true"),
"envelope must include the would_fetch flag, got: {json}"
);
assert_eq!(format_to_provider_format(cli.format), Format::Srt);
}
#[test]
fn dry_run_with_no_cache_returns_zero_exit_code_contract() {
assert_eq!(ExitCode::SUCCESS, ExitCode::from(0));
}
}