Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,11 @@ SPOTIFY_CLIENT_SECRET=<your-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
# NORMALIZE_MAX_GAIN_DB=12

# Optional yt-dlp external JS runtime override, forwarded as
# `--js-runtimes <value>` 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
13 changes: 9 additions & 4 deletions src/commands/music/cmd_stop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();

Expand All @@ -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;
Expand Down
66 changes: 62 additions & 4 deletions src/handlers/error_handler.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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<RwLock<Player>>,
guild_channel: Option<GuildChannel>,
}

impl ErrorHandler {
pub fn new(
serenity_ctx: serenity_prelude::Context,
player: Arc<RwLock<Player>>,
guild_channel: Option<GuildChannel>,
) -> Self {
Self { serenity_ctx, player, guild_channel }
}
}

#[async_trait]
impl EventHandler for ErrorHandler {
async fn act(
&self,
_e: &EventContext<'_>,
e: &EventContext<'_>,
) -> Option<Event> {
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
}
}
Expand Down
11 changes: 9 additions & 2 deletions src/handlers/queue_handler.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);

Expand All @@ -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;
Expand Down
8 changes: 7 additions & 1 deletion src/player/track.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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)
}
}

Expand Down
3 changes: 3 additions & 0 deletions src/service/cache_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -163,6 +164,7 @@ pub async fn cache_track(track: &Track) -> std::io::Result<PathBuf> {
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)
Expand Down Expand Up @@ -267,6 +269,7 @@ pub async fn probe_track(track: &Track) -> Option<TrackProbe> {
.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())
Expand Down
57 changes: 52 additions & 5 deletions src/service/channel_service.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -25,14 +26,26 @@ pub async fn join_user_channel(ctx: Context<'_>) -> Result<ChannelId, MusicBotEr
.await
.ok_or_else(|| MusicBotError::InternalError("Could not locate voice channel. Songbird manager does not exist".to_owned()))?;

// `manager.join` is idempotent — `!play` runs it on every invocation even
// when the bot is already connected. Global event listeners stack on the
// same Call, so register the error handler only on a fresh join.
let already_joined = manager.get(guild_id).is_some();

match manager.join(guild_id, chanel_id).await {
Ok(handle_lock) => {
let mut handle: MutexGuard<Call> = handle_lock.lock().await;
if !already_joined {
let mut handle: MutexGuard<Call> = 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) => {
Expand Down Expand Up @@ -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<ChannelId> {
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)
}
35 changes: 34 additions & 1 deletion src/service/embed_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,26 @@ pub async fn send_channel_embed(
embed: CreateEmbed,
delete_after: Option<u64>,
message: Option<String>,
) -> Result<Message, MusicBotError> {
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<Http>,
channel_id: ChannelId,
embed: CreateEmbed,
delete_after: Option<u64>,
message: Option<String>,
) -> Result<Message, MusicBotError> {
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()))?;
Expand Down Expand Up @@ -93,6 +107,14 @@ pub trait SendEmbed {
delete_after: Option<u64>,
message: Option<String>,
) -> impl std::future::Future<Output = Result<Message, MusicBotError>> + Send;

fn send_channel_id(
&self,
http: Arc<Http>,
channel_id: ChannelId,
delete_after: Option<u64>,
message: Option<String>,
) -> impl std::future::Future<Output = Result<Message, MusicBotError>> + Send;
}

impl SendEmbed for CreateEmbed {
Expand All @@ -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<Http>,
channel_id: ChannelId,
delete_after: Option<u64>,
message: Option<String>,
) -> Result<Message, MusicBotError> {
let message: Message = send_channel_id_embed(http, channel_id, self.clone(), delete_after, message).await?;
Ok(message)
}
}
2 changes: 2 additions & 0 deletions src/sources/youtube_player.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/utils.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
pub mod string_utils;
pub mod time_utils;
pub mod yt_dlp_utils;
24 changes: 24 additions & 0 deletions src/utils/yt_dlp_utils.rs
Original file line number Diff line number Diff line change
@@ -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 <https://github.com/yt-dlp/yt-dlp/wiki/EJS#deno>. 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<String> {
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
}
Loading