Change derives + Reorganize routes + Improve channel ID checking
This commit is contained in:
parent
4b8cccf29f
commit
a210d7003b
@ -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
|
||||
|
@ -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();
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
32
src/main.rs
32
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<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?;
|
||||
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
@ -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>,
|
||||
|
@ -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)]
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
Loading…
x
Reference in New Issue
Block a user