api/src/channel.rs

147 lines
4.4 KiB
Rust
Executable File

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<Self, ChannelError> {
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,
})
}
}