use std::{io, path::Path}; use serde::{Deserialize, Serialize}; use serde_json::Value; use thiserror::Error; use tokio::fs; use tracing::{info, warn}; use url::Url; use crate::string::ToUnquotedString; #[derive(Debug, Error)] pub enum ChannelError { #[error("URL is an invalid YouTube channel URL")] InvalidUrl, #[error("Channel already exists in database")] AlreadyExists, #[error("IO Error: {0}")] IOError(#[from] io::Error), #[error("Could not serialize info JSON: {0}")] SerializeInfoJSON(#[from] serde_json::Error), #[error("Failed to parse value from key '{0}'")] JsonKey(String), } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct Channel { pub id: i64, pub url: String, pub youtube_id: String, pub name: String, pub handle_url: String, pub avatar_url: String, pub banner_url: String, pub description: String, pub subscribers: i64, } impl Channel { async fn yt_dlp_task(url: &str) -> Result<(), ChannelError> { let mut child = tokio::process::Command::new("yt-dlp") .args([ "--write-info-json", "--skip-download", "-v", "-o", "media/channels/tmp/tmp.%(ext)s", url, ]) .spawn()?; info!("yt-dlp task invoked"); child.wait().await?; info!("yt-dlp task completed successfully"); Ok(()) } pub async fn from_url(url: &Url, id: i64) -> Result { let url_path = url.path(); if !url_path.starts_with("/@") && !url_path.starts_with("/c/") && !url_path.starts_with("/channel/") { return Err(ChannelError::InvalidUrl); } Self::yt_dlp_task(url.as_str()).await?; let info: Value = serde_json::from_str(&fs::read_to_string("media/channels/tmp/tmp.info.json").await?)?; let get_info_value = |key: &str| { info.get(key) .ok_or_else(|| ChannelError::JsonKey(key.to_string())) .unwrap() }; let youtube_id = get_info_value("channel_id").to_unquoted_string(); let dir = format!("media/channels/{youtube_id}"); let file_stem = format!("{dir}/{youtube_id}"); // TODO: Detect if ID exists without running yt-dlp task if Path::new(&dir).exists() { warn!("Channel already exists, skipping"); return Err(ChannelError::AlreadyExists); } fs::create_dir(dir).await?; let url = get_info_value("channel_url").to_unquoted_string(); let name = get_info_value("channel").to_unquoted_string(); let handle_url = get_info_value("uploader_url").to_unquoted_string(); let mut avatar_url = String::new(); let mut banner_url = String::new(); if let Some(thumbnails) = info.get("thumbnails").and_then(Value::as_array) { for thumbnail in thumbnails { if let Some(id) = thumbnail.get("id").and_then(Value::as_str) { match id { "avatar_uncropped" => { avatar_url = thumbnail .get("url") .and_then(Value::as_str) .unwrap_or_default() .to_string(); } "banner_uncropped" => { banner_url = thumbnail .get("url") .and_then(Value::as_str) .unwrap_or_default() .to_string(); } _ => {} } } } } else { warn!("Channel {youtube_id} has no thumbnails!"); } let description = get_info_value("description").to_unquoted_string(); let subscribers = get_info_value("channel_follower_count") .as_i64() .unwrap_or_default(); fs::rename( "media/channels/tmp/tmp.info.json", format!("{file_stem}.info.json"), ) .await?; Ok(Self { id, url, youtube_id, name, handle_url, avatar_url, banner_url, description, subscribers, }) } }