youtube downloader :)

hexagon
Patrick Cleavelin 2025-10-08 18:09:46 -05:00
parent c4d12562a1
commit 79a2f2839f
10 changed files with 358 additions and 58 deletions

View File

@ -15,12 +15,12 @@
};
yt-dlp = pkgs.yt-dlp.overrideAttrs (oldAttr: rec {
inherit (oldAttr) name;
version = "2024.05.27";
version = "2025.09.26";
src = pkgs.fetchFromGitHub {
owner = "yt-dlp";
repo = "yt-dlp";
rev = "${version}";
sha256 = "55zDAMwCJPn5zKrAFw4ogTxxmvjrv4PvhYO7PsHbRo4=";
sha256 = "/uzs87Vw+aDNfIJVLOx3C8RyZvWLqjggmnjrOvUX1Eg=";
};
});
local-rust = (pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain).override {
@ -30,6 +30,7 @@
{
devShell = pkgs.mkShell {
buildInputs = with pkgs; [
git
local-rust
rust-analyzer
pkg-config

View File

@ -1,7 +1,7 @@
use chrono::{Duration, Utc};
use crate::lib::domain::intro_tool::{
models,
models::{self, guild::IntroId},
ports::{IntroToolRepository, IntroToolService},
};
@ -112,7 +112,7 @@ where
async fn add_intro_to_guild(
&self,
req: models::guild::AddIntroToGuildRequest,
) -> Result<(), models::guild::AddIntroToGuildError> {
) -> Result<IntroId, models::guild::AddIntroToGuildError> {
self.wrapped_service.add_intro_to_guild(req).await
}

View File

@ -258,10 +258,16 @@ pub struct CreateChannelRequest {
}
pub struct AddIntroToGuildRequest {
guild_id: GuildId,
name: String,
volume: i32,
filename: String,
pub guild_id: GuildId,
pub name: String,
pub volume: i32,
pub data: IntroRequestData,
}
pub enum IntroRequestData {
Data(Vec<u8>),
Url(String),
}
pub struct AddIntroToUserRequest {

View File

@ -1,6 +1,6 @@
use std::{collections::HashMap, future::Future};
use crate::lib::domain::intro_tool::models::guild::ChannelName;
use crate::lib::domain::intro_tool::models::guild::{ChannelName, IntroId};
use super::models::guild::{
AddIntroToGuildError, AddIntroToGuildRequest, AddIntroToUserError, AddIntroToUserRequest,
@ -44,10 +44,10 @@ pub trait IntroToolService: Send + Sync + Clone + 'static {
req: CreateChannelRequest,
) -> Result<Channel, CreateChannelError>;
async fn add_intro_to_guild(
fn add_intro_to_guild(
&self,
req: AddIntroToGuildRequest,
) -> Result<(), AddIntroToGuildError>;
) -> impl Future<Output = Result<IntroId, AddIntroToGuildError>> + Send;
async fn add_intro_to_user(
&self,
@ -104,10 +104,12 @@ pub trait IntroToolRepository: Send + Sync + Clone + 'static {
req: CreateChannelRequest,
) -> Result<Channel, CreateChannelError>;
async fn add_intro_to_guild(
fn add_intro_to_guild(
&self,
req: AddIntroToGuildRequest,
) -> Result<(), AddIntroToGuildError>;
name: &str,
guild_id: GuildId,
filename: String,
) -> impl Future<Output = Result<IntroId, AddIntroToGuildError>> + Send;
async fn add_intro_to_user(
&self,

View File

@ -1,5 +1,8 @@
use anyhow::{anyhow, Context};
use uuid::Uuid;
use crate::lib::domain::intro_tool::{
models::guild::{GetUserError, GuildId, User},
models::guild::{self, GetUserError, GuildId, IntroId, User},
ports::{IntroToolRepository, IntroToolService},
};
@ -37,7 +40,7 @@ where
async fn get_guild(
&self,
guild_id: impl Into<GuildId>,
) -> Result<models::guild::Guild, models::guild::GetGuildError> {
) -> Result<guild::Guild, guild::GetGuildError> {
self.repo.get_guild(guild_id.into()).await
}
@ -47,21 +50,21 @@ where
async fn get_guild_intros(
&self,
guild_id: GuildId,
) -> Result<Vec<models::guild::Intro>, models::guild::GetIntroError> {
) -> Result<Vec<guild::Intro>, guild::GetIntroError> {
self.repo.get_guild_intros(guild_id).await
}
async fn get_user(
&self,
username: impl AsRef<str> + Send,
) -> Result<models::guild::User, models::guild::GetUserError> {
) -> Result<guild::User, guild::GetUserError> {
self.repo.get_user(username).await
}
async fn get_user_guilds(
&self,
username: impl AsRef<str> + Send,
) -> Result<Vec<models::guild::GuildRef>, models::guild::GetGuildError> {
) -> Result<Vec<guild::GuildRef>, guild::GetGuildError> {
self.repo.get_user_guilds(username).await
}
@ -71,36 +74,65 @@ where
async fn create_guild(
&self,
req: models::guild::CreateGuildRequest,
) -> Result<models::guild::Guild, models::guild::CreateGuildError> {
req: guild::CreateGuildRequest,
) -> Result<guild::Guild, guild::CreateGuildError> {
self.repo.create_guild(req).await
}
async fn create_user(
&self,
req: models::guild::CreateUserRequest,
) -> Result<models::guild::User, models::guild::CreateUserError> {
req: guild::CreateUserRequest,
) -> Result<guild::User, guild::CreateUserError> {
self.repo.create_user(req).await
}
async fn create_channel(
&self,
req: models::guild::CreateChannelRequest,
) -> Result<models::guild::Channel, models::guild::CreateChannelError> {
req: guild::CreateChannelRequest,
) -> Result<guild::Channel, guild::CreateChannelError> {
self.repo.create_channel(req).await
}
async fn add_intro_to_guild(
&self,
req: models::guild::AddIntroToGuildRequest,
) -> Result<(), models::guild::AddIntroToGuildError> {
self.repo.add_intro_to_guild(req).await
req: guild::AddIntroToGuildRequest,
) -> Result<IntroId, guild::AddIntroToGuildError> {
let file_name = match &req.data {
guild::IntroRequestData::Data(items) => todo!(),
guild::IntroRequestData::Url(url) => {
let uuid = Uuid::new_v4().to_string();
let file_name = format!("sounds/{uuid}");
// TODO: put this behind an interface
let child = tokio::process::Command::new("yt-dlp")
.arg(&url)
.args(["-o", &file_name])
.args(["-x", "--audio-format", "mp3"])
.spawn()
.context("failed to spawn yt-dlp process")?
.wait()
.await
.context("yt-dlp process failed")?;
if !child.success() {
return Err(guild::AddIntroToGuildError::Unknown(anyhow!(
"yt-dlp terminated unsuccessfully"
)));
}
file_name
}
};
self.repo
.add_intro_to_guild(&req.name, req.guild_id, file_name)
.await
}
async fn add_intro_to_user(
&self,
req: models::guild::AddIntroToUserRequest,
) -> Result<(), models::guild::AddIntroToUserError> {
req: guild::AddIntroToUserRequest,
) -> Result<(), guild::AddIntroToUserError> {
self.repo.add_intro_to_user(req).await
}
}

View File

@ -1,3 +1,4 @@
mod handlers;
mod page;
use std::{net::SocketAddr, sync::Arc};
@ -115,10 +116,8 @@ where
.route("/", get(page::home))
.route("/login", get(page::login))
.route("/guild/:guild_id", get(page::guild_dashboard))
// .route("/", get(page::home))
// .route("/index.html", get(page::home))
// .route("/login", get(page::login))
// .route("/guild/:guild_id", get(page::guild_dashboard))
.route("/v2/intros/:guild/add", get(handlers::add_guild_intro))
// .route("/guild/:guild_id/setup", get(routes::guild_setup))
// .route(
// "/guild/:guild_id/add_channel",

View File

@ -0,0 +1,81 @@
use std::collections::HashMap;
use axum::{
extract::{Path, Query, State},
http::{HeaderMap, HeaderValue},
};
use crate::lib::{
domain::intro_tool::{
models::guild::{AddIntroToGuildRequest, GuildId, IntroRequestData, User},
ports::IntroToolService,
},
inbound::{
http::ApiState,
response::{ApiError, ErrorAsRedirect},
},
};
trait FromApi<T, P>: Sized {
fn from_api(value: T, params: P) -> Result<Self, ApiError>;
}
trait IntoDomain<T, P> {
fn into_domain(self, params: P) -> Result<T, ApiError>;
}
impl<I, O: FromApi<I, P>, P> IntoDomain<O, P> for I {
fn into_domain(self, params: P) -> Result<O, ApiError> {
O::from_api(self, params)
}
}
impl FromApi<HashMap<String, String>, GuildId> for AddIntroToGuildRequest {
fn from_api(value: HashMap<String, String>, params: GuildId) -> Result<Self, ApiError> {
let Some(url) = value.get("url") else {
return Err(ApiError::bad_request("url is required"));
};
let Some(name) = value.get("name") else {
return Err(ApiError::bad_request("name is required"));
};
Ok(Self {
guild_id: params,
name: name.to_string(),
volume: 0,
data: IntroRequestData::Url(url.to_string()),
})
}
}
pub(super) async fn add_guild_intro<S: IntroToolService>(
State(state): State<ApiState<S>>,
Path(guild_id): Path<u64>,
Query(params): Query<HashMap<String, String>>,
user: User,
) -> Result<HeaderMap, ApiError> {
let req = params.into_domain(guild_id.into())?;
let guild = state.intro_tool_service.get_guild(guild_id).await?;
let user_guilds = state
.intro_tool_service
.get_user_guilds(user.name())
.await?;
// does user have access to this guild
if !user_guilds
.iter()
.any(|guild_ref| guild_ref.id() == guild.id())
{
return Err(ApiError::forbidden(
"You do not have access to this guild".to_string(),
));
}
state.intro_tool_service.add_intro_to_guild(req).await?;
let mut headers = HeaderMap::new();
headers.insert("HX-Refresh", HeaderValue::from_static("true"));
Ok(headers)
}

View File

@ -133,6 +133,8 @@ pub async fn guild_dashboard<S: IntroToolService>(
return Err(Redirect::to(&format!("{}/error", state.origin)));
}
let can_upload = true;
Ok(Html(
HtmxBuilder::new(Tag::Html)
.push_builder(page_header("MemeJoin - Dashboard"))
@ -156,24 +158,24 @@ pub async fn guild_dashboard<S: IntroToolService>(
// } else {
// b
// };
// b = if can_upload {
// b.builder(Tag::Div, |b| {
// b.attribute("class", "container")
// .builder(Tag::Article, |b| {
// b.builder_text(Tag::Header, "Upload New Intro")
// .push_builder(upload_form(&state.origin, guild_id))
// })
// })
// .builder(Tag::Div, |b| {
// b.attribute("class", "container")
// .builder(Tag::Article, |b| {
// b.builder_text(Tag::Header, "Upload New Intro from Url")
// .push_builder(ytdl_form(&state.origin, guild_id))
// })
// })
// } else {
// b
// };
let b = if can_upload {
b.builder(Tag::Div, |b| {
b.attribute("class", "container")
.builder(Tag::Article, |b| {
b.builder_text(Tag::Header, "Upload New Intro")
.push_builder(upload_form(&state.origin, guild_id))
})
})
.builder(Tag::Div, |b| {
b.attribute("class", "container")
.builder(Tag::Article, |b| {
b.builder_text(Tag::Header, "Upload New Intro from Url")
.push_builder(ytdl_form(&state.origin, guild_id))
})
})
} else {
b
};
b.builder(Tag::Div, |b| {
b.attribute("class", "container")
@ -290,3 +292,41 @@ fn intro_list<'a>(intros: impl Iterator<Item = &'a Intro>, label: &str, post: &s
.button(|b| b.attribute("type", "submit").text(label))
})
}
fn upload_form(origin: &str, guild_id: u64) -> HtmxBuilder {
HtmxBuilder::new(Tag::Empty).form(|b| {
b.attribute("class", "container")
.hx_post(&format!("{}/v2/intros/{}/upload", origin, guild_id))
.attribute("hx-encoding", "multipart/form-data")
.builder(Tag::FieldSet, |b| {
b.attribute("class", "container")
.attribute("role", "group")
.input(|b| b.attribute("type", "file").attribute("name", "file"))
.input(|b| {
b.attribute("name", "name")
.attribute("placeholder", "enter intro title")
})
.button(|b| b.attribute("type", "submit").text("Upload"))
})
})
}
fn ytdl_form(origin: &str, guild_id: u64) -> HtmxBuilder {
HtmxBuilder::new(Tag::Empty).form(|b| {
b.attribute("class", "container")
.hx_get(&format!("{}/v2/intros/{}/add", origin, guild_id))
.builder(Tag::FieldSet, |b| {
b.attribute("class", "container")
.attribute("role", "group")
.input(|b| {
b.attribute("placeholder", "enter video url")
.attribute("name", "url")
})
.input(|b| {
b.attribute("placeholder", "enter intro title")
.attribute("name", "name")
})
.button(|b| b.attribute("type", "submit").text("Upload"))
})
})
}

View File

@ -1,9 +1,14 @@
use std::fmt::Debug;
use axum::response::Redirect;
use axum::{
response::{IntoResponse, Redirect},
Json,
};
use reqwest::StatusCode;
use serde::Serialize;
use crate::lib::domain::intro_tool::models::guild::{
GetChannelError, GetGuildError, GetIntroError,
AddIntroToGuildError, GetChannelError, GetGuildError, GetIntroError,
};
pub(super) trait ErrorAsRedirect<T>: Sized {
@ -63,3 +68,99 @@ impl<T: Debug> ErrorAsRedirect<T> for Result<T, GetIntroError> {
}
}
}
pub(super) struct ApiResponse<T: Serialize>(StatusCode, Json<T>);
#[derive(Serialize, Debug)]
#[serde(tag = "status")]
pub(super) enum ApiError {
NotFound {
message: String,
},
BadRequest {
message: String,
},
Forbidden {
message: String,
},
InternalServerError {
#[serde(skip)]
message: String,
},
}
impl ApiError {
fn status_code(&self) -> StatusCode {
match self {
ApiError::NotFound { .. } => StatusCode::NOT_FOUND,
ApiError::BadRequest { .. } => StatusCode::BAD_REQUEST,
ApiError::Forbidden { .. } => StatusCode::FORBIDDEN,
ApiError::InternalServerError { .. } => StatusCode::INTERNAL_SERVER_ERROR,
}
}
pub(super) fn not_found(message: impl ToString) -> Self {
Self::NotFound {
message: message.to_string(),
}
}
pub(super) fn bad_request(message: impl ToString) -> Self {
Self::BadRequest {
message: message.to_string(),
}
}
pub(super) fn forbidden(message: impl ToString) -> Self {
Self::Forbidden {
message: message.to_string(),
}
}
pub(super) fn internal(message: impl ToString) -> Self {
Self::InternalServerError {
message: message.to_string(),
}
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> axum::response::Response {
(self.status_code(), Json(self)).into_response()
}
}
impl From<GetGuildError> for ApiError {
fn from(value: GetGuildError) -> Self {
match value {
GetGuildError::NotFound => Self::not_found("Guild not found"),
GetGuildError::CouldNotFetchUsers(get_user_error) => {
tracing::error!(err = ?get_user_error, "could not fetch users from guild");
Self::internal("Could not fetch users from guild".to_string())
}
GetGuildError::CouldNotFetchChannels(get_channel_error) => {
tracing::error!(err = ?get_channel_error, "could not fetch channels from guild");
Self::internal("Could not fetch channels from guild".to_string())
}
GetGuildError::Unknown(error) => {
tracing::error!(err = ?error, "unknown error");
Self::internal(error.to_string())
}
}
}
}
impl From<AddIntroToGuildError> for ApiError {
fn from(value: AddIntroToGuildError) -> Self {
match value {
AddIntroToGuildError::Unknown(error) => {
tracing::error!(err = ?error, "unknown error");
Self::internal(error.to_string())
}
}
}
}

View File

@ -10,7 +10,7 @@ use crate::lib::domain::intro_tool::{
self, AddIntroToGuildError, AddIntroToGuildRequest, AddIntroToUserRequest, Channel,
ChannelName, CreateChannelError, CreateChannelRequest, CreateGuildError,
CreateGuildRequest, CreateUserError, CreateUserRequest, GetChannelError, GetGuildError,
GetIntroError, GetUserError, Guild, GuildId, GuildRef, Intro, User, UserName,
GetIntroError, GetUserError, Guild, GuildId, GuildRef, Intro, IntroId, User, UserName,
},
ports::IntroToolRepository,
};
@ -347,9 +347,47 @@ impl IntroToolRepository for Sqlite {
async fn add_intro_to_guild(
&self,
req: AddIntroToGuildRequest,
) -> Result<(), AddIntroToGuildError> {
todo!()
name: &str,
guild_id: GuildId,
filename: String,
) -> Result<IntroId, AddIntroToGuildError> {
let conn = self.conn.lock().await;
let mut query = conn
.prepare(
"
INSERT INTO Intro
(
name,
volume,
guild_id,
filename
)
VALUES
(
:name,
:volume,
:guild_id,
:filename
)
RETURNING id
",
)
.context("failed to prepare query")?;
let intro_id = query
.query_row(
&[
(":name", name),
(":volume", &0.to_string()),
(":guild_id", &guild_id.to_string()),
(":filename", &filename),
],
|row| Ok(row.get::<_, i32>(0)?.into()),
)
.context("failed to query row")?;
Ok(intro_id)
}
async fn add_intro_to_user(