youtubeinfo-sync 1.0.2

Download YouTube video and channel metadata
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};

/// Atomically writes JSON data to a file using a temporary file and rename
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 {
    /// Process YouTube feeds in batch mode from a TOML config file
    Batch(BatchArgs),
}

#[derive(Parser, Debug)]
struct BatchArgs {
    /// Path to batch file (TOML format)
    #[arg(short, long, default_value = "config.toml")]
    batch_file: PathBuf,

    /// Output directory for feeds
    #[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);

    // Fetch all videos for this feed
    let videos = client.fetch_videos(&feed.videoids).await?;

    // Convert to HashMap<videoId, Video>
    let videos_map: HashMap<String, Video> =
        videos.into_iter().map(|v| (v.id.clone(), v)).collect();

    // Create output directory: {output_dir}/feeds/{name}/
    let feed_dir = output_dir.join("feeds").join(&feed.name);
    tokio::fs::create_dir_all(&feed_dir).await?;

    // Write videos.json
    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<()> {
    // Load configuration
    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());

    // Determine output directory (CLI overrides config)
    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());

    // Create YouTube client
    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;

    // Collect channel IDs from videos where sync_channels is enabled
    let mut channel_ids_to_sync: HashSet<String> = HashSet::new();

    // Process all feeds
    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;

                // Collect channel IDs if sync_channels is enabled for this feed
                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;
            }
        }
    }

    // Fetch and write channels if any need syncing
    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) => {
                // Convert to HashMap<channelId, Channel>
                let channels_map: HashMap<String, Channel> =
                    channels.into_iter().map(|c| (c.id.clone(), c)).collect();

                // Create channels directory and write
                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() {
    // Initialize tracing
    tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
        )
        .init();

    // Parse command line arguments
    let args = Args::parse();

    // Match on the command and execute
    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);
            }
        }
    }
}