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]]
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",
]

View File

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

View File

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

View File

@ -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<String>,
pub author_url: Option<String>,
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<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 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,
}
}
}

View File

@ -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<dyn std::error::Error>> {
.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?;

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 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<Instance>,
Path(id): Path<i64>,
Path(id): Path<String>,
) -> 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)
.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<String>,
}
/// 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) => {

View File

@ -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<Self, VideoError> {
pub async fn from_url(url: &str, id: i64) -> Result<Self, VideoError> {
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());