Comments support

This commit is contained in:
roaming97 2025-04-14 12:20:50 -06:00
parent 628f179e09
commit 12dae0dd5e
10 changed files with 181 additions and 52 deletions

34
Cargo.lock generated
View File

@ -192,9 +192,9 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.18" version = "1.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525046617d8376e3db1deffb079e91cef90a89fc3ca5c185bbf8c9ecdd15cd5c" checksum = "8e3a13707ac958681c13b39b458c073d0d9bc8a22cb1b2f4c8e55eb72c13f362"
dependencies = [ dependencies = [
"shlex", "shlex",
] ]
@ -1414,9 +1414,9 @@ dependencies = [
[[package]] [[package]]
name = "sqlx" name = "sqlx"
version = "0.8.3" version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4410e73b3c0d8442c5f99b425d7a435b5ee0ae4167b3196771dd3f7a01be745f" checksum = "14e22987355fbf8cfb813a0cf8cd97b1b4ec834b94dbd759a9e8679d41fabe83"
dependencies = [ dependencies = [
"sqlx-core", "sqlx-core",
"sqlx-macros", "sqlx-macros",
@ -1427,10 +1427,11 @@ dependencies = [
[[package]] [[package]]
name = "sqlx-core" name = "sqlx-core"
version = "0.8.3" version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a007b6936676aa9ab40207cde35daab0a04b823be8ae004368c0793b96a61e0" checksum = "55c4720d7d4cd3d5b00f61d03751c685ad09c33ae8290c8a2c11335e0604300b"
dependencies = [ dependencies = [
"base64",
"bytes", "bytes",
"crc", "crc",
"crossbeam-queue", "crossbeam-queue",
@ -1460,9 +1461,9 @@ dependencies = [
[[package]] [[package]]
name = "sqlx-macros" name = "sqlx-macros"
version = "0.8.3" version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3112e2ad78643fef903618d78cf0aec1cb3134b019730edb039b69eaf531f310" checksum = "175147fcb75f353ac7675509bc58abb2cb291caf0fd24a3623b8f7e3eb0a754b"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -1473,9 +1474,9 @@ dependencies = [
[[package]] [[package]]
name = "sqlx-macros-core" name = "sqlx-macros-core"
version = "0.8.3" version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e9f90acc5ab146a99bf5061a7eb4976b573f560bc898ef3bf8435448dd5e7ad" checksum = "1cde983058e53bfa75998e1982086c5efe3c370f3250bf0357e344fa3352e32b"
dependencies = [ dependencies = [
"dotenvy", "dotenvy",
"either", "either",
@ -1499,9 +1500,9 @@ dependencies = [
[[package]] [[package]]
name = "sqlx-mysql" name = "sqlx-mysql"
version = "0.8.3" version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4560278f0e00ce64938540546f59f590d60beee33fffbd3b9cd47851e5fff233" checksum = "847d2e5393a4f39e47e4f36cab419709bc2b83cbe4223c60e86e1471655be333"
dependencies = [ dependencies = [
"atoi", "atoi",
"base64", "base64",
@ -1541,9 +1542,9 @@ dependencies = [
[[package]] [[package]]
name = "sqlx-postgres" name = "sqlx-postgres"
version = "0.8.3" version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5b98a57f363ed6764d5b3a12bfedf62f07aa16e1856a7ddc2a0bb190a959613" checksum = "cc35947a541b9e0a2e3d85da444f1c4137c13040267141b208395a0d0ca4659f"
dependencies = [ dependencies = [
"atoi", "atoi",
"base64", "base64",
@ -1578,9 +1579,9 @@ dependencies = [
[[package]] [[package]]
name = "sqlx-sqlite" name = "sqlx-sqlite"
version = "0.8.3" version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f85ca71d3a5b24e64e1d08dd8fe36c6c95c339a896cc33068148906784620540" checksum = "6c48291dac4e5ed32da0927a0b981788be65674aeb62666d19873ab4289febde"
dependencies = [ dependencies = [
"atoi", "atoi",
"flume", "flume",
@ -1595,6 +1596,7 @@ dependencies = [
"serde", "serde",
"serde_urlencoded", "serde_urlencoded",
"sqlx-core", "sqlx-core",
"thiserror",
"tracing", "tracing",
"url", "url",
] ]

View File

@ -1,3 +1,4 @@
host = "127.0.0.1" host = "127.0.0.1"
port = 3000 port = 3000
videos_per_page = 10 videos_per_page = 10
comments_per_page = 10

View File

@ -1,2 +1,4 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Default, Serialize, Deserialize, Clone)] #[derive(Debug, Default, Serialize, Deserialize, Clone)]
pub struct Channel; pub struct Channel;

View File

@ -1,25 +1,77 @@
use serde::{Deserialize, Serialize}; use std::path::Path;
#[derive(Debug, Default, Serialize, Deserialize, Clone)] use serde::{Deserialize, Serialize};
pub enum CommentFlags { use serde_json::Value;
Uploader, use thiserror::Error;
Verified, use tokio::fs;
Hearted, use tracing::{error, warn};
Pinned,
#[default] #[derive(Debug, Error)]
None, 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 struct Comment {
pub id: String,
pub video_id: String, pub video_id: String,
pub parent: String, pub parent: String,
pub text: String, pub text: String,
pub like_count: i64, pub like_count: i64,
pub author_id: String, pub author_id: String,
pub author: String, pub author: String,
pub author_thumbnail: Option<String>, pub author_thumbnail: String,
pub author_url: Option<String>, pub author_url: String,
pub timestamp: i64, 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<Vec<Comment>, 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::<Vec<_>>())
},
)
} }

View File

@ -10,6 +10,7 @@ pub struct Config {
pub host: String, pub host: String,
pub port: u16, pub port: u16,
pub videos_per_page: usize, pub videos_per_page: usize,
pub comments_per_page: usize,
} }
impl Default for Config { impl Default for Config {
@ -18,6 +19,7 @@ impl Default for Config {
host: "0.0.0.0".into(), host: "0.0.0.0".into(),
port: 3000, port: 3000,
videos_per_page: 10, videos_per_page: 10,
comments_per_page: 10,
} }
} }
} }

View File

@ -4,10 +4,14 @@ use axum::{
}; };
use instance::Instance; use instance::Instance;
use middleware::auth; 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 tokio::signal;
use tracing::info; use tracing::info;
mod comment;
mod instance; mod instance;
mod middleware; mod middleware;
mod routes; mod routes;
@ -31,7 +35,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.route("/upload", post(upload_video)) .route("/upload", post(upload_video))
.route_layer(axum::middleware::from_fn_with_state(instance.clone(), auth)) .route_layer(axum::middleware::from_fn_with_state(instance.clone(), auth))
.route("/", get(list_videos)) .route("/", get(list_videos))
.route("/{id}", get(get_video)) .route("/video/{id}", get(get_video))
.route("/comments/{id}", get(video_comments))
.with_state(instance); .with_state(instance);
let listener = tokio::net::TcpListener::bind(address).await?; let listener = tokio::net::TcpListener::bind(address).await?;

68
src/routes/comment.rs Executable file
View File

@ -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<usize>,
}
#[derive(Debug, Serialize)]
pub struct VideoCommentsResponse {
comments: Vec<Comment>,
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<Instance>,
Path(id): Path<String>,
Query(query): Query<VideoCommentsQuery>,
) -> Result<Json<VideoCommentsResponse>, 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),
}
}

2
src/routes/mod.rs Executable file
View File

@ -0,0 +1,2 @@
pub mod comment;
pub mod video;

View File

@ -40,7 +40,7 @@ pub async fn list_videos(
let per_page = state.config.videos_per_page; let per_page = state.config.videos_per_page;
let total = videos.len(); 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 page = query.page.unwrap_or(1).max(1).min(pages);
let start = per_page * (page - 1); 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 /// Get a single video from the database by its ID
pub async fn get_video( pub async fn get_video(
State(state): State<Instance>, State(state): State<Instance>,
Path(id): Path<i64>, Path(id): Path<String>,
) -> Result<Json<Video>, StatusCode> { ) -> Result<Json<Video>, 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) .fetch_optional(&state.pool)
.await .await
.map_or(Err(StatusCode::INTERNAL_SERVER_ERROR), |video| { .map_or(Err(StatusCode::INTERNAL_SERVER_ERROR), |video| {
@ -77,7 +77,6 @@ pub async fn get_video(
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct UploadVideoQuery { pub struct UploadVideoQuery {
url: String, url: String,
cookie: Option<String>,
} }
/// Upload a video to the database /// 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()) let new_video = Video::from_url(&query.url, id).await.map_err(|e| match e {
.await VideoError::InvalidUrl | VideoError::UrlParse(_) => StatusCode::BAD_REQUEST,
.map_err(|e| match e { VideoError::AlreadyExists => StatusCode::OK,
VideoError::InvalidUrl | VideoError::UrlParse(_) => StatusCode::BAD_REQUEST, _ => StatusCode::INTERNAL_SERVER_ERROR,
VideoError::AlreadyExists => StatusCode::OK, });
_ => StatusCode::INTERNAL_SERVER_ERROR,
});
match new_video { match new_video {
Ok(video) => { Ok(video) => {

View File

@ -1,10 +1,11 @@
use std::{fs, io, path::Path}; use std::{io, path::Path};
use regex::Regex; use regex::Regex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use sha3::{Digest, Sha3_256}; use sha3::{Digest, Sha3_256};
use thiserror::Error; use thiserror::Error;
use tokio::fs;
use tracing::{error, info, warn}; use tracing::{error, info, warn};
use url::{ParseError, Url}; use url::{ParseError, Url};
@ -48,7 +49,7 @@ pub struct Video {
} }
impl 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![ let mut args = vec![
"--write-info-json", "--write-info-json",
"--write-thumbnail", "--write-thumbnail",
@ -60,9 +61,6 @@ impl Video {
"-v", "-v",
url, url,
]; ];
if let Some(cookie) = cookie {
args.append(&mut vec!["--cookies", cookie]);
}
args.append(&mut vec![ args.append(&mut vec![
"-o", "-o",
"videos/%(id)s/%(id)s.%(ext)s", "videos/%(id)s/%(id)s.%(ext)s",
@ -97,7 +95,7 @@ impl Video {
Some(query_v.1.to_string()) Some(query_v.1.to_string())
} }
pub async fn from_url(url: &str, id: i64, cookie: Option<&str>) -> Result<Self, VideoError> { pub async fn from_url(url: &str, id: i64) -> Result<Self, VideoError> {
let url = Url::parse(url)?; let url = Url::parse(url)?;
info!("Parsed argument as URL"); info!("Parsed argument as URL");
@ -112,7 +110,7 @@ impl Video {
let dir = format!("videos/{youtube_id}"); let dir = format!("videos/{youtube_id}");
let file_stem = format!("{dir}/{youtube_id}"); let file_stem = format!("{dir}/{youtube_id}");
if !Path::new(&dir).exists() { if !Path::new(&dir).exists() {
fs::create_dir(dir)?; fs::create_dir(dir).await?;
} }
let info_json = format!("{file_stem}.info.json"); let info_json = format!("{file_stem}.info.json");
@ -124,9 +122,9 @@ impl Video {
return Err(VideoError::AlreadyExists); 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}"); // info!("Info JSON for {youtube_id}\n{info}");
@ -163,7 +161,7 @@ impl Video {
let path = Path::new(&file_name); let path = Path::new(&file_name);
if path.exists() { if path.exists() {
info!("File '{}' found", path.display()); info!("File '{}' found", path.display());
buffer = Some(fs::read(path)?); buffer = Some(fs::read(path).await?);
break; break;
} }
warn!("File '{}' not found", path.display()); warn!("File '{}' not found", path.display());