aboutsummaryrefslogtreecommitdiffstats
path: root/src/commands/music.rs
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--src/commands/music.rs414
1 files changed, 414 insertions, 0 deletions
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 @@
1use poise::serenity_prelude as serenity;
2use tracing::{error, info};
3
4use super::autocomplete::audio_file;
5use crate::Data;
6use crate::copyparty::{html_decode, percent_decode};
7use crate::error::{Error, Result};
8use crate::lang;
9
10type Context<'a> = poise::Context<'a, Data, Error>;
11
12// ── Source classification ──────────────────────────────────────────
13
14/// What kind of audio source are we dealing with?
15///
16/// This mirrors the Python original's split: `EXTERNAL_URL_PATTERN`
17/// (youtube/soundcloud/bandcamp) goes through yt-dlp download, while
18/// *every other* URL is streamed directly via ffmpeg (radio, HLS,
19/// playlists, direct media files).
20#[derive(Debug, Clone, PartialEq)]
21enum SourceKind {
22 /// YouTube, SoundCloud, Twitch, Bandcamp — platforms that don't serve
23 /// raw audio at a direct URL. Needs yt-dlp to extract + download the
24 /// media to memory before decoding.
25 YtDlp,
26 /// Any HTTP(S) URL: internet radio (Icecast/Shoutcast), HLS `.m3u8`,
27 /// `.m3u`/`.pls` playlists, Copyparty files, Discord attachments, or
28 /// directly-accessible media files. Streamed via an ffmpeg subprocess
29 /// — NO full download. ffmpeg handles playlist parsing, Icecast
30 /// metadata, reconnection, and codec/container detection universally.
31 Stream,
32}
33
34/// Play music from Copyparty, URL, or file upload
35#[poise::command(slash_command)]
36pub async fn grajcuj(
37 ctx: Context<'_>,
38 #[description = "Plik do odtworzenia"]
39 #[autocomplete = "audio_file"]
40 melodia: Option<String>,
41 #[description = "Bezpośredni plik z twojego systemu"] file: Option<serenity::Attachment>,
42 #[description = "Chujowa kompresja!?"] compressed: Option<bool>,
43) -> Result<()> {
44 let compressed = compressed.unwrap_or(false);
45 eprintln!(
46 "[DEBUG] play() reached, melodia={:?}, file={:?}, compressed={compressed}",
47 melodia,
48 file.as_ref().map(|f| &f.filename)
49 );
50 let result = play_impl(ctx, melodia, file, compressed).await;
51 if let Err(e) = &result {
52 eprintln!("[DEBUG] play() error: {e}");
53 error!("play command failed: {e}");
54 }
55 result
56}
57
58struct ResolvedSource {
59 url: String,
60 display_name: String,
61 kind: SourceKind,
62}
63
64fn clean_name(raw: &str) -> String {
65 html_decode(&percent_decode(raw))
66}
67
68// ── URL classification helpers ──────────────────────────────────────
69
70/// URLs that require yt-dlp extraction (platforms that don't serve
71/// raw audio at a direct URL). These MUST be downloaded first.
72///
73/// Matches the Python original's `EXTERNAL_URL_PATTERN`:
74/// `youtube|youtu.be|soundcloud|bandcamp` (plus twitch).
75fn needs_ytdlp(url: &str) -> bool {
76 let lower = url.to_lowercase();
77 lower.contains("youtube.com/")
78 || lower.contains("youtu.be/")
79 || lower.contains("twitch.tv/")
80 || lower.contains("soundcloud.com/")
81 || lower.contains("bandcamp.com/")
82}
83
84/// Heuristic: does this URL *look* like a live internet radio stream?
85/// (Used for logging / display only — dispatch is controlled by `SourceKind`.)
86fn is_live_stream_hint(url: &str) -> bool {
87 let lower = url.to_lowercase();
88 // Common Icecast/Shoutcast mount patterns
89 lower.contains("/stream")
90 || lower.contains("/live")
91 || lower.contains("/radio")
92 || lower.contains(";stream")
93 || lower.contains("icecast")
94 || lower.contains("shoutcast")
95 || lower.ends_with(".m3u8")
96 || lower.ends_with(".m3u")
97 || lower.ends_with(".pls")
98 || lower.contains(":8000/") // common Icecast port
99 || lower.contains(":8080/") // common alt port
100}
101
102async fn resolve_source(
103 ctx: Context<'_>,
104 melodia: Option<String>,
105 file: Option<serenity::Attachment>,
106) -> Result<ResolvedSource> {
107 // ── File attachment ──────────────────────────────────────────
108 // Discord attachment URLs are direct media file URLs — ffmpeg streams
109 // them without a full download.
110 if let Some(file) = file {
111 return Ok(ResolvedSource {
112 url: file.url.clone(),
113 display_name: file.filename.clone(),
114 kind: SourceKind::Stream,
115 });
116 }
117
118 let name = melodia.ok_or_else(|| Error::Custom(lang::L_MUSIC_NEED_NAME.into()))?;
119
120 // ── Autocomplete cache (per-user: (UserId, filename) → SearchHit) ─
121 if let Some(hit) = ctx.data().ac_lookup(ctx.author().id, &name).await {
122 let path = hit.url_path().unwrap_or("");
123 let path = if path.starts_with('/') {
124 path.to_string()
125 } else {
126 format!("/{path}")
127 };
128 let url = ctx.data().copyparty.build_url(&path);
129 let display_name = clean_name(hit.filename().unwrap_or(&name));
130 return Ok(ResolvedSource {
131 url,
132 display_name,
133 kind: SourceKind::Stream,
134 });
135 }
136
137 // ── Direct Copyparty path (starts with '/') ──────────────────
138 if name.starts_with('/') {
139 let url = ctx.data().copyparty.build_url(&name);
140 let display_name = clean_name(name.rsplit('/').next().unwrap_or(&name));
141 return Ok(ResolvedSource {
142 url,
143 display_name,
144 kind: SourceKind::Stream,
145 });
146 }
147
148 // ── Direct HTTP(S) URL ───────────────────────────────────────
149 if name.starts_with("http://") || name.starts_with("https://") {
150 let url = name.clone();
151 let display_name = clean_name(url.rsplit('/').next().unwrap_or(&url));
152
153 // yt-dlp platforms → download; everything else (radio, HLS,
154 // playlists, Copyparty, direct media) → ffmpeg stream.
155 let kind = if needs_ytdlp(&url) {
156 SourceKind::YtDlp
157 } else {
158 SourceKind::Stream
159 };
160 return Ok(ResolvedSource {
161 url,
162 display_name,
163 kind,
164 });
165 }
166
167 // ── Fallback: search Copyparty ───────────────────────────────
168 info!(query = %name, "searching copyparty for audio");
169 let results = ctx
170 .data()
171 .copyparty
172 .search(&name, 5, &["mp3", "wav", "ogg", "flac", "m4a", "opus"])
173 .await
174 .map_err(|e| {
175 error!(query = %name, error = %e, "copyparty search failed");
176 Error::Custom(lang::fmt_music_search_failed(&e))
177 })?;
178 let hit = results
179 .first()
180 .ok_or_else(|| Error::Custom(lang::L_MUSIC_NO_AUDIO_ON_CP.into()))?;
181 let path = hit
182 .url_path()
183 .ok_or_else(|| Error::Custom(lang::L_MUSIC_NO_URL_IN_RESULT.into()))?;
184 let path = if path.starts_with('/') {
185 path.to_string()
186 } else {
187 format!("/{path}")
188 };
189 let url = ctx.data().copyparty.build_url(&path);
190 let display_name = clean_name(hit.filename().unwrap_or("unknown"));
191 Ok(ResolvedSource {
192 url,
193 display_name,
194 kind: SourceKind::Stream,
195 })
196}
197
198/// Strip `?pw=...` / `&pw=...` query params from a URL for safe logging.
199fn redact_url(url: &str) -> &str {
200 let Some(q) = url.find('?') else {
201 return url;
202 };
203 let before = &url[..q];
204 let after = &url[q..];
205 // Strip `?pw=...` or `&pw=...`
206 if after.strip_prefix("?pw=").is_some() {
207 return before;
208 }
209 if let Some(idx) = after.find("&pw=") {
210 return &url[..q + idx];
211 }
212 url
213}
214
215/// Strip tracking/playlist params (`&...`) from YouTube URLs.
216/// yt-dlp only needs `?v=ID`; extra `&` params can cause failures.
217pub(crate) fn sanitize_yt_url(url: &str) -> String {
218 let lower = url.to_lowercase();
219 if lower.contains("youtube.com/") || lower.contains("youtu.be/") {
220 if let Some(pos) = url.find('&') {
221 return url[..pos].to_string();
222 }
223 }
224 url.to_string()
225}
226
227// ── Playlist resolution is handled by ffmpeg natively ──────────────
228// ffmpeg reads `.m3u`/`.m3u8`/`.pls` playlists directly (HLS segment
229// fetching, PLS/M3U entry parsing) — no manual resolution needed.
230// This mirrors the Python bot's `FFmpegPCMAudio(source, ...)` approach.
231
232async fn play_impl(
233 ctx: Context<'_>,
234 melodia: Option<String>,
235 file: Option<serenity::Attachment>,
236 compressed: bool,
237) -> Result<()> {
238 ctx.defer().await?;
239
240 let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?;
241
242 let channel_id = ctx
243 .serenity_context()
244 .cache
245 .guild(guild_id)
246 .and_then(|g| {
247 g.voice_states
248 .get(&ctx.author().id)
249 .and_then(|vs| vs.channel_id)
250 })
251 .ok_or_else(|| lang::err_not_in_voice())?;
252
253 let src = resolve_source(ctx, melodia, file).await?;
254
255 let call;
256 let input;
257
258 if compressed {
259 // ── Compressed path: download → compress → decode ──────────
260 // Defer stopping current track until compression is done.
261 let audio_bytes = match src.kind {
262 SourceKind::YtDlp => {
263 let clean_url = sanitize_yt_url(&src.url);
264 info!(url = %clean_url, "downloading via yt-dlp for compression");
265 crate::voice::ytdlp_download(&clean_url).await?
266 }
267 SourceKind::Stream => {
268 if is_live_stream_hint(&src.url) {
269 ctx.say(lang::L_MUSIC_CANT_COMPRESS_LIVE).await?;
270 // Fall through to normal streaming below
271 let live = true;
272 info!(url = %redact_url(&src.url), live, "live stream — compression skipped, streaming via ffmpeg");
273 // Stop current, connect, stream
274 {
275 let mut map = ctx.data().music_tracks.lock().await;
276 if let Some(handle) = map.remove(&guild_id.get()) {
277 let _ = handle.stop();
278 }
279 }
280 let (call_res, input_res) = tokio::join!(
281 ctx.data().voice_manager.connect(guild_id, channel_id),
282 crate::voice::ffmpeg_stream_input(&src.url)
283 );
284 call = call_res?;
285 input = input_res?;
286 // skip the rest of the compressed path
287 let handle = {
288 let mut call_lock = call.lock().await;
289 call_lock.play_input(input)
290 };
291 ctx.data()
292 .music_tracks
293 .lock()
294 .await
295 .insert(guild_id.get(), handle);
296 ctx.say(lang::fmt_music_playing(&src.display_name)).await?;
297 return Ok(());
298 }
299 info!(url = %redact_url(&src.url), "downloading for compression");
300 crate::voice::download_url(&ctx.data().http_client, &src.url).await?
301 }
302 };
303
304 info!("degrading audio ({} bytes)", audio_bytes.len());
305 let degraded = crate::voice::degrade_audio(&audio_bytes).await?;
306
307 // Deferred stop: only interrupt current track now that we're ready.
308 {
309 let mut map = ctx.data().music_tracks.lock().await;
310 if let Some(handle) = map.remove(&guild_id.get()) {
311 let _ = handle.stop();
312 }
313 }
314 call = ctx
315 .data()
316 .voice_manager
317 .connect(guild_id, channel_id)
318 .await?;
319 input = degraded;
320 } else {
321 // ── Normal path: stop first, then stream/download ──────────
322 {
323 let mut map = ctx.data().music_tracks.lock().await;
324 if let Some(handle) = map.remove(&guild_id.get()) {
325 let _ = handle.stop();
326 }
327 }
328
329 match src.kind {
330 SourceKind::YtDlp => {
331 let clean_url = sanitize_yt_url(&src.url);
332 info!(url = %clean_url, "downloading via yt-dlp");
333 let audio_bytes = crate::voice::ytdlp_download(&clean_url).await?;
334 let (call_res, input_res) = tokio::join!(
335 ctx.data().voice_manager.connect(guild_id, channel_id),
336 tokio::task::spawn_blocking(move || crate::voice::decode_audio(&audio_bytes))
337 );
338 call = call_res?;
339 input = input_res??;
340 }
341 SourceKind::Stream => {
342 let live = is_live_stream_hint(&src.url);
343 info!(url = %redact_url(&src.url), live, "streaming via ffmpeg subprocess");
344 let (call_res, input_res) = tokio::join!(
345 ctx.data().voice_manager.connect(guild_id, channel_id),
346 crate::voice::ffmpeg_stream_input(&src.url)
347 );
348 call = call_res?;
349 input = input_res?;
350 }
351 }
352 }
353
354 let handle = {
355 let mut call_lock = call.lock().await;
356 call_lock.play_input(input)
357 };
358
359 ctx.data()
360 .music_tracks
361 .lock()
362 .await
363 .insert(guild_id.get(), handle);
364
365 ctx.say(lang::fmt_music_playing(&src.display_name)).await?;
366 Ok(())
367}
368
369/// Stop playback and disconnect from voice
370#[poise::command(slash_command)]
371pub async fn stop(ctx: Context<'_>) -> Result<()> {
372 let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?;
373 ctx.data().voice_manager.disconnect(guild_id).await?;
374 ctx.data().music_tracks.lock().await.remove(&guild_id.get());
375 ctx.send(
376 poise::CreateReply::default()
377 .content(lang::L_MUSIC_STOPPED)
378 .ephemeral(true),
379 )
380 .await?;
381 Ok(())
382}
383
384/// Join your voice channel
385#[poise::command(slash_command)]
386pub async fn join(ctx: Context<'_>) -> Result<()> {
387 ctx.defer().await?;
388 let guild_id = ctx.guild_id().ok_or_else(|| lang::err_not_in_guild())?;
389
390 let channel_id = ctx.serenity_context().cache.guild(guild_id).and_then(|g| {
391 g.voice_states
392 .get(&ctx.author().id)
393 .and_then(|vs| vs.channel_id)
394 });
395
396 match channel_id {
397 Some(cid) => match ctx.data().voice_manager.connect(guild_id, cid).await {
398 Ok(_) => {
399 ctx.say(lang::fmt_voice_joined(&cid)).await?;
400 }
401 Err(e) => {
402 ctx.say(lang::fmt_voice_join_failed(&e)).await?;
403 }
404 },
405 None => {
406 ctx.say(lang::L_VOICE_USER_NOT_IN_VC).await?;
407 }
408 }
409 Ok(())
410}
411
412pub fn commands() -> Vec<poise::Command<Data, Error>> {
413 vec![grajcuj(), stop(), join()]
414}