147 lines
4.4 KiB
Rust
Executable File
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,
|
|
})
|
|
}
|
|
}
|