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/commands/music/cmd_stop.rs b/src/commands/music/cmd_stop.rs index 6eea091..98989fe 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; @@ -39,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(); @@ -56,11 +54,18 @@ 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. + 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/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..f63d614 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; @@ -48,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); @@ -64,11 +64,18 @@ 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. + 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/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/service/channel_service.rs b/src/service/channel_service.rs index 8255765..0ded699 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; @@ -25,14 +26,26 @@ pub async fn join_user_channel(ctx: Context<'_>) -> Result { - let mut handle: MutexGuard = handle_lock.lock().await; + 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. - handle.add_global_event(Event::Track(songbird::TrackEvent::Error), ErrorHandler); + // 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) => { @@ -85,3 +98,37 @@ 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, + } +} + +/// 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) + } } 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 +}