feat: initial work on tenor gif searching

This commit is contained in:
Zomatree
2025-08-08 00:16:20 +01:00
committed by Angelo Kontaxis
parent bfe4018e43
commit b0c977b324
20 changed files with 1268 additions and 524 deletions

View 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(&params.query, &params.locale, params.position.as_deref())
.await
.map_err(|_| create_error!(InternalError))
.map(Json)
}

View 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
}

View 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}");
};
}
}

View 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
}