interfaces for dealing with audio files + move stuff around

hexagon
Patrick Cleavelin 2025-10-08 18:51:11 -05:00
parent 752ce3f16c
commit a1b3bbb999
17 changed files with 223 additions and 236 deletions

View File

@ -3,6 +3,14 @@ name = "memejoin-rs"
version = "0.2.2-alpha" version = "0.2.2-alpha"
edition = "2021" edition = "2021"
[[bin]]
name = "memejoin-rs"
path = "src/main.rs"
[lib]
name = "memejoin_rs"
path = "src/lib/mod.rs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]

View File

@ -249,66 +249,66 @@ impl Database {
intros intros
} }
pub(crate) fn get_all_user_permissions( // pub(crate) fn get_all_user_permissions(
&self, // &self,
guild_id: u64, // guild_id: u64,
) -> Result<Vec<(String, auth::Permissions)>> { // ) -> Result<Vec<(String, auth::Permissions)>> {
let mut query = self.conn.prepare( // let mut query = self.conn.prepare(
" // "
SELECT // SELECT
username, // username,
permissions // permissions
FROM UserPermission // FROM UserPermission
WHERE // WHERE
guild_id = :guild_id // guild_id = :guild_id
", // ",
)?; // )?;
//
// let permissions = query
// .query_map(
// &[
// // :vomit:
// (":guild_id", &guild_id.to_string()),
// ],
// |row| Ok((row.get(0)?, auth::Permissions(row.get(1)?))),
// )?
// .collect::<Result<Vec<(String, auth::Permissions)>>>()?;
//
// Ok(permissions)
// }
let permissions = query // pub(crate) fn get_user_permissions(
.query_map( // &self,
&[ // username: &str,
// :vomit: // guild_id: u64,
(":guild_id", &guild_id.to_string()), // ) -> Result<auth::Permissions> {
], // self.conn.query_row(
|row| Ok((row.get(0)?, auth::Permissions(row.get(1)?))), // "
)? // SELECT
.collect::<Result<Vec<(String, auth::Permissions)>>>()?; // permissions
// FROM UserPermission
// WHERE
// username = ?1
// AND guild_id = ?2
// ",
// [username, &guild_id.to_string()],
// |row| Ok(auth::Permissions(row.get(0)?)),
// )
// }
Ok(permissions) // pub(crate) fn get_user_app_permissions(&self, username: &str) -> Result<auth::AppPermissions> {
} // self.conn.query_row(
// "
pub(crate) fn get_user_permissions( // SELECT
&self, // permissions
username: &str, // FROM UserAppPermission
guild_id: u64, // WHERE
) -> Result<auth::Permissions> { // username = ?1
self.conn.query_row( // ",
" // [username],
SELECT // |row| Ok(auth::AppPermissions(row.get(0)?)),
permissions // )
FROM UserPermission // }
WHERE
username = ?1
AND guild_id = ?2
",
[username, &guild_id.to_string()],
|row| Ok(auth::Permissions(row.get(0)?)),
)
}
pub(crate) fn get_user_app_permissions(&self, username: &str) -> Result<auth::AppPermissions> {
self.conn.query_row(
"
SELECT
permissions
FROM UserAppPermission
WHERE
username = ?1
",
[username],
|row| Ok(auth::AppPermissions(row.get(0)?)),
)
}
pub(crate) fn get_guild_channels(&self, guild_id: u64) -> Result<Vec<String>> { pub(crate) fn get_guild_channels(&self, guild_id: u64) -> Result<Vec<String>> {
let mut query = self.conn.prepare( let mut query = self.conn.prepare(
@ -476,28 +476,29 @@ impl Database {
Ok(()) Ok(())
} }
pub(crate) fn insert_user_permission( // pub(crate) fn insert_user_permission(
&self, // &self,
username: &str, // username: &str,
guild_id: u64, // guild_id: u64,
permissions: auth::Permissions, // permissions: auth::Permissions,
) -> Result<()> { // ) -> Result<()> {
let affected = self.conn.execute( // let affected = self.conn.execute(
" // "
INSERT INTO // INSERT INTO
UserPermission (username, guild_id, permissions) // UserPermission (username, guild_id, permissions)
VALUES (?1, ?2, ?3) // VALUES (?1, ?2, ?3)
ON CONFLICT(username, guild_id) DO UPDATE SET permissions = ?3", // ON CONFLICT(username, guild_id) DO UPDATE SET permissions = ?3",
[username, &guild_id.to_string(), &permissions.0.to_string()], // [username, &guild_id.to_string(), &permissions.0.to_string()],
)?; // )?;
//
if affected < 1 { // if affected < 1 {
warn!("no rows affected when attempting to insert user permissions"); // warn!("no rows affected when attempting to insert user permissions");
} // }
//
Ok(()) // Ok(())
} // }
/*
pub(crate) fn insert_user_app_permission( pub(crate) fn insert_user_app_permission(
&self, &self,
username: &str, username: &str,
@ -518,6 +519,7 @@ impl Database {
Ok(()) Ok(())
} }
*/
pub fn delete_user_intro( pub fn delete_user_intro(
&self, &self,

View File

@ -1,10 +1,16 @@
#[derive(Clone)]
pub struct DiscordSecret {
pub client_id: String,
pub client_secret: String,
pub bot_token: String,
}
/*
use std::str::FromStr; use std::str::FromStr;
use enum_iterator::Sequence; use enum_iterator::Sequence;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::routes::Error;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct Discord { pub(crate) struct Discord {
pub(crate) access_token: String, pub(crate) access_token: String,
@ -14,13 +20,6 @@ pub(crate) struct Discord {
pub(crate) scope: String, pub(crate) scope: String,
} }
#[derive(Clone)]
pub(crate) struct DiscordSecret {
pub(crate) client_id: String,
pub(crate) client_secret: String,
pub(crate) bot_token: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct User { pub(crate) struct User {
pub(crate) auth: Discord, pub(crate) auth: Discord,
@ -143,3 +142,4 @@ impl FromStr for Permission {
} }
} }
} }
*/

View File

@ -1,6 +1,6 @@
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use crate::lib::domain::intro_tool::{ use crate::domain::intro_tool::{
models::{self, guild::IntroId}, models::{self, guild::IntroId},
ports::{IntroToolRepository, IntroToolService}, ports::{IntroToolRepository, IntroToolService},
}; };

View File

@ -1,6 +1,6 @@
use std::{collections::HashMap, future::Future}; use std::{collections::HashMap, future::Future};
use crate::lib::domain::intro_tool::models::guild::{ChannelName, IntroId}; use crate::domain::intro_tool::models::guild::{ChannelName, IntroId};
use super::models::guild::{ use super::models::guild::{
AddIntroToGuildError, AddIntroToGuildRequest, AddIntroToUserError, AddIntroToUserRequest, AddIntroToGuildError, AddIntroToGuildRequest, AddIntroToUserError, AddIntroToUserRequest,
@ -116,3 +116,19 @@ pub trait IntroToolRepository: Send + Sync + Clone + 'static {
req: AddIntroToUserRequest, req: AddIntroToUserRequest,
) -> Result<(), AddIntroToUserError>; ) -> Result<(), AddIntroToUserError>;
} }
pub trait RemoteAudioFetcher: Send + Sync + Clone + 'static {
fn fetch_remote_audio(
&self,
url: &str,
name: &str,
) -> impl Future<Output = Result<String, anyhow::Error>> + Send;
}
pub trait LocalAudioFetcher: Send + Sync + Clone + 'static {
fn save_local_audio(
&self,
bytes: &[u8],
name: &str,
) -> impl Future<Output = Result<String, anyhow::Error>> + Send;
}

View File

@ -1,36 +1,42 @@
use anyhow::{anyhow, Context};
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::domain::intro_tool::{
lib::domain::intro_tool::{ models::guild::{self, GetUserError, GuildId, IntroId, User},
models::guild::{self, GetUserError, GuildId, IntroId, User}, ports::{IntroToolRepository, IntroToolService, LocalAudioFetcher, RemoteAudioFetcher},
ports::{IntroToolRepository, IntroToolService},
},
media,
}; };
use super::models;
#[derive(Clone)] #[derive(Clone)]
pub struct Service<R> pub struct Service<R, RA, LA>
where where
R: IntroToolRepository, R: IntroToolRepository,
RA: RemoteAudioFetcher,
LA: LocalAudioFetcher,
{ {
repo: R, repo: R,
remote_audio_fetcher: RA,
local_audio_fetcher: LA,
} }
impl<R> Service<R> impl<R, RA, LA> Service<R, RA, LA>
where where
R: IntroToolRepository, R: IntroToolRepository,
RA: RemoteAudioFetcher,
LA: LocalAudioFetcher,
{ {
pub fn new(repo: R) -> Self { pub fn new(repo: R, remote_audio_fetcher: RA, local_audio_fetcher: LA) -> Self {
Self { repo } Self {
repo,
remote_audio_fetcher,
local_audio_fetcher,
}
} }
} }
impl<R> IntroToolService for Service<R> impl<R, RA, LA> IntroToolService for Service<R, RA, LA>
where where
R: IntroToolRepository, R: IntroToolRepository,
RA: RemoteAudioFetcher,
LA: LocalAudioFetcher,
{ {
async fn needs_setup(&self) -> bool { async fn needs_setup(&self) -> bool {
let Ok(guild_count) = self.repo.get_guild_count().await else { let Ok(guild_count) = self.repo.get_guild_count().await else {
@ -102,42 +108,14 @@ where
) -> Result<IntroId, guild::AddIntroToGuildError> { ) -> Result<IntroId, guild::AddIntroToGuildError> {
let file_name = match &req.data { let file_name = match &req.data {
guild::IntroRequestData::Data(bytes) => { guild::IntroRequestData::Data(bytes) => {
// TODO: put this behind an interface self.local_audio_fetcher
let uuid = Uuid::new_v4().to_string(); .save_local_audio(bytes, Uuid::new_v4().to_string().as_str())
let temp_path = format!("./sounds/temp/{uuid}"); .await?
let dest_path = format!("./sounds/{uuid}.mp3");
// Write original file so its ready for codec conversion
std::fs::write(&temp_path, bytes).context("failed to write temp file")?;
media::normalize(&temp_path, &dest_path)
.await
.context("failed to normalize file")?;
std::fs::remove_file(&temp_path).context("failed to remove temp file")?;
dest_path
} }
guild::IntroRequestData::Url(url) => { guild::IntroRequestData::Url(url) => {
let uuid = Uuid::new_v4().to_string(); self.remote_audio_fetcher
let file_name = format!("sounds/{uuid}"); .fetch_remote_audio(url, Uuid::new_v4().to_string().as_str())
.await?
// 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
} }
}; };

View File

@ -17,7 +17,7 @@ use tracing::info;
use crate::{ use crate::{
auth, auth,
lib::domain::intro_tool::{models::guild::User, ports::IntroToolService}, domain::intro_tool::{models::guild::User, ports::IntroToolService},
}; };
#[derive(Clone)] #[derive(Clone)]

View File

@ -5,7 +5,7 @@ use axum::{
http::{HeaderMap, HeaderValue}, http::{HeaderMap, HeaderValue},
}; };
use crate::lib::{ use crate::{
domain::intro_tool::{ domain::intro_tool::{
models::guild::{AddIntroToGuildRequest, GuildId, IntroRequestData, User}, models::guild::{AddIntroToGuildRequest, GuildId, IntroRequestData, User},
ports::IntroToolService, ports::IntroToolService,

View File

@ -4,14 +4,12 @@ use axum::{
}; };
use crate::{ use crate::{
htmx::{Build, HtmxBuilder, Tag}, domain::intro_tool::{
lib::{ models::guild::{ChannelName, GuildRef, Intro, User},
domain::intro_tool::{ ports::IntroToolService,
models::guild::{ChannelName, GuildRef, Intro, User},
ports::IntroToolService,
},
inbound::{http::ApiState, response::ErrorAsRedirect},
}, },
htmx::{Build, HtmxBuilder, Tag},
inbound::{http::ApiState, response::ErrorAsRedirect},
}; };
pub async fn home<S: IntroToolService>( pub async fn home<S: IntroToolService>(

View File

@ -7,7 +7,7 @@ use axum::{
use reqwest::StatusCode; use reqwest::StatusCode;
use serde::Serialize; use serde::Serialize;
use crate::lib::domain::intro_tool::models::guild::{ use crate::domain::intro_tool::models::guild::{
AddIntroToGuildError, GetChannelError, GetGuildError, GetIntroError, AddIntroToGuildError, GetChannelError, GetGuildError, GetIntroError,
}; };

View File

@ -1,3 +1,5 @@
pub mod auth;
pub mod domain; pub mod domain;
pub mod htmx;
pub mod inbound; pub mod inbound;
pub mod outbound; pub mod outbound;

View File

@ -0,0 +1,33 @@
use anyhow::{anyhow, Context};
use crate::domain::intro_tool::ports::LocalAudioFetcher;
#[derive(Clone)]
pub struct Ffmpeg;
impl LocalAudioFetcher for Ffmpeg {
async fn save_local_audio(&self, bytes: &[u8], name: &str) -> Result<String, anyhow::Error> {
let temp_path = format!("./sounds/temp/{name}");
let dest_path = format!("./sounds/{name}.mp3");
// Write original file so its ready for codec conversion
std::fs::write(&temp_path, bytes).context("failed to write temp file")?;
let child = tokio::process::Command::new("ffmpeg")
.args(["-i", &temp_path])
.arg("-vn")
.args(["-map", "0:a"])
.arg(&dest_path)
.spawn()
.map_err(|err| anyhow!(err.to_string()))?
.wait()
.await
.map_err(|err| anyhow!(err.to_string()))?;
if !child.success() {
return Err(anyhow!("ffmpeg terminated unsuccessfully"));
}
std::fs::remove_file(&temp_path).context("failed to remove temp file")?;
Ok(dest_path)
}
}

View File

@ -1 +1,3 @@
pub mod ffmpeg;
pub mod sqlite; pub mod sqlite;
pub mod ytdlp;

View File

@ -5,7 +5,7 @@ use tokio::sync::Mutex;
use anyhow::Context; use anyhow::Context;
use rusqlite::Connection; use rusqlite::Connection;
use crate::lib::domain::intro_tool::{ use crate::domain::intro_tool::{
models::guild::{ models::guild::{
self, AddIntroToGuildError, AddIntroToGuildRequest, AddIntroToUserRequest, Channel, self, AddIntroToGuildError, AddIntroToGuildRequest, AddIntroToUserRequest, Channel,
ChannelName, CreateChannelError, CreateChannelRequest, CreateGuildError, ChannelName, CreateChannelError, CreateChannelRequest, CreateGuildError,

28
src/lib/outbound/ytdlp.rs Normal file
View File

@ -0,0 +1,28 @@
use anyhow::{anyhow, Context};
use crate::domain::intro_tool::ports::RemoteAudioFetcher;
#[derive(Clone)]
pub struct Ytdlp;
impl RemoteAudioFetcher for Ytdlp {
async fn fetch_remote_audio(&self, url: &str, name: &str) -> Result<String, anyhow::Error> {
let file_name = format!("sounds/{name}");
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(anyhow!("yt-dlp terminated unsuccessfully"));
}
Ok(format!("{file_name}.mp3"))
}
}

View File

@ -1,26 +1,9 @@
// #![feature(stmt_expr_attributes)]
// #![feature(proc_macro_hygiene)]
// #![feature(async_closure)]
mod lib;
mod auth;
mod db; mod db;
mod htmx;
mod media;
mod page;
mod routes;
pub mod settings; pub mod settings;
use axum::http::Method;
use axum::routing::{get, post};
use axum::Router;
use settings::ApiState;
use std::env; use std::env;
use std::net::SocketAddr;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tower_http::cors::{Any, CorsLayer};
use serenity::async_trait; use serenity::async_trait;
use serenity::model::prelude::{Channel, ChannelId, GuildId, Member, Ready}; use serenity::model::prelude::{Channel, ChannelId, GuildId, Member, Ready};
@ -30,9 +13,7 @@ use serenity::prelude::*;
use songbird::SerenityInit; use songbird::SerenityInit;
use tracing::*; use tracing::*;
use crate::lib::domain::intro_tool; use memejoin_rs::{auth, domain::intro_tool, inbound, outbound};
use crate::lib::{inbound, outbound};
use crate::settings::Settings;
enum HandlerMessage { enum HandlerMessage {
Ready(Context), Ready(Context),
@ -120,67 +101,6 @@ impl EventHandler for Handler {
} }
} }
fn spawn_api(db: Arc<tokio::sync::Mutex<db::Database>>) {
let secrets = auth::DiscordSecret {
client_id: env::var("DISCORD_CLIENT_ID").expect("expected DISCORD_CLIENT_ID env var"),
client_secret: env::var("DISCORD_CLIENT_SECRET")
.expect("expected DISCORD_CLIENT_SECRET env var"),
bot_token: env::var("DISCORD_TOKEN").expect("expected DISCORD_TOKEN env var"),
};
let origin = env::var("APP_ORIGIN").expect("expected APP_ORIGIN");
let state = ApiState {
db,
secrets,
origin: origin.clone(),
};
tokio::spawn(async move {
let api = Router::new()
.route("/", get(page::home))
.route("/index.html", get(page::home))
.route("/login", get(page::login))
.route("/guild/:guild_id", get(page::guild_dashboard))
.route("/guild/:guild_id/setup", get(routes::guild_setup))
.route(
"/guild/:guild_id/add_channel",
post(routes::guild_add_channel),
)
.route(
"/guild/:guild_id/permissions/update",
post(routes::update_guild_permissions),
)
.route("/v2/auth", get(routes::v2_auth))
.route(
"/v2/intros/add/:guild_id/:channel",
post(routes::v2_add_intro_to_user),
)
.route(
"/v2/intros/remove/:guild_id/:channel",
post(routes::v2_remove_intro_from_user),
)
.route("/v2/intros/:guild/add", get(routes::v2_add_guild_intro))
.route(
"/v2/intros/:guild/upload",
post(routes::v2_upload_guild_intro),
)
.route("/health", get(routes::health))
.layer(
CorsLayer::new()
.allow_origin([origin.parse().unwrap()])
.allow_headers(Any)
.allow_methods([Method::GET, Method::POST, Method::DELETE]),
)
.with_state(state);
let addr = SocketAddr::from(([0, 0, 0, 0], 8100));
info!("socket listening on {addr}");
axum::Server::bind(&addr)
.serve(api.into_make_service())
.await
.unwrap();
});
}
async fn spawn_bot(db: Arc<tokio::sync::Mutex<db::Database>>) { async fn spawn_bot(db: Arc<tokio::sync::Mutex<db::Database>>) {
let token = env::var("DISCORD_TOKEN").expect("expected DISCORD_TOKEN env var"); let token = env::var("DISCORD_TOKEN").expect("expected DISCORD_TOKEN env var");
let songbird = songbird::Songbird::serenity(); let songbird = songbird::Songbird::serenity();
@ -318,10 +238,6 @@ async fn main() -> std::io::Result<()> {
dotenv::dotenv().ok(); dotenv::dotenv().ok();
tracing_subscriber::fmt::init(); tracing_subscriber::fmt::init();
let settings = serde_json::from_str::<Settings>(
&std::fs::read_to_string("config/settings.json").expect("no config/settings.json"),
)
.expect("error parsing settings file");
let secrets = auth::DiscordSecret { let secrets = auth::DiscordSecret {
client_id: env::var("DISCORD_CLIENT_ID").expect("expected DISCORD_CLIENT_ID env var"), client_id: env::var("DISCORD_CLIENT_ID").expect("expected DISCORD_CLIENT_ID env var"),
client_secret: env::var("DISCORD_CLIENT_SECRET") client_secret: env::var("DISCORD_CLIENT_SECRET")
@ -331,9 +247,12 @@ async fn main() -> std::io::Result<()> {
let origin = env::var("APP_ORIGIN").expect("expected APP_ORIGIN"); let origin = env::var("APP_ORIGIN").expect("expected APP_ORIGIN");
let db = outbound::sqlite::Sqlite::new("./config/db.sqlite").expect("couldn't open sqlite db"); let db = outbound::sqlite::Sqlite::new("./config/db.sqlite").expect("couldn't open sqlite db");
let local_audio_fetcher = outbound::ffmpeg::Ffmpeg;
let remote_audio_fetcher = outbound::ytdlp::Ytdlp;
if let Ok(impersonated_username) = env::var("IMPERSONATED_USERNAME") { if let Ok(impersonated_username) = env::var("IMPERSONATED_USERNAME") {
let service = intro_tool::service::Service::new(db); let service =
intro_tool::service::Service::new(db, remote_audio_fetcher, local_audio_fetcher);
let service = intro_tool::debug_service::DebugService::new(service, impersonated_username); let service = intro_tool::debug_service::DebugService::new(service, impersonated_username);
let http_server = inbound::http::HttpServer::new(service, secrets, origin) let http_server = inbound::http::HttpServer::new(service, secrets, origin)
@ -341,7 +260,8 @@ async fn main() -> std::io::Result<()> {
http_server.run().await; http_server.run().await;
} else { } else {
let service = intro_tool::service::Service::new(db); let service =
intro_tool::service::Service::new(db, remote_audio_fetcher, local_audio_fetcher);
let http_server = inbound::http::HttpServer::new(service, secrets, origin) let http_server = inbound::http::HttpServer::new(service, secrets, origin)
.expect("couldn't start http server"); .expect("couldn't start http server");