Change derives + Reorganize routes + Improve channel ID checking

This commit is contained in:
roaming97 2025-04-17 00:46:21 -06:00
parent 4b8cccf29f
commit a210d7003b
12 changed files with 205 additions and 59 deletions

View File

@ -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

View File

@ -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<String, ChannelError> {
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<Self, ChannelError> {
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();

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

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

View File

@ -13,12 +13,12 @@ use crate::{
url::is_youtube_url,
};
#[derive(Debug, Deserialize)]
#[derive(Deserialize)]
pub struct ListChannelsQuery {
page: Option<usize>,
}
#[derive(Debug, Serialize)]
#[derive(Serialize)]
pub struct ListChannelsResponse {
channels: Vec<Channel>,
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<Instance>, Path(id): Path<String>) -> 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<Instance>, Path(id): Path<String>) -> 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,
}
}

View File

@ -11,12 +11,12 @@ use crate::{
instance::Instance,
};
#[derive(Debug, Deserialize)]
#[derive(Deserialize)]
pub struct VideoCommentsQuery {
page: Option<usize>,
}
#[derive(Debug, Serialize)]
#[derive(Serialize)]
pub struct VideoCommentsResponse {
comments: Vec<Comment>,
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<Instance>,
Path(id): Path<String>,
Query(query): Query<VideoCommentsQuery>,

View File

@ -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)]

View File

@ -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,

View File

@ -13,12 +13,12 @@ use crate::{
video::{Video, VideoError},
};
#[derive(Debug, Deserialize)]
#[derive(Deserialize)]
pub struct ListVideosQuery {
page: Option<usize>,
}
#[derive(Debug, Serialize)]
#[derive(Serialize)]
pub struct ListVideosResponse {
videos: Vec<Video>,
page: usize,
@ -63,6 +63,23 @@ pub async fn list_videos(
}))
}
/// Get all videos belonging to a channel ID from the database
pub async fn get_channel_videos(
State(state): State<Instance>,
Path(id): Path<String>,
) -> Result<Json<Vec<Video>>, StatusCode> {
sqlx::query_as!(Video, "SELECT * FROM video WHERE author_id = ?", id)
.fetch_all(&state.pool)
.await
.map_or(Err(StatusCode::INTERNAL_SERVER_ERROR), |videos| {
if videos.is_empty() {
Err(StatusCode::NOT_FOUND)
} else {
Ok(Json(videos))
}
})
}
/// Get a single video from the database by its ID
pub async fn get_video(
State(state): State<Instance>,
@ -76,7 +93,7 @@ pub async fn get_video(
})
}
#[derive(Debug, Deserialize)]
#[derive(Deserialize)]
pub struct UploadVideoQuery {
url: String,
}
@ -107,13 +124,11 @@ pub async fn upload_video(
return StatusCode::BAD_REQUEST;
}
let new_video = Video::from_url(&url, id)
.await
.map_err(|e| match e {
VideoError::InvalidUrl => StatusCode::BAD_REQUEST,
VideoError::AlreadyExists => StatusCode::OK,
_ => StatusCode::INTERNAL_SERVER_ERROR,
});
let new_video = Video::from_url(&url, id).await.map_err(|e| match e {
VideoError::InvalidUrl => StatusCode::BAD_REQUEST,
VideoError::AlreadyExists => StatusCode::OK,
_ => StatusCode::INTERNAL_SERVER_ERROR,
});
match new_video {
Ok(video) => {
@ -158,3 +173,36 @@ pub async fn upload_video(
Err(status) => status,
}
}
/// Update an existing video from the database
pub async fn update_video(State(state): State<Instance>, Path(id): Path<String>) -> StatusCode {
match sqlx::query_as!(Video, "SELECT * FROM video WHERE youtube_id = ?", id)
.fetch_optional(&state.pool)
.await
.map_or(Err(StatusCode::INTERNAL_SERVER_ERROR), |video| {
video.ok_or(StatusCode::NOT_FOUND)
}) {
// TODO
Ok(_video) => StatusCode::OK,
Err(status) => {
error!("Failed to update video '{id}'");
status
}
}
}
/// Delete a video from the database
pub async fn delete_video(State(state): State<Instance>, Path(id): Path<String>) -> StatusCode {
match sqlx::query!("DELETE FROM video WHERE youtube_id = ?", id)
.fetch_optional(&state.pool)
.await
.map_or(Err(StatusCode::INTERNAL_SERVER_ERROR), |video| {
video.ok_or(StatusCode::NOT_FOUND)
}) {
Ok(_) => {
info!("Deleted video '{id}' successfully");
StatusCode::OK
}
Err(status) => status,
}
}

View File

@ -26,7 +26,7 @@ pub enum VideoError {
MissingVideoFile,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[derive(Serialize, Deserialize, Clone)]
pub struct Video {
pub id: i64,
pub url: String,