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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ YOUTUBE_TOKEN=<your-youtube-api-token>
SPOTIFY_CLIENT_ID=<your-spotify-client-id>
SPOTIFY_CLIENT_SECRET=<your-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
Expand Down
9 changes: 9 additions & 0 deletions src/commands/music/cmd_normalize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -16,6 +17,14 @@ pub async fn normalize(
ctx: Context<'_>,
state: Option<String>,
) -> 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> = player_arc.write().await;

Expand Down
5 changes: 5 additions & 0 deletions src/embeds/music/player_embed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ pub enum PlayerEmbed<'a> {
VolumeChanged(f32),
SilentState(bool),
NormalizeState(bool),
NormalizeUnavailable,
Skipped(usize),
Shuffled,
Search(&'a [Track]),
Expand Down Expand Up @@ -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 {
(
Expand Down
13 changes: 11 additions & 2 deletions src/player/player.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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());
Expand Down
36 changes: 35 additions & 1 deletion src/service/cache_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<bool> = 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)
}
Expand Down Expand Up @@ -93,6 +117,9 @@ pub fn cache_stem_for(track: &Track) -> Option<String> {
/// 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<PathBuf> {
if !is_enabled() {
return None;
}
let stem = cache_stem_for(track)?;

if let Some(dir) = cache_dir_for(&track.source) {
Expand Down Expand Up @@ -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<PathBuf> {
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 {
Expand Down Expand Up @@ -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.
Expand Down
Loading