forked from jmug/stoatchat
feat: initial work on tenor gif searching
This commit is contained in:
committed by
Angelo Kontaxis
parent
bfe4018e43
commit
b0c977b324
40
crates/services/gifbox/Cargo.toml
Normal file
40
crates/services/gifbox/Cargo.toml
Normal file
@@ -0,0 +1,40 @@
|
||||
[package]
|
||||
name = "revolt-gifbox"
|
||||
version = "0.8.8"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-or-later"
|
||||
|
||||
[dependencies]
|
||||
# Serialisation
|
||||
serde = { version = "1.0", features = ["derive", "rc"] }
|
||||
serde_json = "1.0.68"
|
||||
|
||||
# Async runtime
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
|
||||
# Web requests
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
|
||||
# Core crates
|
||||
revolt-config = { version = "0.8.8", path = "../../core/config" }
|
||||
revolt-models = { version = "0.8.8", path = "../../core/models" }
|
||||
revolt-result = { version = "0.8.8", path = "../../core/result", features = [
|
||||
"utoipa",
|
||||
"axum",
|
||||
] }
|
||||
revolt-coalesced = { version = "0.8.8", path = "../../core/coalesced" }
|
||||
|
||||
# Axum / web server
|
||||
axum = { version = "0.7.5" }
|
||||
axum-extra = { version = "0.9", features = ["typed-header"] }
|
||||
|
||||
# OpenAPI & documentation generation
|
||||
utoipa-scalar = { version = "0.1.0", features = ["axum"] }
|
||||
utoipa = { version = "4.2.3", features = ["axum_extras", "ulid"] }
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
|
||||
# Utils
|
||||
lru_time_cache = "0.11.11"
|
||||
urlencoding = "2.1.3"
|
||||
12
crates/services/gifbox/Dockerfile
Normal file
12
crates/services/gifbox/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
# Build Stage
|
||||
FROM ghcr.io/revoltchat/base:latest AS builder
|
||||
|
||||
# Bundle Stage
|
||||
FROM gcr.io/distroless/cc-debian12:nonroot
|
||||
COPY --from=builder /home/rust/src/target/release/revolt-gifbox ./
|
||||
COPY --from=mwader/static-ffmpeg:7.0.2 /ffmpeg /usr/local/bin/
|
||||
COPY --from=mwader/static-ffmpeg:7.0.2 /ffprobe /usr/local/bin/
|
||||
|
||||
EXPOSE 14706
|
||||
USER nonroot
|
||||
CMD ["./revolt-gifbox"]
|
||||
64
crates/services/gifbox/src/api.rs
Normal file
64
crates/services/gifbox/src/api.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{extract::{Query, State}, routing::get, Json, Router};
|
||||
use revolt_result::{create_error, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::tenor::{Tenor, types};
|
||||
|
||||
pub async fn router() -> Router<Tenor> {
|
||||
Router::new()
|
||||
.route("/", get(root))
|
||||
.route("/search", get(search))
|
||||
}
|
||||
|
||||
/// Successful root response
|
||||
#[derive(Serialize, Debug, ToSchema)]
|
||||
pub struct RootResponse {
|
||||
message: &'static str,
|
||||
version: &'static str,
|
||||
}
|
||||
|
||||
/// Capture crate version from Cargo
|
||||
static CRATE_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// Root response from service
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/",
|
||||
responses(
|
||||
(status = 200, description = "Echo response", body = RootResponse)
|
||||
)
|
||||
)]
|
||||
async fn root() -> Json<RootResponse> {
|
||||
Json(RootResponse {
|
||||
message: "Gifbox lives on!",
|
||||
version: CRATE_VERSION,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SearchQueryParams {
|
||||
pub query: String,
|
||||
pub locale: String,
|
||||
pub position: Option<String>
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/search",
|
||||
responses(
|
||||
(status = 200, description = "Search results", body = SearchResponse)
|
||||
)
|
||||
)]
|
||||
async fn search(
|
||||
Query(params): Query<SearchQueryParams>,
|
||||
State(tenor): State<Tenor>,
|
||||
) -> Result<Json<Arc<types::SearchResponse>>> {
|
||||
// Todo
|
||||
tenor.search(¶ms.query, ¶ms.locale, params.position.as_deref())
|
||||
.await
|
||||
.map_err(|_| create_error!(InternalError))
|
||||
.map(Json)
|
||||
}
|
||||
65
crates/services/gifbox/src/main.rs
Normal file
65
crates/services/gifbox/src/main.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
|
||||
use axum::Router;
|
||||
|
||||
use revolt_config::config;
|
||||
use tokio::net::TcpListener;
|
||||
use utoipa::{
|
||||
openapi::security::{Http, HttpAuthScheme, SecurityScheme},
|
||||
Modify, OpenApi,
|
||||
};
|
||||
use utoipa_scalar::{Scalar, Servable as ScalarServable};
|
||||
|
||||
mod api;
|
||||
mod tenor;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), std::io::Error> {
|
||||
// Configure logging and environment
|
||||
revolt_config::configure!(gifbox);
|
||||
|
||||
// Configure API schema
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
modifiers(&SecurityAddon),
|
||||
paths(
|
||||
api::root,
|
||||
),
|
||||
components(
|
||||
schemas(
|
||||
revolt_result::Error,
|
||||
revolt_result::ErrorType,
|
||||
)
|
||||
)
|
||||
)]
|
||||
struct ApiDoc;
|
||||
|
||||
struct SecurityAddon;
|
||||
|
||||
impl Modify for SecurityAddon {
|
||||
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
|
||||
if let Some(components) = openapi.components.as_mut() {
|
||||
components.add_security_scheme(
|
||||
"api_key",
|
||||
SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let config = config().await;
|
||||
let tenor = tenor::Tenor::new(&config.api.security.tenor_key);
|
||||
|
||||
// Configure Axum and router
|
||||
let app = Router::new()
|
||||
.merge(Scalar::with_url("/scalar", ApiDoc::openapi()))
|
||||
.nest("/", api::router().await)
|
||||
.with_state(tenor);
|
||||
|
||||
// Configure TCP listener and bind
|
||||
tracing::info!("Listening on 0.0.0.0:14706");
|
||||
tracing::info!("Play around with the API: http://localhost:14706/scalar");
|
||||
let address = SocketAddr::from((Ipv4Addr::UNSPECIFIED, 14706));
|
||||
let listener = TcpListener::bind(&address).await?;
|
||||
axum::serve(listener, app.into_make_service()).await
|
||||
}
|
||||
129
crates/services/gifbox/src/tenor/mod.rs
Normal file
129
crates/services/gifbox/src/tenor/mod.rs
Normal file
@@ -0,0 +1,129 @@
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use reqwest::Client;
|
||||
use tokio::sync::RwLock;
|
||||
use lru_time_cache::LruCache;
|
||||
use urlencoding::encode as url_encode;
|
||||
use revolt_coalesced::{CoalescionService, CoalescionServiceConfig};
|
||||
|
||||
pub mod types;
|
||||
|
||||
const TENOR_API_BASE_URL: &str = "https://tenor.googleapis.com/v2";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum TenorError {
|
||||
HttpError,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Tenor {
|
||||
pub key: Arc<str>,
|
||||
pub client: Client,
|
||||
pub coalescion: CoalescionService<String, Result<Arc<types::SearchResponse>, TenorError>>,
|
||||
pub cache: Arc<RwLock<LruCache<String, Arc<types::SearchResponse>>>>
|
||||
}
|
||||
|
||||
impl Tenor {
|
||||
pub fn new(key: &str) -> Self {
|
||||
// 1 hour, 1k queries
|
||||
let cache = LruCache::with_expiry_duration_and_capacity(Duration::from_secs(60*60), 1000);
|
||||
|
||||
Self {
|
||||
key: Arc::from(key),
|
||||
client: Client::new(),
|
||||
coalescion: CoalescionService::from_config(CoalescionServiceConfig {
|
||||
max_concurrent: Some(100),
|
||||
queue_requests: true,
|
||||
max_queue: None
|
||||
}),
|
||||
cache: Arc::new(RwLock::new(cache)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn search(&self, query: &str, locale: &str, position: Option<&str>) -> Result<Arc<types::SearchResponse>, TenorError> {
|
||||
let unique_key = format!("{query}:{locale}:{position:?}");
|
||||
|
||||
if self.cache.read().await.contains_key(&unique_key) {
|
||||
if let Some(response) = self.cache.write().await.get(&unique_key) {
|
||||
return Ok(response.clone())
|
||||
}
|
||||
}
|
||||
|
||||
let res = self.coalescion.execute(unique_key.clone(), || {
|
||||
let client = self.client.clone();
|
||||
|
||||
async move {
|
||||
let mut url = format!(
|
||||
"{TENOR_API_BASE_URL}/search?key={}&q={}&client_key=Gifbox&locale={}&contentfilter=medium&limit=1",
|
||||
&self.key,
|
||||
url_encode(query),
|
||||
url_encode(locale),
|
||||
);
|
||||
|
||||
if let Some(position) = position {
|
||||
url.push_str("&pos=");
|
||||
url.push_str(position);
|
||||
};
|
||||
|
||||
let response = client.get(url)
|
||||
.send()
|
||||
.await
|
||||
.inspect_err(|e| { revolt_config::capture_error(e); })
|
||||
.map_err(|_| TenorError::HttpError)?;
|
||||
|
||||
let text = response.text().await.map_err(|e| { println!("{e:?}"); TenorError::HttpError })?;
|
||||
|
||||
Ok(Arc::new(serde_json::from_str(&text).unwrap()))
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
if let Ok(resp) = &*res {
|
||||
self.cache.write().await.insert(unique_key, resp.clone());
|
||||
}
|
||||
|
||||
(*res).clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use revolt_config::config;
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn test() {
|
||||
let config = config().await;
|
||||
|
||||
let tenor = Tenor::new(&config.api.security.tenor_key);
|
||||
|
||||
let results =tenor.search("amog", "en_US", None).await.unwrap();
|
||||
|
||||
let result = &results.results[0];
|
||||
|
||||
println!("{:?}", result.media_formats.iter().next().unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn test_2() {
|
||||
let config = config().await;
|
||||
let tenor = Tenor::new(&config.api.security.tenor_key);
|
||||
|
||||
let mut tasks = Vec::new();
|
||||
|
||||
for i in 1..1001 {
|
||||
let tenor = tenor.clone();
|
||||
println!("creating search task {i}");
|
||||
|
||||
tasks.push((i, tokio::spawn(async move {
|
||||
tenor.search(&format!("amog-{i}"), "en_US", None).await
|
||||
})));
|
||||
};
|
||||
|
||||
for (i, task) in tasks {
|
||||
task.await.unwrap().unwrap();
|
||||
println!("Got result for {i}");
|
||||
};
|
||||
}
|
||||
}
|
||||
36
crates/services/gifbox/src/tenor/types.rs
Normal file
36
crates/services/gifbox/src/tenor/types.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Serialize, Deserialize};
|
||||
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
||||
pub struct SearchResponse {
|
||||
pub results: Vec<TenorResult>,
|
||||
pub next: String
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
||||
pub struct MediaObject {
|
||||
pub url: String,
|
||||
pub dims: Vec<u64>,
|
||||
pub duration: f64,
|
||||
pub size: f64
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
||||
pub struct TenorResult {
|
||||
pub created: f64,
|
||||
#[serde(default)]
|
||||
pub hasaudio: bool,
|
||||
pub id: String,
|
||||
pub media_formats: HashMap<String, MediaObject>,
|
||||
pub tags: Vec<String>,
|
||||
pub title: String,
|
||||
pub content_description: String,
|
||||
pub itemurl: String,
|
||||
#[serde(default)]
|
||||
pub hascaption: bool,
|
||||
pub flags: Vec<String>,
|
||||
pub bg_color: Option<String>,
|
||||
pub url: String
|
||||
}
|
||||
Reference in New Issue
Block a user