aboutsummaryrefslogtreecommitdiffstats
path: root/src/music.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/music.rs')
-rw-r--r--src/music.rs602
1 files changed, 602 insertions, 0 deletions
diff --git a/src/music.rs b/src/music.rs
new file mode 100644
index 0000000..8dfc4f8
--- /dev/null
+++ b/src/music.rs
@@ -0,0 +1,602 @@
1//! Per-guild music queue and playback pipeline.
2//!
3//! Playback used to live entirely in `commands::music`, tied to a poise
4//! `Context` (voice lookup, replies, ...). A queue needs to keep playing after
5//! the slash command has returned, so the stateful part lives here instead:
6//! the current track, the pending queue, and the voice/announce channels.
7//!
8//! The command layer resolves a user request into a [`TrackRequest`] (URL,
9//! display name, source kind) and hands it to [`MusicState`]. Everything after
10//! that — stopping/interrupting, preparing inputs, connecting, announcing, and
11//! advancing on `TrackEvent::End` — is handled here without a `Context`.
12
13use std::collections::{HashMap, VecDeque};
14use std::sync::Arc;
15
16use poise::serenity_prelude as serenity;
17use songbird::input::Input;
18use songbird::tracks::TrackHandle;
19use tokio::sync::Mutex;
20use tracing::{error, info, warn};
21use uuid::Uuid;
22
23use crate::error::{Error, Result};
24use crate::lang;
25use crate::voice::{self, VoiceManager};
26
27// ── Request types ─────────────────────────────────────────────────────
28
29/// What kind of audio source are we dealing with?
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum SourceKind {
32 /// YouTube, SoundCloud, Twitch, Bandcamp — needs yt-dlp extraction.
33 YtDlp,
34 /// Any HTTP(S) URL streamed directly through ffmpeg.
35 Stream,
36}
37
38/// Everything needed to (re)start one track, independent of the command
39/// invocation that created it.
40#[derive(Debug, Clone)]
41pub struct TrackRequest {
42 pub url: String,
43 pub display_name: String,
44 pub kind: SourceKind,
45 pub compressed: bool,
46 /// `(attachment_url, filename)` when the track came from an upload. Used to
47 /// re-upload the file alongside the "playing" message.
48 pub reupload: Option<(String, String)>,
49 pub requester: Option<serenity::UserId>,
50}
51
52/// Where an enqueued track goes relative to the current playback.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, poise::ChoiceParameter)]
54pub enum QueuePosition {
55 /// Interrupt the current track and play this right away. Pending tracks
56 /// stay queued behind it.
57 #[name = "now"]
58 Now,
59 /// Insert directly after the current track.
60 #[name = "next"]
61 Next,
62 /// Append to the end of the queue (the command default).
63 #[name = "last"]
64 Last,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum EnqueueOutcome {
69 /// Playback started immediately (nothing was playing, or `Now` was used).
70 Started,
71 /// The track was put into the queue; `position` is 1-based.
72 Queued { position: usize },
73}
74
75#[derive(Debug, Clone)]
76pub struct QueueItem {
77 pub display_name: String,
78 pub requester: Option<serenity::UserId>,
79}
80
81#[derive(Debug, Clone, Default)]
82pub struct QueueSnapshot {
83 pub current: Option<QueueItem>,
84 pub upcoming: Vec<QueueItem>,
85}
86
87// ── State ─────────────────────────────────────────────────────────────
88
89struct CurrentTrack {
90 /// Identifies this particular playback. Lets a late `End` event from a
91 /// manually stopped track avoid advancing the queue twice.
92 token: Uuid,
93 handle: TrackHandle,
94 request: TrackRequest,
95}
96
97struct GuildMusic {
98 current: Option<CurrentTrack>,
99 queue: VecDeque<TrackRequest>,
100 /// Voice channel the bot is (or should be) connected to.
101 voice: serenity::ChannelId,
102 /// Text channel where auto-advance announcements are posted.
103 announce: Option<serenity::ChannelId>,
104}
105
106/// Shared, per-guild music state. Stored in [`crate::Data`].
107pub struct MusicState {
108 guilds: Mutex<HashMap<u64, GuildMusic>>,
109 /// One transition lock per guild. Held across the whole stop/prepare/play
110 /// sequence so concurrent `/music` commands cannot interleave.
111 locks: Mutex<HashMap<u64, Arc<Mutex<()>>>>,
112 voice: VoiceManager,
113 http: Arc<serenity::Http>,
114 reqwest: reqwest::Client,
115}
116
117impl MusicState {
118 pub fn new(
119 voice: VoiceManager,
120 http: Arc<serenity::Http>,
121 reqwest: reqwest::Client,
122 ) -> Arc<Self> {
123 Arc::new(Self {
124 guilds: Mutex::new(HashMap::new()),
125 locks: Mutex::new(HashMap::new()),
126 voice,
127 http,
128 reqwest,
129 })
130 }
131
132 async fn guild_lock(&self, guild: u64) -> Arc<Mutex<()>> {
133 let mut locks = self.locks.lock().await;
134 locks
135 .entry(guild)
136 .or_insert_with(|| Arc::new(Mutex::new(())))
137 .clone()
138 }
139
140 /// Add a track to the queue and start it if nothing is playing.
141 pub async fn enqueue(
142 self: &Arc<Self>,
143 guild: u64,
144 voice_channel: serenity::ChannelId,
145 announce: Option<serenity::ChannelId>,
146 request: TrackRequest,
147 position: QueuePosition,
148 ) -> Result<EnqueueOutcome> {
149 let lock = self.guild_lock(guild).await;
150 let _transition = lock.lock().await;
151
152 let was_playing;
153 {
154 let mut map = self.guilds.lock().await;
155 let gm = map.entry(guild).or_insert_with(|| GuildMusic {
156 current: None,
157 queue: VecDeque::new(),
158 voice: voice_channel,
159 announce,
160 });
161 gm.voice = voice_channel;
162 gm.announce = announce;
163 was_playing = gm.current.is_some();
164
165 match position {
166 QueuePosition::Now | QueuePosition::Next => gm.queue.push_front(request),
167 QueuePosition::Last => gm.queue.push_back(request),
168 }
169 }
170
171 if was_playing && position != QueuePosition::Now {
172 let position = {
173 let map = self.guilds.lock().await;
174 let len = map.get(&guild).map(|gm| gm.queue.len()).unwrap_or(1);
175 if position == QueuePosition::Next {
176 1
177 } else {
178 len
179 }
180 };
181 return Ok(EnqueueOutcome::Queued { position });
182 }
183
184 if was_playing {
185 // `Now`: drop the current track. The new request is at the front of
186 // the queue; play_next_locked will pick it up.
187 let current = {
188 let mut map = self.guilds.lock().await;
189 map.get_mut(&guild).and_then(|gm| gm.current.take())
190 };
191 if let Some(current) = current {
192 let _ = current.handle.stop();
193 }
194 }
195
196 let played = self.play_next_locked(guild).await?;
197 Ok(if played {
198 EnqueueOutcome::Started
199 } else {
200 EnqueueOutcome::Queued { position: 1 }
201 })
202 }
203
204 /// Stop the current track and immediately start the next queued one.
205 /// Returns `true` when a track started, `false` when the queue was empty.
206 pub async fn skip(self: &Arc<Self>, guild: u64) -> Result<bool> {
207 let lock = self.guild_lock(guild).await;
208 let _transition = lock.lock().await;
209
210 let current = {
211 let mut map = self.guilds.lock().await;
212 map.get_mut(&guild).and_then(|gm| gm.current.take())
213 };
214 if let Some(current) = current {
215 let _ = current.handle.stop();
216 }
217
218 let has_queue = {
219 let map = self.guilds.lock().await;
220 map.get(&guild)
221 .map(|gm| !gm.queue.is_empty())
222 .unwrap_or(false)
223 };
224 if !has_queue {
225 return Ok(false);
226 }
227
228 self.play_next_locked(guild).await
229 }
230
231 /// Stop playback and drop the queue (does not disconnect from voice).
232 pub async fn stop(self: &Arc<Self>, guild: u64) {
233 let lock = self.guild_lock(guild).await;
234 let _transition = lock.lock().await;
235
236 let current = {
237 let mut map = self.guilds.lock().await;
238 map.remove(&guild).and_then(|gm| gm.current)
239 };
240 if let Some(current) = current {
241 let _ = current.handle.stop();
242 }
243 }
244
245 /// Play a track immediately, interrupting whatever is playing. Used by the
246 /// cron scheduler (no poise context, and the target channel is a voice
247 /// channel rather than a text channel).
248 pub async fn play_now(
249 self: &Arc<Self>,
250 guild: u64,
251 voice_channel: serenity::ChannelId,
252 announce: Option<serenity::ChannelId>,
253 request: TrackRequest,
254 ) -> Result<()> {
255 let lock = self.guild_lock(guild).await;
256 let _transition = lock.lock().await;
257
258 let current = {
259 let mut map = self.guilds.lock().await;
260 map.get_mut(&guild).and_then(|gm| gm.current.take())
261 };
262 if let Some(current) = current {
263 let _ = current.handle.stop();
264 }
265
266 {
267 let mut map = self.guilds.lock().await;
268 let gm = map.entry(guild).or_insert_with(|| GuildMusic {
269 current: None,
270 queue: VecDeque::new(),
271 voice: voice_channel,
272 announce,
273 });
274 gm.voice = voice_channel;
275 // Keep the last text channel when the caller has nowhere to
276 // announce (cron's channel is a voice channel).
277 if announce.is_some() {
278 gm.announce = announce;
279 }
280 gm.queue.push_front(request);
281 }
282
283 if self.play_next_locked(guild).await? {
284 Ok(())
285 } else {
286 Err(Error::Custom(lang::L_MUSIC_QUEUE_EMPTY.into()))
287 }
288 }
289
290 /// Current track followed by the pending queue, for `/music queue`.
291 pub async fn snapshot(&self, guild: u64) -> QueueSnapshot {
292 let map = self.guilds.lock().await;
293 let Some(gm) = map.get(&guild) else {
294 return QueueSnapshot::default();
295 };
296 QueueSnapshot {
297 current: gm.current.as_ref().map(|current| QueueItem {
298 display_name: current.request.display_name.clone(),
299 requester: current.request.requester,
300 }),
301 upcoming: gm
302 .queue
303 .iter()
304 .map(|req| QueueItem {
305 display_name: req.display_name.clone(),
306 requester: req.requester,
307 })
308 .collect(),
309 }
310 }
311
312 /// Pop and play the next playable track. Caller must hold the guild
313 /// transition lock.
314 async fn play_next_locked(self: &Arc<Self>, guild: u64) -> Result<bool> {
315 let mut last_error: Option<Error> = None;
316 loop {
317 let request = {
318 let mut map = self.guilds.lock().await;
319 match map.get_mut(&guild).and_then(|gm| gm.queue.pop_front()) {
320 Some(request) => request,
321 None => {
322 return match last_error {
323 Some(e) => Err(e),
324 None => Ok(false),
325 };
326 }
327 }
328 };
329
330 let input = match build_input(&request, &self.reqwest).await {
331 Ok(input) => input,
332 Err(e) => {
333 warn!(track = %request.display_name, error = %e, "failed to prepare queued track");
334 let message = lang::fmt_music_track_failed(&request.display_name, &e);
335 self.announce_failure(guild, message).await;
336 last_error = Some(e);
337 continue;
338 }
339 };
340
341 let (voice_channel, announce) = {
342 let map = self.guilds.lock().await;
343 match map.get(&guild) {
344 Some(gm) => (gm.voice, gm.announce),
345 None => return Ok(false),
346 }
347 };
348
349 let call = match self.voice.get_call(serenity::GuildId::new(guild)) {
350 Some(call) => call,
351 None => {
352 self.voice
353 .connect(serenity::GuildId::new(guild), voice_channel)
354 .await?
355 }
356 };
357
358 let handle = {
359 let mut call = call.lock().await;
360 call.play_input(input)
361 };
362 let token = Uuid::new_v4();
363 {
364 let mut map = self.guilds.lock().await;
365 if let Some(gm) = map.get_mut(&guild) {
366 gm.current = Some(CurrentTrack {
367 token,
368 handle: handle.clone(),
369 request: request.clone(),
370 });
371 }
372 }
373
374 let _ = handle.add_event(
375 songbird::Event::Track(songbird::TrackEvent::End),
376 TrackEndHandler {
377 state: self.clone(),
378 guild,
379 token,
380 },
381 );
382 let _ = handle.add_event(
383 songbird::Event::Track(songbird::TrackEvent::Error),
384 TrackEndHandler {
385 state: self.clone(),
386 guild,
387 token,
388 },
389 );
390
391 self.announce_playing(announce, &request).await;
392 return Ok(true);
393 }
394 }
395
396 /// Called from the `End` event of a track. Only advances if the track that
397 /// ended is still the current one.
398 async fn on_track_end(self: Arc<Self>, guild: u64, token: Uuid) {
399 let lock = self.guild_lock(guild).await;
400 let _transition = lock.lock().await;
401
402 let is_current = {
403 let mut map = self.guilds.lock().await;
404 match map.get_mut(&guild) {
405 Some(gm) => match &gm.current {
406 Some(current) if current.token == token => {
407 gm.current = None;
408 true
409 }
410 _ => false,
411 },
412 None => false,
413 }
414 };
415
416 if is_current {
417 if let Err(e) = self.play_next_locked(guild).await {
418 error!(guild, error = %e, "failed to advance music queue");
419 }
420 }
421 }
422
423 async fn announce_playing(&self, channel: Option<serenity::ChannelId>, request: &TrackRequest) {
424 let Some(channel) = channel else {
425 return;
426 };
427 let mut message =
428 serenity::CreateMessage::new().content(lang::fmt_music_playing(&request.display_name));
429
430 if let Some((url, filename)) = &request.reupload {
431 match self.reqwest.get(url).send().await {
432 Ok(response) => match response.bytes().await {
433 Ok(bytes) => {
434 message = message.add_file(serenity::CreateAttachment::bytes(
435 bytes.to_vec(),
436 filename.clone(),
437 ));
438 }
439 Err(e) => error!("music announce: failed to read attachment bytes: {e}"),
440 },
441 Err(e) => error!("music announce: failed to download attachment: {e}"),
442 }
443 }
444
445 if let Err(e) = channel.send_message(&*self.http, message).await {
446 error!(channel = %channel, error = %e, "music announce: send failed");
447 }
448 }
449
450 async fn announce_failure(&self, guild: u64, message: String) {
451 let channel = {
452 let map = self.guilds.lock().await;
453 map.get(&guild).and_then(|gm| gm.announce)
454 };
455 let Some(channel) = channel else {
456 return;
457 };
458 let message = serenity::CreateMessage::new().content(message);
459 if let Err(e) = channel.send_message(&*self.http, message).await {
460 error!(channel = %channel, error = %e, "music failure announce: send failed");
461 }
462 }
463}
464
465struct TrackEndHandler {
466 state: Arc<MusicState>,
467 guild: u64,
468 token: Uuid,
469}
470
471#[async_trait::async_trait]
472impl songbird::EventHandler for TrackEndHandler {
473 async fn act(&self, _ctx: &songbird::EventContext<'_>) -> Option<songbird::Event> {
474 let state = self.state.clone();
475 let guild = self.guild;
476 let token = self.token;
477 tokio::spawn(async move {
478 state.on_track_end(guild, token).await;
479 });
480 Some(songbird::Event::Cancel)
481 }
482}
483
484// ── Input construction ────────────────────────────────────────────────
485
486/// URLs that require yt-dlp extraction (platforms that don't serve raw audio
487/// at a direct URL). These MUST be downloaded first.
488fn needs_ytdlp(url: &str) -> bool {
489 let lower = url.to_lowercase();
490 lower.contains("youtube.com/")
491 || lower.contains("youtu.be/")
492 || lower.contains("twitch.tv/")
493 || lower.contains("soundcloud.com/")
494 || lower.contains("bandcamp.com/")
495}
496
497/// Heuristic: does this URL look like a live internet radio stream?
498fn is_live_stream_hint(url: &str) -> bool {
499 let lower = url.to_lowercase();
500 lower.contains("/stream")
501 || lower.contains("/live")
502 || lower.contains("/radio")
503 || lower.contains(";stream")
504 || lower.contains("icecast")
505 || lower.contains("shoutcast")
506 || lower.ends_with(".m3u8")
507 || lower.ends_with(".m3u")
508 || lower.ends_with(".pls")
509 || lower.contains(":8000/")
510 || lower.contains(":8080/")
511}
512
513/// Strip `?pw=...` / `&pw=...` query params from a URL for safe logging.
514fn redact_url(url: &str) -> &str {
515 let Some(q) = url.find('?') else {
516 return url;
517 };
518 let before = &url[..q];
519 let after = &url[q..];
520 if after.strip_prefix("?pw=").is_some() {
521 return before;
522 }
523 if let Some(idx) = after.find("&pw=") {
524 return &url[..q + idx];
525 }
526 url
527}
528
529/// Strip tracking/playlist params (`&...`) from YouTube URLs.
530pub(crate) fn sanitize_yt_url(url: &str) -> String {
531 let lower = url.to_lowercase();
532 if (lower.contains("youtube.com/") || lower.contains("youtu.be/"))
533 && let Some(pos) = url.find('&')
534 {
535 return url[..pos].to_string();
536 }
537 url.to_string()
538}
539
540async fn build_input(request: &TrackRequest, http: &reqwest::Client) -> Result<Input> {
541 if request.compressed {
542 let audio_bytes = match request.kind {
543 SourceKind::YtDlp => {
544 let clean_url = sanitize_yt_url(&request.url);
545 info!(url = %clean_url, "downloading via yt-dlp for compression");
546 voice::ytdlp_download(&clean_url).await?
547 }
548 SourceKind::Stream => {
549 if is_live_stream_hint(&request.url) {
550 info!(
551 url = %redact_url(&request.url),
552 "live stream — compression skipped, streaming via ffmpeg"
553 );
554 return voice::ffmpeg_stream_input(&request.url).await;
555 }
556 info!(url = %redact_url(&request.url), "downloading for compression");
557 voice::download_url(http, &request.url).await?
558 }
559 };
560
561 info!("compressing audio ({} bytes)", audio_bytes.len());
562 let compressed_bytes =
563 crate::commands::utilities::compress_audio_bytes(&audio_bytes, &request.display_name)
564 .await
565 .ok_or_else(|| Error::Custom("compression failed".into()))?;
566 info!("compressed to {} bytes", compressed_bytes.len());
567
568 // Decode the compressed OGG via ffmpeg (symphonia lacks Opus support).
569 let compressed_path = format!(
570 "{}/play_compressed_{}.ogg",
571 crate::config::TEMP_PATH,
572 Uuid::new_v4()
573 );
574 let _ = tokio::fs::create_dir_all(crate::config::TEMP_PATH).await;
575 tokio::fs::write(&compressed_path, &compressed_bytes).await?;
576 let input = voice::ffmpeg_decode_file(&compressed_path).await?;
577 Ok(input)
578 } else {
579 match request.kind {
580 SourceKind::YtDlp => {
581 let clean_url = sanitize_yt_url(&request.url);
582 info!(url = %clean_url, "downloading via yt-dlp");
583 let audio_bytes = voice::ytdlp_download(&clean_url).await?;
584 tokio::task::spawn_blocking(move || voice::decode_audio(&audio_bytes)).await?
585 }
586 SourceKind::Stream => {
587 let live = is_live_stream_hint(&request.url);
588 info!(url = %redact_url(&request.url), live, "streaming via ffmpeg subprocess");
589 voice::ffmpeg_stream_input(&request.url).await
590 }
591 }
592 }
593}
594
595/// Classify a raw URL for the command layer (yt-dlp vs. direct ffmpeg stream).
596pub fn classify_url(url: &str) -> SourceKind {
597 if needs_ytdlp(url) {
598 SourceKind::YtDlp
599 } else {
600 SourceKind::Stream
601 }
602}