diff options
Diffstat (limited to 'src/commands')
| -rw-r--r-- | src/commands/music.rs | 355 | ||||
| -rw-r--r-- | src/commands/utilities.rs | 2 |
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 @@ | |||
| 1 | use poise::serenity_prelude as serenity; | ||
| 2 | use super::utilities::{FileType, extract_urls, file_type}; | 1 | use super::utilities::{FileType, extract_urls, file_type}; |
| 2 | use poise::serenity_prelude as serenity; | ||
| 3 | 3 | ||
| 4 | use tracing::{error, info}; | 4 | use tracing::{error, info}; |
| 5 | 5 | ||
| @@ -8,29 +8,16 @@ use crate::Data; | |||
| 8 | use crate::copyparty::{html_decode, percent_decode}; | 8 | use crate::copyparty::{html_decode, percent_decode}; |
| 9 | use crate::error::{Error, Result}; | 9 | use crate::error::{Error, Result}; |
| 10 | use crate::lang; | 10 | use crate::lang; |
| 11 | use crate::music::{EnqueueOutcome, QueuePosition, SourceKind, TrackRequest, classify_url}; | ||
| 11 | 12 | ||
| 12 | type Context<'a> = poise::Context<'a, Data, Error>; | 13 | type 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` | 19 | pub 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)] | ||
| 23 | enum 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")] | ||
| 41 | pub 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 | |||
| 55 | async 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). | ||
| 77 | fn 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`.) | ||
| 88 | fn 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 | |||
| 104 | async fn resolve_source( | 89 | async 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. | ||
| 206 | fn 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. | ||
| 224 | pub(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); | 253 | pub 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 | } | 273 | pub 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 | ¤t.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)] |
| 393 | pub async fn stop(ctx: Context<'_>) -> Result<()> { | 310 | pub 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")] |
| 467 | pub async fn message_grajcuj(ctx: Context<'_>, message: serenity::Message) -> Result<()> { | 389 | pub 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 | |||
| 483 | pub fn commands() -> Vec<poise::Command<Data, Error>> { | 406 | pub 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; | |||
| 2 | use tracing::warn; | 2 | use tracing::warn; |
| 3 | 3 | ||
| 4 | use super::autocomplete::any_file; | 4 | use super::autocomplete::any_file; |
| 5 | use super::music::sanitize_yt_url; | 5 | use crate::music::sanitize_yt_url; |
| 6 | use crate::Data; | 6 | use crate::Data; |
| 7 | use crate::copyparty::{html_decode, percent_decode}; | 7 | use crate::copyparty::{html_decode, percent_decode}; |
| 8 | use crate::error::{Error, Result}; | 8 | use crate::error::{Error, Result}; |
