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"
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
[dependencies]

View File

@ -249,66 +249,66 @@ impl Database {
intros
}
pub(crate) fn get_all_user_permissions(
&self,
guild_id: u64,
) -> Result<Vec<(String, auth::Permissions)>> {
let mut query = self.conn.prepare(
"
SELECT
username,
permissions
FROM UserPermission
WHERE
guild_id = :guild_id
",
)?;
// pub(crate) fn get_all_user_permissions(
// &self,
// guild_id: u64,
// ) -> Result<Vec<(String, auth::Permissions)>> {
// let mut query = self.conn.prepare(
// "
// SELECT
// username,
// permissions
// FROM UserPermission
// WHERE
// 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
.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)>>>()?;
// pub(crate) fn get_user_permissions(
// &self,
// username: &str,
// guild_id: u64,
// ) -> Result<auth::Permissions> {
// self.conn.query_row(
// "
// SELECT
// 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_permissions(
&self,
username: &str,
guild_id: u64,
) -> Result<auth::Permissions> {
self.conn.query_row(
"
SELECT
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_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>> {
let mut query = self.conn.prepare(
@ -476,28 +476,29 @@ impl Database {
Ok(())
}
pub(crate) fn insert_user_permission(
&self,
username: &str,
guild_id: u64,
permissions: auth::Permissions,
) -> Result<()> {
let affected = self.conn.execute(
"
INSERT INTO
UserPermission (username, guild_id, permissions)
VALUES (?1, ?2, ?3)
ON CONFLICT(username, guild_id) DO UPDATE SET permissions = ?3",
[username, &guild_id.to_string(), &permissions.0.to_string()],
)?;
if affected < 1 {
warn!("no rows affected when attempting to insert user permissions");
}
Ok(())
}
// pub(crate) fn insert_user_permission(
// &self,
// username: &str,
// guild_id: u64,
// permissions: auth::Permissions,
// ) -> Result<()> {
// let affected = self.conn.execute(
// "
// INSERT INTO
// UserPermission (username, guild_id, permissions)
// VALUES (?1, ?2, ?3)
// ON CONFLICT(username, guild_id) DO UPDATE SET permissions = ?3",
// [username, &guild_id.to_string(), &permissions.0.to_string()],
// )?;
//
// if affected < 1 {
// warn!("no rows affected when attempting to insert user permissions");
// }
//
// Ok(())
// }
/*
pub(crate) fn insert_user_app_permission(
&self,
username: &str,
@ -518,6 +519,7 @@ impl Database {
Ok(())
}
*/
pub fn delete_user_intro(
&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 enum_iterator::Sequence;
use serde::{Deserialize, Serialize};
use crate::routes::Error;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct Discord {
pub(crate) access_token: String,
@ -14,13 +20,6 @@ pub(crate) struct Discord {
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)]
pub(crate) struct User {
pub(crate) auth: Discord,
@ -143,3 +142,4 @@ impl FromStr for Permission {
}
}
}
*/

View File

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

View File

@ -1,6 +1,6 @@
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::{
AddIntroToGuildError, AddIntroToGuildRequest, AddIntroToUserError, AddIntroToUserRequest,
@ -116,3 +116,19 @@ pub trait IntroToolRepository: Send + Sync + Clone + 'static {
req: AddIntroToUserRequest,
) -> 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 crate::{
lib::domain::intro_tool::{
models::guild::{self, GetUserError, GuildId, IntroId, User},
ports::{IntroToolRepository, IntroToolService},
},
media,
use crate::domain::intro_tool::{
models::guild::{self, GetUserError, GuildId, IntroId, User},
ports::{IntroToolRepository, IntroToolService, LocalAudioFetcher, RemoteAudioFetcher},
};
use super::models;
#[derive(Clone)]
pub struct Service<R>
pub struct Service<R, RA, LA>
where
R: IntroToolRepository,
RA: RemoteAudioFetcher,
LA: LocalAudioFetcher,
{
repo: R,
remote_audio_fetcher: RA,
local_audio_fetcher: LA,
}
impl<R> Service<R>
impl<R, RA, LA> Service<R, RA, LA>
where
R: IntroToolRepository,
RA: RemoteAudioFetcher,
LA: LocalAudioFetcher,
{
pub fn new(repo: R) -> Self {
Self { repo }
pub fn new(repo: R, remote_audio_fetcher: RA, local_audio_fetcher: LA) -> Self {
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
R: IntroToolRepository,
RA: RemoteAudioFetcher,
LA: LocalAudioFetcher,
{
async fn needs_setup(&self) -> bool {
let Ok(guild_count) = self.repo.get_guild_count().await else {
@ -102,42 +108,14 @@ where
) -> Result<IntroId, guild::AddIntroToGuildError> {
let file_name = match &req.data {
guild::IntroRequestData::Data(bytes) => {
// TODO: put this behind an interface
let uuid = Uuid::new_v4().to_string();
let temp_path = format!("./sounds/temp/{uuid}");
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
self.local_audio_fetcher
.save_local_audio(bytes, Uuid::new_v4().to_string().as_str())
.await?
}
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.remote_audio_fetcher
.fetch_remote_audio(url, Uuid::new_v4().to_string().as_str())
.await?
}
};

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,5 @@
pub mod auth;
pub mod domain;
pub mod htmx;
pub mod inbound;
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 ytdlp;

View File

@ -5,7 +5,7 @@ use tokio::sync::Mutex;
use anyhow::Context;
use rusqlite::Connection;
use crate::lib::domain::intro_tool::{
use crate::domain::intro_tool::{
models::guild::{
self, AddIntroToGuildError, AddIntroToGuildRequest, AddIntroToUserRequest, Channel,
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 htmx;
mod media;
mod page;
mod routes;
pub mod settings;
use axum::http::Method;
use axum::routing::{get, post};
use axum::Router;
use settings::ApiState;
use std::env;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::sync::mpsc;
use tower_http::cors::{Any, CorsLayer};
use serenity::async_trait;
use serenity::model::prelude::{Channel, ChannelId, GuildId, Member, Ready};
@ -30,9 +13,7 @@ use serenity::prelude::*;
use songbird::SerenityInit;
use tracing::*;
use crate::lib::domain::intro_tool;
use crate::lib::{inbound, outbound};
use crate::settings::Settings;
use memejoin_rs::{auth, domain::intro_tool, inbound, outbound};
enum HandlerMessage {
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>>) {
let token = env::var("DISCORD_TOKEN").expect("expected DISCORD_TOKEN env var");
let songbird = songbird::Songbird::serenity();
@ -318,10 +238,6 @@ async fn main() -> std::io::Result<()> {
dotenv::dotenv().ok();
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 {
client_id: env::var("DISCORD_CLIENT_ID").expect("expected DISCORD_CLIENT_ID env var"),
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 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") {
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 http_server = inbound::http::HttpServer::new(service, secrets, origin)
@ -341,7 +260,8 @@ async fn main() -> std::io::Result<()> {
http_server.run().await;
} 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)
.expect("couldn't start http server");