use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; use poise::serenity_prelude as serenity; use tokio::sync::oneshot; use tracing::{debug, warn}; use crate::copyparty::{self, html_decode, percent_decode}; use crate::error::Error; use crate::Data; type Context<'a> = poise::Context<'a, Data, Error>; const MAX_CHOICE_LEN: usize = 99; /// Maximum time we wait for a fresh search before returning a mock entry. /// Discord gives 3 seconds; we use 2 to leave room for the API call. const AC_SEARCH_TIMEOUT: Duration = Duration::from_secs(2); /// Value used for the mock "searching" placeholder — recognisable by the /// command handler as a no-op. pub(crate) const MOCK_SEARCHING: &str = "\u{231B}..."; fn truncate_choice(s: &str) -> String { if s.chars().count() <= MAX_CHOICE_LEN { return s.to_string(); } let mut t: String = s.chars().take(MAX_CHOICE_LEN - 1).collect(); t.push('\u{2026}'); t } fn filename_label(raw_path: &str) -> Option { let raw_name = raw_path.rsplit('/').next()?; if raw_name.is_empty() { return None; } let decoded = html_decode(&percent_decode(raw_name)); Some(truncate_choice(&decoded)) } /// Build (label, value) pairs from search hits. Sync because it doesn't stash. fn build_choices(hits: &[copyparty::SearchHit]) -> Vec<(String, String)> { let mut out = Vec::new(); for hit in hits { let rp = match &hit.rp { Some(p) => p.clone(), None => continue, }; let Some(label) = filename_label(&rp) else { continue; }; let value = truncate_choice(&rp); out.push((label, value)); if out.len() >= 25 { break; } } out } /// Stash choices into the per-user ac_cache, then wrap into AutocompleteChoice. async fn stash_and_wrap( ctx: Context<'_>, hits: &[copyparty::SearchHit], pairs: Vec<(String, String)>, ) -> Vec { let mut choices = Vec::with_capacity(pairs.len()); for (label, value) in pairs { // Re-stash into per-user cache so the command handler can resolve let hit = hits .iter() .find(|h| { h.rp .as_deref() .map(|rp| truncate_choice(rp) == value) .unwrap_or(false) }) .cloned(); if let Some(h) = hit { ctx.data() .ac_stash(ctx.author().id, value.clone(), h) .await; } choices.push(serenity::AutocompleteChoice::new(label, value)); } choices } /// Spawn a single Copyparty search task. Returns a oneshot receiver that will /// receive the results when the search completes. /// /// The task always runs to completion even if the receiver is dropped, and /// writes results into the shared `ac_search_cache` so *future* autocomplete /// invocations can serve from cache. type SearchCache = Arc< tokio::sync::Mutex, std::time::Instant)>>, >; fn search_cache_key(partial: &str, extensions: &[&str]) -> String { // The extension filter changes the result set, so it must be part of the // key; otherwise `/music` and `/upload` poison each other's cache. format!("{partial}\u{1f}{}", extensions.join(",")) } fn spawn_search( cache: SearchCache, client: copyparty::CopypartyClient, query: String, extensions: Vec, ) -> oneshot::Receiver> { let (tx, rx) = oneshot::channel(); tokio::spawn(async move { let exts: Vec<&str> = extensions.iter().map(|s| s.as_str()).collect(); let result = client.search(&query, 25, &exts).await; match result { Ok(hits) => { debug!(%query, count = hits.len(), "background search completed"); // Always fill the shared search cache cache.lock().await.insert( search_cache_key(&query, &exts), (hits.clone(), std::time::Instant::now()), ); // Send to foreground (ignores error if receiver was dropped) let _ = tx.send(hits); } Err(e) => { debug!(%query, error = %e, "background search failed"); } } }); rx } /// Run a Copyparty search with mock fallback and background cache fill. /// /// 1. Check shared search cache → return instantly if fresh and non-empty. /// 2. Spawn a *single* search task that always runs to completion. /// 3. Wait for it up to 2 seconds. /// 4. On success: stash per-user + return real choices. /// 5. On timeout: return a mock entry; the background task fills the cache, /// so the next keystroke's autocomplete will get real results. async fn timed_search( ctx: Context<'_>, partial: &str, extensions: &[&str], ) -> Vec { // The placeholder is a UI hint, not a real query. if partial == MOCK_SEARCHING { return vec![]; } // 1. Shared search cache hit? let cache_key = search_cache_key(partial, extensions); if let Some(hits) = ctx.data().ac_search_cache_lookup(&cache_key).await && !hits.is_empty() { debug!(%partial, count = hits.len(), "autocomplete cache hit"); let pairs = build_choices(&hits); return stash_and_wrap(ctx, &hits, pairs).await; } // 2. Spawn ONE search task (survives timeout → fills cache) let exts: Vec = extensions.iter().map(|s| s.to_string()).collect(); let mut rx = spawn_search( ctx.data().ac_search_cache.clone(), ctx.data().copyparty.clone(), partial.to_string(), exts, ); // 3. Wait for the task with a timeout match tokio::time::timeout(AC_SEARCH_TIMEOUT, &mut rx).await { Ok(Ok(hits)) => { debug!(%partial, count = hits.len(), "autocomplete got results in time"); let pairs = build_choices(&hits); stash_and_wrap(ctx, &hits, pairs).await } Ok(Err(_)) => { warn!(%partial, "autocomplete search task finished without sending (sender dropped)"); vec![] } Err(_elapsed) => { warn!( %partial, "autocomplete search timed out after {AC_SEARCH_TIMEOUT:?}, returning mock" ); // The search task is still running in the background and will fill // the cache when done. Return a mock so the user sees *something* // and is prompted to type more → fresh autocomplete → cache hit. vec![serenity::AutocompleteChoice::new( format!( "\u{231B} Szukanie \u{201E}{}\u{201D}\u{2026}", truncate_choice(partial) ), MOCK_SEARCHING.to_string(), )] } } } pub async fn audio_file( ctx: Context<'_>, partial: &str, ) -> Vec { timed_search( ctx, partial, &["mp3", "wav", "ogg", "flac", "m4a", "opus", "aac", "wma", "oga", "mp4", "webm", "mkv", "avi", "mov", "flv", "wmv", "m4v", "mpg", "mpeg", "3gp", "ts"], ) .await } pub async fn any_file(ctx: Context<'_>, partial: &str) -> Vec { timed_search(ctx, partial, &[]).await } pub async fn trigger_name(ctx: Context<'_>, partial: &str) -> Vec { let Some(guild_id) = ctx.guild_id() else { return Vec::new(); }; let db_pool = &ctx.data().db_pool; match crate::database::list_triggers(db_pool, guild_id.get() as i64).await { Ok(triggers) => { let names: Vec<_> = triggers .into_iter() .map(|t| t.name) .filter(|name| partial.is_empty() || name.starts_with(partial)) .take(25) .collect(); names } Err(e) => { warn!(%partial, error = %e, "autocomplete trigger_name failed"); vec![] } } }