diff --git a/Cargo.lock b/Cargo.lock index 0940a72..827e832 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -192,9 +192,9 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" [[package]] name = "cc" -version = "1.2.18" +version = "1.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "525046617d8376e3db1deffb079e91cef90a89fc3ca5c185bbf8c9ecdd15cd5c" +checksum = "8e3a13707ac958681c13b39b458c073d0d9bc8a22cb1b2f4c8e55eb72c13f362" dependencies = [ "shlex", ] @@ -1414,9 +1414,9 @@ dependencies = [ [[package]] name = "sqlx" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4410e73b3c0d8442c5f99b425d7a435b5ee0ae4167b3196771dd3f7a01be745f" +checksum = "14e22987355fbf8cfb813a0cf8cd97b1b4ec834b94dbd759a9e8679d41fabe83" dependencies = [ "sqlx-core", "sqlx-macros", @@ -1427,10 +1427,11 @@ dependencies = [ [[package]] name = "sqlx-core" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a007b6936676aa9ab40207cde35daab0a04b823be8ae004368c0793b96a61e0" +checksum = "55c4720d7d4cd3d5b00f61d03751c685ad09c33ae8290c8a2c11335e0604300b" dependencies = [ + "base64", "bytes", "crc", "crossbeam-queue", @@ -1460,9 +1461,9 @@ dependencies = [ [[package]] name = "sqlx-macros" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3112e2ad78643fef903618d78cf0aec1cb3134b019730edb039b69eaf531f310" +checksum = "175147fcb75f353ac7675509bc58abb2cb291caf0fd24a3623b8f7e3eb0a754b" dependencies = [ "proc-macro2", "quote", @@ -1473,9 +1474,9 @@ dependencies = [ [[package]] name = "sqlx-macros-core" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e9f90acc5ab146a99bf5061a7eb4976b573f560bc898ef3bf8435448dd5e7ad" +checksum = "1cde983058e53bfa75998e1982086c5efe3c370f3250bf0357e344fa3352e32b" dependencies = [ "dotenvy", "either", @@ -1499,9 +1500,9 @@ dependencies = [ [[package]] name = "sqlx-mysql" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4560278f0e00ce64938540546f59f590d60beee33fffbd3b9cd47851e5fff233" +checksum = "847d2e5393a4f39e47e4f36cab419709bc2b83cbe4223c60e86e1471655be333" dependencies = [ "atoi", "base64", @@ -1541,9 +1542,9 @@ dependencies = [ [[package]] name = "sqlx-postgres" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5b98a57f363ed6764d5b3a12bfedf62f07aa16e1856a7ddc2a0bb190a959613" +checksum = "cc35947a541b9e0a2e3d85da444f1c4137c13040267141b208395a0d0ca4659f" dependencies = [ "atoi", "base64", @@ -1578,9 +1579,9 @@ dependencies = [ [[package]] name = "sqlx-sqlite" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f85ca71d3a5b24e64e1d08dd8fe36c6c95c339a896cc33068148906784620540" +checksum = "6c48291dac4e5ed32da0927a0b981788be65674aeb62666d19873ab4289febde" dependencies = [ "atoi", "flume", @@ -1595,6 +1596,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", + "thiserror", "tracing", "url", ] diff --git a/almond.example.toml b/almond.example.toml index b6b5226..8c64359 100755 --- a/almond.example.toml +++ b/almond.example.toml @@ -1,3 +1,4 @@ host = "127.0.0.1" port = 3000 videos_per_page = 10 +comments_per_page = 10 diff --git a/src/channel.rs b/src/channel.rs index 3ea44d4..263d62c 100755 --- a/src/channel.rs +++ b/src/channel.rs @@ -1,2 +1,4 @@ +use serde::{Deserialize, Serialize}; + #[derive(Debug, Default, Serialize, Deserialize, Clone)] pub struct Channel; diff --git a/src/comment.rs b/src/comment.rs index b6920e9..aec1e27 100755 --- a/src/comment.rs +++ b/src/comment.rs @@ -1,25 +1,77 @@ -use serde::{Deserialize, Serialize}; +use std::path::Path; -#[derive(Debug, Default, Serialize, Deserialize, Clone)] -pub enum CommentFlags { - Uploader, - Verified, - Hearted, - Pinned, - #[default] - None, +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use thiserror::Error; +use tokio::fs; +use tracing::{error, warn}; + +#[derive(Debug, Error)] +pub enum CommentsError { + #[error("Target video {0} not found in database")] + InvalidTarget(String), + #[error("IO Error: {0}")] + IOError(#[from] std::io::Error), + #[error("Could not serialize info JSON: {0}")] + SerializeInfoJSON(#[from] serde_json::Error), } -#[derive(Debug, Default, Serialize, Deserialize, Clone)] +#[allow(clippy::struct_excessive_bools)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct Comment { + pub id: String, pub video_id: String, pub parent: String, pub text: String, pub like_count: i64, pub author_id: String, pub author: String, - pub author_thumbnail: Option, - pub author_url: Option, + pub author_thumbnail: String, + pub author_url: String, pub timestamp: i64, - pub flags: CommentFlags, + pub author_is_uploader: bool, + pub author_is_verified: bool, + pub is_hearted: bool, + pub is_pinned: bool, +} + +pub async fn get_comments_from_video(id: &str) -> Result, CommentsError> { + let info_json = format!("videos/{id}/{id}.info.json"); + let info_json = Path::new(&info_json); + + if !info_json.exists() { + return Err(CommentsError::InvalidTarget(id.into())); + } + + let info: Value = serde_json::from_str(&fs::read_to_string(info_json).await?)?; + + let Some(comments) = info.get("comments") else { + warn!("Video {id} has no comments!"); + return Ok(vec![]); + }; + + comments.as_array().map_or_else( + || Ok(vec![]), + |comments| { + Ok(comments + .iter() + .map(|c| Comment { + id: c["id"].to_string(), + video_id: id.into(), + parent: c["parent"].to_string(), + text: c["text"].to_string(), + like_count: c["like_count"].as_i64().unwrap_or_default(), + author_id: c["author_id"].to_string(), + author: c["author"].to_string(), + author_thumbnail: c["author_thumbnail"].to_string(), + author_url: c["author_url"].to_string(), + timestamp: c["timestamp"].as_i64().unwrap_or_default(), + author_is_uploader: c["author_is_uploader"].as_bool().unwrap_or_default(), + author_is_verified: c["author_is_verified"].as_bool().unwrap_or_default(), + is_hearted: c["is_favorited"].as_bool().unwrap_or_default(), + is_pinned: c["is_pinned"].as_bool().unwrap_or_default(), + }) + .collect::>()) + }, + ) } diff --git a/src/instance.rs b/src/instance.rs index 234fb18..e6f497b 100755 --- a/src/instance.rs +++ b/src/instance.rs @@ -10,6 +10,7 @@ pub struct Config { pub host: String, pub port: u16, pub videos_per_page: usize, + pub comments_per_page: usize, } impl Default for Config { @@ -18,6 +19,7 @@ impl Default for Config { host: "0.0.0.0".into(), port: 3000, videos_per_page: 10, + comments_per_page: 10, } } } diff --git a/src/main.rs b/src/main.rs index 08e0dea..bd6b2b5 100755 --- a/src/main.rs +++ b/src/main.rs @@ -4,10 +4,14 @@ use axum::{ }; use instance::Instance; use middleware::auth; -use routes::{get_video, list_videos, upload_video}; +use routes::{ + comment::video_comments, + video::{get_video, list_videos, upload_video}, +}; use tokio::signal; use tracing::info; +mod comment; mod instance; mod middleware; mod routes; @@ -31,7 +35,8 @@ async fn main() -> Result<(), Box> { .route("/upload", post(upload_video)) .route_layer(axum::middleware::from_fn_with_state(instance.clone(), auth)) .route("/", get(list_videos)) - .route("/{id}", get(get_video)) + .route("/video/{id}", get(get_video)) + .route("/comments/{id}", get(video_comments)) .with_state(instance); let listener = tokio::net::TcpListener::bind(address).await?; diff --git a/src/routes/comment.rs b/src/routes/comment.rs new file mode 100755 index 0000000..ff12145 --- /dev/null +++ b/src/routes/comment.rs @@ -0,0 +1,68 @@ +use axum::{ + Json, + extract::{Path, Query, State}, + http::StatusCode, +}; +use serde::{Deserialize, Serialize}; +use tracing::error; + +use crate::{ + comment::{Comment, CommentsError, get_comments_from_video}, + instance::Instance, +}; + +#[derive(Debug, Deserialize)] +pub struct VideoCommentsQuery { + page: Option, +} + +#[derive(Debug, Serialize)] +pub struct VideoCommentsResponse { + comments: Vec, + page: usize, + per_page: usize, + total: usize, + pages: usize, +} + +/// Fetches the comments from a video, will return an empty vec if the video has no comments +pub async fn video_comments( + State(state): State, + Path(id): Path, + Query(query): Query, +) -> Result, StatusCode> { + let comments = get_comments_from_video(&id).await.map_err(|e| match e { + CommentsError::InvalidTarget(t) => { + error!("Video {t} does not exist in database!"); + StatusCode::NOT_FOUND + } + _ => StatusCode::INTERNAL_SERVER_ERROR, + }); + + match comments { + Ok(comments) => { + let per_page = state.config.comments_per_page; + let total = comments.len(); + let pages = total.div_ceil(per_page); + let page = query.page.unwrap_or(1).max(1).min(pages); + + let start = per_page * (page - 1); + let end = (start + per_page).min(total); + + let comments = if start < total { + comments[start..end].to_vec() + } else { + vec![] + }; + + Ok(Json(VideoCommentsResponse { + comments, + page, + per_page, + total, + pages, + })) + } + Err(status) => Err(status), + } +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs new file mode 100755 index 0000000..f97533d --- /dev/null +++ b/src/routes/mod.rs @@ -0,0 +1,2 @@ +pub mod comment; +pub mod video; diff --git a/src/routes.rs b/src/routes/video.rs similarity index 87% rename from src/routes.rs rename to src/routes/video.rs index 8fbbd6a..02858d5 100755 --- a/src/routes.rs +++ b/src/routes/video.rs @@ -40,7 +40,7 @@ pub async fn list_videos( let per_page = state.config.videos_per_page; let total = videos.len(); - let pages = (total + per_page - 1).div_ceil(per_page); + let pages = total.div_ceil(per_page); let page = query.page.unwrap_or(1).max(1).min(pages); let start = per_page * (page - 1); @@ -64,9 +64,9 @@ pub async fn list_videos( /// Get a single video from the database by its ID pub async fn get_video( State(state): State, - Path(id): Path, + Path(id): Path, ) -> Result, StatusCode> { - sqlx::query_as!(Video, "SELECT * FROM video WHERE id = ?", id) + sqlx::query_as!(Video, "SELECT * FROM video WHERE youtube_id = ?", id) .fetch_optional(&state.pool) .await .map_or(Err(StatusCode::INTERNAL_SERVER_ERROR), |video| { @@ -77,7 +77,6 @@ pub async fn get_video( #[derive(Debug, Deserialize)] pub struct UploadVideoQuery { url: String, - cookie: Option, } /// Upload a video to the database @@ -96,13 +95,11 @@ pub async fn upload_video( } }; - let new_video = Video::from_url(&query.url, id, query.cookie.as_deref()) - .await - .map_err(|e| match e { - VideoError::InvalidUrl | VideoError::UrlParse(_) => StatusCode::BAD_REQUEST, - VideoError::AlreadyExists => StatusCode::OK, - _ => StatusCode::INTERNAL_SERVER_ERROR, - }); + let new_video = Video::from_url(&query.url, id).await.map_err(|e| match e { + VideoError::InvalidUrl | VideoError::UrlParse(_) => StatusCode::BAD_REQUEST, + VideoError::AlreadyExists => StatusCode::OK, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }); match new_video { Ok(video) => { diff --git a/src/video.rs b/src/video.rs index b923c1e..dc9eee8 100755 --- a/src/video.rs +++ b/src/video.rs @@ -1,10 +1,11 @@ -use std::{fs, io, path::Path}; +use std::{io, path::Path}; use regex::Regex; use serde::{Deserialize, Serialize}; use serde_json::Value; use sha3::{Digest, Sha3_256}; use thiserror::Error; +use tokio::fs; use tracing::{error, info, warn}; use url::{ParseError, Url}; @@ -48,7 +49,7 @@ pub struct Video { } impl Video { - async fn yt_dlp_task(url: &str, cookie: Option<&str>) -> Result<(), VideoError> { + async fn yt_dlp_task(url: &str) -> Result<(), VideoError> { let mut args = vec![ "--write-info-json", "--write-thumbnail", @@ -60,9 +61,6 @@ impl Video { "-v", url, ]; - if let Some(cookie) = cookie { - args.append(&mut vec!["--cookies", cookie]); - } args.append(&mut vec![ "-o", "videos/%(id)s/%(id)s.%(ext)s", @@ -97,7 +95,7 @@ impl Video { Some(query_v.1.to_string()) } - pub async fn from_url(url: &str, id: i64, cookie: Option<&str>) -> Result { + pub async fn from_url(url: &str, id: i64) -> Result { let url = Url::parse(url)?; info!("Parsed argument as URL"); @@ -112,7 +110,7 @@ impl Video { let dir = format!("videos/{youtube_id}"); let file_stem = format!("{dir}/{youtube_id}"); if !Path::new(&dir).exists() { - fs::create_dir(dir)?; + fs::create_dir(dir).await?; } let info_json = format!("{file_stem}.info.json"); @@ -124,9 +122,9 @@ impl Video { return Err(VideoError::AlreadyExists); } - Self::yt_dlp_task(url.as_str(), cookie).await?; + Self::yt_dlp_task(url.as_str()).await?; - let info: Value = serde_json::from_str(&fs::read_to_string(info_json)?)?; + let info: Value = serde_json::from_str(&fs::read_to_string(info_json).await?)?; // info!("Info JSON for {youtube_id}\n{info}"); @@ -163,7 +161,7 @@ impl Video { let path = Path::new(&file_name); if path.exists() { info!("File '{}' found", path.display()); - buffer = Some(fs::read(path)?); + buffer = Some(fs::read(path).await?); break; } warn!("File '{}' not found", path.display());