use super::utilities::{FileType, extract_urls, file_type}; 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; use crate::music::{EnqueueOutcome, QueuePosition, SourceKind, TrackRequest, classify_url}; type Context<'a> = poise::Context<'a, Data, Error>; // ── Source classification ────────────────────────────────────────── /// Music playback and queue management. #[poise::command( slash_command, subcommands("grajcuj", "skip", "queue", "stop", "join") )] pub async fn music(_ctx: Context<'_>) -> Result<()> { Ok(()) } /// 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, #[description = "Gdzie dodać utwór w kolejce (domyślnie: last)"] position: Option, ) -> Result<()> { play_command(ctx, melodia, file, compressed, position).await } async fn play_command( ctx: Context<'_>, melodia: Option, file: Option, compressed: Option, position: Option, ) -> Result<()> { let compressed = compressed.unwrap_or(false); let position = position.unwrap_or(QueuePosition::Last); eprintln!( "[DEBUG] play() reached, melodia={:?}, file={:?}, compressed={compressed}, position={position:?}", melodia, file.as_ref().map(|f| &f.filename) ); let result = play_impl(ctx, melodia, file, compressed, position).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 ────────────────────────────────────── 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 = classify_url(&url); 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, }) } // ── 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, position: QueuePosition, ) -> 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 request = TrackRequest { url: src.url, display_name: src.display_name.clone(), kind: src.kind, compressed, reupload: file_reupload.clone(), requester: Some(ctx.author().id), }; let outcome = ctx .data() .music .enqueue( guild_id.get(), channel_id, Some(ctx.channel_id()), request, position, ) .await?; match outcome { EnqueueOutcome::Started => { send_music_msg(ctx, &src.display_name, file_reupload).await?; } EnqueueOutcome::Queued { position } => { ctx.say(lang::fmt_music_queued(&src.display_name, position)) .await?; } } Ok(()) } /// Skip the current track and play the next queued one #[poise::command(slash_command)] pub async fn skip(ctx: Context<'_>) -> Result<()> { ctx.defer().await?; let guild_id = ctx.guild_id().ok_or_else(lang::err_not_in_guild)?; let played = ctx.data().music.skip(guild_id.get()).await?; let content = if played { lang::L_MUSIC_SKIPPED } else { lang::L_MUSIC_QUEUE_EMPTY }; ctx.send( poise::CreateReply::default() .content(content) .ephemeral(true), ) .await?; Ok(()) } /// Show the current track and the pending queue #[poise::command(slash_command)] pub async fn queue(ctx: Context<'_>) -> Result<()> { let guild_id = ctx.guild_id().ok_or_else(lang::err_not_in_guild)?; let snapshot = ctx.data().music.snapshot(guild_id.get()).await; if snapshot.current.is_none() && snapshot.upcoming.is_empty() { ctx.say(lang::L_MUSIC_QUEUE_EMPTY).await?; return Ok(()); } let who = |requester: Option| { requester .map(|id| format!(" <@{}>", id.get())) .unwrap_or_default() }; let mut lines = Vec::with_capacity(snapshot.upcoming.len() + 2); lines.push(lang::L_MUSIC_QUEUE_HEADER.to_string()); if let Some(current) = &snapshot.current { lines.push(lang::fmt_music_queue_current( ¤t.display_name, &who(current.requester), )); } for (index, item) in snapshot.upcoming.iter().enumerate() { lines.push(lang::fmt_music_queue_item( index + 1, &item.display_name, &who(item.requester), )); } ctx.say(lines.join("\n")).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().music.stop(guild_id.get()).await; ctx.data().voice_manager.disconnect(guild_id).await?; 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, QueuePosition::Now).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, QueuePosition::Now).await; } ctx.say(lang::L_MUSIC_NO_AUDIO_ATTACHMENTS).await?; Ok(()) } pub fn commands() -> Vec> { vec![music(), message_grajcuj()] }