PUT routes

This commit is contained in:
roaming97 2025-04-17 13:38:48 -06:00
parent a210d7003b
commit 9247a39094
5 changed files with 238 additions and 76 deletions

8
TODO.md Executable file
View File

@ -0,0 +1,8 @@
# Task list
Tasks that have been completed in commits before this one have been omitted from this list.
- [x] /video PUT route
- [x] /channel PUT route
- [ ] OpenAPI documentation
- [ ] Video versioning

View File

@ -19,8 +19,8 @@ pub enum ChannelError {
IOError(#[from] io::Error), IOError(#[from] io::Error),
#[error("UTF-8 error: {0}")] #[error("UTF-8 error: {0}")]
Utf8Error(#[from] Utf8Error), Utf8Error(#[from] Utf8Error),
#[error("Invalid output from ID extraction function")] #[error("Got erroneous output for yt-dlp task")]
InvalidOutput, TaskFailed,
#[error("Failed to extract channel ID from '{0}'")] #[error("Failed to extract channel ID from '{0}'")]
ExtractID(String), ExtractID(String),
#[error("Could not serialize info JSON: {0}")] #[error("Could not serialize info JSON: {0}")]
@ -43,7 +43,7 @@ pub struct Channel {
} }
impl Channel { impl Channel {
async fn yt_dlp_task(url: &str) -> Result<(), ChannelError> { async fn run_task(url: &str) -> Result<(), ChannelError> {
let mut child = Command::new("yt-dlp") let mut child = Command::new("yt-dlp")
.args([ .args([
"--write-info-json", "--write-info-json",
@ -57,12 +57,60 @@ impl Channel {
]) ])
.spawn()?; .spawn()?;
info!("yt-dlp task invoked"); info!("yt-dlp task invoked");
child.wait().await?; child.wait().await.map_err(|_| ChannelError::TaskFailed)?;
info!("yt-dlp task completed successfully"); info!("yt-dlp task completed successfully");
Ok(()) Ok(())
} }
async fn run_update_task(url: &str) -> Result<(), ChannelError> {
let mut child = Command::new("yt-dlp")
.args([
"--write-info-json",
"--skip-download",
"--playlist-items",
"1",
"-v",
"-o",
"media/channels/%(id)s/%(id)s.%(ext)s",
url,
])
.spawn()?;
info!("yt-dlp task invoked");
child.wait().await.map_err(|_| ChannelError::TaskFailed)?;
info!("yt-dlp task completed successfully");
Ok(())
}
pub async fn update_metadata(&mut self) -> Result<(), ChannelError> {
Self::run_update_task(&self.url).await?;
let json_path = format!("media/channels/{0}/{0}.info.json", self.youtube_id);
let info: Value = serde_json::from_str(&fs::read_to_string(&json_path).await?)?;
self.name = Self::get_json_value(&info, "channel")?.to_unquoted_string();
self.handle_url = Self::get_json_value(&info, "uploader_url")?.to_unquoted_string();
(self.avatar_url, self.banner_url) = Self::get_thumbnails(&info);
self.description = Self::get_json_value(&info, "description")?.to_unquoted_string();
self.subscribers = Self::get_json_value(&info, "channel_follower_count")?
.as_i64()
.unwrap_or_default();
info!(
"New information for '{}': {{ name: {}, handle_url: {}, avatar_url: {}, banner_url: {}, description: {}, subscribers: {} }}",
self.youtube_id,
self.name,
self.handle_url,
self.avatar_url,
self.banner_url,
self.description,
self.subscribers
);
Ok(())
}
/// Transforms a channel handle or username into a canonical channel ID. /// Transforms a channel handle or username into a canonical channel ID.
/// ///
/// While it invariably returns a canonical ID even if the URL already contains one, /// While it invariably returns a canonical ID even if the URL already contains one,
@ -85,7 +133,7 @@ impl Channel {
error!( error!(
"yt-dlp could not download channel. Does the URL point to a valid/existing channel?" "yt-dlp could not download channel. Does the URL point to a valid/existing channel?"
); );
return Err(ChannelError::InvalidOutput); return Err(ChannelError::TaskFailed);
} }
let stdout = str::from_utf8(&output.stdout)?; let stdout = str::from_utf8(&output.stdout)?;
@ -97,6 +145,43 @@ impl Channel {
) )
} }
fn get_json_value(json: &Value, key: &str) -> Result<Value, ChannelError> {
json.get(key)
.ok_or_else(|| ChannelError::JsonKey(key.to_string()))
.cloned()
}
fn get_thumbnails(info_json: &Value) -> (String, String) {
let mut avatar_url = String::new();
let mut banner_url = String::new();
if let Some(thumbnails) = info_json.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();
}
_ => {}
}
}
}
}
(avatar_url, banner_url)
}
pub async fn from_url(url: &Url, id: i64) -> Result<Self, ChannelError> { pub async fn from_url(url: &Url, id: i64) -> Result<Self, ChannelError> {
let url_path = url.path(); let url_path = url.path();
@ -130,51 +215,23 @@ impl Channel {
fs::create_dir(dir).await?; fs::create_dir(dir).await?;
Self::yt_dlp_task(url).await?; Self::run_task(url).await?;
let info: Value = let info: Value =
serde_json::from_str(&fs::read_to_string("media/channels/tmp/tmp.info.json").await?)?; serde_json::from_str(&fs::read_to_string("media/channels/tmp/tmp.info.json").await?)?;
let get_info_value = |key: &str| { let url = Self::get_json_value(&info, "channel_url")?.to_unquoted_string();
info.get(key) let name = Self::get_json_value(&info, "channel")?.to_unquoted_string();
.ok_or_else(|| ChannelError::JsonKey(key.to_string())) let handle_url = Self::get_json_value(&info, "uploader_url")?.to_unquoted_string();
.unwrap()
};
let url = get_info_value("channel_url").to_unquoted_string(); let (avatar_url, banner_url) = Self::get_thumbnails(&info);
let name = get_info_value("channel").to_unquoted_string(); if avatar_url.is_empty() {
let handle_url = get_info_value("uploader_url").to_unquoted_string(); warn!("Channel {youtube_id} has no avatar!");
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!");
} }
if banner_url.is_empty() {
let description = get_info_value("description").to_unquoted_string(); warn!("Channel {youtube_id} has no banner!");
let subscribers = get_info_value("channel_follower_count") }
let description = Self::get_json_value(&info, "description")?.to_unquoted_string();
let subscribers = Self::get_json_value(&info, "channel_follower_count")?
.as_i64() .as_i64()
.unwrap_or_default(); .unwrap_or_default();

View File

@ -109,7 +109,7 @@ pub async fn upload_channel(
let new_channel = Channel::from_url(&url, id).await.map_err(|e| match e { let new_channel = Channel::from_url(&url, id).await.map_err(|e| match e {
ChannelError::AlreadyExists => StatusCode::OK, ChannelError::AlreadyExists => StatusCode::OK,
ChannelError::InvalidOutput => StatusCode::BAD_REQUEST, ChannelError::TaskFailed => StatusCode::BAD_REQUEST,
e => { e => {
error!("{e:?}"); error!("{e:?}");
StatusCode::INTERNAL_SERVER_ERROR StatusCode::INTERNAL_SERVER_ERROR
@ -161,10 +161,37 @@ pub async fn update_channel(State(state): State<Instance>, Path(id): Path<String
.map_or(Err(StatusCode::INTERNAL_SERVER_ERROR), |channel| { .map_or(Err(StatusCode::INTERNAL_SERVER_ERROR), |channel| {
channel.ok_or(StatusCode::NOT_FOUND) channel.ok_or(StatusCode::NOT_FOUND)
}) { }) {
// TODO Ok(mut channel) => match channel.update_metadata().await {
Ok(_channel) => StatusCode::OK, Ok(()) => {
match sqlx::query!(
"UPDATE channel SET name = ?, handle_url = ?, avatar_url = ?, banner_url = ?, description = ?, subscribers = ? WHERE youtube_id = ?",
channel.name,
channel.handle_url,
channel.avatar_url,
channel.banner_url,
channel.description,
channel.subscribers,
id
).execute(&state.pool).await {
Ok(r) => {
info!("Updated channel '{id}' successfully: {r:?}");
StatusCode::OK
},
Err(e) => {
error!("Update channel failed: {e:?}");
StatusCode::INTERNAL_SERVER_ERROR
}
}
}
Err(e) => {
error!("Update video failed: {e:?}");
StatusCode::INTERNAL_SERVER_ERROR
}
},
Err(status) => { Err(status) => {
error!("Failed to update video '{id}'"); if status != StatusCode::NOT_FOUND {
error!("Failed to update video '{id}'");
}
status status
} }
} }

View File

@ -182,10 +182,35 @@ pub async fn update_video(State(state): State<Instance>, Path(id): Path<String>)
.map_or(Err(StatusCode::INTERNAL_SERVER_ERROR), |video| { .map_or(Err(StatusCode::INTERNAL_SERVER_ERROR), |video| {
video.ok_or(StatusCode::NOT_FOUND) video.ok_or(StatusCode::NOT_FOUND)
}) { }) {
// TODO Ok(mut video) => match video.update_metadata().await {
Ok(_video) => StatusCode::OK, Ok(()) => {
match sqlx::query!(
"UPDATE video SET title = ?, views = ?, likes = ?, dislikes = ? WHERE youtube_id = ?",
video.title,
video.views,
video.likes,
video.dislikes,
id
).execute(&state.pool).await {
Ok(r) => {
info!("Updated video '{id}' successfully: {r:?}");
StatusCode::OK
},
Err(e) => {
error!("Update video failed: {e:?}");
StatusCode::INTERNAL_SERVER_ERROR
}
}
}
Err(e) => {
error!("Update video failed: {e:?}");
StatusCode::INTERNAL_SERVER_ERROR
}
},
Err(status) => { Err(status) => {
error!("Failed to update video '{id}'"); if status != StatusCode::NOT_FOUND {
error!("Failed to update video '{id}'");
}
status status
} }
} }

View File

@ -18,6 +18,8 @@ pub enum VideoError {
AlreadyExists, AlreadyExists,
#[error("IO Error: {0}")] #[error("IO Error: {0}")]
IOError(#[from] io::Error), IOError(#[from] io::Error),
#[error("Got erroneous status code for yt-dlp task")]
TaskFailed,
#[error("Could not serialize info JSON: {0}")] #[error("Could not serialize info JSON: {0}")]
SerializeInfoJSON(#[from] serde_json::Error), SerializeInfoJSON(#[from] serde_json::Error),
#[error("Failed to parse value from key '{0}'")] #[error("Failed to parse value from key '{0}'")]
@ -48,7 +50,7 @@ pub struct Video {
} }
impl Video { impl Video {
async fn yt_dlp_task(url: &str) -> Result<(), VideoError> { async fn run_task(url: &str) -> Result<(), VideoError> {
let args = vec![ let args = vec![
"--write-info-json", "--write-info-json",
"--write-thumbnail", "--write-thumbnail",
@ -66,12 +68,66 @@ impl Video {
]; ];
let mut child = tokio::process::Command::new("yt-dlp").args(args).spawn()?; let mut child = tokio::process::Command::new("yt-dlp").args(args).spawn()?;
info!("yt-dlp task invoked"); info!("yt-dlp task invoked");
child.wait().await?; child.wait().await.map_err(|_| VideoError::TaskFailed)?;
info!("yt-dlp task completed successfully"); info!("yt-dlp task completed successfully");
Ok(()) Ok(())
} }
async fn run_update_task(url: &str) -> Result<(), VideoError> {
let args = vec![
"--write-info-json",
"--skip-download",
"--write-thumbnail",
"--write-description",
"--write-comments",
"--force-write",
"--no-playlist",
"--use-postprocessor",
"ReturnYoutubeDislike:when=pre_process",
"-v",
url,
"-o",
"media/videos/%(id)s/%(id)s.%(ext)s",
];
let mut child = tokio::process::Command::new("yt-dlp").args(args).spawn()?;
info!("yt-dlp task invoked");
child.wait().await.map_err(|_| VideoError::TaskFailed)?;
info!("yt-dlp task completed successfully");
Ok(())
}
pub async fn update_metadata(&mut self) -> Result<(), VideoError> {
Self::run_update_task(&self.url).await?;
let json_path = format!("media/videos/{0}/{0}.info.json", self.youtube_id);
let info: Value = serde_json::from_str(&fs::read_to_string(&json_path).await?)?;
self.title = Self::get_json_value(&info, "title")?.to_unquoted_string();
self.views = Self::get_json_value(&info, "view_count")?
.as_i64()
.unwrap_or(-1);
// Use RYD field from info JSON
self.likes = Self::get_json_value(&info["RYD"]["response"], "likes")?.as_i64();
self.dislikes = Self::get_json_value(&info["RYD"]["response"], "dislikes")?.as_i64();
info!(
"New information for video '{}': {{ title: {}, views: {}, likes: {:?}, dislikes: {:?} }}",
self.youtube_id, self.title, self.views, self.likes, self.dislikes
);
Ok(())
}
fn get_json_value(json: &Value, key: &str) -> Result<Value, VideoError> {
json.get(key)
.ok_or_else(|| VideoError::JsonKey(key.to_string()))
.cloned()
}
pub async fn from_url(url: &Url, id: i64) -> Result<Self, VideoError> { pub async fn from_url(url: &Url, id: i64) -> Result<Self, VideoError> {
let mut pairs = url.query_pairs(); let mut pairs = url.query_pairs();
let Some(query_v) = pairs.find(|(key, _)| key == "v") else { let Some(query_v) = pairs.find(|(key, _)| key == "v") else {
@ -96,34 +152,23 @@ impl Video {
let info_json = format!("{file_stem}.info.json"); let info_json = format!("{file_stem}.info.json");
let info_json = Path::new(&info_json); let info_json = Path::new(&info_json);
Self::yt_dlp_task(url.as_str()).await?; Self::run_task(url.as_str()).await?;
let info: Value = serde_json::from_str(&fs::read_to_string(info_json).await?)?; let info: Value = serde_json::from_str(&fs::read_to_string(info_json).await?)?;
// info!("Info JSON for {youtube_id}\n{info}"); // info!("Info JSON for {youtube_id}\n{info}");
let get_info_value = |key: &str| {
info.get(key)
.ok_or_else(|| VideoError::JsonKey(key.to_string()))
.unwrap()
};
let get_ryd_value = |key: &str| {
info["RYD"]["response"]
.get(key)
.ok_or_else(|| VideoError::JsonKey(key.to_string()))
.unwrap()
};
let url = url.to_string(); let url = url.to_string();
let description = format!("{file_stem}.description"); let description = format!("{file_stem}.description");
let title = get_info_value("title").to_unquoted_string(); let title = Self::get_json_value(&info, "title")?.to_unquoted_string();
let author = get_info_value("uploader").to_unquoted_string(); let author = Self::get_json_value(&info, "uploader")?.to_unquoted_string();
let views = get_info_value("view_count").as_i64().unwrap_or(-1); let views = Self::get_json_value(&info, "view_count")?
.as_i64()
.unwrap_or(-1);
// Use RYD field from info JSON // Use RYD field from info JSON
let likes = get_ryd_value("likes").as_i64(); let likes = Self::get_json_value(&info["RYD"]["response"], "likes")?.as_i64();
let dislikes = get_ryd_value("dislikes").as_i64(); let dislikes = Self::get_json_value(&info["RYD"]["response"], "dislikes")?.as_i64();
let possible_extensions = ["mkv", "mp4", "webm"]; let possible_extensions = ["mkv", "mp4", "webm"];
@ -149,9 +194,9 @@ impl Video {
let sha256 = format!("{:x}", Sha3_256::digest(&buffer)); let sha256 = format!("{:x}", Sha3_256::digest(&buffer));
#[allow(clippy::cast_possible_wrap)] #[allow(clippy::cast_possible_wrap)]
let file_size = buffer.len() as i64; let file_size = buffer.len() as i64;
let author_id = get_info_value("channel_id").to_unquoted_string(); let author_id = Self::get_json_value(&info, "channel_id")?.to_unquoted_string();
let author_url = get_info_value("channel_url").to_unquoted_string(); let author_url = Self::get_json_value(&info, "channel_url")?.to_unquoted_string();
let upload_date = get_info_value("upload_date").to_unquoted_string(); let upload_date = Self::get_json_value(&info, "upload_date")?.to_unquoted_string();
let thumbnail = format!("{file_stem}.webp"); let thumbnail = format!("{file_stem}.webp");