aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-08-19 02:45:49 +0200
committersalmonik <salmonik@tuta.io>2026-08-19 02:45:49 +0200
commit850fad62f12b4e764f5e64036db35259237994cb (patch)
treed5b25e9c00f01e3fd19f47b0270a88e1e5f5576e
parentfeat: local TTS via piper subprocess with Google fallback (diff)
downloadsenator-sperma-850fad62f12b4e764f5e64036db35259237994cb.tar.gz
senator-sperma-850fad62f12b4e764f5e64036db35259237994cb.zip
fix: Various cron fixes
Not saving full URL Not using autocompletion content, just label
Diffstat (limited to '')
-rw-r--r--src/commands/cron.rs12
-rw-r--r--src/copyparty/mod.rs15
-rw-r--r--src/database/mod.rs19
-rw-r--r--src/main.rs7
-rw-r--r--src/tasks/mod.rs37
5 files changed, 73 insertions, 17 deletions
diff --git a/src/commands/cron.rs b/src/commands/cron.rs
index 48f6ff1..a645581 100644
--- a/src/commands/cron.rs
+++ b/src/commands/cron.rs
@@ -32,12 +32,22 @@ pub async fn dodaj_audio(
32 _ => return Err(lang::err_bad_channel_type()), 32 _ => return Err(lang::err_bad_channel_type()),
33 }; 33 };
34 34
35 // Resolve full path from autocomplete cache (value is truncated to 99 chars by Discord)
36 let full_url = if let Some(hit) = ctx.data().ac_lookup(ctx.author().id, &audio_filename).await {
37 let path = hit.url_path().unwrap_or(&audio_filename);
38 ctx.data().copyparty.build_url_no_pw(path)
39 } else if audio_filename.starts_with("http://") || audio_filename.starts_with("https://") {
40 audio_filename.clone()
41 } else {
42 ctx.data().copyparty.build_url_no_pw(&audio_filename)
43 };
44
35 database::add_cron_job( 45 database::add_cron_job(
36 &ctx.data().db_pool, 46 &ctx.data().db_pool,
37 &cron_expression, 47 &cron_expression,
38 "audio", 48 "audio",
39 channel_id, 49 channel_id,
40 Some(&audio_filename), 50 Some(&full_url),
41 None, 51 None,
42 ) 52 )
43 .await?; 53 .await?;
diff --git a/src/copyparty/mod.rs b/src/copyparty/mod.rs
index 164c9f8..a9f7d01 100644
--- a/src/copyparty/mod.rs
+++ b/src/copyparty/mod.rs
@@ -158,11 +158,7 @@ impl CopypartyClient {
158 /// if a password is configured. Path can be absolute (e.g. `/music/foo.mp3`) 158 /// if a password is configured. Path can be absolute (e.g. `/music/foo.mp3`)
159 /// or relative (`music/foo.mp3`). 159 /// or relative (`music/foo.mp3`).
160 pub fn build_url(&self, path: &str) -> String { 160 pub fn build_url(&self, path: &str) -> String {
161 let prefixed = if path.starts_with('/') { 161 let prefixed = self.build_url_no_pw(path);
162 format!("{}{}", self.base_url, path)
163 } else {
164 format!("{}/{}", self.base_url, path)
165 };
166 if let Some(pw) = &self.password { 162 if let Some(pw) = &self.password {
167 format!("{prefixed}?pw={pw}") 163 format!("{prefixed}?pw={pw}")
168 } else { 164 } else {
@@ -170,6 +166,15 @@ impl CopypartyClient {
170 } 166 }
171 } 167 }
172 168
169 /// Build full URL without the password parameter.
170 pub fn build_url_no_pw(&self, path: &str) -> String {
171 if path.starts_with('/') {
172 format!("{}{}", self.base_url, path)
173 } else {
174 format!("{}/{}", self.base_url, path)
175 }
176 }
177
173 /// Search copyparty via the jPOST `/?srch` endpoint. 178 /// Search copyparty via the jPOST `/?srch` endpoint.
174 /// 179 ///
175 /// Uses `serde_json::Value` for flexible response parsing (mirroring the Python 180 /// Uses `serde_json::Value` for flexible response parsing (mirroring the Python
diff --git a/src/database/mod.rs b/src/database/mod.rs
index e803da9..0674477 100644
--- a/src/database/mod.rs
+++ b/src/database/mod.rs
@@ -43,7 +43,7 @@ async fn run_migrations(pool: &DbPool) -> Result<()> {
43 43
44 sqlx::query( 44 sqlx::query(
45 "CREATE TABLE IF NOT EXISTS cron_jobs ( 45 "CREATE TABLE IF NOT EXISTS cron_jobs (
46 id INTEGER PRIMARY KEY AUTOINCREMENT, 46 id INTEGER PRIMARY KEY,
47 cron_expression TEXT NOT NULL, 47 cron_expression TEXT NOT NULL,
48 type TEXT NOT NULL CHECK(type IN ('audio', 'wiadomość')), 48 type TEXT NOT NULL CHECK(type IN ('audio', 'wiadomość')),
49 channel_id INTEGER NOT NULL, 49 channel_id INTEGER NOT NULL,
@@ -189,9 +189,24 @@ pub async fn add_cron_job(
189 audio_filename: Option<&str>, 189 audio_filename: Option<&str>,
190 message: Option<&str>, 190 message: Option<&str>,
191) -> Result<i64> { 191) -> Result<i64> {
192 // Find the lowest unused ID to reuse freed slots
193 let existing: Vec<i64> = sqlx::query_scalar("SELECT id FROM cron_jobs ORDER BY id")
194 .fetch_all(pool)
195 .await?;
196
197 let next_id = existing.iter().enumerate().find_map(|(i, &id)| {
198 let expected = (i as i64) + 1;
199 if id != expected {
200 Some(expected)
201 } else {
202 None
203 }
204 }).unwrap_or(existing.len() as i64 + 1);
205
192 let result = sqlx::query( 206 let result = sqlx::query(
193 "INSERT INTO cron_jobs (cron_expression, type, channel_id, audio_filename, message) VALUES (?, ?, ?, ?, ?)", 207 "INSERT INTO cron_jobs (id, cron_expression, type, channel_id, audio_filename, message) VALUES (?, ?, ?, ?, ?, ?)",
194 ) 208 )
209 .bind(next_id)
195 .bind(cron_expression) 210 .bind(cron_expression)
196 .bind(r#type) 211 .bind(r#type)
197 .bind(channel_id) 212 .bind(channel_id)
diff --git a/src/main.rs b/src/main.rs
index 31c9e94..7f57421 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -49,7 +49,7 @@ pub struct Data {
49 /// next keystroke's autocomplete can serve real results immediately. 49 /// next keystroke's autocomplete can serve real results immediately.
50 pub ac_search_cache: AcSearchCache, 50 pub ac_search_cache: AcSearchCache,
51 /// Active music track per guild (only one music source at a time). 51 /// Active music track per guild (only one music source at a time).
52 pub music_tracks: tokio::sync::Mutex<HashMap<u64, songbird::tracks::TrackHandle>>, 52 pub music_tracks: Arc<tokio::sync::Mutex<HashMap<u64, songbird::tracks::TrackHandle>>>,
53} 53}
54 54
55impl Data { 55impl Data {
@@ -185,6 +185,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
185 Box::pin(async move { 185 Box::pin(async move {
186 let vm = voice::VoiceManager::new(ctx).await?; 186 let vm = voice::VoiceManager::new(ctx).await?;
187 187
188 let music_tracks: Arc<tokio::sync::Mutex<HashMap<u64, songbird::tracks::TrackHandle>>> = Arc::new(tokio::sync::Mutex::new(HashMap::new()));
189
188 let data = Data { 190 let data = Data {
189 config, 191 config,
190 db_pool, 192 db_pool,
@@ -195,7 +197,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
195 triggers: tokio::sync::Mutex::new(triggers), 197 triggers: tokio::sync::Mutex::new(triggers),
196 ac_cache: tokio::sync::Mutex::new(HashMap::new()), 198 ac_cache: tokio::sync::Mutex::new(HashMap::new()),
197 ac_search_cache: Arc::new(tokio::sync::Mutex::new(HashMap::new())), 199 ac_search_cache: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
198 music_tracks: tokio::sync::Mutex::new(HashMap::new()), 200 music_tracks: music_tracks.clone(),
199 }; 201 };
200 202
201 // Spawn cron scheduler loop. 203 // Spawn cron scheduler loop.
@@ -205,6 +207,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
205 http: ctx.http.clone(), 207 http: ctx.http.clone(),
206 voice_manager: data.voice_manager.clone(), 208 voice_manager: data.voice_manager.clone(),
207 copyparty: data.copyparty.clone(), 209 copyparty: data.copyparty.clone(),
210 music_tracks: music_tracks.clone(),
208 }; 211 };
209 tokio::spawn(async move { 212 tokio::spawn(async move {
210 crate::tasks::cron_scheduler_loop(cron_ctx).await; 213 crate::tasks::cron_scheduler_loop(cron_ctx).await;
diff --git a/src/tasks/mod.rs b/src/tasks/mod.rs
index 4408857..d4cf9b2 100644
--- a/src/tasks/mod.rs
+++ b/src/tasks/mod.rs
@@ -1,3 +1,4 @@
1use std::collections::HashMap;
1use std::sync::Arc; 2use std::sync::Arc;
2 3
3use chrono::{DateTime, Duration, Local, TimeZone, Timelike, Utc}; 4use chrono::{DateTime, Duration, Local, TimeZone, Timelike, Utc};
@@ -18,6 +19,7 @@ pub struct CronContext {
18 pub http: Arc<serenity::Http>, 19 pub http: Arc<serenity::Http>,
19 pub voice_manager: VoiceManager, 20 pub voice_manager: VoiceManager,
20 pub copyparty: CopypartyClient, 21 pub copyparty: CopypartyClient,
22 pub music_tracks: Arc<tokio::sync::Mutex<HashMap<u64, songbird::tracks::TrackHandle>>>,
21} 23}
22 24
23/// Normalize user-friendly cron expressions to the 7-field format the `cron` crate expects. 25/// Normalize user-friendly cron expressions to the 7-field format the `cron` crate expects.
@@ -118,15 +120,25 @@ async fn execute_message_job(ctx: &CronContext, job: &crate::database::CronJob)
118} 120}
119 121
120async fn execute_audio_job(ctx: &CronContext, job: &crate::database::CronJob) { 122async fn execute_audio_job(ctx: &CronContext, job: &crate::database::CronJob) {
121 let Some(ref audio_path) = job.audio_filename else { 123 let Some(ref audio_url) = job.audio_filename else {
122 error!("Cron audio job {} has no audio_filename", job.id); 124 error!("Cron audio job {} has no audio_filename", job.id);
123 return; 125 return;
124 }; 126 };
125 127
126 let channel_id = serenity::ChannelId::new(job.channel_id as u64); 128 let channel_id = serenity::ChannelId::new(job.channel_id as u64);
127 129
128 // Resolve guild + build URL concurrently. 130 // Build the full URL with password
129 let url = ctx.copyparty.build_url(audio_path); 131 let url = if audio_url.starts_with("http://") || audio_url.starts_with("https://") {
132 // Already a full URL (stored without password), append it now
133 if let Some(pw) = &ctx.copyparty.password {
134 format!("{audio_url}?pw={pw}")
135 } else {
136 audio_url.clone()
137 }
138 } else {
139 // Legacy relative path — build full URL with password
140 ctx.copyparty.build_url(audio_url)
141 };
130 info!("Cron audio job {} streaming via ffmpeg: {url}", job.id); 142 info!("Cron audio job {} streaming via ffmpeg: {url}", job.id);
131 let guild_channel_fut = channel_id.to_channel(&ctx.http); 143 let guild_channel_fut = channel_id.to_channel(&ctx.http);
132 let input_fut = crate::voice::ffmpeg_stream_input(&url); 144 let input_fut = crate::voice::ffmpeg_stream_input(&url);
@@ -157,6 +169,14 @@ async fn execute_audio_job(ctx: &CronContext, job: &crate::database::CronJob) {
157 } 169 }
158 }; 170 };
159 171
172 // Stop any existing track in this guild.
173 {
174 let mut map = ctx.music_tracks.lock().await;
175 if let Some(handle) = map.remove(&guild_id.get()) {
176 let _ = handle.stop();
177 }
178 }
179
160 // Connect to voice and play. 180 // Connect to voice and play.
161 let call = match ctx.voice_manager.connect(guild_id, channel_id).await { 181 let call = match ctx.voice_manager.connect(guild_id, channel_id).await {
162 Ok(c) => c, 182 Ok(c) => c,
@@ -166,14 +186,17 @@ async fn execute_audio_job(ctx: &CronContext, job: &crate::database::CronJob) {
166 } 186 }
167 }; 187 };
168 188
169 { 189 let handle = {
170 let mut call_lock = call.lock().await; 190 let mut call_lock = call.lock().await;
171 call_lock.play_input(input); 191 call_lock.play_input(input)
172 } 192 };
193
194 // Store the handle so the track stays alive.
195 ctx.music_tracks.lock().await.insert(guild_id.get(), handle);
173 196
174 info!( 197 info!(
175 "Cron audio job {} playing '{}' (url: {url}) in guild {}", 198 "Cron audio job {} playing '{}' (url: {url}) in guild {}",
176 job.id, audio_path, guild_id 199 job.id, audio_url, guild_id
177 ); 200 );
178} 201}
179 202