diff --git a/.env.example b/.env.example index 71f4249..71c07c6 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,13 @@ YOUTUBE_TOKEN= SPOTIFY_CLIENT_ID= SPOTIFY_CLIENT_SECRET= +# Optional on-disk track cache toggle. Defaults to enabled — tracks are +# downloaded via yt-dlp into ./cache/ after first play so replays skip the +# network round-trip. Set to false to stream every play fresh from yt-dlp. +# Disabling the cache also disables !normalize, which needs an analyzable +# file path to measure loudness. +# CACHE_ENABLED=true + # Optional loudness normalization tuning (used by !normalize). Higher # (less negative) target = louder output. Default target -10 LUFS. # NORMALIZE_TARGET_LUFS=-10 diff --git a/src/commands/music/cmd_normalize.rs b/src/commands/music/cmd_normalize.rs index 37406ab..5d24179 100644 --- a/src/commands/music/cmd_normalize.rs +++ b/src/commands/music/cmd_normalize.rs @@ -2,6 +2,7 @@ use crate::bot::{Context, MusicBotError}; use crate::checks::channel_checks::check_author_in_same_voice_channel; use crate::embeds::music::player_embed::PlayerEmbed; use crate::player::player::{self, Player}; +use crate::service::cache_service; use crate::service::embed_service::SendEmbed; use tokio::sync::RwLockWriteGuard; @@ -16,6 +17,14 @@ pub async fn normalize( ctx: Context<'_>, state: Option, ) -> Result<(), MusicBotError> { + if !cache_service::is_enabled() { + PlayerEmbed::NormalizeUnavailable + .to_embed() + .send_context(ctx, true, Some(30)) + .await?; + return Ok(()); + } + let player_arc = ctx.data().player.clone(); let mut player: RwLockWriteGuard = player_arc.write().await; diff --git a/src/embeds/music/player_embed.rs b/src/embeds/music/player_embed.rs index 9d9a8f6..91e6fa2 100644 --- a/src/embeds/music/player_embed.rs +++ b/src/embeds/music/player_embed.rs @@ -32,6 +32,7 @@ pub enum PlayerEmbed<'a> { VolumeChanged(f32), SilentState(bool), NormalizeState(bool), + NormalizeUnavailable, Skipped(usize), Shuffled, Search(&'a [Track]), @@ -130,6 +131,10 @@ impl<'a> PlayerEmbed<'a> { .title(title) .description(body) } + PlayerEmbed::NormalizeUnavailable => CreateEmbed::new() + .color(Color::ORANGE) + .title("🎚️ Normalization unavailable") + .description("Loudness normalization needs the on-disk track cache, which is disabled (`CACHE_ENABLED=false`). Re-enable the cache to use `!normalize`."), PlayerEmbed::SilentState(on) => { let (title, body) = if *on { ( diff --git a/src/player/player.rs b/src/player/player.rs index 792d27f..bea5d11 100644 --- a/src/player/player.rs +++ b/src/player/player.rs @@ -84,9 +84,11 @@ impl Player { } } - /// Whether loudness normalization should apply this session. + /// Whether loudness normalization should apply this session. Off whenever + /// the on-disk cache is disabled — normalization measures loudness from + /// the cached file, so it has nothing to work with when there's no cache. pub fn should_normalize(&self) -> bool { - self.normalize + self.normalize && cache_service::is_enabled() } pub fn push_to_history( @@ -591,6 +593,13 @@ pub fn spawn_cache_and_apply( return; } tokio::spawn(async move { + // Give songbird's streaming yt-dlp a few seconds of clean bandwidth + // before the caching yt-dlp starts pulling the same audio. The two + // invocations are independent, so without a head-start they race for + // the network and the user hears the streamed playback buffer instead + // of starting promptly. + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + match cache_service::cache_track(&track).await { Ok(path) => { tracing::info!("Cached '{}' to {}", track.metadata.title, path.display()); diff --git a/src/service/cache_service.rs b/src/service/cache_service.rs index 0238f8e..866a133 100644 --- a/src/service/cache_service.rs +++ b/src/service/cache_service.rs @@ -14,6 +14,7 @@ use crate::player::track::{Track, TrackSource}; use crate::service::normalize_service; use std::path::{Path, PathBuf}; use std::process::Stdio; +use std::sync::OnceLock; use std::time::Duration; use tokio::process::Command; @@ -26,6 +27,29 @@ const YOUTUBE_SUBDIR: &str = "youtube"; const SPOTIFY_SUBDIR: &str = "spotify"; const MAX_FILENAME_STEM: usize = 80; +/// Whether the on-disk track cache is enabled. Controlled via the +/// `CACHE_ENABLED` env var; defaults to `true`. When disabled, every play +/// streams fresh from yt-dlp — `find_cached` returns `None`, `is_cacheable` +/// returns `false`, and the cache background task is skipped. Loudness +/// normalization rides along since it needs an analyzable file path. +pub fn is_enabled() -> bool { + static CACHED: OnceLock = OnceLock::new(); + *CACHED.get_or_init(|| match std::env::var("CACHE_ENABLED") { + Ok(raw) => match raw.trim().to_ascii_lowercase().as_str() { + "0" | "false" | "no" | "off" => { + tracing::info!("Track cache: disabled (CACHE_ENABLED={raw:?})"); + false + } + "" | "1" | "true" | "yes" | "on" => true, + _ => { + tracing::warn!("Track cache: ignoring invalid CACHE_ENABLED={raw:?}, leaving enabled"); + true + } + }, + Err(_) => true, + }) +} + pub fn cache_dir() -> PathBuf { PathBuf::from(CACHE_DIR) } @@ -93,6 +117,9 @@ pub fn cache_stem_for(track: &Track) -> Option { /// Search order: per-source subdirectory first, then the legacy flat root so /// pre-split caches keep working without a migration step. pub async fn find_cached(track: &Track) -> Option { + if !is_enabled() { + return None; + } let stem = cache_stem_for(track)?; if let Some(dir) = cache_dir_for(&track.source) { @@ -138,6 +165,12 @@ async fn find_in_dir( /// Download `track` through yt-dlp into the cache, returning the final path. /// No-op (returns existing path) if a cached copy already exists. pub async fn cache_track(track: &Track) -> std::io::Result { + if !is_enabled() { + return Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "track cache is disabled", + )); + } let stem = cache_stem_for(track).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "track is not cacheable"))?; if let Some(existing) = find_cached(track).await { @@ -241,8 +274,9 @@ async fn cleanup_part_files( /// Whether `track` has enough metadata to be cacheable. Used by callers /// before they spawn a cache-and-apply background job so we don't kick off /// work for tracks we can't cache (local files, or sources missing an id). +/// Also returns `false` when caching is globally disabled. pub fn is_cacheable(track: &Track) -> bool { - cache_stem_for(track).is_some() + is_enabled() && cache_stem_for(track).is_some() } /// Result of a single combined yt-dlp metadata probe.