youtube downloader :)
parent
c4d12562a1
commit
79a2f2839f
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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"))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue