diff --git a/README.md b/README.md index 259da2f..b36b0f6 100755 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ You can get a binary build in the [Releases](https://git.roaming97.com/Almond/ap However, if no release build is compatible with your system, you can build one yourself by following the [Building](#building) guide. -To learn how to use the Almond API, you can read the documentation [here](todo) (TODO: Generate OpenAPI docs). +To learn how to use the Almond API, you can read the documentation [here](todo) (TODO: Generate stable OpenAPI docs). ## Requirements @@ -34,7 +34,7 @@ To learn how to use the Almond API, you can read the documentation [here](todo) ## Building -Since this project is written in Rust, you will need [Rust](https://rust-lang.org/) installed on your system. The site has an [installation](https://www.rust-lang.org/tools/install) page that includes instructions for macOS, Linux, Windows, and more. +Since this project is written in Rust, you will need [Rust](https://rust-lang.org/) installed on your system, check these [installation options](https://rustup.rs/) If you have Git installed on your system and are comfortable using a terminal, you can clone the repository with the following command: @@ -42,6 +42,6 @@ If you have Git installed on your system and are comfortable using a terminal, y $ git clone https://git.roaming97.com/Almond/api.git ``` -If not, you can download a file by clicking on the "Code" button at the top and extract the source code. +If not, you can download a file by clicking on the "Code" button at the top. Once downloaded, extract the source code. TODO: Finish build guide diff --git a/src/channel.rs b/src/channel.rs index 5d7d947..12cf60d 100755 --- a/src/channel.rs +++ b/src/channel.rs @@ -1,10 +1,10 @@ -use std::{io, path::Path}; +use std::{io, path::Path, str::Utf8Error}; use serde::{Deserialize, Serialize}; use serde_json::Value; use thiserror::Error; -use tokio::fs; -use tracing::{info, warn}; +use tokio::{fs, process::Command}; +use tracing::{error, info, warn}; use url::Url; use crate::string::ToUnquotedString; @@ -15,15 +15,21 @@ pub enum ChannelError { InvalidUrl, #[error("Channel already exists in database")] AlreadyExists, - #[error("IO Error: {0}")] + #[error("IO error: {0}")] IOError(#[from] io::Error), + #[error("UTF-8 error: {0}")] + Utf8Error(#[from] Utf8Error), + #[error("Invalid output from ID extraction function")] + InvalidOutput, + #[error("Failed to extract channel ID from '{0}'")] + ExtractID(String), #[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)] +#[derive(Serialize, Deserialize, Clone)] pub struct Channel { pub id: i64, pub url: String, @@ -38,10 +44,12 @@ pub struct Channel { impl Channel { async fn yt_dlp_task(url: &str) -> Result<(), ChannelError> { - let mut child = tokio::process::Command::new("yt-dlp") + let mut child = Command::new("yt-dlp") .args([ "--write-info-json", "--skip-download", + "--playlist-items", + "1", "-v", "-o", "media/channels/tmp/tmp.%(ext)s", @@ -55,6 +63,40 @@ impl Channel { Ok(()) } + /// 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, + /// it's not recommended to call this function when that is the case in order to + /// prevent multiple calls to yt-dlp. + async fn extract_id(url: &str) -> Result { + let output = Command::new("yt-dlp") + .args([ + "--skip-download", + "--playlist-items", + "1", + "--print", + "channel_id", + url, + ]) + .output() + .await?; + + if !output.status.success() { + error!( + "yt-dlp could not download channel. Does the URL point to a valid/existing channel?" + ); + return Err(ChannelError::InvalidOutput); + } + + let stdout = str::from_utf8(&output.stdout)?; + let mut lines = stdout.lines().filter(|line| line.starts_with("UC")); + + lines.next().map_or_else( + || Err(ChannelError::ExtractID(url.into())), + |id| Ok(id.into()), + ) + } + pub async fn from_url(url: &Url, id: i64) -> Result { let url_path = url.path(); @@ -65,7 +107,30 @@ impl Channel { return Err(ChannelError::InvalidUrl); } - Self::yt_dlp_task(url.as_str()).await?; + let url = url.as_str(); + let youtube_id = if let Some(id) = url_path.strip_prefix("/channel/") { + if id.starts_with("UC") { + id.into() + } else { + warn!("Channel ID contains unexpected format: {id}, extracting"); + Self::extract_id(url).await? + } + } else { + warn!("URL is not canonical, extracting ID"); + Self::extract_id(url).await? + }; + + let dir = format!("media/channels/{youtube_id}"); + let file_stem = format!("{dir}/{youtube_id}"); + + if Path::new(&dir).exists() { + warn!("Channel already exists, skipping"); + return Err(ChannelError::AlreadyExists); + } + + fs::create_dir(dir).await?; + + Self::yt_dlp_task(url).await?; let info: Value = serde_json::from_str(&fs::read_to_string("media/channels/tmp/tmp.info.json").await?)?; @@ -75,18 +140,6 @@ impl Channel { .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(); diff --git a/src/comment.rs b/src/comment.rs index f61cda3..9e7a299 100755 --- a/src/comment.rs +++ b/src/comment.rs @@ -19,7 +19,7 @@ pub enum CommentsError { } #[allow(clippy::struct_excessive_bools)] -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Clone)] pub struct Comment { pub id: String, pub video_id: String, diff --git a/src/config.rs b/src/config.rs index 9282c6b..d71339e 100755 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,6 @@ use serde::Deserialize; -#[derive(Debug, Clone, Deserialize)] +#[derive(Clone, Deserialize)] pub struct Address { pub host: String, pub port: u16, @@ -21,7 +21,7 @@ impl Address { } } -#[derive(Debug, Clone, Deserialize)] +#[derive(Clone, Deserialize)] pub struct Pagination { pub videos: usize, pub comments: usize, @@ -38,7 +38,7 @@ impl Default for Pagination { } } -#[derive(Debug, Default, Clone, Deserialize)] +#[derive(Default, Clone, Deserialize)] pub struct Config { pub address: Address, pub pagination: Pagination, diff --git a/src/instance.rs b/src/instance.rs index cc171a7..0b6e1af 100755 --- a/src/instance.rs +++ b/src/instance.rs @@ -7,7 +7,7 @@ use tracing::{error, warn}; use crate::config::Config; /// This controls an instance and its state. -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct Instance { pub config: Config, pub key_hash: String, diff --git a/src/main.rs b/src/main.rs index caded08..b5f8aa5 100755 --- a/src/main.rs +++ b/src/main.rs @@ -2,15 +2,15 @@ use std::net::SocketAddr; use axum::{ Router, - routing::{get, post}, + routing::{delete, get, post, put}, }; use instance::Instance; use routes::{ - channel::{get_channel, list_channels, upload_channel}, - comment::get_comments, + channel::{delete_channel, get_channel, list_channels, update_channel, upload_channel}, + comment::get_video_comments, index, middleware::auth, - video::{get_video, list_videos, upload_video}, + video::{delete_video, get_channel_videos, get_video, list_videos, update_video, upload_video}, }; use tokio::signal; use tracing::info; @@ -42,17 +42,25 @@ async fn main() -> Result<(), Box> { let address = instance.config.address.get_url(); - let almond = Router::new() - .route("/upload_video", post(upload_video)) - .route("/upload_channel", post(upload_channel)) - .route_layer(axum::middleware::from_fn_with_state(instance.clone(), auth)) + let router = Router::new() .route("/", get(index)) - .route("/videos", get(list_videos)) + .route("/video", get(list_videos)) .route("/video/{id}", get(get_video)) - .route("/comments/{id}", get(get_comments)) - .route("/channels", get(list_channels)) + .route("/video/{id}/comments", get(get_video_comments)) + .route("/channel", get(list_channels)) .route("/channel/{id}", get(get_channel)) - .with_state(instance); + .route("/channel/{id}/videos", get(get_channel_videos)); + + let protected = Router::new() + .route("/video", post(upload_video)) + .route("/video/{id}", put(update_video)) + .route("/video/{id}", delete(delete_video)) + .route("/channel", post(upload_channel)) + .route("/channel/{id}", put(update_channel)) + .route("/channel/{id}", delete(delete_channel)) + .route_layer(axum::middleware::from_fn_with_state(instance.clone(), auth)); + + let almond = router.merge(protected).with_state(instance); let listener = tokio::net::TcpListener::bind(address).await?; diff --git a/src/routes/channel.rs b/src/routes/channel.rs index 7de9921..1ae9c2a 100755 --- a/src/routes/channel.rs +++ b/src/routes/channel.rs @@ -13,12 +13,12 @@ use crate::{ url::is_youtube_url, }; -#[derive(Debug, Deserialize)] +#[derive(Deserialize)] pub struct ListChannelsQuery { page: Option, } -#[derive(Debug, Serialize)] +#[derive(Serialize)] pub struct ListChannelsResponse { channels: Vec, page: usize, @@ -76,7 +76,7 @@ pub async fn get_channel( }) } -#[derive(Debug, Deserialize)] +#[derive(Deserialize)] pub struct UploadChannelQuery { url: String, } @@ -109,7 +109,11 @@ pub async fn upload_channel( let new_channel = Channel::from_url(&url, id).await.map_err(|e| match e { ChannelError::AlreadyExists => StatusCode::OK, - _ => StatusCode::INTERNAL_SERVER_ERROR, + ChannelError::InvalidOutput => StatusCode::BAD_REQUEST, + e => { + error!("{e:?}"); + StatusCode::INTERNAL_SERVER_ERROR + } }); match new_channel { @@ -148,3 +152,36 @@ pub async fn upload_channel( Err(status) => status, } } + +/// Update an existing channel from the database +pub async fn update_channel(State(state): State, Path(id): Path) -> StatusCode { + match sqlx::query_as!(Channel, "SELECT * FROM channel WHERE youtube_id = ?", id) + .fetch_optional(&state.pool) + .await + .map_or(Err(StatusCode::INTERNAL_SERVER_ERROR), |channel| { + channel.ok_or(StatusCode::NOT_FOUND) + }) { + // TODO + Ok(_channel) => StatusCode::OK, + Err(status) => { + error!("Failed to update video '{id}'"); + status + } + } +} + +/// Delete a channel from the database +pub async fn delete_channel(State(state): State, Path(id): Path) -> StatusCode { + match sqlx::query!("DELETE FROM channel WHERE youtube_id = ?", id) + .fetch_optional(&state.pool) + .await + .map_or(Err(StatusCode::INTERNAL_SERVER_ERROR), |channel| { + channel.ok_or(StatusCode::NOT_FOUND) + }) { + Ok(_) => { + info!("Deleted channel '{id}' successfully"); + StatusCode::OK + } + Err(status) => status, + } +} diff --git a/src/routes/comment.rs b/src/routes/comment.rs index 0ecfa0e..c693838 100755 --- a/src/routes/comment.rs +++ b/src/routes/comment.rs @@ -11,12 +11,12 @@ use crate::{ instance::Instance, }; -#[derive(Debug, Deserialize)] +#[derive(Deserialize)] pub struct VideoCommentsQuery { page: Option, } -#[derive(Debug, Serialize)] +#[derive(Serialize)] pub struct VideoCommentsResponse { comments: Vec, page: usize, @@ -26,7 +26,7 @@ pub struct VideoCommentsResponse { } /// Fetches the comments from a video, will return an empty vec if the video has no comments -pub async fn get_comments( +pub async fn get_video_comments( State(state): State, Path(id): Path, Query(query): Query, diff --git a/src/routes/middleware.rs b/src/routes/middleware.rs index 63303f0..8bc9192 100755 --- a/src/routes/middleware.rs +++ b/src/routes/middleware.rs @@ -12,7 +12,7 @@ use tracing::error; use crate::instance::Instance; -#[derive(Debug, PartialEq, Eq, Deserialize)] +#[derive(PartialEq, Eq, Deserialize)] pub struct Key(pub String); #[derive(Debug)] diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 509cd2c..eacc584 100755 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -6,7 +6,7 @@ pub mod comment; pub mod middleware; pub mod video; -#[derive(Debug, Serialize)] +#[derive(Serialize)] pub struct IndexResponse { app_name: String, version: String, diff --git a/src/routes/video.rs b/src/routes/video.rs index 9efc571..ad8bb79 100755 --- a/src/routes/video.rs +++ b/src/routes/video.rs @@ -13,12 +13,12 @@ use crate::{ video::{Video, VideoError}, }; -#[derive(Debug, Deserialize)] +#[derive(Deserialize)] pub struct ListVideosQuery { page: Option, } -#[derive(Debug, Serialize)] +#[derive(Serialize)] pub struct ListVideosResponse { videos: Vec