diff options
| author | salmonik <salmonik@tuta.io> | 2026-10-27 18:14:07 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-10-27 18:14:07 +0100 |
| commit | fc5e0f48477366641b97efb0541053cc1e061be5 (patch) | |
| tree | 99ad0352552a0eebc527f3184a11ad2e7676ffea | |
| parent | silence songbird udp_rx decode noise (diff) | |
| download | senator-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.rs | 355 | ||||
| -rw-r--r-- | src/commands/utilities.rs | 2 | ||||
| -rw-r--r-- | src/lang.rs | 17 | ||||
| -rw-r--r-- | src/main.rs | 16 | ||||
| -rw-r--r-- | src/music.rs | 602 | ||||
| -rw-r--r-- | src/tasks/mod.rs | 67 |
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 @@ | |||
| 1 | use poise::serenity_prelude as serenity; | ||
| 2 | use super::utilities::{FileType, extract_urls, file_type}; | 1 | use super::utilities::{FileType, extract_urls, file_type}; |
| 2 | use poise::serenity_prelude as serenity; | ||
| 3 | 3 | ||
| 4 | use tracing::{error, info}; | 4 | use tracing::{error, info}; |
| 5 | 5 | ||
| @@ -8,29 +8,16 @@ 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}; |
| 10 | use crate::lang; | 10 | use crate::lang; |
| 11 | use crate::music::{EnqueueOutcome, QueuePosition, SourceKind, TrackRequest, classify_url}; | ||
| 11 | 12 | ||
| 12 | type Context<'a> = poise::Context<'a, Data, Error>; | 13 | type 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` | 19 | pub 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)] | ||
| 23 | enum 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")] | ||
| 41 | pub 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 | |||
| 55 | async 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). | ||
| 77 | fn 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`.) | ||
| 88 | fn 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 | |||
| 104 | async fn resolve_source( | 89 | async 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. | ||
| 206 | fn 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. | ||
| 224 | pub(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); | 253 | pub 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 | } | 273 | pub 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 | ¤t.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)] |
| 393 | pub async fn stop(ctx: Context<'_>) -> Result<()> { | 310 | pub 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")] |
| 467 | pub async fn message_grajcuj(ctx: Context<'_>, message: serenity::Message) -> Result<()> { | 389 | pub 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 | |||
| 483 | pub fn commands() -> Vec<poise::Command<Data, Error>> { | 406 | pub 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; | |||
| 2 | use tracing::warn; | 2 | use tracing::warn; |
| 3 | 3 | ||
| 4 | use super::autocomplete::any_file; | 4 | use super::autocomplete::any_file; |
| 5 | use super::music::sanitize_yt_url; | 5 | use crate::music::sanitize_yt_url; |
| 6 | use crate::Data; | 6 | use crate::Data; |
| 7 | use crate::copyparty::{html_decode, percent_decode}; | 7 | use crate::copyparty::{html_decode, percent_decode}; |
| 8 | use crate::error::{Error, Result}; | 8 | use 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 { | |||
| 91 | pub const L_MUSIC_NEED_NAME: &str = "Daj mi nazwę pliku, URL albo plik"; | 91 | pub const L_MUSIC_NEED_NAME: &str = "Daj mi nazwę pliku, URL albo plik"; |
| 92 | pub const L_MUSIC_NO_AUDIO_ON_CP: &str = "Brak powiązanego audio na copyparty"; | 92 | pub const L_MUSIC_NO_AUDIO_ON_CP: &str = "Brak powiązanego audio na copyparty"; |
| 93 | pub const L_MUSIC_NO_URL_IN_RESULT: &str = "Wynik nie ma URL"; | 93 | pub const L_MUSIC_NO_URL_IN_RESULT: &str = "Wynik nie ma URL"; |
| 94 | pub const L_MUSIC_CANT_COMPRESS_LIVE: &str = | ||
| 95 | "Nie można skompresować transmisji na żywo, odtwarzam normalnie"; | ||
| 96 | pub const L_MUSIC_STOPPED: &str = "wyszed"; | 94 | pub const L_MUSIC_STOPPED: &str = "wyszed"; |
| 97 | pub const L_MUSIC_NO_AUDIO_ATTACHMENTS: &str = "Brak plików audio w wiadomości"; | 95 | pub const L_MUSIC_NO_AUDIO_ATTACHMENTS: &str = "Brak plików audio w wiadomości"; |
| 98 | pub const L_VOICE_USER_NOT_IN_VC: &str = "You are not in a voice channel"; | 96 | pub 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 { | |||
| 122 | pub fn fmt_music_playing(name: &str) -> String { | 120 | pub fn fmt_music_playing(name: &str) -> String { |
| 123 | format!("Grajcowanie: `{name}`") | 121 | format!("Grajcowanie: `{name}`") |
| 124 | } | 122 | } |
| 123 | pub const L_MUSIC_QUEUE_EMPTY: &str = "Kolejka jest pusta"; | ||
| 124 | pub const L_MUSIC_QUEUE_HEADER: &str = "**Kolejka:**"; | ||
| 125 | pub const L_MUSIC_SKIPPED: &str = "Pominięto"; | ||
| 126 | pub fn fmt_music_queued(name: &str, position: usize) -> String { | ||
| 127 | format!("W kolejce (#{position}): `{name}`") | ||
| 128 | } | ||
| 129 | pub fn fmt_music_track_failed(name: &str, e: &dyn std::fmt::Display) -> String { | ||
| 130 | format!("Nie udało się odtworzyć `{name}`: `{e}`") | ||
| 131 | } | ||
| 132 | pub fn fmt_music_queue_current(name: &str, who: &str) -> String { | ||
| 133 | format!("▶ `{name}`{who}") | ||
| 134 | } | ||
| 135 | pub fn fmt_music_queue_item(index: usize, name: &str, who: &str) -> String { | ||
| 136 | format!("{index}. `{name}`{who}") | ||
| 137 | } | ||
| 125 | pub fn fmt_voice_joined(cid: &dyn std::fmt::Display) -> String { | 138 | pub 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; | |||
| 13 | mod error; | 13 | mod error; |
| 14 | mod events; | 14 | mod events; |
| 15 | mod lang; | 15 | mod lang; |
| 16 | mod music; | ||
| 16 | mod openrouter; | 17 | mod openrouter; |
| 17 | mod recording; | 18 | mod recording; |
| 18 | mod tasks; | 19 | mod 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 | |||
| 13 | use std::collections::{HashMap, VecDeque}; | ||
| 14 | use std::sync::Arc; | ||
| 15 | |||
| 16 | use poise::serenity_prelude as serenity; | ||
| 17 | use songbird::input::Input; | ||
| 18 | use songbird::tracks::TrackHandle; | ||
| 19 | use tokio::sync::Mutex; | ||
| 20 | use tracing::{error, info, warn}; | ||
| 21 | use uuid::Uuid; | ||
| 22 | |||
| 23 | use crate::error::{Error, Result}; | ||
| 24 | use crate::lang; | ||
| 25 | use 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)] | ||
| 31 | pub 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)] | ||
| 41 | pub 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)] | ||
| 54 | pub 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)] | ||
| 68 | pub 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)] | ||
| 76 | pub struct QueueItem { | ||
| 77 | pub display_name: String, | ||
| 78 | pub requester: Option<serenity::UserId>, | ||
| 79 | } | ||
| 80 | |||
| 81 | #[derive(Debug, Clone, Default)] | ||
| 82 | pub struct QueueSnapshot { | ||
| 83 | pub current: Option<QueueItem>, | ||
| 84 | pub upcoming: Vec<QueueItem>, | ||
| 85 | } | ||
| 86 | |||
| 87 | // ── State ───────────────────────────────────────────────────────────── | ||
| 88 | |||
| 89 | struct 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 | |||
| 97 | struct 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`]. | ||
| 107 | pub 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 | |||
| 117 | impl 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 | |||
| 465 | struct TrackEndHandler { | ||
| 466 | state: Arc<MusicState>, | ||
| 467 | guild: u64, | ||
| 468 | token: Uuid, | ||
| 469 | } | ||
| 470 | |||
| 471 | #[async_trait::async_trait] | ||
| 472 | impl 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. | ||
| 488 | fn 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? | ||
| 498 | fn 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. | ||
| 514 | fn 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. | ||
| 530 | pub(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 | |||
| 540 | async 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). | ||
| 596 | pub 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 @@ | |||
| 1 | use std::collections::HashMap; | ||
| 2 | use std::sync::Arc; | 1 | use std::sync::Arc; |
| 3 | 2 | ||
| 4 | use chrono::{DateTime, Duration, Local, TimeZone, Timelike, Utc}; | 3 | use chrono::{DateTime, Duration, Local, TimeZone, Timelike, Utc}; |
| @@ -10,16 +9,15 @@ use tracing::{error, info}; | |||
| 10 | 9 | ||
| 11 | use crate::copyparty::CopypartyClient; | 10 | use crate::copyparty::CopypartyClient; |
| 12 | use crate::database::{self, DbPool}; | 11 | use crate::database::{self, DbPool}; |
| 13 | use crate::voice::VoiceManager; | 12 | use 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)] |
| 17 | pub struct CronContext { | 16 | pub 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 | ||
