aboutsummaryrefslogtreecommitdiffstats
path: root/src/commands
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--src/commands/music.rs355
-rw-r--r--src/commands/utilities.rs2
2 files changed, 140 insertions, 217 deletions
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 @@
1use poise::serenity_prelude as serenity;
2use super::utilities::{FileType, extract_urls, file_type}; 1use super::utilities::{FileType, extract_urls, file_type};
2use poise::serenity_prelude as serenity;
3 3
4use tracing::{error, info}; 4use tracing::{error, info};
5 5
@@ -8,29 +8,16 @@ use crate::Data;
8use crate::copyparty::{html_decode, percent_decode}; 8use crate::copyparty::{html_decode, percent_decode};
9use crate::error::{Error, Result}; 9use crate::error::{Error, Result};
10use crate::lang; 10use crate::lang;
11use crate::music::{EnqueueOutcome, QueuePosition, SourceKind, TrackRequest, classify_url};
11 12
12type Context<'a> = poise::Context<'a, Data, Error>; 13type Context<'a> = poise::Context<'a, Data, Error>;
13 14
14// ── Source classification ────────────────────────────────────────── 15// ── Source classification ──────────────────────────────────────────
15 16
16/// What kind of audio source are we dealing with? 17/// Music playback and queue management.
17/// 18#[poise::command(slash_command, subcommands("grajcuj", "skip", "queue"))]
18/// This mirrors the Python original's split: `EXTERNAL_URL_PATTERN` 19pub async fn music(_ctx: Context<'_>) -> Result<()> {
19/// (youtube/soundcloud/bandcamp) goes through yt-dlp download, while 20 Ok(())
20/// *every other* URL is streamed directly via ffmpeg (radio, HLS,
21/// playlists, direct media files).
22#[derive(Debug, Clone, PartialEq)]
23enum SourceKind {
24 /// YouTube, SoundCloud, Twitch, Bandcamp — platforms that don't serve
25 /// raw audio at a direct URL. Needs yt-dlp to extract + download the
26 /// media to memory before decoding.
27 YtDlp,
28 /// Any HTTP(S) URL: internet radio (Icecast/Shoutcast), HLS `.m3u8`,
29 /// `.m3u`/`.pls` playlists, Copyparty files, Discord attachments, or
30 /// directly-accessible media files. Streamed via an ffmpeg subprocess
31 /// — NO full download. ffmpeg handles playlist parsing, Icecast
32 /// metadata, reconnection, and codec/container detection universally.
33 Stream,
34} 21}
35 22
36/// Play music from Copyparty, URL, or file upload 23/// Play music from Copyparty, URL, or file upload
@@ -42,14 +29,44 @@ pub async fn grajcuj(
42 melodia: Option<String>, 29 melodia: Option<String>,
43 #[description = "Bezpośredni plik z twojego systemu"] file: Option<serenity::Attachment>, 30 #[description = "Bezpośredni plik z twojego systemu"] file: Option<serenity::Attachment>,
44 #[description = "Chujowa kompresja!?"] compressed: Option<bool>, 31 #[description = "Chujowa kompresja!?"] compressed: Option<bool>,
32 #[description = "Gdzie dodać utwór w kolejce (domyślnie: last)"] position: Option<
33 QueuePosition,
34 >,
35) -> Result<()> {
36 play_command(ctx, melodia, file, compressed, position).await
37}
38
39/// Play music from Copyparty, URL, or file upload (top-level alias)
40#[poise::command(slash_command, rename = "grajcuj")]
41pub async fn grajcuj_top(
42 ctx: Context<'_>,
43 #[description = "Plik do odtworzenia"]
44 #[autocomplete = "audio_file"]
45 melodia: Option<String>,
46 #[description = "Bezpośredni plik z twojego systemu"] file: Option<serenity::Attachment>,
47 #[description = "Chujowa kompresja!?"] compressed: Option<bool>,
48 #[description = "Gdzie dodać utwór w kolejce (domyślnie: last)"] position: Option<
49 QueuePosition,
50 >,
51) -> Result<()> {
52 play_command(ctx, melodia, file, compressed, position).await
53}
54
55async fn play_command(
56 ctx: Context<'_>,
57 melodia: Option<String>,
58 file: Option<serenity::Attachment>,
59 compressed: Option<bool>,
60 position: Option<QueuePosition>,
45) -> Result<()> { 61) -> Result<()> {
46 let compressed = compressed.unwrap_or(false); 62 let compressed = compressed.unwrap_or(false);
63 let position = position.unwrap_or(QueuePosition::Last);
47 eprintln!( 64 eprintln!(
48 "[DEBUG] play() reached, melodia={:?}, file={:?}, compressed={compressed}", 65 "[DEBUG] play() reached, melodia={:?}, file={:?}, compressed={compressed}, position={position:?}",
49 melodia, 66 melodia,
50 file.as_ref().map(|f| &f.filename) 67 file.as_ref().map(|f| &f.filename)
51 ); 68 );
52 let result = play_impl(ctx, melodia, file, compressed).await; 69 let result = play_impl(ctx, melodia, file, compressed, position).await;
53 if let Err(e) = &result { 70 if let Err(e) = &result {
54 eprintln!("[DEBUG] play() error: {e}"); 71 eprintln!("[DEBUG] play() error: {e}");
55 error!("play command failed: {e}"); 72 error!("play command failed: {e}");
@@ -69,38 +86,6 @@ fn clean_name(raw: &str) -> String {
69 86
70// ── URL classification helpers ────────────────────────────────────── 87// ── URL classification helpers ──────────────────────────────────────
71 88
72/// URLs that require yt-dlp extraction (platforms that don't serve
73/// raw audio at a direct URL). These MUST be downloaded first.
74///
75/// Matches the Python original's `EXTERNAL_URL_PATTERN`:
76/// `youtube|youtu.be|soundcloud|bandcamp` (plus twitch).
77fn needs_ytdlp(url: &str) -> bool {
78 let lower = url.to_lowercase();
79 lower.contains("youtube.com/")
80 || lower.contains("youtu.be/")
81 || lower.contains("twitch.tv/")
82 || lower.contains("soundcloud.com/")
83 || lower.contains("bandcamp.com/")
84}
85
86/// Heuristic: does this URL *look* like a live internet radio stream?
87/// (Used for logging / display only — dispatch is controlled by `SourceKind`.)
88fn is_live_stream_hint(url: &str) -> bool {
89 let lower = url.to_lowercase();
90 // Common Icecast/Shoutcast mount patterns
91 lower.contains("/stream")
92 || lower.contains("/live")
93 || lower.contains("/radio")
94 || lower.contains(";stream")
95 || lower.contains("icecast")
96 || lower.contains("shoutcast")
97 || lower.ends_with(".m3u8")
98 || lower.ends_with(".m3u")
99 || lower.ends_with(".pls")
100 || lower.contains(":8000/") // common Icecast port
101 || lower.contains(":8080/") // common alt port
102}
103
104async fn resolve_source( 89async fn resolve_source(
105 ctx: Context<'_>, 90 ctx: Context<'_>,
106 melodia: Option<String>, 91 melodia: Option<String>,
@@ -154,11 +139,7 @@ async fn resolve_source(
154 139
155 // yt-dlp platforms → download; everything else (radio, HLS, 140 // yt-dlp platforms → download; everything else (radio, HLS,
156 // playlists, Copyparty, direct media) → ffmpeg stream. 141 // playlists, Copyparty, direct media) → ffmpeg stream.
157 let kind = if needs_ytdlp(&url) { 142 let kind = classify_url(&url);
158 SourceKind::YtDlp
159 } else {
160 SourceKind::Stream
161 };
162 return Ok(ResolvedSource { 143 return Ok(ResolvedSource {
163 url, 144 url,
164 display_name, 145 display_name,
@@ -174,8 +155,10 @@ async fn resolve_source(
174 .search( 155 .search(
175 &name, 156 &name,
176 5, 157 5,
177 &["mp3", "wav", "ogg", "flac", "m4a", "opus", "aac", "wma", "oga", 158 &[
178 "mp4", "webm", "mkv", "avi", "mov", "flv", "wmv", "m4v", "mpg", "mpeg", "3gp", "ts"], 159 "mp3", "wav", "ogg", "flac", "m4a", "opus", "aac", "wma", "oga", "mp4", "webm",
160 "mkv", "avi", "mov", "flv", "wmv", "m4v", "mpg", "mpeg", "3gp", "ts",
161 ],
179 ) 162 )
180 .await 163 .await
181 .map_err(|e| { 164 .map_err(|e| {
@@ -202,34 +185,6 @@ async fn resolve_source(
202 }) 185 })
203} 186}
204 187
205/// Strip `?pw=...` / `&pw=...` query params from a URL for safe logging.
206fn redact_url(url: &str) -> &str {
207 let Some(q) = url.find('?') else {
208 return url;
209 };
210 let before = &url[..q];
211 let after = &url[q..];
212 // Strip `?pw=...` or `&pw=...`
213 if after.strip_prefix("?pw=").is_some() {
214 return before;
215 }
216 if let Some(idx) = after.find("&pw=") {
217 return &url[..q + idx];
218 }
219 url
220}
221
222/// Strip tracking/playlist params (`&...`) from YouTube URLs.
223/// yt-dlp only needs `?v=ID`; extra `&` params can cause failures.
224pub(crate) fn sanitize_yt_url(url: &str) -> String {
225 let lower = url.to_lowercase();
226 if (lower.contains("youtube.com/") || lower.contains("youtu.be/"))
227 && let Some(pos) = url.find('&') {
228 return url[..pos].to_string();
229 }
230 url.to_string()
231}
232
233// ── Playlist resolution is handled by ffmpeg natively ────────────── 188// ── Playlist resolution is handled by ffmpeg natively ──────────────
234// ffmpeg reads `.m3u`/`.m3u8`/`.pls` playlists directly (HLS segment 189// ffmpeg reads `.m3u`/`.m3u8`/`.pls` playlists directly (HLS segment
235// fetching, PLS/M3U entry parsing) — no manual resolution needed. 190// fetching, PLS/M3U entry parsing) — no manual resolution needed.
@@ -240,6 +195,7 @@ async fn play_impl(
240 melodia: Option<String>, 195 melodia: Option<String>,
241 file: Option<serenity::Attachment>, 196 file: Option<serenity::Attachment>,
242 compressed: bool, 197 compressed: bool,
198 position: QueuePosition,
243) -> Result<()> { 199) -> Result<()> {
244 ctx.defer().await?; 200 ctx.defer().await?;
245 201
@@ -259,132 +215,93 @@ async fn play_impl(
259 let file_reupload = file.as_ref().map(|f| (f.url.clone(), f.filename.clone())); 215 let file_reupload = file.as_ref().map(|f| (f.url.clone(), f.filename.clone()));
260 let src = resolve_source(ctx, melodia, file).await?; 216 let src = resolve_source(ctx, melodia, file).await?;
261 217
262 let call; 218 let request = TrackRequest {
263 let input; 219 url: src.url,
264 220 display_name: src.display_name.clone(),
265 if compressed { 221 kind: src.kind,
266 // ── Compressed path: download → compress → decode ────────── 222 compressed,
267 // Defer stopping current track until compression is done. 223 reupload: file_reupload.clone(),
268 let audio_bytes = match src.kind { 224 requester: Some(ctx.author().id),
269 SourceKind::YtDlp => { 225 };
270 let clean_url = sanitize_yt_url(&src.url);
271 info!(url = %clean_url, "downloading via yt-dlp for compression");
272 crate::voice::ytdlp_download(&clean_url).await?
273 }
274 SourceKind::Stream => {
275 if is_live_stream_hint(&src.url) {
276 ctx.say(lang::L_MUSIC_CANT_COMPRESS_LIVE).await?;
277 // Fall through to normal streaming below
278 let live = true;
279 info!(url = %redact_url(&src.url), live, "live stream — compression skipped, streaming via ffmpeg");
280 // Stop current, connect, stream
281 {
282 let mut map = ctx.data().music_tracks.lock().await;
283 if let Some(handle) = map.remove(&guild_id.get()) {
284 let _ = handle.stop();
285 }
286 }
287 let (call_res, input_res) = tokio::join!(
288 ctx.data().voice_manager.connect(guild_id, channel_id),
289 crate::voice::ffmpeg_stream_input(&src.url)
290 );
291 call = call_res?;
292 input = input_res?;
293 // skip the rest of the compressed path
294 let handle = {
295 let mut call_lock = call.lock().await;
296 call_lock.play_input(input)
297 };
298 ctx.data()
299 .music_tracks
300 .lock()
301 .await
302 .insert(guild_id.get(), handle);
303 send_music_msg(ctx, &src.display_name, file_reupload).await?;
304 return Ok(());
305 }
306 info!(url = %redact_url(&src.url), "downloading for compression");
307 crate::voice::download_url(&ctx.data().http_client, &src.url).await?
308 }
309 };
310 226
311 info!("compressing audio ({} bytes)", audio_bytes.len()); 227 let outcome = ctx
312 let compressed_bytes = 228 .data()
313 crate::commands::utilities::compress_audio_bytes(&audio_bytes, &src.display_name) 229 .music
314 .await 230 .enqueue(
315 .ok_or_else(|| Error::Custom("compression failed".into()))?; 231 guild_id.get(),
316 info!("compressed to {} bytes", compressed_bytes.len()); 232 channel_id,
317 233 Some(ctx.channel_id()),
318 // Decode the compressed OGG via ffmpeg (symphonia lacks Opus support). 234 request,
319 // Write to temp file, then stream through ffmpeg to raw PCM. 235 position,
320 let compressed_path = format!( 236 )
321 "{}/play_compressed_{}.ogg", 237 .await?;
322 crate::config::TEMP_PATH, 238
323 uuid::Uuid::new_v4() 239 match outcome {
324 ); 240 EnqueueOutcome::Started => {
325 let _ = tokio::fs::create_dir_all(crate::config::TEMP_PATH).await; 241 send_music_msg(ctx, &src.display_name, file_reupload).await?;
326 tokio::fs::write(&compressed_path, &compressed_bytes).await?;
327 let decoded = crate::voice::ffmpeg_decode_file(&compressed_path).await?;
328
329 // Deferred stop: only interrupt current track now that we're ready.
330 {
331 let mut map = ctx.data().music_tracks.lock().await;
332 if let Some(handle) = map.remove(&guild_id.get()) {
333 let _ = handle.stop();
334 }
335 } 242 }
336 call = ctx 243 EnqueueOutcome::Queued { position } => {
337 .data() 244 ctx.say(lang::fmt_music_queued(&src.display_name, position))
338 .voice_manager 245 .await?;
339 .connect(guild_id, channel_id)
340 .await?;
341 input = decoded;
342 } else {
343 // ── Normal path: stop first, then stream/download ──────────
344 {
345 let mut map = ctx.data().music_tracks.lock().await;
346 if let Some(handle) = map.remove(&guild_id.get()) {
347 let _ = handle.stop();
348 }
349 } 246 }
247 }
248 Ok(())
249}
350 250
351 match src.kind { 251/// Skip the current track and play the next queued one
352 SourceKind::YtDlp => { 252#[poise::command(slash_command)]
353 let clean_url = sanitize_yt_url(&src.url); 253pub async fn skip(ctx: Context<'_>) -> Result<()> {
354 info!(url = %clean_url, "downloading via yt-dlp"); 254 ctx.defer().await?;
355 let audio_bytes = crate::voice::ytdlp_download(&clean_url).await?; 255 let guild_id = ctx.guild_id().ok_or_else(lang::err_not_in_guild)?;
356 let (call_res, input_res) = tokio::join!( 256 let played = ctx.data().music.skip(guild_id.get()).await?;
357 ctx.data().voice_manager.connect(guild_id, channel_id), 257 let content = if played {
358 tokio::task::spawn_blocking(move || crate::voice::decode_audio(&audio_bytes)) 258 lang::L_MUSIC_SKIPPED
359 ); 259 } else {
360 call = call_res?; 260 lang::L_MUSIC_QUEUE_EMPTY
361 input = input_res??; 261 };
362 } 262 ctx.send(
363 SourceKind::Stream => { 263 poise::CreateReply::default()
364 let live = is_live_stream_hint(&src.url); 264 .content(content)
365 info!(url = %redact_url(&src.url), live, "streaming via ffmpeg subprocess"); 265 .ephemeral(true),
366 let (call_res, input_res) = tokio::join!( 266 )
367 ctx.data().voice_manager.connect(guild_id, channel_id), 267 .await?;
368 crate::voice::ffmpeg_stream_input(&src.url) 268 Ok(())
369 ); 269}
370 call = call_res?; 270
371 input = input_res?; 271/// Show the current track and the pending queue
372 } 272#[poise::command(slash_command)]
373 } 273pub async fn queue(ctx: Context<'_>) -> Result<()> {
274 let guild_id = ctx.guild_id().ok_or_else(lang::err_not_in_guild)?;
275 let snapshot = ctx.data().music.snapshot(guild_id.get()).await;
276
277 if snapshot.current.is_none() && snapshot.upcoming.is_empty() {
278 ctx.say(lang::L_MUSIC_QUEUE_EMPTY).await?;
279 return Ok(());
374 } 280 }
375 281
376 let handle = { 282 let who = |requester: Option<serenity::UserId>| {
377 let mut call_lock = call.lock().await; 283 requester
378 call_lock.play_input(input) 284 .map(|id| format!(" <@{}>", id.get()))
285 .unwrap_or_default()
379 }; 286 };
380 287
381 ctx.data() 288 let mut lines = Vec::with_capacity(snapshot.upcoming.len() + 2);
382 .music_tracks 289 lines.push(lang::L_MUSIC_QUEUE_HEADER.to_string());
383 .lock() 290 if let Some(current) = &snapshot.current {
384 .await 291 lines.push(lang::fmt_music_queue_current(
385 .insert(guild_id.get(), handle); 292 &current.display_name,
293 &who(current.requester),
294 ));
295 }
296 for (index, item) in snapshot.upcoming.iter().enumerate() {
297 lines.push(lang::fmt_music_queue_item(
298 index + 1,
299 &item.display_name,
300 &who(item.requester),
301 ));
302 }
386 303
387 send_music_msg(ctx, &src.display_name, file_reupload).await?; 304 ctx.say(lines.join("\n")).await?;
388 Ok(()) 305 Ok(())
389} 306}
390 307
@@ -392,8 +309,8 @@ async fn play_impl(
392#[poise::command(slash_command)] 309#[poise::command(slash_command)]
393pub async fn stop(ctx: Context<'_>) -> Result<()> { 310pub async fn stop(ctx: Context<'_>) -> Result<()> {
394 let guild_id = ctx.guild_id().ok_or_else(lang::err_not_in_guild)?; 311 let guild_id = ctx.guild_id().ok_or_else(lang::err_not_in_guild)?;
312 ctx.data().music.stop(guild_id.get()).await;
395 ctx.data().voice_manager.disconnect(guild_id).await?; 313 ctx.data().voice_manager.disconnect(guild_id).await?;
396 ctx.data().music_tracks.lock().await.remove(&guild_id.get());
397 ctx.send( 314 ctx.send(
398 poise::CreateReply::default() 315 poise::CreateReply::default()
399 .content(lang::L_MUSIC_STOPPED) 316 .content(lang::L_MUSIC_STOPPED)
@@ -443,9 +360,14 @@ async fn send_music_msg(
443 Ok(resp) => match resp.bytes().await { 360 Ok(resp) => match resp.bytes().await {
444 Ok(bytes) => { 361 Ok(bytes) => {
445 if ctx 362 if ctx
446 .send(poise::CreateReply::default().content(lang::fmt_music_playing(display_name)).attachment( 363 .send(
447 serenity::CreateAttachment::bytes(bytes.to_vec(), filename.clone()), 364 poise::CreateReply::default()
448 )) 365 .content(lang::fmt_music_playing(display_name))
366 .attachment(serenity::CreateAttachment::bytes(
367 bytes.to_vec(),
368 filename.clone(),
369 )),
370 )
449 .await 371 .await
450 .is_ok() 372 .is_ok()
451 { 373 {
@@ -466,20 +388,21 @@ async fn send_music_msg(
466#[poise::command(context_menu_command = "Odtwórz")] 388#[poise::command(context_menu_command = "Odtwórz")]
467pub async fn message_grajcuj(ctx: Context<'_>, message: serenity::Message) -> Result<()> { 389pub async fn message_grajcuj(ctx: Context<'_>, message: serenity::Message) -> Result<()> {
468 // Files > links: prefer audio/video attachments first. 390 // Files > links: prefer audio/video attachments first.
469 if let Some(att) = message.attachments.iter().find(|a| { 391 if let Some(att) = message
470 matches!(file_type(&a.filename), FileType::Audio | FileType::Video) 392 .attachments
471 }) { 393 .iter()
472 return play_impl(ctx, None, Some(att.clone()), false).await; 394 .find(|a| matches!(file_type(&a.filename), FileType::Audio | FileType::Video))
395 {
396 return play_impl(ctx, None, Some(att.clone()), false, QueuePosition::Now).await;
473 } 397 }
474 // Fallback: first URL in message content. 398 // Fallback: first URL in message content.
475 if let Some(url) = extract_urls(&message.content).into_iter().next() { 399 if let Some(url) = extract_urls(&message.content).into_iter().next() {
476 return play_impl(ctx, Some(url), None, false).await; 400 return play_impl(ctx, Some(url), None, false, QueuePosition::Now).await;
477 } 401 }
478 ctx.say(lang::L_MUSIC_NO_AUDIO_ATTACHMENTS).await?; 402 ctx.say(lang::L_MUSIC_NO_AUDIO_ATTACHMENTS).await?;
479 Ok(()) 403 Ok(())
480} 404}
481 405
482
483pub fn commands() -> Vec<poise::Command<Data, Error>> { 406pub fn commands() -> Vec<poise::Command<Data, Error>> {
484 vec![grajcuj(), stop(), join(), message_grajcuj()] 407 vec![music(), grajcuj_top(), stop(), join(), message_grajcuj()]
485} 408}
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;
2use tracing::warn; 2use tracing::warn;
3 3
4use super::autocomplete::any_file; 4use super::autocomplete::any_file;
5use super::music::sanitize_yt_url; 5use crate::music::sanitize_yt_url;
6use crate::Data; 6use crate::Data;
7use crate::copyparty::{html_decode, percent_decode}; 7use crate::copyparty::{html_decode, percent_decode};
8use crate::error::{Error, Result}; 8use crate::error::{Error, Result};