From fc5e0f48477366641b97efb0541053cc1e061be5 Mon Sep 17 00:00:00 2001 From: salmonik Date: Tue, 27 Oct 2026 18:14:07 +0100 Subject: music: add per-guild queue with now/next/last, skip and queue Tracks are queued in memory per guild, defaulting to append (last). Playback auto-advances on TrackEvent::End/Error with a per-track token so manual stops and skips cannot double-advance. /music queue shows the current track followed by pending items. /stop clears the queue. Cron audio now goes through the same MusicState instead of poking the track-handle map directly. --- src/commands/music.rs | 355 ++++++++++++++++++---------------------------- src/commands/utilities.rs | 2 +- 2 files changed, 140 insertions(+), 217 deletions(-) (limited to 'src/commands') diff --git a/src/commands/music.rs b/src/commands/music.rs index 8739a5b..8841329 100644 --- a/src/commands/music.rs +++ b/src/commands/music.rs @@ -1,5 +1,5 @@ -use poise::serenity_prelude as serenity; use super::utilities::{FileType, extract_urls, file_type}; +use poise::serenity_prelude as serenity; use tracing::{error, info}; @@ -8,29 +8,16 @@ 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 ────────────────────────────────────────── -/// 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, +/// Music playback and queue management. +#[poise::command(slash_command, subcommands("grajcuj", "skip", "queue"))] +pub async fn music(_ctx: Context<'_>) -> Result<()> { + Ok(()) } /// Play music from Copyparty, URL, or file upload @@ -42,14 +29,44 @@ pub async fn grajcuj( 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< + QueuePosition, + >, +) -> Result<()> { + play_command(ctx, melodia, file, compressed, position).await +} + +/// Play music from Copyparty, URL, or file upload (top-level alias) +#[poise::command(slash_command, rename = "grajcuj")] +pub async fn grajcuj_top( + 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< + QueuePosition, + >, +) -> 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}", + "[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).await; + 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}"); @@ -69,38 +86,6 @@ fn clean_name(raw: &str) -> String { // ── 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, @@ -154,11 +139,7 @@ async fn resolve_source( // 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 - }; + let kind = classify_url(&url); return Ok(ResolvedSource { url, display_name, @@ -174,8 +155,10 @@ async fn resolve_source( .search( &name, 5, - &["mp3", "wav", "ogg", "flac", "m4a", "opus", "aac", "wma", "oga", - "mp4", "webm", "mkv", "avi", "mov", "flv", "wmv", "m4v", "mpg", "mpeg", "3gp", "ts"], + &[ + "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| { @@ -202,34 +185,6 @@ async fn resolve_source( }) } -/// 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. @@ -240,6 +195,7 @@ async fn play_impl( melodia: Option, file: Option, compressed: bool, + position: QueuePosition, ) -> Result<()> { ctx.defer().await?; @@ -259,132 +215,93 @@ async fn play_impl( 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? - } - }; + 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), + }; - 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(); - } + 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?; } - 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(); - } + EnqueueOutcome::Queued { position } => { + ctx.say(lang::fmt_music_queued(&src.display_name, position)) + .await?; } + } + Ok(()) +} - 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?; - } - } +/// 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 handle = { - let mut call_lock = call.lock().await; - call_lock.play_input(input) + let who = |requester: Option| { + requester + .map(|id| format!(" <@{}>", id.get())) + .unwrap_or_default() }; - ctx.data() - .music_tracks - .lock() - .await - .insert(guild_id.get(), handle); + 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), + )); + } - send_music_msg(ctx, &src.display_name, file_reupload).await?; + ctx.say(lines.join("\n")).await?; Ok(()) } @@ -392,8 +309,8 @@ async fn play_impl( #[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.data().music_tracks.lock().await.remove(&guild_id.get()); ctx.send( poise::CreateReply::default() .content(lang::L_MUSIC_STOPPED) @@ -443,9 +360,14 @@ async fn send_music_msg( 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()), - )) + .send( + poise::CreateReply::default() + .content(lang::fmt_music_playing(display_name)) + .attachment(serenity::CreateAttachment::bytes( + bytes.to_vec(), + filename.clone(), + )), + ) .await .is_ok() { @@ -466,20 +388,21 @@ async fn send_music_msg( #[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; + 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).await; + 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![grajcuj(), stop(), join(), message_grajcuj()] + vec![music(), grajcuj_top(), stop(), join(), message_grajcuj()] } diff --git a/src/commands/utilities.rs b/src/commands/utilities.rs index 87a5089..25d4bcf 100644 --- a/src/commands/utilities.rs +++ b/src/commands/utilities.rs @@ -2,7 +2,7 @@ use poise::serenity_prelude as serenity; use tracing::warn; use super::autocomplete::any_file; -use super::music::sanitize_yt_url; +use crate::music::sanitize_yt_url; use crate::Data; use crate::copyparty::{html_decode, percent_decode}; use crate::error::{Error, Result}; -- cgit v1.2.3