From cd8ed8bc5793985df2c1692072a932ceee517b95 Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 8 Jul 2026 02:13:35 +0200 Subject: OPEN THE RUST GATES Signed-off-by: salmonik --- src/commands/music.rs | 414 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 414 insertions(+) create mode 100644 src/commands/music.rs (limited to 'src/commands/music.rs') 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 @@ +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"]) + .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/") { + if 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()] +} -- cgit v1.2.3