diff options
Diffstat (limited to '')
| -rw-r--r-- | src/commands/music.rs | 414 |
1 files changed, 414 insertions, 0 deletions
diff --git a/src/commands/music.rs b/src/commands/music.rs new file mode 100644 index 0000000..313577d --- /dev/null +++ b/src/commands/music.rs | |||
| @@ -0,0 +1,414 @@ | |||
| 1 | use poise::serenity_prelude as serenity; | ||
| 2 | use tracing::{error, info}; | ||
| 3 | |||
| 4 | use super::autocomplete::audio_file; | ||
| 5 | use crate::Data; | ||
| 6 | use crate::copyparty::{html_decode, percent_decode}; | ||
| 7 | use crate::error::{Error, Result}; | ||
| 8 | use crate::lang; | ||
| 9 | |||
| 10 | type Context<'a> = poise::Context<'a, Data, Error>; | ||
| 11 | |||
| 12 | // ── Source classification ────────────────────────────────────────── | ||
| 13 | |||
| 14 | /// What kind of audio source are we dealing with? | ||
| 15 | /// | ||
| 16 | /// This mirrors the Python original's split: `EXTERNAL_URL_PATTERN` | ||
| 17 | /// (youtube/soundcloud/bandcamp) goes through yt-dlp download, while | ||
| 18 | /// *every other* URL is streamed directly via ffmpeg (radio, HLS, | ||
| 19 | /// playlists, direct media files). | ||
| 20 | #[derive(Debug, Clone, PartialEq)] | ||
| 21 | enum SourceKind { | ||
| 22 | /// YouTube, SoundCloud, Twitch, Bandcamp — platforms that don't serve | ||
| 23 | /// raw audio at a direct URL. Needs yt-dlp to extract + download the | ||
| 24 | /// media to memory before decoding. | ||
| 25 | YtDlp, | ||
| 26 | /// Any HTTP(S) URL: internet radio (Icecast/Shoutcast), HLS `.m3u8`, | ||
| 27 | /// `.m3u`/`.pls` playlists, Copyparty files, Discord attachments, or | ||
| 28 | /// directly-accessible media files. Streamed via an ffmpeg subprocess | ||
| 29 | /// — NO full download. ffmpeg handles playlist parsing, Icecast | ||
| 30 | /// metadata, reconnection, and codec/container detection universally. | ||
| 31 | Stream, | ||
| 32 | } | ||
| 33 | |||
| 34 | /// Play music from Copyparty, URL, or file upload | ||
| 35 | #[poise::command(slash_command)] | ||
| 36 | pub async fn grajcuj( | ||
| 37 | ctx: Context<'_>, | ||
| 38 | #[description = "Plik do odtworzenia"] | ||
| 39 | #[autocomplete = "audio_file"] | ||
| 40 | melodia: Option<String>, | ||
| 41 | #[description = "Bezpośredni plik z twojego systemu"] file: Option<serenity::Attachment>, | ||
| 42 | #[description = "Chujowa kompresja!?"] compressed: Option<bool>, | ||
| 43 | ) -> Result<()> { | ||
| 44 | let compressed = compressed.unwrap_or(false); | ||
| 45 | eprintln!( | ||
| 46 | "[DEBUG] play() reached, melodia={:?}, file={:?}, compressed={compressed}", | ||
| 47 | melodia, | ||
| 48 | file.as_ref().map(|f| &f.filename) | ||
| 49 | ); | ||
| 50 | let result = play_impl(ctx, melodia, file, compressed).await; | ||
| 51 | if let Err(e) = &result { | ||
| 52 | eprintln!("[DEBUG] play() error: {e}"); | ||
| 53 | error!("play command failed: {e}"); | ||
| 54 | } | ||
| 55 | result | ||
| 56 | } | ||
| 57 | |||
| 58 | struct ResolvedSource { | ||
| 59 | url: String, | ||
| 60 | display_name: String, | ||
| 61 | kind: SourceKind, | ||
| 62 | } | ||
| 63 | |||
| 64 | fn clean_name(raw: &str) -> String { | ||
| 65 | html_decode(&percent_decode(raw)) | ||
| 66 | } | ||
| 67 | |||
| 68 | // ── URL classification helpers ────────────────────────────────────── | ||
| 69 | |||
| 70 | /// URLs that require yt-dlp extraction (platforms that don't serve | ||
| 71 | /// raw audio at a direct URL). These MUST be downloaded first. | ||
| 72 | /// | ||
| 73 | /// Matches the Python original's `EXTERNAL_URL_PATTERN`: | ||
| 74 | /// `youtube|youtu.be|soundcloud|bandcamp` (plus twitch). | ||
| 75 | fn needs_ytdlp(url: &str) -> bool { | ||
| 76 | let lower = url.to_lowercase(); | ||
| 77 | lower.contains("youtube.com/") | ||
| 78 | || lower.contains("youtu.be/") | ||
| 79 | || lower.contains("twitch.tv/") | ||
| 80 | || lower.contains("soundcloud.com/") | ||
| 81 | || lower.contains("bandcamp.com/") | ||
| 82 | } | ||
| 83 | |||
| 84 | /// Heuristic: does this URL *look* like a live internet radio stream? | ||
| 85 | /// (Used for logging / display only — dispatch is controlled by `SourceKind`.) | ||
| 86 | fn is_live_stream_hint(url: &str) -> bool { | ||
| 87 | let lower = url.to_lowercase(); | ||
| 88 | // Common Icecast/Shoutcast mount patterns | ||
| 89 | lower.contains("/stream") | ||
| 90 | || lower.contains("/live") | ||
| 91 | || lower.contains("/radio") | ||
| 92 | || lower.contains(";stream") | ||
| 93 | || lower.contains("icecast") | ||
| 94 | || lower.contains("shoutcast") | ||
| 95 | || lower.ends_with(".m3u8") | ||
| 96 | || lower.ends_with(".m3u") | ||
| 97 | || lower.ends_with(".pls") | ||
| 98 | || lower.contains(":8000/") // common Icecast port | ||
| 99 | || lower.contains(":8080/") // common alt port | ||
| 100 | } | ||
| 101 | |||
| 102 | async fn resolve_source( | ||
| 103 | ctx: Context<'_>, | ||
| 104 | melodia: Option<String>, | ||
| 105 | file: Option<serenity::Attachment>, | ||
| 106 | ) -> Result<ResolvedSource> { | ||
| 107 | // ── File attachment ────────────────────────────────────────── | ||
| 108 | // Discord attachment URLs are direct media file URLs — ffmpeg streams | ||
| 109 | // them without a full download. | ||
| 110 | if let Some(file) = file { | ||
| 111 | return Ok(ResolvedSource { | ||
| 112 | url: file.url.clone(), | ||
| 113 | display_name: file.filename.clone(), | ||
| 114 | kind: SourceKind::Stream, | ||
| 115 | }); | ||
| 116 | } | ||
| 117 | |||
| 118 | let name = melodia.ok_or_else(|| Error::Custom(lang::L_MUSIC_NEED_NAME.into()))?; | ||
| 119 | |||
| 120 | // ── Autocomplete cache (per-user: (UserId, filename) → SearchHit) ─ | ||
| 121 | if let Some(hit) = ctx.data().ac_lookup(ctx.author().id, &name).await { | ||
| 122 | let path = hit.url_path().unwrap_or(""); | ||
| 123 | let path = if path.starts_with('/') { | ||
| 124 | path.to_string() | ||
| 125 | } else { | ||
| 126 | format!("/{path}") | ||
| 127 | }; | ||
| 128 | let url = ctx.data().copyparty.build_url(&path); | ||
| 129 | let display_name = clean_name(hit.filename().unwrap_or(&name)); | ||
| 130 | return Ok(ResolvedSource { | ||
| 131 | url, | ||
| 132 | display_name, | ||
| 133 | kind: SourceKind::Stream, | ||
| 134 | }); | ||
| 135 | } | ||
| 136 | |||
| 137 | // ── Direct Copyparty path (starts with '/') ────────────────── | ||
| 138 | if name.starts_with('/') { | ||
| 139 | let url = ctx.data().copyparty.build_url(&name); | ||
| 140 | let display_name = clean_name(name.rsplit('/').next().unwrap_or(&name)); | ||
| 141 | return Ok(ResolvedSource { | ||
| 142 | url, | ||
| 143 | display_name, | ||
| 144 | kind: SourceKind::Stream, | ||
| 145 | }); | ||
| 146 | } | ||
| 147 | |||
| 148 | // ── Direct HTTP(S) URL ─────────────────────────────────────── | ||
| 149 | if name.starts_with("http://") || name.starts_with("https://") { | ||
| 150 | let url = name.clone(); | ||
| 151 | let display_name = clean_name(url.rsplit('/').next().unwrap_or(&url)); | ||
| 152 | |||
| 153 | // yt-dlp platforms → download; everything else (radio, HLS, | ||
| 154 | // playlists, Copyparty, direct media) → ffmpeg stream. | ||
| 155 | let kind = if needs_ytdlp(&url) { | ||
| 156 | SourceKind::YtDlp | ||
| 157 | } else { | ||
| 158 | SourceKind::Stream | ||
| 159 | }; | ||
| 160 | return Ok(ResolvedSource { | ||
| 161 | url, | ||
| 162 | display_name, | ||
| 163 | kind, | ||
| 164 | }); | ||
| 165 | } | ||
| 166 | |||
| 167 | // ── Fallback: search Copyparty ─────────────────────────────── | ||
| 168 | info!(query = %name, "searching copyparty for audio"); | ||
| 169 | let results = ctx | ||
| 170 | .data() | ||
| 171 | .copyparty | ||
| 172 | .search(&name, 5, &["mp3", "wav", "ogg", "flac", "m4a", "opus"]) | ||
| 173 | .await | ||
| 174 | .map_err(|e| { | ||
| 175 | error!(query = %name, error = %e, "copyparty search failed"); | ||
| 176 | Error::Custom(lang::fmt_music_search_failed(&e)) | ||
| 177 | })?; | ||
| 178 | let hit = results | ||
| 179 | .first() | ||
| 180 | .ok_or_else(|| Error::Custom(lang::L_MUSIC_NO_AUDIO_ON_CP.into()))?; | ||
| 181 | let path = hit | ||
| 182 | .url_path() | ||
| 183 | .ok_or_else(|| Error::Custom(lang::L_MUSIC_NO_URL_IN_RESULT.into()))?; | ||
| 184 | let path = if path.starts_with('/') { | ||
| 185 | path.to_string() | ||
| 186 | } else { | ||
| 187 | format!("/{path}") | ||
| 188 | }; | ||
| 189 | let url = ctx.data().copyparty.build_url(&path); | ||
| 190 | let display_name = clean_name(hit.filename().unwrap_or("unknown")); | ||
| 191 | Ok(ResolvedSource { | ||
| 192 | url, | ||
| 193 | display_name, | ||
| 194 | kind: SourceKind::Stream, | ||
| 195 | }) | ||
| 196 | } | ||
| 197 | |||
| 198 | /// Strip `?pw=...` / `&pw=...` query params from a URL for safe logging. | ||
| 199 | fn redact_url(url: &str) -> &str { | ||
| 200 | let Some(q) = url.find('?') else { | ||
| 201 | return url; | ||
| 202 | }; | ||
| 203 | let before = &url[..q]; | ||
| 204 | let after = &url[q..]; | ||
| 205 | // Strip `?pw=...` or `&pw=...` | ||
| 206 | if after.strip_prefix("?pw=").is_some() { | ||
| 207 | return before; | ||
| 208 | } | ||
| 209 | if let Some(idx) = after.find("&pw=") { | ||
| 210 | return &url[..q + idx]; | ||
| 211 | } | ||
| 212 | url | ||
| 213 | } | ||
| 214 | |||
| 215 | /// Strip tracking/playlist params (`&...`) from YouTube URLs. | ||
| 216 | /// yt-dlp only needs `?v=ID`; extra `&` params can cause failures. | ||
| 217 | pub(crate) fn sanitize_yt_url(url: &str) -> String { | ||
| 218 | let lower = url.to_lowercase(); | ||
| 219 | if lower.contains("youtube.com/") || lower.contains("youtu.be/") { | ||
| 220 | if let Some(pos) = url.find('&') { | ||
| 221 | return url[..pos].to_string(); | ||
| 222 | } | ||
| 223 | } | ||
| 224 | url.to_string() | ||
| 225 | } | ||
| 226 | |||
| 227 | // ── Playlist resolution is handled by ffmpeg natively ────────────── | ||
| 228 | // ffmpeg reads `.m3u`/`.m3u8`/`.pls` playlists directly (HLS segment | ||
| 229 | // fetching, PLS/M3U entry parsing) — no manual resolution needed. | ||
| 230 | // This mirrors the Python bot's `FFmpegPCMAudio(source, ...)` approach. | ||
| 231 | |||
| 232 | async fn play_impl( | ||
| 233 | ctx: Context<'_>, | ||
| 234 | melodia: Option<String>, | ||
| 235 | file: Option<serenity::Attachment>, | ||
| 236 | compressed: bool, | ||
| 237 | ) -> Result<()> { | ||
| 238 | ctx.defer().await?; | ||
| 239 | |||
| 240 | let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?; | ||
| 241 | |||
| 242 | let channel_id = ctx | ||
| 243 | .serenity_context() | ||
| 244 | .cache | ||
| 245 | .guild(guild_id) | ||
| 246 | .and_then(|g| { | ||
| 247 | g.voice_states | ||
| 248 | .get(&ctx.author().id) | ||
| 249 | .and_then(|vs| vs.channel_id) | ||
| 250 | }) | ||
| 251 | .ok_or_else(|| lang::err_not_in_voice())?; | ||
| 252 | |||
| 253 | let src = resolve_source(ctx, melodia, file).await?; | ||
| 254 | |||
| 255 | let call; | ||
| 256 | let input; | ||
| 257 | |||
| 258 | if compressed { | ||
| 259 | // ── Compressed path: download → compress → decode ────────── | ||
| 260 | // Defer stopping current track until compression is done. | ||
| 261 | let audio_bytes = match src.kind { | ||
| 262 | SourceKind::YtDlp => { | ||
| 263 | let clean_url = sanitize_yt_url(&src.url); | ||
| 264 | info!(url = %clean_url, "downloading via yt-dlp for compression"); | ||
| 265 | crate::voice::ytdlp_download(&clean_url).await? | ||
| 266 | } | ||
| 267 | SourceKind::Stream => { | ||
| 268 | if is_live_stream_hint(&src.url) { | ||
| 269 | ctx.say(lang::L_MUSIC_CANT_COMPRESS_LIVE).await?; | ||
| 270 | // Fall through to normal streaming below | ||
| 271 | let live = true; | ||
| 272 | info!(url = %redact_url(&src.url), live, "live stream — compression skipped, streaming via ffmpeg"); | ||
| 273 | // Stop current, connect, stream | ||
| 274 | { | ||
| 275 | let mut map = ctx.data().music_tracks.lock().await; | ||
| 276 | if let Some(handle) = map.remove(&guild_id.get()) { | ||
| 277 | let _ = handle.stop(); | ||
| 278 | } | ||
| 279 | } | ||
| 280 | let (call_res, input_res) = tokio::join!( | ||
| 281 | ctx.data().voice_manager.connect(guild_id, channel_id), | ||
| 282 | crate::voice::ffmpeg_stream_input(&src.url) | ||
| 283 | ); | ||
| 284 | call = call_res?; | ||
| 285 | input = input_res?; | ||
| 286 | // skip the rest of the compressed path | ||
| 287 | let handle = { | ||
| 288 | let mut call_lock = call.lock().await; | ||
| 289 | call_lock.play_input(input) | ||
| 290 | }; | ||
| 291 | ctx.data() | ||
| 292 | .music_tracks | ||
| 293 | .lock() | ||
| 294 | .await | ||
| 295 | .insert(guild_id.get(), handle); | ||
| 296 | ctx.say(lang::fmt_music_playing(&src.display_name)).await?; | ||
| 297 | return Ok(()); | ||
| 298 | } | ||
| 299 | info!(url = %redact_url(&src.url), "downloading for compression"); | ||
| 300 | crate::voice::download_url(&ctx.data().http_client, &src.url).await? | ||
| 301 | } | ||
| 302 | }; | ||
| 303 | |||
| 304 | info!("degrading audio ({} bytes)", audio_bytes.len()); | ||
| 305 | let degraded = crate::voice::degrade_audio(&audio_bytes).await?; | ||
| 306 | |||
| 307 | // Deferred stop: only interrupt current track now that we're ready. | ||
| 308 | { | ||
| 309 | let mut map = ctx.data().music_tracks.lock().await; | ||
| 310 | if let Some(handle) = map.remove(&guild_id.get()) { | ||
| 311 | let _ = handle.stop(); | ||
| 312 | } | ||
| 313 | } | ||
| 314 | call = ctx | ||
| 315 | .data() | ||
| 316 | .voice_manager | ||
| 317 | .connect(guild_id, channel_id) | ||
| 318 | .await?; | ||
| 319 | input = degraded; | ||
| 320 | } else { | ||
| 321 | // ── Normal path: stop first, then stream/download ────────── | ||
| 322 | { | ||
| 323 | let mut map = ctx.data().music_tracks.lock().await; | ||
| 324 | if let Some(handle) = map.remove(&guild_id.get()) { | ||
| 325 | let _ = handle.stop(); | ||
| 326 | } | ||
| 327 | } | ||
| 328 | |||
| 329 | match src.kind { | ||
| 330 | SourceKind::YtDlp => { | ||
| 331 | let clean_url = sanitize_yt_url(&src.url); | ||
| 332 | info!(url = %clean_url, "downloading via yt-dlp"); | ||
| 333 | let audio_bytes = crate::voice::ytdlp_download(&clean_url).await?; | ||
| 334 | let (call_res, input_res) = tokio::join!( | ||
| 335 | ctx.data().voice_manager.connect(guild_id, channel_id), | ||
| 336 | tokio::task::spawn_blocking(move || crate::voice::decode_audio(&audio_bytes)) | ||
| 337 | ); | ||
| 338 | call = call_res?; | ||
| 339 | input = input_res??; | ||
| 340 | } | ||
| 341 | SourceKind::Stream => { | ||
| 342 | let live = is_live_stream_hint(&src.url); | ||
| 343 | info!(url = %redact_url(&src.url), live, "streaming via ffmpeg subprocess"); | ||
| 344 | let (call_res, input_res) = tokio::join!( | ||
| 345 | ctx.data().voice_manager.connect(guild_id, channel_id), | ||
| 346 | crate::voice::ffmpeg_stream_input(&src.url) | ||
| 347 | ); | ||
| 348 | call = call_res?; | ||
| 349 | input = input_res?; | ||
| 350 | } | ||
| 351 | } | ||
| 352 | } | ||
| 353 | |||
| 354 | let handle = { | ||
| 355 | let mut call_lock = call.lock().await; | ||
| 356 | call_lock.play_input(input) | ||
| 357 | }; | ||
| 358 | |||
| 359 | ctx.data() | ||
| 360 | .music_tracks | ||
| 361 | .lock() | ||
| 362 | .await | ||
| 363 | .insert(guild_id.get(), handle); | ||
| 364 | |||
| 365 | ctx.say(lang::fmt_music_playing(&src.display_name)).await?; | ||
| 366 | Ok(()) | ||
| 367 | } | ||
| 368 | |||
| 369 | /// Stop playback and disconnect from voice | ||
| 370 | #[poise::command(slash_command)] | ||
| 371 | pub async fn stop(ctx: Context<'_>) -> Result<()> { | ||
| 372 | let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?; | ||
| 373 | ctx.data().voice_manager.disconnect(guild_id).await?; | ||
| 374 | ctx.data().music_tracks.lock().await.remove(&guild_id.get()); | ||
| 375 | ctx.send( | ||
| 376 | poise::CreateReply::default() | ||
| 377 | .content(lang::L_MUSIC_STOPPED) | ||
| 378 | .ephemeral(true), | ||
| 379 | ) | ||
| 380 | .await?; | ||
| 381 | Ok(()) | ||
| 382 | } | ||
| 383 | |||
| 384 | /// Join your voice channel | ||
| 385 | #[poise::command(slash_command)] | ||
| 386 | pub async fn join(ctx: Context<'_>) -> Result<()> { | ||
| 387 | ctx.defer().await?; | ||
| 388 | let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?; | ||
| 389 | |||
| 390 | let channel_id = ctx.serenity_context().cache.guild(guild_id).and_then(|g| { | ||
| 391 | g.voice_states | ||
| 392 | .get(&ctx.author().id) | ||
| 393 | .and_then(|vs| vs.channel_id) | ||
| 394 | }); | ||
| 395 | |||
| 396 | match channel_id { | ||
| 397 | Some(cid) => match ctx.data().voice_manager.connect(guild_id, cid).await { | ||
| 398 | Ok(_) => { | ||
| 399 | ctx.say(lang::fmt_voice_joined(&cid)).await?; | ||
| 400 | } | ||
| 401 | Err(e) => { | ||
| 402 | ctx.say(lang::fmt_voice_join_failed(&e)).await?; | ||
| 403 | } | ||
| 404 | }, | ||
| 405 | None => { | ||
| 406 | ctx.say(lang::L_VOICE_USER_NOT_IN_VC).await?; | ||
| 407 | } | ||
| 408 | } | ||
| 409 | Ok(()) | ||
| 410 | } | ||
| 411 | |||
| 412 | pub fn commands() -> Vec<poise::Command<Data, Error>> { | ||
| 413 | vec![grajcuj(), stop(), join()] | ||
| 414 | } | ||
