aboutsummaryrefslogtreecommitdiffstats
path: root/src/commands/autocomplete.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/commands/autocomplete.rs')
-rw-r--r--src/commands/autocomplete.rs249
1 files changed, 171 insertions, 78 deletions
diff --git a/src/commands/autocomplete.rs b/src/commands/autocomplete.rs
index 97a18ec..434870c 100644
--- a/src/commands/autocomplete.rs
+++ b/src/commands/autocomplete.rs
@@ -1,13 +1,27 @@
1use std::collections::HashMap;
2use std::sync::Arc;
3use std::time::Duration;
4
1use poise::serenity_prelude as serenity; 5use poise::serenity_prelude as serenity;
6use tokio::sync::oneshot;
7use tracing::{debug, warn};
2 8
3use crate::Data; 9use crate::copyparty::{self, html_decode, percent_decode};
4use crate::copyparty::{html_decode, percent_decode};
5use crate::error::Error; 10use crate::error::Error;
11use crate::Data;
6 12
7type Context<'a> = poise::Context<'a, Data, Error>; 13type Context<'a> = poise::Context<'a, Data, Error>;
8 14
9const MAX_CHOICE_LEN: usize = 99; 15const MAX_CHOICE_LEN: usize = 99;
10 16
17/// Maximum time we wait for a fresh search before returning a mock entry.
18/// Discord gives 3 seconds; we use 2 to leave room for the API call.
19const AC_SEARCH_TIMEOUT: Duration = Duration::from_secs(2);
20
21/// Value used for the mock "searching" placeholder — recognisable by the
22/// command handler as a no-op.
23const MOCK_SEARCHING: &str = "\u{231B}...";
24
11fn truncate_choice(s: &str) -> String { 25fn truncate_choice(s: &str) -> String {
12 if s.chars().count() <= MAX_CHOICE_LEN { 26 if s.chars().count() <= MAX_CHOICE_LEN {
13 return s.to_string(); 27 return s.to_string();
@@ -26,93 +40,173 @@ fn filename_label(raw_path: &str) -> Option<String> {
26 Some(truncate_choice(&decoded)) 40 Some(truncate_choice(&decoded))
27} 41}
28 42
29pub async fn audio_file<'a>( 43/// Build (label, value) pairs from search hits. Sync because it doesn't stash.
44fn build_choices(hits: &[copyparty::SearchHit]) -> Vec<(String, String)> {
45 let mut out = Vec::new();
46 for hit in hits {
47 let rp = match &hit.rp {
48 Some(p) => p.clone(),
49 None => continue,
50 };
51 let Some(label) = filename_label(&rp) else {
52 continue;
53 };
54 let value = truncate_choice(&rp);
55 out.push((label, value));
56 if out.len() >= 25 {
57 break;
58 }
59 }
60 out
61}
62
63/// Stash choices into the per-user ac_cache, then wrap into AutocompleteChoice.
64async fn stash_and_wrap(
30 ctx: Context<'_>, 65 ctx: Context<'_>,
31 partial: &'a str, 66 hits: &[copyparty::SearchHit],
67 pairs: Vec<(String, String)>,
32) -> Vec<serenity::AutocompleteChoice> { 68) -> Vec<serenity::AutocompleteChoice> {
33 eprintln!("[DEBUG] autocomplete audio_file(partial={partial:?})"); 69 let mut choices = Vec::with_capacity(pairs.len());
34 let results = ctx 70 for (label, value) in pairs {
35 .data() 71 // Re-stash into per-user cache so the command handler can resolve
36 .copyparty 72 let hit = hits
37 .search(partial, 25, &["mp3", "wav", "ogg", "flac", "m4a", "opus"]) 73 .iter()
38 .await; 74 .find(|h| {
39 match results { 75 h.rp
40 Ok(hits) => { 76 .as_deref()
41 eprintln!("[DEBUG] audio_file got {} raw hits", hits.len()); 77 .map(|rp| truncate_choice(rp) == value)
42 let mut choices = Vec::new(); 78 .unwrap_or(false)
43 for (i, hit) in hits.into_iter().enumerate() { 79 })
44 let rp = match &hit.rp { 80 .cloned();
45 Some(p) => p.clone(), 81 if let Some(h) = hit {
46 None => continue, 82 ctx.data()
47 }; 83 .ac_stash(ctx.author().id, value.clone(), h)
48 eprintln!( 84 .await;
49 "[DEBUG] hit[{i}]: rp={rp:?}, url={:?}, name={:?}, path={:?}",
50 hit.url, hit.name, hit.path
51 );
52 let Some(label) = filename_label(&rp) else {
53 continue;
54 };
55
56 let value = truncate_choice(&rp);
57 ctx.data()
58 .ac_stash(ctx.author().id, value.clone(), hit)
59 .await;
60 eprintln!("[DEBUG] -> cached as {value:?}");
61 choices.push(serenity::AutocompleteChoice::new(label, value));
62 if choices.len() >= 25 {
63 break;
64 }
65 }
66 eprintln!("[DEBUG] audio_file returning {} choices", choices.len());
67 choices
68 }
69 Err(e) => {
70 eprintln!("[DEBUG] audio_file search failed: {e}");
71 vec![]
72 } 85 }
86 choices.push(serenity::AutocompleteChoice::new(label, value));
73 } 87 }
88 choices
74} 89}
75 90
76pub async fn any_file<'a>(ctx: Context<'_>, partial: &'a str) -> Vec<serenity::AutocompleteChoice> { 91/// Spawn a single Copyparty search task. Returns a oneshot receiver that will
77 eprintln!("[DEBUG] autocomplete any_file(partial={partial:?})"); 92/// receive the results when the search completes.
78 let results = ctx.data().copyparty.search(partial, 25, &[]).await; 93///
79 match results { 94/// The task always runs to completion even if the receiver is dropped, and
80 Ok(hits) => { 95/// writes results into the shared `ac_search_cache` so *future* autocomplete
81 eprintln!("[DEBUG] any_file got {} raw hits", hits.len()); 96/// invocations can serve from cache.
82 let mut choices = Vec::new(); 97type SearchCache = Arc<
83 for (i, hit) in hits.into_iter().enumerate() { 98 tokio::sync::Mutex<HashMap<String, (Vec<copyparty::SearchHit>, std::time::Instant)>>,
84 let rp = match &hit.rp { 99>;
85 Some(p) => p.clone(), 100
86 None => continue, 101fn spawn_search(
87 }; 102 cache: SearchCache,
88 eprintln!( 103 client: copyparty::CopypartyClient,
89 "[DEBUG] hit[{i}]: rp={rp:?}, url={:?}, name={:?}, path={:?}", 104 query: String,
90 hit.url, hit.name, hit.path 105 extensions: Vec<String>,
91 ); 106) -> oneshot::Receiver<Vec<copyparty::SearchHit>> {
92 let Some(label) = filename_label(&rp) else { 107 let (tx, rx) = oneshot::channel();
93 continue; 108
94 }; 109 tokio::spawn(async move {
95 ctx.data() 110 let exts: Vec<&str> = extensions.iter().map(|s| s.as_str()).collect();
96 .ac_stash(ctx.author().id, label.clone(), hit) 111 let result = client.search(&query, 25, &exts).await;
97 .await; 112
98 eprintln!("[DEBUG] -> cached as {label:?}"); 113 match result {
99 choices.push(serenity::AutocompleteChoice::new(label.clone(), label)); 114 Ok(hits) => {
100 if choices.len() >= 25 { 115 debug!(%query, count = hits.len(), "background search completed");
101 break; 116 // Always fill the shared search cache
102 } 117 cache
118 .lock()
119 .await
120 .insert(query, (hits.clone(), std::time::Instant::now()));
121 // Send to foreground (ignores error if receiver was dropped)
122 let _ = tx.send(hits);
123 }
124 Err(e) => {
125 debug!(%query, error = %e, "background search failed");
103 } 126 }
104 eprintln!("[DEBUG] any_file returning {} choices", choices.len());
105 choices
106 } 127 }
107 Err(e) => { 128 });
108 eprintln!("[DEBUG] any_file search failed: {e}"); 129
130 rx
131}
132
133/// Run a Copyparty search with mock fallback and background cache fill.
134///
135/// 1. Check shared search cache → return instantly if fresh and non-empty.
136/// 2. Spawn a *single* search task that always runs to completion.
137/// 3. Wait for it up to 2 seconds.
138/// 4. On success: stash per-user + return real choices.
139/// 5. On timeout: return a mock entry; the background task fills the cache,
140/// so the next keystroke's autocomplete will get real results.
141async fn timed_search(
142 ctx: Context<'_>,
143 partial: &str,
144 extensions: &[&str],
145) -> Vec<serenity::AutocompleteChoice> {
146 // 1. Shared search cache hit?
147 if let Some(hits) = ctx.data().ac_search_cache_lookup(partial).await
148 && !hits.is_empty() {
149 debug!(%partial, count = hits.len(), "autocomplete cache hit");
150 let pairs = build_choices(&hits);
151 return stash_and_wrap(ctx, &hits, pairs).await;
152 }
153
154 // 2. Spawn ONE search task (survives timeout → fills cache)
155 let exts: Vec<String> = extensions.iter().map(|s| s.to_string()).collect();
156 let mut rx = spawn_search(
157 ctx.data().ac_search_cache.clone(),
158 ctx.data().copyparty.clone(),
159 partial.to_string(),
160 exts,
161 );
162
163 // 3. Wait for the task with a timeout
164 match tokio::time::timeout(AC_SEARCH_TIMEOUT, &mut rx).await {
165 Ok(Ok(hits)) => {
166 debug!(%partial, count = hits.len(), "autocomplete got results in time");
167 let pairs = build_choices(&hits);
168 stash_and_wrap(ctx, &hits, pairs).await
169 }
170 Ok(Err(_)) => {
171 warn!(%partial, "autocomplete search task finished without sending (sender dropped)");
109 vec![] 172 vec![]
110 } 173 }
174 Err(_elapsed) => {
175 warn!(
176 %partial,
177 "autocomplete search timed out after {AC_SEARCH_TIMEOUT:?}, returning mock"
178 );
179 // The search task is still running in the background and will fill
180 // the cache when done. Return a mock so the user sees *something*
181 // and is prompted to type more → fresh autocomplete → cache hit.
182 vec![serenity::AutocompleteChoice::new(
183 format!(
184 "\u{231B} Szukanie \u{201E}{}\u{201D}\u{2026}",
185 truncate_choice(partial)
186 ),
187 MOCK_SEARCHING.to_string(),
188 )]
189 }
111 } 190 }
112} 191}
113 192
114pub async fn trigger_name<'a>(ctx: Context<'_>, partial: &'a str) -> Vec<String> { 193pub async fn audio_file(
115 eprintln!("[DEBUG] autocomplete trigger_name(partial={partial:?})"); 194 ctx: Context<'_>,
195 partial: &str,
196) -> Vec<serenity::AutocompleteChoice> {
197 timed_search(
198 ctx,
199 partial,
200 &["mp3", "wav", "ogg", "flac", "m4a", "opus"],
201 )
202 .await
203}
204
205pub async fn any_file(ctx: Context<'_>, partial: &str) -> Vec<serenity::AutocompleteChoice> {
206 timed_search(ctx, partial, &[]).await
207}
208
209pub async fn trigger_name(ctx: Context<'_>, partial: &str) -> Vec<String> {
116 let db_pool = &ctx.data().db_pool; 210 let db_pool = &ctx.data().db_pool;
117 match crate::database::list_triggers(db_pool).await { 211 match crate::database::list_triggers(db_pool).await {
118 Ok(triggers) => { 212 Ok(triggers) => {
@@ -122,11 +216,10 @@ pub async fn trigger_name<'a>(ctx: Context<'_>, partial: &'a str) -> Vec<String>
122 .filter(|name| partial.is_empty() || name.starts_with(partial)) 216 .filter(|name| partial.is_empty() || name.starts_with(partial))
123 .take(25) 217 .take(25)
124 .collect(); 218 .collect();
125 eprintln!("[DEBUG] trigger_name returning {} names", names.len());
126 names 219 names
127 } 220 }
128 Err(e) => { 221 Err(e) => {
129 eprintln!("[DEBUG] trigger_name query failed: {e}"); 222 warn!(%partial, error = %e, "autocomplete trigger_name failed");
130 vec![] 223 vec![]
131 } 224 }
132 } 225 }