diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/commands/cron.rs | 12 | ||||
| -rw-r--r-- | src/copyparty/mod.rs | 15 | ||||
| -rw-r--r-- | src/database/mod.rs | 19 | ||||
| -rw-r--r-- | src/main.rs | 7 | ||||
| -rw-r--r-- | src/tasks/mod.rs | 37 |
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 | ||
| 55 | impl Data { | 55 | impl 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 @@ | |||
| 1 | use std::collections::HashMap; | ||
| 1 | use std::sync::Arc; | 2 | use std::sync::Arc; |
| 2 | 3 | ||
| 3 | use chrono::{DateTime, Duration, Local, TimeZone, Timelike, Utc}; | 4 | use 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 | ||
| 120 | async fn execute_audio_job(ctx: &CronContext, job: &crate::database::CronJob) { | 122 | async 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 | ||
