diff options
Diffstat (limited to '')
| -rw-r--r-- | src/music.rs | 602 |
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 | |||
| 13 | use std::collections::{HashMap, VecDeque}; | ||
| 14 | use std::sync::Arc; | ||
| 15 | |||
| 16 | use poise::serenity_prelude as serenity; | ||
| 17 | use songbird::input::Input; | ||
| 18 | use songbird::tracks::TrackHandle; | ||
| 19 | use tokio::sync::Mutex; | ||
| 20 | use tracing::{error, info, warn}; | ||
| 21 | use uuid::Uuid; | ||
| 22 | |||
| 23 | use crate::error::{Error, Result}; | ||
| 24 | use crate::lang; | ||
| 25 | use 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)] | ||
| 31 | pub 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)] | ||
| 41 | pub 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)] | ||
| 54 | pub 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)] | ||
| 68 | pub 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)] | ||
| 76 | pub struct QueueItem { | ||
| 77 | pub display_name: String, | ||
| 78 | pub requester: Option<serenity::UserId>, | ||
| 79 | } | ||
| 80 | |||
| 81 | #[derive(Debug, Clone, Default)] | ||
| 82 | pub struct QueueSnapshot { | ||
| 83 | pub current: Option<QueueItem>, | ||
| 84 | pub upcoming: Vec<QueueItem>, | ||
| 85 | } | ||
| 86 | |||
| 87 | // ── State ───────────────────────────────────────────────────────────── | ||
| 88 | |||
| 89 | struct 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 | |||
| 97 | struct 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`]. | ||
| 107 | pub 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 | |||
| 117 | impl 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 | |||
| 465 | struct TrackEndHandler { | ||
| 466 | state: Arc<MusicState>, | ||
| 467 | guild: u64, | ||
| 468 | token: Uuid, | ||
| 469 | } | ||
| 470 | |||
| 471 | #[async_trait::async_trait] | ||
| 472 | impl 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. | ||
| 488 | fn 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? | ||
| 498 | fn 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. | ||
| 514 | fn 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. | ||
| 530 | pub(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 | |||
| 540 | async 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). | ||
| 596 | pub fn classify_url(url: &str) -> SourceKind { | ||
| 597 | if needs_ytdlp(url) { | ||
| 598 | SourceKind::YtDlp | ||
| 599 | } else { | ||
| 600 | SourceKind::Stream | ||
| 601 | } | ||
| 602 | } | ||
