use poise::serenity_prelude as serenity; 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", "mp4"], ) .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 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); ctx.say(lang::fmt_music_playing(&src.display_name)).await?; return Ok(()); } info!(url = %redact_url(&src.url), "downloading for compression"); crate::voice::download_url(&ctx.data().http_client, &src.url).await? } }; info!("degrading audio ({} bytes)", audio_bytes.len()); let degraded = crate::voice::degrade_audio(&audio_bytes).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 = degraded; } 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); ctx.say(lang::fmt_music_playing(&src.display_name)).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(()) } pub fn commands() -> Vec> { vec![grajcuj(), stop(), join()] }