use crate::cache;
use crate::cli::Cli;
use crate::commands::{
convert_format, format_to_provider_format, language_to_str, output_dry_run, output_error,
output_success, parse_video_id_from_url,
};
use crate::error::{AppError, AppResult};
use crate::io;
use crate::provider::ProviderChain;
use crate::retry::retry_with_backoff;
use std::collections::HashSet;
use std::process::ExitCode;
use std::time::Instant;
use tracing::instrument;
#[instrument(skip(cli, chain), fields(total))]
pub async fn run(cli: &Cli, chain: &ProviderChain) -> AppResult<ExitCode> {
let urls = io::read_urls_from_stdin().await?;
tracing::Span::current().record("total", urls.len());
let mut seen: HashSet<String> = HashSet::new();
let unique: Vec<String> = urls
.into_iter()
.filter(|u| seen.insert(u.clone()))
.collect();
let total = unique.len();
let mut last_err: Option<AppError> = None;
let mut succeeded = 0u32;
let lang = language_to_str(cli.lang);
let format = format_to_provider_format(cli.format);
let cache_ttl = cli.cache_ttl_duration();
tracing::info!(target: "events", event = "batch_started", total = total);
for (idx, url) in unique.iter().enumerate() {
if !cli.quiet {
tracing::info!(target: "events", event = "progress", index = idx + 1, total, url = %url);
}
let video_id = match parse_video_id_from_url(cli, url) {
Ok(id) => id,
Err(e) => {
tracing::error!(target: "events", event = "failed", url = %url, error = %e);
output_error(cli, &e).await.ok();
last_err = Some(e);
continue;
}
};
let started = Instant::now();
if !cli.no_cache {
if let Ok(path) = cache::cache_path(&video_id, lang, format.extension(), cache_ttl) {
match cache::read_cache_with_hint(&path, cache_ttl).await {
Ok(Some((bytes, format_hint))) => {
let bytes_len = bytes.len() as u64;
let duration_ms = started.elapsed().as_millis() as u64;
match convert_format(&bytes, format, format_hint) {
Ok(converted) => {
write_item(
cli,
idx,
total,
"cache",
&video_id,
&converted,
"cache",
bytes_len,
duration_ms,
)
.await?;
succeeded += 1;
continue;
}
Err(e) => {
output_error(cli, &e).await.ok();
last_err = Some(e);
}
}
}
Ok(None) => {}
Err(_) => {}
}
}
}
if cli.dry_run {
tracing::info!(target: "events", event = "dry_run_cache_miss", video_id = %video_id);
let _ = output_dry_run(cli, &video_id, true).await;
continue;
}
let result = retry_with_backoff(
|| async { chain.fetch_subtitle(&video_id, lang, format).await },
3,
)
.await;
match result {
Ok((info, content)) => {
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(), cache_ttl)
{
let _ = cache::write_cache_with_hint(&path, &content, info.format_hint).await;
}
}
let converted = match convert_format(&content, format, info.format_hint) {
Ok(s) => s,
Err(e) => {
output_error(cli, &e).await.ok();
last_err = Some(e);
continue;
}
};
let bytes = content.len() as u64;
let duration_ms = started.elapsed().as_millis() as u64;
let source = info.source_url.clone();
let provider = info.provider;
match write_item(
cli,
idx,
total,
provider,
&video_id,
&converted,
&source,
bytes,
duration_ms,
)
.await
{
Ok(()) => succeeded += 1,
Err(e) => {
output_error(cli, &e).await.ok();
last_err = Some(e);
}
}
}
Err(e) => {
tracing::error!(target: "events", event = "failed", video_id = %video_id, error = %e);
output_error(cli, &e).await.ok();
last_err = Some(e);
}
}
}
if !cli.quiet {
tracing::info!(target: "events", event = "batch_completed", succeeded, total);
}
match last_err {
Some(e) if succeeded == 0 => Ok(ExitCode::from(e.exit_code())),
_ => Ok(ExitCode::SUCCESS),
}
}
#[allow(clippy::too_many_arguments)]
async fn write_item(
cli: &Cli,
idx: usize,
total: usize,
provider: &'static str,
video_id: &str,
converted: &str,
source: &str,
bytes: u64,
duration_ms: u64,
) -> AppResult<()> {
if cli.json {
output_success(cli, provider, video_id, converted, source, bytes, duration_ms).await?;
} else {
let separator = if idx > 0 { "\n---\n" } else { "" };
let mut buf = separator.as_bytes().to_vec();
buf.extend_from_slice(converted.as_bytes());
buf.push(b'\n');
io::write_subtitle_to_stdout(&buf).await?;
}
let _ = total;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::Cli;
use clap::Parser;
#[test]
fn dry_run_envelope_uses_stable_event_field() {
let cli = Cli::parse_from(["youtube-legend-cli", "--batch", "--dry-run", "--json"]);
let payload = serde_json::json!({
"event": "dry_run_cache_miss",
"video_id": "dQw4w9WgXcQ",
"language": crate::commands::language_to_str(cli.lang),
"format": crate::commands::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}"
);
}
#[test]
fn dry_run_does_not_populate_last_err() {
let cli = Cli::parse_from(["youtube-legend-cli", "--batch", "--dry-run"]);
assert!(cli.dry_run, "dry-run flag must be parsed");
assert!(cli.batch, "batch flag must be parsed");
let _ = output_dry_run;
}
}