From 1fd094a16738883a36fe1696e4005dc57c528503 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 20:16:16 +0000 Subject: [PATCH 1/4] feat(yt-dlp): forward YT_DLP_JS_RUNTIMES to every invocation Hosts where yt-dlp's bundled JS interpreter can't decipher YouTube signatures need an external runtime (Deno/Node) via --js-runtimes. The new YT_DLP_JS_RUNTIMES env var is appended to the download, probe, playlist-scrape, and streaming YoutubeDl input so the workaround applies everywhere yt-dlp runs. See https://github.com/yt-dlp/yt-dlp/wiki/EJS#deno --- .env.example | 9 ++++++++- src/player/track.rs | 8 +++++++- src/service/cache_service.rs | 3 +++ src/sources/youtube_player.rs | 2 ++ src/utils.rs | 1 + src/utils/yt_dlp_utils.rs | 24 ++++++++++++++++++++++++ 6 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 src/utils/yt_dlp_utils.rs diff --git a/.env.example b/.env.example index 71f4249..207e82b 100644 --- a/.env.example +++ b/.env.example @@ -8,4 +8,11 @@ SPOTIFY_CLIENT_SECRET= # (less negative) target = louder output. Default target -10 LUFS. # NORMALIZE_TARGET_LUFS=-10 # NORMALIZE_MIN_GAIN_DB=-3 -# NORMALIZE_MAX_GAIN_DB=12 \ No newline at end of file +# NORMALIZE_MAX_GAIN_DB=12 + +# Optional yt-dlp external JS runtime override, forwarded as +# `--js-runtimes ` to every yt-dlp call (download, probe, playlist +# scrape, and the streaming input). Set this when the bundled JS engine +# can't decipher YouTube signatures on the host — see +# https://github.com/yt-dlp/yt-dlp/wiki/EJS. Typical values: deno, node. +# YT_DLP_JS_RUNTIMES=deno \ No newline at end of file diff --git a/src/player/track.rs b/src/player/track.rs index 8974f98..d8d74ca 100644 --- a/src/player/track.rs +++ b/src/player/track.rs @@ -1,4 +1,5 @@ use crate::service::cache_service; +use crate::utils::yt_dlp_utils; use songbird::input::{File, Input, YoutubeDl}; use std::path::PathBuf; use std::time::Duration; @@ -113,7 +114,12 @@ impl Track { .play_url .clone() .unwrap_or_else(|| self.metadata.track_url.clone()); - (YoutubeDl::new(req_client.clone(), input_url).into(), None) + let mut ytdl = YoutubeDl::new(req_client.clone(), input_url); + let extra = yt_dlp_utils::extra_args(); + if !extra.is_empty() { + ytdl = ytdl.user_args(extra); + } + (ytdl.into(), None) } } diff --git a/src/service/cache_service.rs b/src/service/cache_service.rs index 0238f8e..5c5da13 100644 --- a/src/service/cache_service.rs +++ b/src/service/cache_service.rs @@ -12,6 +12,7 @@ use crate::player::track::{Track, TrackSource}; use crate::service::normalize_service; +use crate::utils::yt_dlp_utils; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::time::Duration; @@ -163,6 +164,7 @@ pub async fn cache_track(track: &Track) -> std::io::Result { let output_template = dir.join(format!("{stem}.part.%(ext)s")); let output = Command::new("yt-dlp") + .args(yt_dlp_utils::extra_args()) .args(["--no-warnings", "--no-playlist", "-f", "bestaudio/best", "-o"]) .arg(&output_template) .arg(&input_url) @@ -267,6 +269,7 @@ pub async fn probe_track(track: &Track) -> Option { .unwrap_or_else(|| track.metadata.track_url.clone()); let output = Command::new("yt-dlp") + .args(yt_dlp_utils::extra_args()) .args(["--no-warnings", "--no-playlist", "--print", "%(duration)s", "--print", "%(is_live)s"]) .arg(&input_url) .stdout(Stdio::piped()) diff --git a/src/sources/youtube_player.rs b/src/sources/youtube_player.rs index c387bf3..24287a1 100644 --- a/src/sources/youtube_player.rs +++ b/src/sources/youtube_player.rs @@ -1,4 +1,5 @@ use crate::player::track::{Playlist, Track, TrackMetadata}; +use crate::utils::yt_dlp_utils; use dotenv::var; use google_youtube3::api::{PlaylistItem, PlaylistItemSnippet, SearchResult, SearchResultSnippet}; use google_youtube3::client::NoToken; @@ -243,6 +244,7 @@ impl YoutubeClient { let playlist_id = url.trim_start_matches(PLAYLIST_URI).to_string(); let mut child = tokio::process::Command::new("yt-dlp") + .args(yt_dlp_utils::extra_args()) .args([ "--flat-playlist", "--no-warnings", diff --git a/src/utils.rs b/src/utils.rs index 8ce4325..c1f1f95 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,2 +1,3 @@ pub mod string_utils; pub mod time_utils; +pub mod yt_dlp_utils; diff --git a/src/utils/yt_dlp_utils.rs b/src/utils/yt_dlp_utils.rs new file mode 100644 index 0000000..b832013 --- /dev/null +++ b/src/utils/yt_dlp_utils.rs @@ -0,0 +1,24 @@ +//! Shared helpers for assembling yt-dlp invocations. +//! +//! Hosts where yt-dlp's pure-Python JS interpreter struggles can install a +//! native runtime (Deno, Node, …) and point yt-dlp at it via `--js-runtimes`. +//! See . When the +//! `YT_DLP_JS_RUNTIMES` env var is set we forward its value to every yt-dlp +//! call so spawn, probe, playlist enumeration, and the streaming `YoutubeDl` +//! input all stay in sync. + +const JS_RUNTIMES_ENV: &str = "YT_DLP_JS_RUNTIMES"; + +/// Extra CLI args to append to any yt-dlp invocation. Empty when no +/// configuration applies. +pub fn extra_args() -> Vec { + let mut args = Vec::new(); + if let Ok(runtimes) = std::env::var(JS_RUNTIMES_ENV) { + let trimmed = runtimes.trim(); + if !trimmed.is_empty() { + args.push("--js-runtimes".to_string()); + args.push(trimmed.to_string()); + } + } + args +} From 835d9185e920aaaa68231a6283ed949c2c542513 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 20:22:09 +0000 Subject: [PATCH 2/4] fix(player): surface yt-dlp errors and gate inactivity leave on presence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track errors now reach the text channel: `ErrorHandler` carries the serenity context, the player, and the guild channel so it can read the current track and post a `PlaybackErrorEmbed` with the underlying PlayError (e.g. yt-dlp signature/extraction failure). Songbird still fires TrackEvent::End after the error, so QueueHandler keeps the queue advancing — we only add the user-facing notice here. The 5-minute inactivity timer (queue_handler + cmd_stop) used to fire unconditionally, including after the bot had already been kicked or dragged out of voice. It now checks `channel_service::bot_in_voice` before announcing the leave, so a vacated bot doesn't post "leaving voice channel" from a channel it isn't in anymore. --- src/commands/music/cmd_stop.rs | 8 +++++ src/handlers/error_handler.rs | 66 +++++++++++++++++++++++++++++++--- src/handlers/queue_handler.rs | 8 +++++ src/service/channel_service.rs | 23 +++++++++++- 4 files changed, 100 insertions(+), 5 deletions(-) diff --git a/src/commands/music/cmd_stop.rs b/src/commands/music/cmd_stop.rs index 6eea091..853f969 100644 --- a/src/commands/music/cmd_stop.rs +++ b/src/commands/music/cmd_stop.rs @@ -3,6 +3,7 @@ use crate::checks::channel_checks::check_author_in_same_voice_channel; use crate::checks::player_checks::check_if_player_is_playing; use crate::embeds::music::player_embed::PlayerEmbed; use crate::player::player::Player; +use crate::service::channel_service; use crate::service::embed_service::SendEmbed; use std::sync::atomic::Ordering; use std::sync::Arc; @@ -56,6 +57,13 @@ pub async fn stop(ctx: Context<'_>) -> Result<(), MusicBotError> { return; } + // Voice handler already cleaned up if the bot was kicked or + // dragged out — don't announce a leave we didn't perform. + if !channel_service::bot_in_voice(&serenity_ctx, guild_id).await { + tracing::debug!("Bot already left voice channel — skipping inactivity leave notice"); + return; + } + tracing::info!("Leaving voice channel after 5 minutes of inactivity following stop"); let _ = PlayerEmbed::InactivityLeave diff --git a/src/handlers/error_handler.rs b/src/handlers/error_handler.rs index e694f93..2f96678 100644 --- a/src/handlers/error_handler.rs +++ b/src/handlers/error_handler.rs @@ -1,7 +1,15 @@ use crate::bot::{MusicBotData, MusicBotError}; use crate::embeds::bot::bot_embeds::BotEmbed; +use crate::embeds::music::player_embed::PlayerEmbed; +use crate::player::player::Player; +use crate::service::embed_service::SendEmbed; use async_trait::async_trait; +use poise::serenity_prelude; +use serenity::all::GuildChannel; +use songbird::tracks::PlayMode; use songbird::{Event, EventContext, EventHandler}; +use std::sync::Arc; +use tokio::sync::RwLock; /// Delete the user's prefix-command invocation after 30s so the channel /// doesn't get cluttered. Slash commands clean themselves up. @@ -17,16 +25,66 @@ pub fn schedule_prefix_delete(ctx: poise::Context<'_, MusicBotData, MusicBotErro } } -/// Songbird event handler for `TrackEvent::Error` — logs and continues. -pub struct ErrorHandler; +/// Songbird event handler for `TrackEvent::Error` — logs the failure and +/// surfaces it to the text channel so users see *why* a track was skipped +/// (typically a yt-dlp signature/extraction error). Songbird also fires +/// `TrackEvent::End` after an error, so the per-track `QueueHandler` keeps +/// the queue moving — we only handle the user-facing notice here. +pub struct ErrorHandler { + serenity_ctx: serenity_prelude::Context, + player: Arc>, + guild_channel: Option, +} + +impl ErrorHandler { + pub fn new( + serenity_ctx: serenity_prelude::Context, + player: Arc>, + guild_channel: Option, + ) -> Self { + Self { serenity_ctx, player, guild_channel } + } +} #[async_trait] impl EventHandler for ErrorHandler { async fn act( &self, - _e: &EventContext<'_>, + e: &EventContext<'_>, ) -> Option { - tracing::error!("Track error event: {:?}", _e); + let reason = match e { + EventContext::Track(track_list) => track_list + .iter() + .find_map(|(state, _)| match &state.playing { + PlayMode::Errored(err) => Some(err.to_string()), + _ => None, + }) + .unwrap_or_else(|| "unknown playback error".to_string()), + _ => "unknown playback error".to_string(), + }; + + tracing::error!("Track error event: {}", reason); + + let title = self + .player + .read() + .await + .current_track + .as_ref() + .map(|t| t.metadata.title.clone()); + + let description = match title { + Some(t) => format!("Failed to play **{t}** — {reason}"), + None => format!("Playback failed — {reason}"), + }; + + if let Some(channel) = &self.guild_channel { + let _ = PlayerEmbed::PlaybackErrorEmbed(description) + .to_embed() + .send_channel(self.serenity_ctx.http.clone(), channel, Some(60), None) + .await; + } + None } } diff --git a/src/handlers/queue_handler.rs b/src/handlers/queue_handler.rs index 9adbb02..77e21cd 100644 --- a/src/handlers/queue_handler.rs +++ b/src/handlers/queue_handler.rs @@ -1,6 +1,7 @@ use crate::embeds::music::player_embed::PlayerEmbed; use crate::player::player::{self, Player}; // Odebral jsem PlaybackError, v tomto kontextu nebyl správně použit +use crate::service::channel_service; use crate::service::embed_service::SendEmbed; use async_trait::async_trait; use lombok::AllArgsConstructor; @@ -64,6 +65,13 @@ impl EventHandler for QueueHandler { return; } + // Voice handler already cleaned up if the bot was kicked or + // dragged out — don't announce a leave we didn't perform. + if !channel_service::bot_in_voice(&serenity_ctx, guild_id).await { + tracing::debug!("Bot already left voice channel — skipping inactivity leave notice"); + return; + } + tracing::info!("Leaving voice channel after 5 minutes of inactivity"); let _ = PlayerEmbed::InactivityLeave diff --git a/src/service/channel_service.rs b/src/service/channel_service.rs index 8255765..6bbe8d0 100644 --- a/src/service/channel_service.rs +++ b/src/service/channel_service.rs @@ -1,5 +1,6 @@ use crate::bot::{Context, MusicBotError}; use crate::handlers::error_handler::ErrorHandler; +use poise::serenity_prelude; use serenity::all::{ChannelId, GuildId, UserId}; use songbird::{Call, Event, Songbird}; use std::sync::Arc; @@ -32,7 +33,12 @@ pub async fn join_user_channel(ctx: Context<'_>) -> Result { @@ -85,3 +91,18 @@ pub fn get_user_voice_channel( .and_then(|guild| guild.voice_states.get(user_id)) .and_then(|voice_state| voice_state.channel_id) } + +/// Whether the bot still has an active songbird Call for `guild_id`. +/// `voice_handler` drops the Call as soon as the bot is kicked/dragged out +/// of voice, so callers spawning delayed "leaving voice channel" notices +/// should gate them on this — otherwise they announce a leave that already +/// happened from somewhere they're no longer in. +pub async fn bot_in_voice( + serenity_ctx: &serenity_prelude::Context, + guild_id: GuildId, +) -> bool { + match songbird::get(serenity_ctx).await { + Some(manager) => manager.get(guild_id).is_some(), + None => false, + } +} From 6de85320a3ebf3bc5bb9e15b44427a2ea33a0aad Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 20:26:05 +0000 Subject: [PATCH 3/4] fix(player): post inactivity leave notice in the voice channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 5-minute inactivity leave used to land in whatever text channel issued the first !play, which gets noisy when the music conversation has moved on. Route it through the bot's current voice channel instead — Discord voice channels have an integrated text chat on the same ChannelId, so messages there sit next to the voice activity they describe. Adds `channel_service::bot_voice_channel` (looks up the bot's current voice channel via cache) and a `send_channel_id` variant on the SendEmbed trait so we can post to a raw ChannelId without first resolving a GuildChannel. The kicked/dragged-out check from the previous commit folds into the same lookup — `None` already means "not in voice", so the timer bails for free. --- src/commands/music/cmd_stop.rs | 9 +++------ src/handlers/queue_handler.rs | 7 +++---- src/service/channel_service.rs | 19 ++++++++++++++++++ src/service/embed_service.rs | 35 +++++++++++++++++++++++++++++++++- 4 files changed, 59 insertions(+), 11 deletions(-) diff --git a/src/commands/music/cmd_stop.rs b/src/commands/music/cmd_stop.rs index 853f969..98989fe 100644 --- a/src/commands/music/cmd_stop.rs +++ b/src/commands/music/cmd_stop.rs @@ -40,9 +40,6 @@ pub async fn stop(ctx: Context<'_>) -> Result<(), MusicBotError> { let guild_id = ctx .guild_id() .ok_or_else(|| MusicBotError::InternalError("no guild".into()))?; - let Some(guild_channel) = ctx.guild_channel().await else { - return Ok(()); - }; let serenity_ctx = ctx.serenity_context().clone(); let player_arc = ctx.data().player.clone(); @@ -59,16 +56,16 @@ pub async fn stop(ctx: Context<'_>) -> Result<(), MusicBotError> { // Voice handler already cleaned up if the bot was kicked or // dragged out — don't announce a leave we didn't perform. - if !channel_service::bot_in_voice(&serenity_ctx, guild_id).await { + let Some(voice_channel_id) = channel_service::bot_voice_channel(&serenity_ctx, guild_id) else { tracing::debug!("Bot already left voice channel — skipping inactivity leave notice"); return; - } + }; tracing::info!("Leaving voice channel after 5 minutes of inactivity following stop"); let _ = PlayerEmbed::InactivityLeave .to_embed() - .send_channel(serenity_ctx.http.clone(), &guild_channel, Some(60), None) + .send_channel_id(serenity_ctx.http.clone(), voice_channel_id, Some(60), None) .await; let _ = player_arc.write().await.stop_playback().await; diff --git a/src/handlers/queue_handler.rs b/src/handlers/queue_handler.rs index 77e21cd..f63d614 100644 --- a/src/handlers/queue_handler.rs +++ b/src/handlers/queue_handler.rs @@ -49,7 +49,6 @@ impl EventHandler for QueueHandler { let serenity_ctx = self.serenity_ctx.clone(); let player_arc = self.player.clone(); let guild_id = self.guild_id; - let guild_channel = self.guild_channel.clone(); drop(player); @@ -67,16 +66,16 @@ impl EventHandler for QueueHandler { // Voice handler already cleaned up if the bot was kicked or // dragged out — don't announce a leave we didn't perform. - if !channel_service::bot_in_voice(&serenity_ctx, guild_id).await { + let Some(voice_channel_id) = channel_service::bot_voice_channel(&serenity_ctx, guild_id) else { tracing::debug!("Bot already left voice channel — skipping inactivity leave notice"); return; - } + }; tracing::info!("Leaving voice channel after 5 minutes of inactivity"); let _ = PlayerEmbed::InactivityLeave .to_embed() - .send_channel(serenity_ctx.http.clone(), &guild_channel, Some(60), None) + .send_channel_id(serenity_ctx.http.clone(), voice_channel_id, Some(60), None) .await; let _ = player_arc.write().await.stop_playback().await; diff --git a/src/service/channel_service.rs b/src/service/channel_service.rs index 6bbe8d0..6937ac4 100644 --- a/src/service/channel_service.rs +++ b/src/service/channel_service.rs @@ -106,3 +106,22 @@ pub async fn bot_in_voice( None => false, } } + +/// The voice channel the bot is currently connected to in `guild_id`, if +/// any. Discord voice channels carry an integrated text chat reachable by +/// the same `ChannelId`, so this is what to write into when announcing +/// voice-scoped events (joining, leaving for inactivity) — keeps the +/// chatter close to the voice activity rather than spamming whichever text +/// channel the original `!play` came from. +pub fn bot_voice_channel( + serenity_ctx: &serenity_prelude::Context, + guild_id: GuildId, +) -> Option { + let bot_id = serenity_ctx.cache.current_user().id; + serenity_ctx + .cache + .guild(guild_id) + .as_ref() + .and_then(|g| g.voice_states.get(&bot_id)) + .and_then(|vs| vs.channel_id) +} diff --git a/src/service/embed_service.rs b/src/service/embed_service.rs index 6b0294a..7590015 100644 --- a/src/service/embed_service.rs +++ b/src/service/embed_service.rs @@ -19,12 +19,26 @@ pub async fn send_channel_embed( embed: CreateEmbed, delete_after: Option, message: Option, +) -> Result { + send_channel_id_embed(http, channel.id, embed, delete_after, message).await +} + +/// Same as `send_channel_embed` but targets a raw `ChannelId`. Used when we +/// need to write into a channel we don't have a cached `GuildChannel` for — +/// e.g. the voice channel's integrated text chat, looked up through the +/// bot's current voice state. +pub async fn send_channel_id_embed( + http: Arc, + channel_id: ChannelId, + embed: CreateEmbed, + delete_after: Option, + message: Option, ) -> Result { let created_message = CreateMessage::default() .content(message.unwrap_or_default()) .embed(embed); - let message = channel + let message = channel_id .send_message(http.clone(), created_message) .await .map_err(|error| MusicBotError::InternalError(error.to_string()))?; @@ -93,6 +107,14 @@ pub trait SendEmbed { delete_after: Option, message: Option, ) -> impl std::future::Future> + Send; + + fn send_channel_id( + &self, + http: Arc, + channel_id: ChannelId, + delete_after: Option, + message: Option, + ) -> impl std::future::Future> + Send; } impl SendEmbed for CreateEmbed { @@ -116,4 +138,15 @@ impl SendEmbed for CreateEmbed { let message: Message = send_channel_embed(http, channel, self.clone(), delete_after, message).await?; Ok(message) } + + async fn send_channel_id( + &self, + http: Arc, + channel_id: ChannelId, + delete_after: Option, + message: Option, + ) -> Result { + let message: Message = send_channel_id_embed(http, channel_id, self.clone(), delete_after, message).await?; + Ok(message) + } } From eeb97209cc2ae8811b073654edb57d6f55597e7d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 20:34:53 +0000 Subject: [PATCH 4/4] fix(voice): register TrackError handler only on a fresh join MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `manager.join` is idempotent — `!play` calls it on every invocation even when the bot is already connected, and songbird stacks global event listeners on the same Call. With the previous unit-struct ErrorHandler that just logged, this produced duplicate log lines; now that the handler posts an embed, every prior `!play` would contribute another duplicate PlaybackErrorEmbed on a single failure. Check `manager.get(guild_id)` before joining and only register the handler on the first connect. --- src/service/channel_service.rs | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/src/service/channel_service.rs b/src/service/channel_service.rs index 6937ac4..0ded699 100644 --- a/src/service/channel_service.rs +++ b/src/service/channel_service.rs @@ -26,19 +26,26 @@ pub async fn join_user_channel(ctx: Context<'_>) -> Result { - let mut handle: MutexGuard = handle_lock.lock().await; - - // Disconnect detection lives in voice_handler — songbird's - // CoreEvent::DriverDisconnect also fires on transient drops - // (e.g. when an admin moves the bot), which is too aggressive. - let error_handler = ErrorHandler::new( - ctx.serenity_context().clone(), - ctx.data().player.clone(), - ctx.guild_channel().await, - ); - handle.add_global_event(Event::Track(songbird::TrackEvent::Error), error_handler); + if !already_joined { + let mut handle: MutexGuard = handle_lock.lock().await; + + // Disconnect detection lives in voice_handler — songbird's + // CoreEvent::DriverDisconnect also fires on transient drops + // (e.g. when an admin moves the bot), which is too aggressive. + let error_handler = ErrorHandler::new( + ctx.serenity_context().clone(), + ctx.data().player.clone(), + ctx.guild_channel().await, + ); + handle.add_global_event(Event::Track(songbird::TrackEvent::Error), error_handler); + } } Err(error) => {