use poise::serenity_prelude as serenity; use super::utilities::{FileType, extract_urls, file_type}; use tracing::{error, info}; use super::autocomplete::audio_file; use crate::Data; use crate::copyparty::{html_decode, percent_decode}; use crate::error::{Error, Result}; use crate::lang; type Context<'a> = poise::Context<'a, Data, Error>; // ── Source classification ────────────────────────────────────────── /// What kind of audio source are we dealing with? /// /// This mirrors the Python original's split: `EXTERNAL_URL_PATTERN` /// (youtube/soundcloud/bandcamp) goes through yt-dlp download, while /// *every other* URL is streamed directly via ffmpeg (radio, HLS, /// playlists, direct media files). #[derive(Debug, Clone, PartialEq)] enum SourceKind { /// YouTube, SoundCloud, Twitch, Bandcamp — platforms that don't serve /// raw audio at a direct URL. Needs yt-dlp to extract + download the /// media to memory before decoding. YtDlp, /// Any HTTP(S) URL: internet radio (Icecast/Shoutcast), HLS `.m3u8`, /// `.m3u`/`.pls` playlists, Copyparty files, Discord attachments, or /// directly-accessible media files. Streamed via an ffmpeg subprocess /// — NO full download. ffmpeg handles playlist parsing, Icecast /// metadata, reconnection, and codec/container detection universally. Stream, } /// Play music from Copyparty, URL, or file upload #[poise::command(slash_command)] pub async fn grajcuj( ctx: Context<'_>, #[description = "Plik do odtworzenia"] #[autocomplete = "audio_file"] melodia: Option, #[description = "Bezpośredni plik z twojego systemu"] file: Option, #[description = "Chujowa kompresja!?"] compressed: Option, ) -> Result<()> { let compressed = compressed.unwrap_or(false); eprintln!( "[DEBUG] play() reached, melodia={:?}, file={:?}, compressed={compressed}", melodia, file.as_ref().map(|f| &f.filename) ); let result = play_impl(ctx, melodia, file, compressed).await; if let Err(e) = &result { eprintln!("[DEBUG] play() error: {e}"); error!("play command failed: {e}"); } result } struct ResolvedSource { url: String, display_name: String, kind: SourceKind, } fn clean_name(raw: &str) -> String { html_decode(&percent_decode(raw)) } // ── URL classification helpers ────────────────────────────────────── /// URLs that require yt-dlp extraction (platforms that don't serve /// raw audio at a direct URL). These MUST be downloaded first. /// /// Matches the Python original's `EXTERNAL_URL_PATTERN`: /// `youtube|youtu.be|soundcloud|bandcamp` (plus twitch). fn needs_ytdlp(url: &str) -> bool { let lower = url.to_lowercase(); lower.contains("youtube.com/") || lower.contains("youtu.be/") || lower.contains("twitch.tv/") || lower.contains("soundcloud.com/") || lower.contains("bandcamp.com/") } /// Heuristic: does this URL *look* like a live internet radio stream? /// (Used for logging / display only — dispatch is controlled by `SourceKind`.) fn is_live_stream_hint(url: &str) -> bool { let lower = url.to_lowercase(); // Common Icecast/Shoutcast mount patterns lower.contains("/stream") || lower.contains("/live") || lower.contains("/radio") || lower.contains(";stream") || lower.contains("icecast") || lower.contains("shoutcast") || lower.ends_with(".m3u8") || lower.ends_with(".m3u") || lower.ends_with(".pls") || lower.contains(":8000/") // common Icecast port || lower.contains(":8080/") // common alt port } async fn resolve_source( ctx: Context<'_>, melodia: Option, file: Option, ) -> Result { // ── File attachment ────────────────────────────────────────── // Discord attachment URLs are direct media file URLs — ffmpeg streams // them without a full download. if let Some(file) = file { return Ok(ResolvedSource { url: file.url.clone(), display_name: file.filename.clone(), kind: SourceKind::Stream, }); } let name = melodia.ok_or_else(|| Error::Custom(lang::L_MUSIC_NEED_NAME.into()))?; // ── Autocomplete cache (per-user: (UserId, filename) → SearchHit) ─ if let Some(hit) = ctx.data().ac_lookup(ctx.author().id, &name).await { let path = hit.url_path().unwrap_or(""); let path = if path.starts_with('/') { path.to_string() } else { format!("/{path}") }; let url = ctx.data().copyparty.build_url(&path); let display_name = clean_name(hit.filename().unwrap_or(&name)); return Ok(ResolvedSource { url, display_name, kind: SourceKind::Stream, }); } // ── Direct Copyparty path (starts with '/') ────────────────── if name.starts_with('/') { let url = ctx.data().copyparty.build_url(&name); let display_name = clean_name(name.rsplit('/').next().unwrap_or(&name)); return Ok(ResolvedSource { url, display_name, kind: SourceKind::Stream, }); } // ── Direct HTTP(S) URL ─────────────────────────────────────── if name.starts_with("http://") || name.starts_with("https://") { let url = name.clone(); let display_name = clean_name(url.rsplit('/').next().unwrap_or(&url)); // yt-dlp platforms → download; everything else (radio, HLS, // playlists, Copyparty, direct media) → ffmpeg stream. let kind = if needs_ytdlp(&url) { SourceKind::YtDlp } else { SourceKind::Stream }; return Ok(ResolvedSource { url, display_name, kind, }); } // ── Fallback: search Copyparty ─────────────────────────────── info!(query = %name, "searching copyparty for audio"); let results = ctx .data() .copyparty .search( &name, 5, &["mp3", "wav", "ogg", "flac", "m4a", "opus", "aac", "wma", "oga", "mp4", "webm", "mkv", "avi", "mov", "flv", "wmv", "m4v", "mpg", "mpeg", "3gp", "ts"], ) .await .map_err(|e| { error!(query = %name, error = %e, "copyparty search failed"); Error::Custom(lang::fmt_music_search_failed(&e)) })?; let hit = results .first() .ok_or_else(|| Error::Custom(lang::L_MUSIC_NO_AUDIO_ON_CP.into()))?; let path = hit .url_path() .ok_or_else(|| Error::Custom(lang::L_MUSIC_NO_URL_IN_RESULT.into()))?; let path = if path.starts_with('/') { path.to_string() } else { format!("/{path}") }; let url = ctx.data().copyparty.build_url(&path); let display_name = clean_name(hit.filename().unwrap_or("unknown")); Ok(ResolvedSource { url, display_name, kind: SourceKind::Stream, }) } /// Strip `?pw=...` / `&pw=...` query params from a URL for safe logging. fn redact_url(url: &str) -> &str { let Some(q) = url.find('?') else { return url; }; let before = &url[..q]; let after = &url[q..]; // Strip `?pw=...` or `&pw=...` if after.strip_prefix("?pw=").is_some() { return before; } if let Some(idx) = after.find("&pw=") { return &url[..q + idx]; } url } /// Strip tracking/playlist params (`&...`) from YouTube URLs. /// yt-dlp only needs `?v=ID`; extra `&` params can cause failures. pub(crate) fn sanitize_yt_url(url: &str) -> String { let lower = url.to_lowercase(); if (lower.contains("youtube.com/") || lower.contains("youtu.be/")) && let Some(pos) = url.find('&') { return url[..pos].to_string(); } url.to_string() } // ── Playlist resolution is handled by ffmpeg natively ────────────── // ffmpeg reads `.m3u`/`.m3u8`/`.pls` playlists directly (HLS segment // fetching, PLS/M3U entry parsing) — no manual resolution needed. // This mirrors the Python bot's `FFmpegPCMAudio(source, ...)` approach. async fn play_impl( ctx: Context<'_>, melodia: Option, file: Option, compressed: bool, ) -> Result<()> { ctx.defer().await?; let guild_id = ctx.guild_id().ok_or_else(lang::err_not_in_guild)?; let channel_id = ctx .serenity_context() .cache .guild(guild_id) .and_then(|g| { g.voice_states .get(&ctx.author().id) .and_then(|vs| vs.channel_id) }) .ok_or_else(lang::err_not_in_voice)?; let file_reupload = file.as_ref().map(|f| (f.url.clone(), f.filename.clone())); let src = resolve_source(ctx, melodia, file).await?; let call; let input; if compressed { // ── Compressed path: download → compress → decode ────────── // Defer stopping current track until compression is done. let audio_bytes = match src.kind { SourceKind::YtDlp => { let clean_url = sanitize_yt_url(&src.url); info!(url = %clean_url, "downloading via yt-dlp for compression"); crate::voice::ytdlp_download(&clean_url).await? } SourceKind::Stream => { if is_live_stream_hint(&src.url) { ctx.say(lang::L_MUSIC_CANT_COMPRESS_LIVE).await?; // Fall through to normal streaming below let live = true; info!(url = %redact_url(&src.url), live, "live stream — compression skipped, streaming via ffmpeg"); // Stop current, connect, stream { let mut map = ctx.data().music_tracks.lock().await; if let Some(handle) = map.remove(&guild_id.get()) { let _ = handle.stop(); } } let (call_res, input_res) = tokio::join!( ctx.data().voice_manager.connect(guild_id, channel_id), crate::voice::ffmpeg_stream_input(&src.url) ); call = call_res?; input = input_res?; // skip the rest of the compressed path let handle = { let mut call_lock = call.lock().await; call_lock.play_input(input) }; ctx.data() .music_tracks .lock() .await .insert(guild_id.get(), handle); send_music_msg(ctx, &src.display_name, file_reupload).await?; return Ok(()); } info!(url = %redact_url(&src.url), "downloading for compression"); crate::voice::download_url(&ctx.data().http_client, &src.url).await? } }; info!("compressing audio ({} bytes)", audio_bytes.len()); let compressed_bytes = crate::commands::utilities::compress_audio_bytes(&audio_bytes, &src.display_name) .await .ok_or_else(|| Error::Custom("compression failed".into()))?; info!("compressed to {} bytes", compressed_bytes.len()); // Decode the compressed OGG via ffmpeg (symphonia lacks Opus support). // Write to temp file, then stream through ffmpeg to raw PCM. let compressed_path = format!( "{}/play_compressed_{}.ogg", crate::config::TEMP_PATH, uuid::Uuid::new_v4() ); let _ = tokio::fs::create_dir_all(crate::config::TEMP_PATH).await; tokio::fs::write(&compressed_path, &compressed_bytes).await?; let decoded = crate::voice::ffmpeg_decode_file(&compressed_path).await?; // Deferred stop: only interrupt current track now that we're ready. { let mut map = ctx.data().music_tracks.lock().await; if let Some(handle) = map.remove(&guild_id.get()) { let _ = handle.stop(); } } call = ctx .data() .voice_manager .connect(guild_id, channel_id) .await?; input = decoded; } else { // ── Normal path: stop first, then stream/download ────────── { let mut map = ctx.data().music_tracks.lock().await; if let Some(handle) = map.remove(&guild_id.get()) { let _ = handle.stop(); } } match src.kind { SourceKind::YtDlp => { let clean_url = sanitize_yt_url(&src.url); info!(url = %clean_url, "downloading via yt-dlp"); let audio_bytes = crate::voice::ytdlp_download(&clean_url).await?; let (call_res, input_res) = tokio::join!( ctx.data().voice_manager.connect(guild_id, channel_id), tokio::task::spawn_blocking(move || crate::voice::decode_audio(&audio_bytes)) ); call = call_res?; input = input_res??; } SourceKind::Stream => { let live = is_live_stream_hint(&src.url); info!(url = %redact_url(&src.url), live, "streaming via ffmpeg subprocess"); let (call_res, input_res) = tokio::join!( ctx.data().voice_manager.connect(guild_id, channel_id), crate::voice::ffmpeg_stream_input(&src.url) ); call = call_res?; input = input_res?; } } } let handle = { let mut call_lock = call.lock().await; call_lock.play_input(input) }; ctx.data() .music_tracks .lock() .await .insert(guild_id.get(), handle); send_music_msg(ctx, &src.display_name, file_reupload).await?; Ok(()) } /// Stop playback and disconnect from voice #[poise::command(slash_command)] pub async fn stop(ctx: Context<'_>) -> Result<()> { let guild_id = ctx.guild_id().ok_or_else(lang::err_not_in_guild)?; ctx.data().voice_manager.disconnect(guild_id).await?; ctx.data().music_tracks.lock().await.remove(&guild_id.get()); ctx.send( poise::CreateReply::default() .content(lang::L_MUSIC_STOPPED) .ephemeral(true), ) .await?; Ok(()) } /// Join your voice channel #[poise::command(slash_command)] pub async fn join(ctx: Context<'_>) -> Result<()> { ctx.defer().await?; let guild_id = ctx.guild_id().ok_or_else(lang::err_not_in_guild)?; let channel_id = ctx.serenity_context().cache.guild(guild_id).and_then(|g| { g.voice_states .get(&ctx.author().id) .and_then(|vs| vs.channel_id) }); match channel_id { Some(cid) => match ctx.data().voice_manager.connect(guild_id, cid).await { Ok(_) => { ctx.say(lang::fmt_voice_joined(&cid)).await?; } Err(e) => { ctx.say(lang::fmt_voice_join_failed(&e)).await?; } }, None => { ctx.say(lang::L_VOICE_USER_NOT_IN_VC).await?; } } Ok(()) } /// Send the "playing" message, optionally reuploading a user-provided file /// so other channel members can download it. async fn send_music_msg( ctx: Context<'_>, display_name: &str, file_reupload: Option<(String, String)>, ) -> Result<()> { if let Some((url, filename)) = file_reupload { match ctx.data().http_client.get(&url).send().await { Ok(resp) => match resp.bytes().await { Ok(bytes) => { if ctx .send(poise::CreateReply::default().content(lang::fmt_music_playing(display_name)).attachment( serenity::CreateAttachment::bytes(bytes.to_vec(), filename.clone()), )) .await .is_ok() { return Ok(()); } error!("send_music_msg: ctx.send with attachment failed, falling back"); } Err(e) => error!("send_music_msg: failed to read file bytes: {e}"), }, Err(e) => error!("send_music_msg: failed to download file: {e}"), } } ctx.say(lang::fmt_music_playing(display_name)).await?; Ok(()) } /// Right-click a message → play the first audio/video file, or first URL. #[poise::command(context_menu_command = "Odtwórz")] pub async fn message_grajcuj(ctx: Context<'_>, message: serenity::Message) -> Result<()> { // Files > links: prefer audio/video attachments first. if let Some(att) = message.attachments.iter().find(|a| { matches!(file_type(&a.filename), FileType::Audio | FileType::Video) }) { return play_impl(ctx, None, Some(att.clone()), false).await; } // Fallback: first URL in message content. if let Some(url) = extract_urls(&message.content).into_iter().next() { return play_impl(ctx, Some(url), None, false).await; } ctx.say(lang::L_MUSIC_NO_AUDIO_ATTACHMENTS).await?; Ok(()) } pub fn commands() -> Vec> { vec![grajcuj(), stop(), join(), message_grajcuj()] }