mod client;
mod config;
mod error;
mod models;
use clap::{Parser, Subcommand};
use serde::Serialize;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use tracing::{error, info};
use client::YouTubeClient;
use config::Config;
use error::Result;
use models::{Channel, Video};
async fn write_json_atomic<T: Serialize>(dir: &Path, filename: &str, data: &T) -> Result<PathBuf> {
let file_path = dir.join(filename);
let temp_path = dir.join(format!(".{}.tmp", filename));
let json = serde_json::to_string_pretty(data)?;
tokio::fs::write(&temp_path, &json).await?;
tokio::fs::rename(&temp_path, &file_path).await?;
Ok(file_path)
}
#[derive(Subcommand, Debug)]
enum Commands {
Batch(BatchArgs),
}
#[derive(Parser, Debug)]
struct BatchArgs {
#[arg(short, long, default_value = "config.toml")]
batch_file: PathBuf,
#[arg(short, long)]
output_dir: Option<PathBuf>,
}
#[derive(Parser, Debug)]
#[command(author, version, about = "Download YouTube video and channel metadata", long_about = None)]
struct Args {
#[command(subcommand)]
command: Commands,
}
async fn process_feed(
client: &YouTubeClient,
feed: &config::Feed,
output_dir: &Path,
global_sync_channels: bool,
) -> Result<(HashMap<String, Video>, bool)> {
info!("Processing feed: {}", feed.name);
let videos = client.fetch_videos(&feed.videoids).await?;
let videos_map: HashMap<String, Video> =
videos.into_iter().map(|v| (v.id.clone(), v)).collect();
let feed_dir = output_dir.join("feeds").join(&feed.name);
tokio::fs::create_dir_all(&feed_dir).await?;
let file = write_json_atomic(&feed_dir, "videos.json", &videos_map).await?;
info!(
"Wrote {} videos for feed '{}' to {}",
videos_map.len(),
feed.name,
file.display()
);
let should_sync_channels = feed.should_sync_channels(global_sync_channels);
Ok((videos_map, should_sync_channels))
}
async fn run_batch(args: BatchArgs) -> Result<()> {
let config = match Config::load(&args.batch_file) {
Ok(cfg) => cfg,
Err(e) => {
error!(
"Failed to load config from {}: {}",
args.batch_file.display(),
e
);
std::process::exit(1);
}
};
info!("Loaded config with {} feeds", config.feed.len());
let output_dir = args
.output_dir
.or_else(|| config.output_dir.as_ref().map(PathBuf::from))
.unwrap_or_else(|| PathBuf::from("."));
info!("Output directory: {}", output_dir.display());
let client = match YouTubeClient::new() {
Ok(c) => c,
Err(e) => {
error!("Failed to create YouTube client: {}", e);
std::process::exit(1);
}
};
let mut feed_success_count = 0;
let mut feed_error_count = 0;
let mut channel_ids_to_sync: HashSet<String> = HashSet::new();
for feed in &config.feed {
match process_feed(&client, feed, &output_dir, config.sync_channels).await {
Ok((videos_map, should_sync_channels)) => {
feed_success_count += 1;
if should_sync_channels {
for video in videos_map.values() {
if let Some(ref snippet) = video.snippet {
channel_ids_to_sync.insert(snippet.channel_id.clone());
}
}
}
}
Err(e) => {
error!("Failed to process feed '{}': {}", feed.name, e);
feed_error_count += 1;
}
}
}
if !channel_ids_to_sync.is_empty() {
info!(
"Syncing {} unique channels from videos with sync_channels enabled",
channel_ids_to_sync.len()
);
let channel_ids: Vec<String> = channel_ids_to_sync.into_iter().collect();
match client.fetch_channels(&channel_ids).await {
Ok(channels) => {
let channels_map: HashMap<String, Channel> =
channels.into_iter().map(|c| (c.id.clone(), c)).collect();
let channels_dir = output_dir.join("channels");
tokio::fs::create_dir_all(&channels_dir).await?;
let file = write_json_atomic(&channels_dir, "channels.json", &channels_map).await?;
info!(
"Wrote {} channels to {}",
channels_map.len(),
file.display()
);
}
Err(e) => {
error!("Failed to fetch channels: {}", e);
}
}
}
info!(
"Batch processing completed: {} feeds successful, {} failed",
feed_success_count, feed_error_count
);
if feed_error_count > 0 {
std::process::exit(1);
}
Ok(())
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
let args = Args::parse();
match args.command {
Commands::Batch(batch_args) => {
if let Err(e) = run_batch(batch_args).await {
error!("Batch processing failed: {}", e);
std::process::exit(1);
}
}
}
}