diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-04 01:32:06 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-04 01:32:06 +0100 |
| commit | 382bb2af15e7076f8dcbbe6cc4a6b135709ef7b9 (patch) | |
| tree | 5e03e559d05ff2510781d5b760f5737e4ff03580 | |
| parent | cron: validate expressions and allocate ids atomically (diff) | |
| download | senator-sperma-382bb2af15e7076f8dcbbe6cc4a6b135709ef7b9.tar.gz senator-sperma-382bb2af15e7076f8dcbbe6cc4a6b135709ef7b9.zip | |
autocomplete: key results by extension filter and reject the placeholder
| -rw-r--r-- | src/commands/autocomplete.rs | 24 | ||||
| -rw-r--r-- | src/commands/cron.rs | 5 | ||||
| -rw-r--r-- | src/commands/music.rs | 5 |
3 files changed, 27 insertions, 7 deletions
diff --git a/src/commands/autocomplete.rs b/src/commands/autocomplete.rs index d05a48b..4cefd67 100644 --- a/src/commands/autocomplete.rs +++ b/src/commands/autocomplete.rs | |||
| @@ -20,7 +20,7 @@ const AC_SEARCH_TIMEOUT: Duration = Duration::from_secs(2); | |||
| 20 | 20 | ||
| 21 | /// Value used for the mock "searching" placeholder — recognisable by the | 21 | /// Value used for the mock "searching" placeholder — recognisable by the |
| 22 | /// command handler as a no-op. | 22 | /// command handler as a no-op. |
| 23 | const MOCK_SEARCHING: &str = "\u{231B}..."; | 23 | pub(crate) const MOCK_SEARCHING: &str = "\u{231B}..."; |
| 24 | 24 | ||
| 25 | fn truncate_choice(s: &str) -> String { | 25 | fn truncate_choice(s: &str) -> String { |
| 26 | if s.chars().count() <= MAX_CHOICE_LEN { | 26 | if s.chars().count() <= MAX_CHOICE_LEN { |
| @@ -98,6 +98,12 @@ type SearchCache = Arc< | |||
| 98 | tokio::sync::Mutex<HashMap<String, (Vec<copyparty::SearchHit>, std::time::Instant)>>, | 98 | tokio::sync::Mutex<HashMap<String, (Vec<copyparty::SearchHit>, std::time::Instant)>>, |
| 99 | >; | 99 | >; |
| 100 | 100 | ||
| 101 | fn search_cache_key(partial: &str, extensions: &[&str]) -> String { | ||
| 102 | // The extension filter changes the result set, so it must be part of the | ||
| 103 | // key; otherwise `/music` and `/upload` poison each other's cache. | ||
| 104 | format!("{partial}\u{1f}{}", extensions.join(",")) | ||
| 105 | } | ||
| 106 | |||
| 101 | fn spawn_search( | 107 | fn spawn_search( |
| 102 | cache: SearchCache, | 108 | cache: SearchCache, |
| 103 | client: copyparty::CopypartyClient, | 109 | client: copyparty::CopypartyClient, |
| @@ -114,10 +120,10 @@ fn spawn_search( | |||
| 114 | Ok(hits) => { | 120 | Ok(hits) => { |
| 115 | debug!(%query, count = hits.len(), "background search completed"); | 121 | debug!(%query, count = hits.len(), "background search completed"); |
| 116 | // Always fill the shared search cache | 122 | // Always fill the shared search cache |
| 117 | cache | 123 | cache.lock().await.insert( |
| 118 | .lock() | 124 | search_cache_key(&query, &exts), |
| 119 | .await | 125 | (hits.clone(), std::time::Instant::now()), |
| 120 | .insert(query, (hits.clone(), std::time::Instant::now())); | 126 | ); |
| 121 | // Send to foreground (ignores error if receiver was dropped) | 127 | // Send to foreground (ignores error if receiver was dropped) |
| 122 | let _ = tx.send(hits); | 128 | let _ = tx.send(hits); |
| 123 | } | 129 | } |
| @@ -143,8 +149,14 @@ async fn timed_search( | |||
| 143 | partial: &str, | 149 | partial: &str, |
| 144 | extensions: &[&str], | 150 | extensions: &[&str], |
| 145 | ) -> Vec<serenity::AutocompleteChoice> { | 151 | ) -> Vec<serenity::AutocompleteChoice> { |
| 152 | // The placeholder is a UI hint, not a real query. | ||
| 153 | if partial == MOCK_SEARCHING { | ||
| 154 | return vec![]; | ||
| 155 | } | ||
| 156 | |||
| 146 | // 1. Shared search cache hit? | 157 | // 1. Shared search cache hit? |
| 147 | if let Some(hits) = ctx.data().ac_search_cache_lookup(partial).await | 158 | let cache_key = search_cache_key(partial, extensions); |
| 159 | if let Some(hits) = ctx.data().ac_search_cache_lookup(&cache_key).await | ||
| 148 | && !hits.is_empty() { | 160 | && !hits.is_empty() { |
| 149 | debug!(%partial, count = hits.len(), "autocomplete cache hit"); | 161 | debug!(%partial, count = hits.len(), "autocomplete cache hit"); |
| 150 | let pairs = build_choices(&hits); | 162 | let pairs = build_choices(&hits); |
diff --git a/src/commands/cron.rs b/src/commands/cron.rs index 94cf955..ec5b702 100644 --- a/src/commands/cron.rs +++ b/src/commands/cron.rs | |||
| @@ -34,6 +34,11 @@ pub async fn dodaj_audio( | |||
| 34 | _ => return Err(lang::err_bad_channel_type()), | 34 | _ => return Err(lang::err_bad_channel_type()), |
| 35 | }; | 35 | }; |
| 36 | 36 | ||
| 37 | if audio_filename == crate::commands::autocomplete::MOCK_SEARCHING { | ||
| 38 | ctx.say(lang::L_UTIL_PICK_FROM_AUTOCOMPLETE).await?; | ||
| 39 | return Ok(()); | ||
| 40 | } | ||
| 41 | |||
| 37 | // Resolve full path from autocomplete cache (value is truncated to 99 chars by Discord) | 42 | // Resolve full path from autocomplete cache (value is truncated to 99 chars by Discord) |
| 38 | let full_url = if let Some(hit) = ctx.data().ac_lookup(ctx.author().id, &audio_filename).await { | 43 | let full_url = if let Some(hit) = ctx.data().ac_lookup(ctx.author().id, &audio_filename).await { |
| 39 | let path = hit.url_path().unwrap_or(&audio_filename); | 44 | let path = hit.url_path().unwrap_or(&audio_filename); |
diff --git a/src/commands/music.rs b/src/commands/music.rs index baa91b1..51bd619 100644 --- a/src/commands/music.rs +++ b/src/commands/music.rs | |||
| @@ -3,7 +3,7 @@ use poise::serenity_prelude as serenity; | |||
| 3 | 3 | ||
| 4 | use tracing::{error, info}; | 4 | use tracing::{error, info}; |
| 5 | 5 | ||
| 6 | use super::autocomplete::audio_file; | 6 | use super::autocomplete::{MOCK_SEARCHING, audio_file}; |
| 7 | use crate::Data; | 7 | use crate::Data; |
| 8 | use crate::copyparty::{html_decode, percent_decode}; | 8 | use crate::copyparty::{html_decode, percent_decode}; |
| 9 | use crate::error::{Error, Result}; | 9 | use crate::error::{Error, Result}; |
| @@ -87,6 +87,9 @@ async fn resolve_source( | |||
| 87 | } | 87 | } |
| 88 | 88 | ||
| 89 | let name = melodia.ok_or_else(|| Error::Custom(lang::L_MUSIC_NEED_NAME.into()))?; | 89 | let name = melodia.ok_or_else(|| Error::Custom(lang::L_MUSIC_NEED_NAME.into()))?; |
| 90 | if name == MOCK_SEARCHING { | ||
| 91 | return Err(Error::Custom(lang::L_UTIL_PICK_FROM_AUTOCOMPLETE.into())); | ||
| 92 | } | ||
| 90 | 93 | ||
| 91 | // ── Autocomplete cache (per-user: (UserId, filename) → SearchHit) ─ | 94 | // ── Autocomplete cache (per-user: (UserId, filename) → SearchHit) ─ |
| 92 | if let Some(hit) = ctx.data().ac_lookup(ctx.author().id, &name).await { | 95 | if let Some(hit) = ctx.data().ac_lookup(ctx.author().id, &name).await { |
