aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-10-27 18:14:07 +0100
committersalmonik <salmonik@tuta.io>2026-10-27 18:14:07 +0100
commitfc5e0f48477366641b97efb0541053cc1e061be5 (patch)
tree99ad0352552a0eebc527f3184a11ad2e7676ffea
parentsilence songbird udp_rx decode noise (diff)
downloadsenator-sperma-fc5e0f48477366641b97efb0541053cc1e061be5.tar.gz
senator-sperma-fc5e0f48477366641b97efb0541053cc1e061be5.zip
music: add per-guild queue with now/next/last, skip and queue
Tracks are queued in memory per guild, defaulting to append (last). Playback auto-advances on TrackEvent::End/Error with a per-track token so manual stops and skips cannot double-advance. /music queue shows the current track followed by pending items. /stop clears the queue. Cron audio now goes through the same MusicState instead of poking the track-handle map directly.
-rw-r--r--src/commands/music.rs355
-rw-r--r--src/commands/utilities.rs2
-rw-r--r--src/lang.rs17
-rw-r--r--src/main.rs16
-rw-r--r--src/music.rs602
-rw-r--r--src/tasks/mod.rs67
6 files changed, 790 insertions, 269 deletions
diff --git a/src/commands/music.rs b/src/commands/music.rs
index 8739a5b..8841329 100644
--- a/src/commands/music.rs
+++ b/src/commands/music.rs
@@ -1,5 +1,5 @@
1use poise::serenity_prelude as serenity;
2use super::utilities::{FileType, extract_urls, file_type}; 1use super::utilities::{FileType, extract_urls, file_type};
2use poise::serenity_prelude as serenity;
3 3
4use tracing::{error, info}; 4use tracing::{error, info};
5 5
@@ -8,29 +8,16 @@ use crate::Data;
8use crate::copyparty::{html_decode, percent_decode}; 8use crate::copyparty::{html_decode, percent_decode};
9use crate::error::{Error, Result}; 9use crate::error::{Error, Result};
10use crate::lang; 10use crate::lang;
11use crate::music::{EnqueueOutcome, QueuePosition, SourceKind, TrackRequest, classify_url};
11 12
12type Context<'a> = poise::Context<'a, Data, Error>; 13type Context<'a> = poise::Context<'a, Data, Error>;
13 14
14// ── Source classification ────────────────────────────────────────── 15// ── Source classification ──────────────────────────────────────────
15 16
16/// What kind of audio source are we dealing with? 17/// Music playback and queue management.
17/// 18#[poise::command(slash_command, subcommands("grajcuj", "skip", "queue"))]
18/// This mirrors the Python original's split: `EXTERNAL_URL_PATTERN` 19pub async fn music(_ctx: Context<'_>) -> Result<()> {
19/// (youtube/soundcloud/bandcamp) goes through yt-dlp download, while 20 Ok(())
20/// *every other* URL is streamed directly via ffmpeg (radio, HLS,
21/// playlists, direct media files).
22#[derive(Debug, Clone, PartialEq)]
23enum SourceKind {
24 /// YouTube, SoundCloud, Twitch, Bandcamp — platforms that don't serve
25 /// raw audio at a direct URL. Needs yt-dlp to extract + download the
26 /// media to memory before decoding.
27 YtDlp,
28 /// Any HTTP(S) URL: internet radio (Icecast/Shoutcast), HLS `.m3u8`,
29 /// `.m3u`/`.pls` playlists, Copyparty files, Discord attachments, or
30 /// directly-accessible media files. Streamed via an ffmpeg subprocess
31 /// — NO full download. ffmpeg handles playlist parsing, Icecast
32 /// metadata, reconnection, and codec/container detection universally.
33 Stream,
34} 21}
35 22
36/// Play music from Copyparty, URL, or file upload 23/// Play music from Copyparty, URL, or file upload
@@ -42,14 +29,44 @@ pub async fn grajcuj(
42 melodia: Option<String>, 29 melodia: Option<String>,
43 #[description = "Bezpośredni plik z twojego systemu"] file: Option<serenity::Attachment>, 30 #[description = "Bezpośredni plik z twojego systemu"] file: Option<serenity::Attachment>,
44 #[description = "Chujowa kompresja!?"] compressed: Option<bool>, 31 #[description = "Chujowa kompresja!?"] compressed: Option<bool>,
32 #[description = "Gdzie dodać utwór w kolejce (domyślnie: last)"] position: Option<
33 QueuePosition,
34 >,
35) -> Result<()> {
36 play_command(ctx, melodia, file, compressed, position).await
37}
38
39/// Play music from Copyparty, URL, or file upload (top-level alias)
40#[poise::command(slash_command, rename = "grajcuj")]
41pub async fn grajcuj_top(
42 ctx: Context<'_>,
43 #[description = "Plik do odtworzenia"]
44 #[autocomplete = "audio_file"]
45 melodia: Option<String>,
46 #[description = "Bezpośredni plik z twojego systemu"] file: Option<serenity::Attachment>,
47 #[description = "Chujowa kompresja!?"] compressed: Option<bool>,
48 #[description = "Gdzie dodać utwór w kolejce (domyślnie: last)"] position: Option<
49 QueuePosition,
50 >,
51) -> Result<()> {
52 play_command(ctx, melodia, file, compressed, position).await
53}
54
55async fn play_command(
56 ctx: Context<'_>,
57 melodia: Option<String>,
58 file: Option<serenity::Attachment>,
59 compressed: Option<bool>,
60 position: Option<QueuePosition>,
45) -> Result<()> { 61) -> Result<()> {
46 let compressed = compressed.unwrap_or(false); 62 let compressed = compressed.unwrap_or(false);
63 let position = position.unwrap_or(QueuePosition::Last);
47 eprintln!( 64 eprintln!(
48 "[DEBUG] play() reached, melodia={:?}, file={:?}, compressed={compressed}", 65 "[DEBUG] play() reached, melodia={:?}, file={:?}, compressed={compressed}, position={position:?}",
49 melodia, 66 melodia,
50 file.as_ref().map(|f| &f.filename) 67 file.as_ref().map(|f| &f.filename)
51 ); 68 );
52 let result = play_impl(ctx, melodia, file, compressed).await; 69 let result = play_impl(ctx, melodia, file, compressed, position).await;
53 if let Err(e) = &result { 70 if let Err(e) = &result {
54 eprintln!("[DEBUG] play() error: {e}"); 71 eprintln!("[DEBUG] play() error: {e}");
55 error!("play command failed: {e}"); 72 error!("play command failed: {e}");
@@ -69,38 +86,6 @@ fn clean_name(raw: &str) -> String {
69 86
70// ── URL classification helpers ────────────────────────────────────── 87// ── URL classification helpers ──────────────────────────────────────
71 88
72/// URLs that require yt-dlp extraction (platforms that don't serve
73/// raw audio at a direct URL). These MUST be downloaded first.
74///
75/// Matches the Python original's `EXTERNAL_URL_PATTERN`:
76/// `youtube|youtu.be|soundcloud|bandcamp` (plus twitch).
77fn needs_ytdlp(url: &str) -> bool {
78 let lower = url.to_lowercase();
79 lower.contains("youtube.com/")
80 || lower.contains("youtu.be/")
81 || lower.contains("twitch.tv/")
82 || lower.contains("soundcloud.com/")
83 || lower.contains("bandcamp.com/")
84}
85
86/// Heuristic: does this URL *look* like a live internet radio stream?
87/// (Used for logging / display only — dispatch is controlled by `SourceKind`.)
88fn is_live_stream_hint(url: &str) -> bool {
89 let lower = url.to_lowercase();
90 // Common Icecast/Shoutcast mount patterns
91 lower.contains("/stream")
92 || lower.contains("/live")
93 || lower.contains("/radio")
94 || lower.contains(";stream")
95 || lower.contains("icecast")
96 || lower.contains("shoutcast")
97 || lower.ends_with(".m3u8")
98 || lower.ends_with(".m3u")
99 || lower.ends_with(".pls")
100 || lower.contains(":8000/") // common Icecast port
101 || lower.contains(":8080/") // common alt port
102}
103
104async fn resolve_source( 89async fn resolve_source(
105 ctx: Context<'_>, 90 ctx: Context<'_>,
106 melodia: Option<String>, 91 melodia: Option<String>,
@@ -154,11 +139,7 @@ async fn resolve_source(
154 139
155 // yt-dlp platforms → download; everything else (radio, HLS, 140 // yt-dlp platforms → download; everything else (radio, HLS,
156 // playlists, Copyparty, direct media) → ffmpeg stream. 141 // playlists, Copyparty, direct media) → ffmpeg stream.
157 let kind = if needs_ytdlp(&url) { 142 let kind = classify_url(&url);
158 SourceKind::YtDlp
159 } else {
160 SourceKind::Stream
161 };
162 return Ok(ResolvedSource { 143 return Ok(ResolvedSource {
163 url, 144 url,
164 display_name, 145 display_name,
@@ -174,8 +155,10 @@ async fn resolve_source(
174 .search( 155 .search(
175 &name, 156 &name,
176 5, 157 5,
177 &["mp3", "wav", "ogg", "flac", "m4a", "opus", "aac", "wma", "oga", 158 &[
178 "mp4", "webm", "mkv", "avi", "mov", "flv", "wmv", "m4v", "mpg", "mpeg", "3gp", "ts"], 159 "mp3", "wav", "ogg", "flac", "m4a", "opus", "aac", "wma", "oga", "mp4", "webm",
160 "mkv", "avi", "mov", "flv", "wmv", "m4v", "mpg", "mpeg", "3gp", "ts",
161 ],
179 ) 162 )
180 .await 163 .await
181 .map_err(|e| { 164 .map_err(|e| {
@@ -202,34 +185,6 @@ async fn resolve_source(
202 }) 185 })
203} 186}
204 187
205/// Strip `?pw=...` / `&pw=...` query params from a URL for safe logging.
206fn redact_url(url: &str) -> &str {
207 let Some(q) = url.find('?') else {
208 return url;
209 };
210 let before = &url[..q];
211 let after = &url[q..];
212 // Strip `?pw=...` or `&pw=...`
213 if after.strip_prefix("?pw=").is_some() {
214 return before;
215 }
216 if let Some(idx) = after.find("&pw=") {
217 return &url[..q + idx];
218 }
219 url
220}
221
222/// Strip tracking/playlist params (`&...`) from YouTube URLs.
223/// yt-dlp only needs `?v=ID`; extra `&` params can cause failures.
224pub(crate) fn sanitize_yt_url(url: &str) -> String {
225 let lower = url.to_lowercase();
226 if (lower.contains("youtube.com/") || lower.contains("youtu.be/"))
227 && let Some(pos) = url.find('&') {
228 return url[..pos].to_string();
229 }
230 url.to_string()
231}
232
233// ── Playlist resolution is handled by ffmpeg natively ────────────── 188// ── Playlist resolution is handled by ffmpeg natively ──────────────
234// ffmpeg reads `.m3u`/`.m3u8`/`.pls` playlists directly (HLS segment 189// ffmpeg reads `.m3u`/`.m3u8`/`.pls` playlists directly (HLS segment
235// fetching, PLS/M3U entry parsing) — no manual resolution needed. 190// fetching, PLS/M3U entry parsing) — no manual resolution needed.
@@ -240,6 +195,7 @@ async fn play_impl(
240 melodia: Option<String>, 195 melodia: Option<String>,
241 file: Option<serenity::Attachment>, 196 file: Option<serenity::Attachment>,
242 compressed: bool, 197 compressed: bool,
198 position: QueuePosition,
243) -> Result<()> { 199) -> Result<()> {
244 ctx.defer().await?; 200 ctx.defer().await?;
245 201
@@ -259,132 +215,93 @@ async fn play_impl(
259 let file_reupload = file.as_ref().map(|f| (f.url.clone(), f.filename.clone())); 215 let file_reupload = file.as_ref().map(|f| (f.url.clone(), f.filename.clone()));
260 let src = resolve_source(ctx, melodia, file).await?; 216 let src = resolve_source(ctx, melodia, file).await?;
261 217
262 let call; 218 let request = TrackRequest {
263 let input; 219 url: src.url,
264 220 display_name: src.display_name.clone(),
265 if compressed { 221 kind: src.kind,
266 // ── Compressed path: download → compress → decode ────────── 222 compressed,
267 // Defer stopping current track until compression is done. 223 reupload: file_reupload.clone(),
268 let audio_bytes = match src.kind { 224 requester: Some(ctx.author().id),
269 SourceKind::YtDlp => { 225 };
270 let clean_url = sanitize_yt_url(&src.url);
271 info!(url = %clean_url, "downloading via yt-dlp for compression");
272 crate::voice::ytdlp_download(&clean_url).await?
273 }
274 SourceKind::Stream => {
275 if is_live_stream_hint(&src.url) {
276 ctx.say(lang::L_MUSIC_CANT_COMPRESS_LIVE).await?;
277 // Fall through to normal streaming below
278 let live = true;
279 info!(url = %redact_url(&src.url), live, "live stream — compression skipped, streaming via ffmpeg");
280 // Stop current, connect, stream
281 {
282 let mut map = ctx.data().music_tracks.lock().await;
283 if let Some(handle) = map.remove(&guild_id.get()) {
284 let _ = handle.stop();
285 }
286 }
287 let (call_res, input_res) = tokio::join!(
288 ctx.data().voice_manager.connect(guild_id, channel_id),
289 crate::voice::ffmpeg_stream_input(&src.url)
290 );
291 call = call_res?;
292 input = input_res?;
293 // skip the rest of the compressed path
294 let handle = {
295 let mut call_lock = call.lock().await;
296 call_lock.play_input(input)
297 };
298 ctx.data()
299 .music_tracks
300 .lock()
301 .await
302 .insert(guild_id.get(), handle);
303 send_music_msg(ctx, &src.display_name, file_reupload).await?;
304 return Ok(());
305 }
306 info!(url = %redact_url(&src.url), "downloading for compression");
307 crate::voice::download_url(&ctx.data().http_client, &src.url).await?
308 }
309 };
310 226
311 info!("compressing audio ({} bytes)", audio_bytes.len()); 227 let outcome = ctx
312 let compressed_bytes = 228 .data()
313 crate::commands::utilities::compress_audio_bytes(&audio_bytes, &src.display_name) 229 .music
314 .await 230 .enqueue(
315 .ok_or_else(|| Error::Custom("compression failed".into()))?; 231 guild_id.get(),
316 info!("compressed to {} bytes", compressed_bytes.len()); 232 channel_id,
317 233 Some(ctx.channel_id()),
318 // Decode the compressed OGG via ffmpeg (symphonia lacks Opus support). 234 request,
319 // Write to temp file, then stream through ffmpeg to raw PCM. 235 position,
320 let compressed_path = format!( 236 )
321 "{}/play_compressed_{}.ogg", 237 .await?;
322 crate::config::TEMP_PATH, 238
323 uuid::Uuid::new_v4() 239 match outcome {
324 ); 240 EnqueueOutcome::Started => {
325 let _ = tokio::fs::create_dir_all(crate::config::TEMP_PATH).await; 241 send_music_msg(ctx, &src.display_name, file_reupload).await?;
326 tokio::fs::write(&compressed_path, &compressed_bytes).await?;
327 let decoded = crate::voice::ffmpeg_decode_file(&compressed_path).await?;
328
329 // Deferred stop: only interrupt current track now that we're ready.
330 {
331 let mut map = ctx.data().music_tracks.lock().await;
332 if let Some(handle) = map.remove(&guild_id.get()) {
333 let _ = handle.stop();
334 }
335 } 242 }
336 call = ctx 243 EnqueueOutcome::Queued { position } => {
337 .data() 244 ctx.say(lang::fmt_music_queued(&src.display_name, position))
338 .voice_manager 245 .await?;
339 .connect(guild_id, channel_id)
340 .await?;
341 input = decoded;
342 } else {
343 // ── Normal path: stop first, then stream/download ──────────
344 {
345 let mut map = ctx.data().music_tracks.lock().await;
346 if let Some(handle) = map.remove(&guild_id.get()) {
347 let _ = handle.stop();
348 }
349 } 246 }
247 }
248 Ok(())
249}
350 250
351 match src.kind { 251/// Skip the current track and play the next queued one
352 SourceKind::YtDlp => { 252#[poise::command(slash_command)]
353 let clean_url = sanitize_yt_url(&src.url); 253pub async fn skip(ctx: Context<'_>) -> Result<()> {
354 info!(url = %clean_url, "downloading via yt-dlp"); 254 ctx.defer().await?;
355 let audio_bytes = crate::voice::ytdlp_download(&clean_url).await?; 255 let guild_id = ctx.guild_id().ok_or_else(lang::err_not_in_guild)?;
356 let (call_res, input_res) = tokio::join!( 256 let played = ctx.data().music.skip(guild_id.get()).await?;
357 ctx.data().voice_manager.connect(guild_id, channel_id), 257 let content = if played {
358 tokio::task::spawn_blocking(move || crate::voice::decode_audio(&audio_bytes)) 258 lang::L_MUSIC_SKIPPED
359 ); 259 } else {
360 call = call_res?; 260 lang::L_MUSIC_QUEUE_EMPTY
361 input = input_res??; 261 };
362 } 262 ctx.send(
363 SourceKind::Stream => { 263 poise::CreateReply::default()
364 let live = is_live_stream_hint(&src.url); 264 .content(content)
365 info!(url = %redact_url(&src.url), live, "streaming via ffmpeg subprocess"); 265 .ephemeral(true),
366 let (call_res, input_res) = tokio::join!( 266 )
367 ctx.data().voice_manager.connect(guild_id, channel_id), 267 .await?;
368 crate::voice::ffmpeg_stream_input(&src.url) 268 Ok(())
369 ); 269}
370 call = call_res?; 270
371 input = input_res?; 271/// Show the current track and the pending queue
372 } 272#[poise::command(slash_command)]
373 } 273pub async fn queue(ctx: Context<'_>) -> Result<()> {
274 let guild_id = ctx.guild_id().ok_or_else(lang::err_not_in_guild)?;
275 let snapshot = ctx.data().music.snapshot(guild_id.get()).await;
276
277 if snapshot.current.is_none() && snapshot.upcoming.is_empty() {
278 ctx.say(lang::L_MUSIC_QUEUE_EMPTY).await?;
279 return Ok(());
374 } 280 }
375 281
376 let handle = { 282 let who = |requester: Option<serenity::UserId>| {
377 let mut call_lock = call.lock().await; 283 requester
378 call_lock.play_input(input) 284 .map(|id| format!(" <@{}>", id.get()))
285 .unwrap_or_default()
379 }; 286 };
380 287
381 ctx.data() 288 let mut lines = Vec::with_capacity(snapshot.upcoming.len() + 2);
382 .music_tracks 289 lines.push(lang::L_MUSIC_QUEUE_HEADER.to_string());
383 .lock() 290 if let Some(current) = &snapshot.current {
384 .await 291 lines.push(lang::fmt_music_queue_current(
385 .insert(guild_id.get(), handle); 292 &current.display_name,
293 &who(current.requester),
294 ));
295 }
296 for (index, item) in snapshot.upcoming.iter().enumerate() {
297 lines.push(lang::fmt_music_queue_item(
298 index + 1,
299 &item.display_name,
300 &who(item.requester),
301 ));
302 }
386 303
387 send_music_msg(ctx, &src.display_name, file_reupload).await?; 304 ctx.say(lines.join("\n")).await?;
388 Ok(()) 305 Ok(())
389} 306}
390 307
@@ -392,8 +309,8 @@ async fn play_impl(
392#[poise::command(slash_command)] 309#[poise::command(slash_command)]
393pub async fn stop(ctx: Context<'_>) -> Result<()> { 310pub async fn stop(ctx: Context<'_>) -> Result<()> {
394 let guild_id = ctx.guild_id().ok_or_else(lang::err_not_in_guild)?; 311 let guild_id = ctx.guild_id().ok_or_else(lang::err_not_in_guild)?;
312 ctx.data().music.stop(guild_id.get()).await;
395 ctx.data().voice_manager.disconnect(guild_id).await?; 313 ctx.data().voice_manager.disconnect(guild_id).await?;
396 ctx.data().music_tracks.lock().await.remove(&guild_id.get());
397 ctx.send( 314 ctx.send(
398 poise::CreateReply::default() 315 poise::CreateReply::default()
399 .content(lang::L_MUSIC_STOPPED) 316 .content(lang::L_MUSIC_STOPPED)
@@ -443,9 +360,14 @@ async fn send_music_msg(
443 Ok(resp) => match resp.bytes().await { 360 Ok(resp) => match resp.bytes().await {
444 Ok(bytes) => { 361 Ok(bytes) => {
445 if ctx 362 if ctx
446 .send(poise::CreateReply::default().content(lang::fmt_music_playing(display_name)).attachment( 363 .send(
447 serenity::CreateAttachment::bytes(bytes.to_vec(), filename.clone()), 364 poise::CreateReply::default()
448 )) 365 .content(lang::fmt_music_playing(display_name))
366 .attachment(serenity::CreateAttachment::bytes(
367 bytes.to_vec(),
368 filename.clone(),
369 )),
370 )
449 .await 371 .await
450 .is_ok() 372 .is_ok()
451 { 373 {
@@ -466,20 +388,21 @@ async fn send_music_msg(
466#[poise::command(context_menu_command = "Odtwórz")] 388#[poise::command(context_menu_command = "Odtwórz")]
467pub async fn message_grajcuj(ctx: Context<'_>, message: serenity::Message) -> Result<()> { 389pub async fn message_grajcuj(ctx: Context<'_>, message: serenity::Message) -> Result<()> {
468 // Files > links: prefer audio/video attachments first. 390 // Files > links: prefer audio/video attachments first.
469 if let Some(att) = message.attachments.iter().find(|a| { 391 if let Some(att) = message
470 matches!(file_type(&a.filename), FileType::Audio | FileType::Video) 392 .attachments
471 }) { 393 .iter()
472 return play_impl(ctx, None, Some(att.clone()), false).await; 394 .find(|a| matches!(file_type(&a.filename), FileType::Audio | FileType::Video))
395 {
396 return play_impl(ctx, None, Some(att.clone()), false, QueuePosition::Now).await;
473 } 397 }
474 // Fallback: first URL in message content. 398 // Fallback: first URL in message content.
475 if let Some(url) = extract_urls(&message.content).into_iter().next() { 399 if let Some(url) = extract_urls(&message.content).into_iter().next() {
476 return play_impl(ctx, Some(url), None, false).await; 400 return play_impl(ctx, Some(url), None, false, QueuePosition::Now).await;
477 } 401 }
478 ctx.say(lang::L_MUSIC_NO_AUDIO_ATTACHMENTS).await?; 402 ctx.say(lang::L_MUSIC_NO_AUDIO_ATTACHMENTS).await?;
479 Ok(()) 403 Ok(())
480} 404}
481 405
482
483pub fn commands() -> Vec<poise::Command<Data, Error>> { 406pub fn commands() -> Vec<poise::Command<Data, Error>> {
484 vec![grajcuj(), stop(), join(), message_grajcuj()] 407 vec![music(), grajcuj_top(), stop(), join(), message_grajcuj()]
485} 408}
diff --git a/src/commands/utilities.rs b/src/commands/utilities.rs
index 87a5089..25d4bcf 100644
--- a/src/commands/utilities.rs
+++ b/src/commands/utilities.rs
@@ -2,7 +2,7 @@ use poise::serenity_prelude as serenity;
2use tracing::warn; 2use tracing::warn;
3 3
4use super::autocomplete::any_file; 4use super::autocomplete::any_file;
5use super::music::sanitize_yt_url; 5use crate::music::sanitize_yt_url;
6use crate::Data; 6use crate::Data;
7use crate::copyparty::{html_decode, percent_decode}; 7use crate::copyparty::{html_decode, percent_decode};
8use crate::error::{Error, Result}; 8use crate::error::{Error, Result};
diff --git a/src/lang.rs b/src/lang.rs
index 4bba9b5..de6d23f 100644
--- a/src/lang.rs
+++ b/src/lang.rs
@@ -91,8 +91,6 @@ pub fn fmt_math_result(r: &str) -> String {
91pub const L_MUSIC_NEED_NAME: &str = "Daj mi nazwę pliku, URL albo plik"; 91pub const L_MUSIC_NEED_NAME: &str = "Daj mi nazwę pliku, URL albo plik";
92pub const L_MUSIC_NO_AUDIO_ON_CP: &str = "Brak powiązanego audio na copyparty"; 92pub const L_MUSIC_NO_AUDIO_ON_CP: &str = "Brak powiązanego audio na copyparty";
93pub const L_MUSIC_NO_URL_IN_RESULT: &str = "Wynik nie ma URL"; 93pub const L_MUSIC_NO_URL_IN_RESULT: &str = "Wynik nie ma URL";
94pub const L_MUSIC_CANT_COMPRESS_LIVE: &str =
95 "Nie można skompresować transmisji na żywo, odtwarzam normalnie";
96pub const L_MUSIC_STOPPED: &str = "wyszed"; 94pub const L_MUSIC_STOPPED: &str = "wyszed";
97pub const L_MUSIC_NO_AUDIO_ATTACHMENTS: &str = "Brak plików audio w wiadomości"; 95pub const L_MUSIC_NO_AUDIO_ATTACHMENTS: &str = "Brak plików audio w wiadomości";
98pub const L_VOICE_USER_NOT_IN_VC: &str = "You are not in a voice channel"; 96pub const L_VOICE_USER_NOT_IN_VC: &str = "You are not in a voice channel";
@@ -122,6 +120,21 @@ pub fn fmt_music_search_failed(e: &dyn std::fmt::Display) -> String {
122pub fn fmt_music_playing(name: &str) -> String { 120pub fn fmt_music_playing(name: &str) -> String {
123 format!("Grajcowanie: `{name}`") 121 format!("Grajcowanie: `{name}`")
124} 122}
123pub const L_MUSIC_QUEUE_EMPTY: &str = "Kolejka jest pusta";
124pub const L_MUSIC_QUEUE_HEADER: &str = "**Kolejka:**";
125pub const L_MUSIC_SKIPPED: &str = "Pominięto";
126pub fn fmt_music_queued(name: &str, position: usize) -> String {
127 format!("W kolejce (#{position}): `{name}`")
128}
129pub fn fmt_music_track_failed(name: &str, e: &dyn std::fmt::Display) -> String {
130 format!("Nie udało się odtworzyć `{name}`: `{e}`")
131}
132pub fn fmt_music_queue_current(name: &str, who: &str) -> String {
133 format!("▶ `{name}`{who}")
134}
135pub fn fmt_music_queue_item(index: usize, name: &str, who: &str) -> String {
136 format!("{index}. `{name}`{who}")
137}
125pub fn fmt_voice_joined(cid: &dyn std::fmt::Display) -> String { 138pub fn fmt_voice_joined(cid: &dyn std::fmt::Display) -> String {
126 format!("Dołączon <#{cid}>") 139 format!("Dołączon <#{cid}>")
127} 140}
diff --git a/src/main.rs b/src/main.rs
index df40481..4e308b8 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -13,6 +13,7 @@ mod database;
13mod error; 13mod error;
14mod events; 14mod events;
15mod lang; 15mod lang;
16mod music;
16mod openrouter; 17mod openrouter;
17mod recording; 18mod recording;
18mod tasks; 19mod tasks;
@@ -49,8 +50,8 @@ pub struct Data {
49 /// Populated by background tasks when the first search times out, so the 50 /// Populated by background tasks when the first search times out, so the
50 /// next keystroke's autocomplete can serve real results immediately. 51 /// next keystroke's autocomplete can serve real results immediately.
51 pub ac_search_cache: AcSearchCache, 52 pub ac_search_cache: AcSearchCache,
52 /// Active music track per guild (only one music source at a time). 53 /// Active music queue and current track per guild.
53 pub music_tracks: Arc<tokio::sync::Mutex<HashMap<u64, songbird::tracks::TrackHandle>>>, 54 pub music: Arc<music::MusicState>,
54 /// TTS engine: piper model + audio cache. 55 /// TTS engine: piper model + audio cache.
55 pub tts: Arc<tts::TtsState>, 56 pub tts: Arc<tts::TtsState>,
56} 57}
@@ -198,7 +199,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
198 ) 199 )
199 .await?; 200 .await?;
200 201
201 let music_tracks: Arc<tokio::sync::Mutex<HashMap<u64, songbird::tracks::TrackHandle>>> = Arc::new(tokio::sync::Mutex::new(HashMap::new())); 202 let music = music::MusicState::new(
203 vm.clone(),
204 ctx.http.clone(),
205 http_client.clone(),
206 );
202 207
203 let tts = Arc::new(tts::TtsState::new(config.piper_model_path())); 208 let tts = Arc::new(tts::TtsState::new(config.piper_model_path()));
204 209
@@ -213,7 +218,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
213 triggers: tokio::sync::Mutex::new(triggers), 218 triggers: tokio::sync::Mutex::new(triggers),
214 ac_cache: tokio::sync::Mutex::new(HashMap::new()), 219 ac_cache: tokio::sync::Mutex::new(HashMap::new()),
215 ac_search_cache: Arc::new(tokio::sync::Mutex::new(HashMap::new())), 220 ac_search_cache: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
216 music_tracks: music_tracks.clone(), 221 music,
217 }; 222 };
218 223
219 // Spawn cron scheduler loop. 224 // Spawn cron scheduler loop.
@@ -221,9 +226,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
221 let cron_ctx = crate::tasks::CronContext { 226 let cron_ctx = crate::tasks::CronContext {
222 pool: data.db_pool.clone(), 227 pool: data.db_pool.clone(),
223 http: ctx.http.clone(), 228 http: ctx.http.clone(),
224 voice_manager: data.voice_manager.clone(),
225 copyparty: data.copyparty.clone(), 229 copyparty: data.copyparty.clone(),
226 music_tracks: music_tracks.clone(), 230 music: data.music.clone(),
227 }; 231 };
228 tokio::spawn(async move { 232 tokio::spawn(async move {
229 crate::tasks::cron_scheduler_loop(cron_ctx).await; 233 crate::tasks::cron_scheduler_loop(cron_ctx).await;
diff --git a/src/music.rs b/src/music.rs
new file mode 100644
index 0000000..8dfc4f8
--- /dev/null
+++ b/src/music.rs
@@ -0,0 +1,602 @@
1//! Per-guild music queue and playback pipeline.
2//!
3//! Playback used to live entirely in `commands::music`, tied to a poise
4//! `Context` (voice lookup, replies, ...). A queue needs to keep playing after
5//! the slash command has returned, so the stateful part lives here instead:
6//! the current track, the pending queue, and the voice/announce channels.
7//!
8//! The command layer resolves a user request into a [`TrackRequest`] (URL,
9//! display name, source kind) and hands it to [`MusicState`]. Everything after
10//! that — stopping/interrupting, preparing inputs, connecting, announcing, and
11//! advancing on `TrackEvent::End` — is handled here without a `Context`.
12
13use std::collections::{HashMap, VecDeque};
14use std::sync::Arc;
15
16use poise::serenity_prelude as serenity;
17use songbird::input::Input;
18use songbird::tracks::TrackHandle;
19use tokio::sync::Mutex;
20use tracing::{error, info, warn};
21use uuid::Uuid;
22
23use crate::error::{Error, Result};
24use crate::lang;
25use crate::voice::{self, VoiceManager};
26
27// ── Request types ─────────────────────────────────────────────────────
28
29/// What kind of audio source are we dealing with?
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum SourceKind {
32 /// YouTube, SoundCloud, Twitch, Bandcamp — needs yt-dlp extraction.
33 YtDlp,
34 /// Any HTTP(S) URL streamed directly through ffmpeg.
35 Stream,
36}
37
38/// Everything needed to (re)start one track, independent of the command
39/// invocation that created it.
40#[derive(Debug, Clone)]
41pub struct TrackRequest {
42 pub url: String,
43 pub display_name: String,
44 pub kind: SourceKind,
45 pub compressed: bool,
46 /// `(attachment_url, filename)` when the track came from an upload. Used to
47 /// re-upload the file alongside the "playing" message.
48 pub reupload: Option<(String, String)>,
49 pub requester: Option<serenity::UserId>,
50}
51
52/// Where an enqueued track goes relative to the current playback.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, poise::ChoiceParameter)]
54pub enum QueuePosition {
55 /// Interrupt the current track and play this right away. Pending tracks
56 /// stay queued behind it.
57 #[name = "now"]
58 Now,
59 /// Insert directly after the current track.
60 #[name = "next"]
61 Next,
62 /// Append to the end of the queue (the command default).
63 #[name = "last"]
64 Last,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum EnqueueOutcome {
69 /// Playback started immediately (nothing was playing, or `Now` was used).
70 Started,
71 /// The track was put into the queue; `position` is 1-based.
72 Queued { position: usize },
73}
74
75#[derive(Debug, Clone)]
76pub struct QueueItem {
77 pub display_name: String,
78 pub requester: Option<serenity::UserId>,
79}
80
81#[derive(Debug, Clone, Default)]
82pub struct QueueSnapshot {
83 pub current: Option<QueueItem>,
84 pub upcoming: Vec<QueueItem>,
85}
86
87// ── State ─────────────────────────────────────────────────────────────
88
89struct CurrentTrack {
90 /// Identifies this particular playback. Lets a late `End` event from a
91 /// manually stopped track avoid advancing the queue twice.
92 token: Uuid,
93 handle: TrackHandle,
94 request: TrackRequest,
95}
96
97struct GuildMusic {
98 current: Option<CurrentTrack>,
99 queue: VecDeque<TrackRequest>,
100 /// Voice channel the bot is (or should be) connected to.
101 voice: serenity::ChannelId,
102 /// Text channel where auto-advance announcements are posted.
103 announce: Option<serenity::ChannelId>,
104}
105
106/// Shared, per-guild music state. Stored in [`crate::Data`].
107pub struct MusicState {
108 guilds: Mutex<HashMap<u64, GuildMusic>>,
109 /// One transition lock per guild. Held across the whole stop/prepare/play
110 /// sequence so concurrent `/music` commands cannot interleave.
111 locks: Mutex<HashMap<u64, Arc<Mutex<()>>>>,
112 voice: VoiceManager,
113 http: Arc<serenity::Http>,
114 reqwest: reqwest::Client,
115}
116
117impl MusicState {
118 pub fn new(
119 voice: VoiceManager,
120 http: Arc<serenity::Http>,
121 reqwest: reqwest::Client,
122 ) -> Arc<Self> {
123 Arc::new(Self {
124 guilds: Mutex::new(HashMap::new()),
125 locks: Mutex::new(HashMap::new()),
126 voice,
127 http,
128 reqwest,
129 })
130 }
131
132 async fn guild_lock(&self, guild: u64) -> Arc<Mutex<()>> {
133 let mut locks = self.locks.lock().await;
134 locks
135 .entry(guild)
136 .or_insert_with(|| Arc::new(Mutex::new(())))
137 .clone()
138 }
139
140 /// Add a track to the queue and start it if nothing is playing.
141 pub async fn enqueue(
142 self: &Arc<Self>,
143 guild: u64,
144 voice_channel: serenity::ChannelId,
145 announce: Option<serenity::ChannelId>,
146 request: TrackRequest,
147 position: QueuePosition,
148 ) -> Result<EnqueueOutcome> {
149 let lock = self.guild_lock(guild).await;
150 let _transition = lock.lock().await;
151
152 let was_playing;
153 {
154 let mut map = self.guilds.lock().await;
155 let gm = map.entry(guild).or_insert_with(|| GuildMusic {
156 current: None,
157 queue: VecDeque::new(),
158 voice: voice_channel,
159 announce,
160 });
161 gm.voice = voice_channel;
162 gm.announce = announce;
163 was_playing = gm.current.is_some();
164
165 match position {
166 QueuePosition::Now | QueuePosition::Next => gm.queue.push_front(request),
167 QueuePosition::Last => gm.queue.push_back(request),
168 }
169 }
170
171 if was_playing && position != QueuePosition::Now {
172 let position = {
173 let map = self.guilds.lock().await;
174 let len = map.get(&guild).map(|gm| gm.queue.len()).unwrap_or(1);
175 if position == QueuePosition::Next {
176 1
177 } else {
178 len
179 }
180 };
181 return Ok(EnqueueOutcome::Queued { position });
182 }
183
184 if was_playing {
185 // `Now`: drop the current track. The new request is at the front of
186 // the queue; play_next_locked will pick it up.
187 let current = {
188 let mut map = self.guilds.lock().await;
189 map.get_mut(&guild).and_then(|gm| gm.current.take())
190 };
191 if let Some(current) = current {
192 let _ = current.handle.stop();
193 }
194 }
195
196 let played = self.play_next_locked(guild).await?;
197 Ok(if played {
198 EnqueueOutcome::Started
199 } else {
200 EnqueueOutcome::Queued { position: 1 }
201 })
202 }
203
204 /// Stop the current track and immediately start the next queued one.
205 /// Returns `true` when a track started, `false` when the queue was empty.
206 pub async fn skip(self: &Arc<Self>, guild: u64) -> Result<bool> {
207 let lock = self.guild_lock(guild).await;
208 let _transition = lock.lock().await;
209
210 let current = {
211 let mut map = self.guilds.lock().await;
212 map.get_mut(&guild).and_then(|gm| gm.current.take())
213 };
214 if let Some(current) = current {
215 let _ = current.handle.stop();
216 }
217
218 let has_queue = {
219 let map = self.guilds.lock().await;
220 map.get(&guild)
221 .map(|gm| !gm.queue.is_empty())
222 .unwrap_or(false)
223 };
224 if !has_queue {
225 return Ok(false);
226 }
227
228 self.play_next_locked(guild).await
229 }
230
231 /// Stop playback and drop the queue (does not disconnect from voice).
232 pub async fn stop(self: &Arc<Self>, guild: u64) {
233 let lock = self.guild_lock(guild).await;
234 let _transition = lock.lock().await;
235
236 let current = {
237 let mut map = self.guilds.lock().await;
238 map.remove(&guild).and_then(|gm| gm.current)
239 };
240 if let Some(current) = current {
241 let _ = current.handle.stop();
242 }
243 }
244
245 /// Play a track immediately, interrupting whatever is playing. Used by the
246 /// cron scheduler (no poise context, and the target channel is a voice
247 /// channel rather than a text channel).
248 pub async fn play_now(
249 self: &Arc<Self>,
250 guild: u64,
251 voice_channel: serenity::ChannelId,
252 announce: Option<serenity::ChannelId>,
253 request: TrackRequest,
254 ) -> Result<()> {
255 let lock = self.guild_lock(guild).await;
256 let _transition = lock.lock().await;
257
258 let current = {
259 let mut map = self.guilds.lock().await;
260 map.get_mut(&guild).and_then(|gm| gm.current.take())
261 };
262 if let Some(current) = current {
263 let _ = current.handle.stop();
264 }
265
266 {
267 let mut map = self.guilds.lock().await;
268 let gm = map.entry(guild).or_insert_with(|| GuildMusic {
269 current: None,
270 queue: VecDeque::new(),
271 voice: voice_channel,
272 announce,
273 });
274 gm.voice = voice_channel;
275 // Keep the last text channel when the caller has nowhere to
276 // announce (cron's channel is a voice channel).
277 if announce.is_some() {
278 gm.announce = announce;
279 }
280 gm.queue.push_front(request);
281 }
282
283 if self.play_next_locked(guild).await? {
284 Ok(())
285 } else {
286 Err(Error::Custom(lang::L_MUSIC_QUEUE_EMPTY.into()))
287 }
288 }
289
290 /// Current track followed by the pending queue, for `/music queue`.
291 pub async fn snapshot(&self, guild: u64) -> QueueSnapshot {
292 let map = self.guilds.lock().await;
293 let Some(gm) = map.get(&guild) else {
294 return QueueSnapshot::default();
295 };
296 QueueSnapshot {
297 current: gm.current.as_ref().map(|current| QueueItem {
298 display_name: current.request.display_name.clone(),
299 requester: current.request.requester,
300 }),
301 upcoming: gm
302 .queue
303 .iter()
304 .map(|req| QueueItem {
305 display_name: req.display_name.clone(),
306 requester: req.requester,
307 })
308 .collect(),
309 }
310 }
311
312 /// Pop and play the next playable track. Caller must hold the guild
313 /// transition lock.
314 async fn play_next_locked(self: &Arc<Self>, guild: u64) -> Result<bool> {
315 let mut last_error: Option<Error> = None;
316 loop {
317 let request = {
318 let mut map = self.guilds.lock().await;
319 match map.get_mut(&guild).and_then(|gm| gm.queue.pop_front()) {
320 Some(request) => request,
321 None => {
322 return match last_error {
323 Some(e) => Err(e),
324 None => Ok(false),
325 };
326 }
327 }
328 };
329
330 let input = match build_input(&request, &self.reqwest).await {
331 Ok(input) => input,
332 Err(e) => {
333 warn!(track = %request.display_name, error = %e, "failed to prepare queued track");
334 let message = lang::fmt_music_track_failed(&request.display_name, &e);
335 self.announce_failure(guild, message).await;
336 last_error = Some(e);
337 continue;
338 }
339 };
340
341 let (voice_channel, announce) = {
342 let map = self.guilds.lock().await;
343 match map.get(&guild) {
344 Some(gm) => (gm.voice, gm.announce),
345 None => return Ok(false),
346 }
347 };
348
349 let call = match self.voice.get_call(serenity::GuildId::new(guild)) {
350 Some(call) => call,
351 None => {
352 self.voice
353 .connect(serenity::GuildId::new(guild), voice_channel)
354 .await?
355 }
356 };
357
358 let handle = {
359 let mut call = call.lock().await;
360 call.play_input(input)
361 };
362 let token = Uuid::new_v4();
363 {
364 let mut map = self.guilds.lock().await;
365 if let Some(gm) = map.get_mut(&guild) {
366 gm.current = Some(CurrentTrack {
367 token,
368 handle: handle.clone(),
369 request: request.clone(),
370 });
371 }
372 }
373
374 let _ = handle.add_event(
375 songbird::Event::Track(songbird::TrackEvent::End),
376 TrackEndHandler {
377 state: self.clone(),
378 guild,
379 token,
380 },
381 );
382 let _ = handle.add_event(
383 songbird::Event::Track(songbird::TrackEvent::Error),
384 TrackEndHandler {
385 state: self.clone(),
386 guild,
387 token,
388 },
389 );
390
391 self.announce_playing(announce, &request).await;
392 return Ok(true);
393 }
394 }
395
396 /// Called from the `End` event of a track. Only advances if the track that
397 /// ended is still the current one.
398 async fn on_track_end(self: Arc<Self>, guild: u64, token: Uuid) {
399 let lock = self.guild_lock(guild).await;
400 let _transition = lock.lock().await;
401
402 let is_current = {
403 let mut map = self.guilds.lock().await;
404 match map.get_mut(&guild) {
405 Some(gm) => match &gm.current {
406 Some(current) if current.token == token => {
407 gm.current = None;
408 true
409 }
410 _ => false,
411 },
412 None => false,
413 }
414 };
415
416 if is_current {
417 if let Err(e) = self.play_next_locked(guild).await {
418 error!(guild, error = %e, "failed to advance music queue");
419 }
420 }
421 }
422
423 async fn announce_playing(&self, channel: Option<serenity::ChannelId>, request: &TrackRequest) {
424 let Some(channel) = channel else {
425 return;
426 };
427 let mut message =
428 serenity::CreateMessage::new().content(lang::fmt_music_playing(&request.display_name));
429
430 if let Some((url, filename)) = &request.reupload {
431 match self.reqwest.get(url).send().await {
432 Ok(response) => match response.bytes().await {
433 Ok(bytes) => {
434 message = message.add_file(serenity::CreateAttachment::bytes(
435 bytes.to_vec(),
436 filename.clone(),
437 ));
438 }
439 Err(e) => error!("music announce: failed to read attachment bytes: {e}"),
440 },
441 Err(e) => error!("music announce: failed to download attachment: {e}"),
442 }
443 }
444
445 if let Err(e) = channel.send_message(&*self.http, message).await {
446 error!(channel = %channel, error = %e, "music announce: send failed");
447 }
448 }
449
450 async fn announce_failure(&self, guild: u64, message: String) {
451 let channel = {
452 let map = self.guilds.lock().await;
453 map.get(&guild).and_then(|gm| gm.announce)
454 };
455 let Some(channel) = channel else {
456 return;
457 };
458 let message = serenity::CreateMessage::new().content(message);
459 if let Err(e) = channel.send_message(&*self.http, message).await {
460 error!(channel = %channel, error = %e, "music failure announce: send failed");
461 }
462 }
463}
464
465struct TrackEndHandler {
466 state: Arc<MusicState>,
467 guild: u64,
468 token: Uuid,
469}
470
471#[async_trait::async_trait]
472impl songbird::EventHandler for TrackEndHandler {
473 async fn act(&self, _ctx: &songbird::EventContext<'_>) -> Option<songbird::Event> {
474 let state = self.state.clone();
475 let guild = self.guild;
476 let token = self.token;
477 tokio::spawn(async move {
478 state.on_track_end(guild, token).await;
479 });
480 Some(songbird::Event::Cancel)
481 }
482}
483
484// ── Input construction ────────────────────────────────────────────────
485
486/// URLs that require yt-dlp extraction (platforms that don't serve raw audio
487/// at a direct URL). These MUST be downloaded first.
488fn needs_ytdlp(url: &str) -> bool {
489 let lower = url.to_lowercase();
490 lower.contains("youtube.com/")
491 || lower.contains("youtu.be/")
492 || lower.contains("twitch.tv/")
493 || lower.contains("soundcloud.com/")
494 || lower.contains("bandcamp.com/")
495}
496
497/// Heuristic: does this URL look like a live internet radio stream?
498fn is_live_stream_hint(url: &str) -> bool {
499 let lower = url.to_lowercase();
500 lower.contains("/stream")
501 || lower.contains("/live")
502 || lower.contains("/radio")
503 || lower.contains(";stream")
504 || lower.contains("icecast")
505 || lower.contains("shoutcast")
506 || lower.ends_with(".m3u8")
507 || lower.ends_with(".m3u")
508 || lower.ends_with(".pls")
509 || lower.contains(":8000/")
510 || lower.contains(":8080/")
511}
512
513/// Strip `?pw=...` / `&pw=...` query params from a URL for safe logging.
514fn redact_url(url: &str) -> &str {
515 let Some(q) = url.find('?') else {
516 return url;
517 };
518 let before = &url[..q];
519 let after = &url[q..];
520 if after.strip_prefix("?pw=").is_some() {
521 return before;
522 }
523 if let Some(idx) = after.find("&pw=") {
524 return &url[..q + idx];
525 }
526 url
527}
528
529/// Strip tracking/playlist params (`&...`) from YouTube URLs.
530pub(crate) fn sanitize_yt_url(url: &str) -> String {
531 let lower = url.to_lowercase();
532 if (lower.contains("youtube.com/") || lower.contains("youtu.be/"))
533 && let Some(pos) = url.find('&')
534 {
535 return url[..pos].to_string();
536 }
537 url.to_string()
538}
539
540async fn build_input(request: &TrackRequest, http: &reqwest::Client) -> Result<Input> {
541 if request.compressed {
542 let audio_bytes = match request.kind {
543 SourceKind::YtDlp => {
544 let clean_url = sanitize_yt_url(&request.url);
545 info!(url = %clean_url, "downloading via yt-dlp for compression");
546 voice::ytdlp_download(&clean_url).await?
547 }
548 SourceKind::Stream => {
549 if is_live_stream_hint(&request.url) {
550 info!(
551 url = %redact_url(&request.url),
552 "live stream — compression skipped, streaming via ffmpeg"
553 );
554 return voice::ffmpeg_stream_input(&request.url).await;
555 }
556 info!(url = %redact_url(&request.url), "downloading for compression");
557 voice::download_url(http, &request.url).await?
558 }
559 };
560
561 info!("compressing audio ({} bytes)", audio_bytes.len());
562 let compressed_bytes =
563 crate::commands::utilities::compress_audio_bytes(&audio_bytes, &request.display_name)
564 .await
565 .ok_or_else(|| Error::Custom("compression failed".into()))?;
566 info!("compressed to {} bytes", compressed_bytes.len());
567
568 // Decode the compressed OGG via ffmpeg (symphonia lacks Opus support).
569 let compressed_path = format!(
570 "{}/play_compressed_{}.ogg",
571 crate::config::TEMP_PATH,
572 Uuid::new_v4()
573 );
574 let _ = tokio::fs::create_dir_all(crate::config::TEMP_PATH).await;
575 tokio::fs::write(&compressed_path, &compressed_bytes).await?;
576 let input = voice::ffmpeg_decode_file(&compressed_path).await?;
577 Ok(input)
578 } else {
579 match request.kind {
580 SourceKind::YtDlp => {
581 let clean_url = sanitize_yt_url(&request.url);
582 info!(url = %clean_url, "downloading via yt-dlp");
583 let audio_bytes = voice::ytdlp_download(&clean_url).await?;
584 tokio::task::spawn_blocking(move || voice::decode_audio(&audio_bytes)).await?
585 }
586 SourceKind::Stream => {
587 let live = is_live_stream_hint(&request.url);
588 info!(url = %redact_url(&request.url), live, "streaming via ffmpeg subprocess");
589 voice::ffmpeg_stream_input(&request.url).await
590 }
591 }
592 }
593}
594
595/// Classify a raw URL for the command layer (yt-dlp vs. direct ffmpeg stream).
596pub fn classify_url(url: &str) -> SourceKind {
597 if needs_ytdlp(url) {
598 SourceKind::YtDlp
599 } else {
600 SourceKind::Stream
601 }
602}
diff --git a/src/tasks/mod.rs b/src/tasks/mod.rs
index d4cf9b2..ab0d062 100644
--- a/src/tasks/mod.rs
+++ b/src/tasks/mod.rs
@@ -1,4 +1,3 @@
1use std::collections::HashMap;
2use std::sync::Arc; 1use std::sync::Arc;
3 2
4use chrono::{DateTime, Duration, Local, TimeZone, Timelike, Utc}; 3use chrono::{DateTime, Duration, Local, TimeZone, Timelike, Utc};
@@ -10,16 +9,15 @@ use tracing::{error, info};
10 9
11use crate::copyparty::CopypartyClient; 10use crate::copyparty::CopypartyClient;
12use crate::database::{self, DbPool}; 11use crate::database::{self, DbPool};
13use crate::voice::VoiceManager; 12use crate::music::{MusicState, SourceKind, TrackRequest};
14 13
15/// Shared resources the cron scheduler needs. 14/// Shared resources the cron scheduler needs.
16#[derive(Clone)] 15#[derive(Clone)]
17pub struct CronContext { 16pub struct CronContext {
18 pub pool: DbPool, 17 pub pool: DbPool,
19 pub http: Arc<serenity::Http>, 18 pub http: Arc<serenity::Http>,
20 pub voice_manager: VoiceManager,
21 pub copyparty: CopypartyClient, 19 pub copyparty: CopypartyClient,
22 pub music_tracks: Arc<tokio::sync::Mutex<HashMap<u64, songbird::tracks::TrackHandle>>>, 20 pub music: Arc<MusicState>,
23} 21}
24 22
25/// Normalize user-friendly cron expressions to the 7-field format the `cron` crate expects. 23/// Normalize user-friendly cron expressions to the 7-field format the `cron` crate expects.
@@ -139,14 +137,9 @@ async fn execute_audio_job(ctx: &CronContext, job: &crate::database::CronJob) {
139 // Legacy relative path — build full URL with password 137 // Legacy relative path — build full URL with password
140 ctx.copyparty.build_url(audio_url) 138 ctx.copyparty.build_url(audio_url)
141 }; 139 };
142 info!("Cron audio job {} streaming via ffmpeg: {url}", job.id); 140 info!("Cron audio job {} playing: {url}", job.id);
143 let guild_channel_fut = channel_id.to_channel(&ctx.http); 141 let channel = match channel_id.to_channel(&ctx.http).await {
144 let input_fut = crate::voice::ffmpeg_stream_input(&url); 142 Ok(serenity::Channel::Guild(ch)) => ch,
145
146 let (channel_result, input_result) = tokio::join!(guild_channel_fut, input_fut);
147
148 let guild_id = match channel_result {
149 Ok(serenity::Channel::Guild(ch)) => ch.guild_id,
150 other => { 143 other => {
151 error!( 144 error!(
152 "Cron audio job {}: channel {} is not a guild voice channel ({:?})", 145 "Cron audio job {}: channel {} is not a guild voice channel ({:?})",
@@ -158,45 +151,31 @@ async fn execute_audio_job(ctx: &CronContext, job: &crate::database::CronJob) {
158 } 151 }
159 }; 152 };
160 153
161 let input = match input_result { 154 let request = TrackRequest {
162 Ok(i) => i, 155 url: url.clone(),
163 Err(e) => { 156 display_name: audio_url
164 error!( 157 .rsplit('/')
165 "Cron audio job {} ffmpeg stream failed ({url}): {e}", 158 .next()
166 job.id 159 .unwrap_or(audio_url)
167 ); 160 .to_string(),
168 return; 161 kind: SourceKind::Stream,
169 } 162 compressed: false,
163 reupload: None,
164 requester: None,
170 }; 165 };
171 166
172 // Stop any existing track in this guild. 167 if let Err(e) = ctx
168 .music
169 .play_now(channel.guild_id.get(), channel_id, None, request)
170 .await
173 { 171 {
174 let mut map = ctx.music_tracks.lock().await; 172 error!("Cron audio job {} playback failed: {e}", job.id);
175 if let Some(handle) = map.remove(&guild_id.get()) { 173 return;
176 let _ = handle.stop();
177 }
178 } 174 }
179 175
180 // Connect to voice and play.
181 let call = match ctx.voice_manager.connect(guild_id, channel_id).await {
182 Ok(c) => c,
183 Err(e) => {
184 error!("Cron audio job {} voice connect failed: {e}", job.id);
185 return;
186 }
187 };
188
189 let handle = {
190 let mut call_lock = call.lock().await;
191 call_lock.play_input(input)
192 };
193
194 // Store the handle so the track stays alive.
195 ctx.music_tracks.lock().await.insert(guild_id.get(), handle);
196
197 info!( 176 info!(
198 "Cron audio job {} playing '{}' (url: {url}) in guild {}", 177 "Cron audio job {} playing '{}' (url: {url}) in guild {}",
199 job.id, audio_url, guild_id 178 job.id, audio_url, channel.guild_id
200 ); 179 );
201} 180}
202 181