encore des problèmes de configuration

This commit is contained in:
2025-10-26 15:16:40 +01:00
parent 1e0a0e2acb
commit 261ac885c1
12 changed files with 496 additions and 457 deletions

47
Cargo.lock generated
View File

@@ -447,9 +447,9 @@ dependencies = [
[[package]]
name = "built"
version = "0.7.7"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b"
checksum = "38d17f4d6e4dc36d1a02fbedc2753a096848e7c1b0772f7654eab8e2c927dd53"
[[package]]
name = "bumpalo"
@@ -633,7 +633,16 @@ version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49fc9a695bca7f35f5f4c15cddc84415f66a74ea78eef08e90c5024f2b540e23"
dependencies = [
"crc-catalog",
"crc-catalog 1.1.1",
]
[[package]]
name = "crc"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675"
dependencies = [
"crc-catalog 2.4.0",
]
[[package]]
@@ -642,6 +651,12 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccaeedb56da03b09f598226e25e80088cb4cd25f316e6e4df7d695f0feeb1403"
[[package]]
name = "crc-catalog"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -998,7 +1013,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb6da14d3c6605689b5c9ed5187a5218a6d3888e14b747bc18fd4e4bafd452bd"
dependencies = [
"built",
"crc",
"crc 2.1.0",
"crossbeam-channel",
"heapless",
"log",
"md-5",
"num-traits",
"rustversion",
"seq-macro",
"serde",
]
[[package]]
name = "flacenc"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22846e369206f5c3caa4c59071041fe70c1c2863ebdd2341e6ad17490d53772b"
dependencies = [
"built",
"crc 3.3.0",
"crossbeam-channel",
"heapless",
"log",
@@ -2359,7 +2392,7 @@ dependencies = [
"axum",
"chrono",
"claxon",
"flacenc",
"flacenc 0.4.0",
"futures-util",
"lofty",
"pmocache",
@@ -2498,7 +2531,7 @@ dependencies = [
"bytes",
"chrono",
"claxon",
"flacenc",
"flacenc 0.5.0",
"futures",
"hex",
"hound",
@@ -2516,7 +2549,7 @@ dependencies = [
"sha2",
"symphonia",
"tempfile",
"thiserror 1.0.69",
"thiserror 2.0.17",
"tokio",
"tokio-test",
"tokio-util",

View File

@@ -461,7 +461,10 @@ impl Config {
pub fn get_managed_dir(&self, path: &[&str], default: &str) -> Result<String> {
let dir_path = match self.get_value(path) {
Ok(Value::String(s)) => s,
_ => default.to_string(),
_ => {
self.set_managed_dir(path, default.to_string())?;
default.to_string()
},
};
self.resolve_and_create_dir(&dir_path)
}

View File

@@ -19,7 +19,7 @@ tokio = { version = "1", features = ["full"] }
# Sérialisation/Désérialisation JSON
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_yaml = "0.9"
serde_yaml = "0.9.33"
# Helpers
chrono = { version = "0.4", features = ["serde"] }
@@ -31,13 +31,13 @@ async-stream = "0.3"
rusqlite = { version = "0.37", features = ["bundled"] }
# Gestion des erreurs
thiserror = "1.0"
thiserror = "2.0.17"
anyhow = "1.0"
# Streaming de bytes
bytes = "1.5"
futures = "0.3"
flacenc = "0.4"
flacenc = "0.5.0"
# Logging
tracing = "0.1"

View File

@@ -3,6 +3,14 @@
//! Ce module fournit le trait `RadioParadiseConfigExt` qui permet d'ajouter facilement
//! des méthodes de gestion de la configuration Radio Paradise à pmoconfig::Config.
//!
//! La configuration est minimale - seulement ce qui doit vraiment être configurable :
//! - Activation/désactivation de la source
//! - Chemin de la base de données d'historique
//! - Taille maximale de l'historique
//!
//! Tous les autres paramètres (polling, timeouts, etc.) sont des constantes
//! définies dans `paradise::constants`.
//!
//! # Exemple
//!
//! ```rust,ignore
@@ -10,24 +18,41 @@
//! use pmoparadise::RadioParadiseConfigExt;
//!
//! let config = get_config();
//! let history_db = config.get_paradise_history_database()?;
//! let history_size = config.get_paradise_history_size()?;
//!
//! // Check if enabled
//! if !config.get_paradise_enabled()? {
//! println!("Radio Paradise is disabled");
//! return Ok(());
//! }
//!
//! // Get configuration
//! let db_path = config.get_paradise_history_database()?;
//! let max_tracks = config.get_paradise_history_size()?;
//! ```
use anyhow::Result;
use std::path::PathBuf;
use anyhow::{anyhow, Result};
use pmoconfig::Config;
use serde_yaml::{Number, Value};
/// Chemin par défaut de la base de données d'historique (relatif au config_dir)
const DEFAULT_HISTORY_DATABASE: &str = "paradise_history.db";
use crate::paradise::constants;
/// Nombre maximal par défaut de pistes dans l'historique
const DEFAULT_HISTORY_SIZE: usize = 100;
/// Nom du répertoire pour Radio Paradise (relatif au config_dir)
///
/// La base de données sera stockée dans `<config_dir>/paradise/history.db`
const DEFAULT_HISTORY_DATABASE_DIR: &str = "paradise";
/// Trait d'extension pour gérer la configuration Radio Paradise dans pmoconfig
///
/// Ce trait étend `pmoconfig::Config` avec des méthodes spécifiques
/// à la configuration de Radio Paradise (historique, etc.).
/// à la configuration minimale de Radio Paradise.
///
/// # Auto-persist des valeurs par défaut
///
/// Tous les getters persistent automatiquement la valeur par défaut dans la
/// configuration si elle n'existe pas encore. Cela permet à l'utilisateur de
/// voir la configuration effective dans le fichier YAML et de la modifier facilement.
///
/// # Exemple
///
@@ -37,30 +62,62 @@ const DEFAULT_HISTORY_SIZE: usize = 100;
///
/// let config = get_config();
///
/// // Récupérer le chemin de la base de données d'historique
/// let db_path = config.get_paradise_history_database()?;
/// println!("History database: {}", db_path);
/// // Premier appel : persiste "enabled: true" dans la config et retourne true
/// let enabled = config.get_paradise_enabled()?;
///
/// // Récupérer la taille maximale de l'historique
/// // Premier appel : persiste "max_tracks: 100" dans la config et retourne 100
/// let max_tracks = config.get_paradise_history_size()?;
/// println!("Max history tracks: {}", max_tracks);
///
/// // L'utilisateur peut maintenant éditer ces valeurs dans le fichier YAML
/// ```
pub trait RadioParadiseConfigExt {
/// Récupère le chemin de la base de données d'historique
///
/// Le chemin retourné est absolu, mais peut être configuré de manière relative
/// au répertoire de configuration (via `get_managed_dir`).
/// Vérifie si Radio Paradise est activé
///
/// # Returns
///
/// Le chemin absolu vers la base de données SQLite d'historique
/// (default: `<config_dir>/paradise_history.db`)
/// `true` si la source est activée (default), `false` sinon.
///
/// Si la valeur n'existe pas dans la configuration, elle est automatiquement
/// définie à `true` (activé par défaut) et persistée.
///
/// # Exemple
///
/// ```rust,ignore
/// if config.get_paradise_enabled()? {
/// // Initialize Radio Paradise...
/// }
/// ```
fn get_paradise_enabled(&self) -> Result<bool>;
/// Active ou désactive Radio Paradise
///
/// # Arguments
///
/// * `enabled` - `true` pour activer, `false` pour désactiver
///
/// # Exemple
///
/// ```rust,ignore
/// // Disable Radio Paradise
/// config.set_paradise_enabled(false)?;
/// ```
fn set_paradise_enabled(&self, enabled: bool) -> Result<()>;
/// Récupère le chemin de la base de données d'historique
///
/// Le chemin retourné est absolu et pointe vers `<config_dir>/paradise/history.db`.
/// Le répertoire `paradise` est créé automatiquement s'il n'existe pas.
///
/// # Returns
///
/// Le chemin absolu vers la base de données SQLite d'historique.
/// Exemple: `/home/user/.config/pmo/paradise/history.db`
///
/// # Exemple
///
/// ```rust,ignore
/// let db_path = config.get_paradise_history_database()?;
/// // Exemple: "/home/user/.config/pmo/paradise_history.db"
/// let backend = SqliteHistoryBackend::new(&db_path)?;
/// ```
fn get_paradise_history_database(&self) -> Result<String>;
@@ -68,16 +125,15 @@ pub trait RadioParadiseConfigExt {
///
/// # Arguments
///
/// * `path` - Chemin de la base de données (absolu ou relatif au config_dir)
/// * `path` - Chemin complet vers la base de données (doit inclure le nom du fichier)
///
/// Le répertoire parent sera extrait et stocké dans la configuration.
///
/// # Exemple
///
/// ```rust,ignore
/// // Chemin relatif au config_dir
/// config.set_paradise_history_database("my_paradise.db".to_string())?;
///
/// // Ou chemin absolu
/// config.set_paradise_history_database("/var/lib/paradise.db".to_string())?;
/// // Set custom path
/// config.set_paradise_history_database("/var/lib/pmo/paradise.db".to_string())?;
/// ```
fn set_paradise_history_database(&self, path: String) -> Result<()>;
@@ -85,7 +141,10 @@ pub trait RadioParadiseConfigExt {
///
/// # Returns
///
/// Le nombre maximal de pistes à conserver dans l'historique (default: 100)
/// Le nombre maximal de pistes à conserver dans l'historique.
///
/// Si la valeur n'existe pas dans la configuration, elle est automatiquement
/// définie à la constante `HISTORY_DEFAULT_MAX_TRACKS` (100) et persistée.
///
/// # Exemple
///
@@ -104,32 +163,69 @@ pub trait RadioParadiseConfigExt {
/// # Exemple
///
/// ```rust,ignore
/// // Conserver les 200 dernières pistes
/// // Keep last 200 tracks
/// config.set_paradise_history_size(200)?;
/// ```
fn set_paradise_history_size(&self, size: usize) -> Result<()>;
}
impl RadioParadiseConfigExt for Config {
fn get_paradise_history_database(&self) -> Result<String> {
// Utilise get_managed_dir qui gère automatiquement les chemins
// relatifs au config_dir et les chemins absolus
self.get_managed_dir(
&["sources", "radio_paradise", "history", "database"],
DEFAULT_HISTORY_DATABASE,
fn get_paradise_enabled(&self) -> Result<bool> {
match self.get_value(&["sources", "radio_paradise", "enabled"]) {
Ok(Value::Bool(b)) => Ok(b),
_ => {
// Use default (enabled) and persist it
self.set_paradise_enabled(true)?;
Ok(true)
}
}
}
fn set_paradise_enabled(&self, enabled: bool) -> Result<()> {
self.set_value(
&["sources", "radio_paradise", "enabled"],
Value::Bool(enabled),
)
}
fn get_paradise_history_database(&self) -> Result<String> {
// Get managed directory: ~/.config/pmo/paradise/
let dir = self.get_managed_dir(
&["sources", "radio_paradise", "database"],
DEFAULT_HISTORY_DATABASE_DIR,
)?;
// Ensure directory exists
std::fs::create_dir_all(&dir)?;
// Build full path: ~/.config/pmo/paradise/history.db
let mut path = PathBuf::from(dir);
path.push("history.db");
Ok(path.to_string_lossy().to_string())
}
fn set_paradise_history_database(&self, path: String) -> Result<()> {
self.set_managed_dir(&["sources", "radio_paradise", "history", "database"], path)
// Extract parent directory from the full path
match PathBuf::from(&path).parent() {
Some(dir) => self.set_managed_dir(
&["sources", "radio_paradise", "database"],
dir.to_string_lossy().to_string(),
),
None => Err(anyhow!("Invalid database path: no parent directory")),
}
}
fn get_paradise_history_size(&self) -> Result<usize> {
// Tente de lire depuis la configuration YAML
match self.get_value(&["sources", "radio_paradise", "history", "max_tracks"]) {
Ok(Value::Number(n)) if n.is_u64() => Ok(n.as_u64().unwrap() as usize),
Ok(Value::Number(n)) if n.is_i64() => Ok(n.as_i64().unwrap() as usize),
_ => Ok(DEFAULT_HISTORY_SIZE),
_ => {
// Use default and persist it
let default = constants::HISTORY_DEFAULT_MAX_TRACKS;
self.set_paradise_history_size(default)?;
Ok(default)
}
}
}
@@ -148,8 +244,20 @@ mod tests {
#[test]
fn test_default_values() {
// Les valeurs par défaut doivent être cohérentes
assert_eq!(DEFAULT_HISTORY_DATABASE, "paradise_history.db");
assert_eq!(DEFAULT_HISTORY_SIZE, 100);
assert_eq!(DEFAULT_HISTORY_DATABASE_DIR, "paradise");
assert_eq!(constants::HISTORY_DEFAULT_MAX_TRACKS, 100);
}
#[test]
fn test_database_path_construction() {
// Simulating path construction
let base = "/home/user/.config/pmo/paradise";
let mut path = PathBuf::from(base);
path.push("history.db");
assert_eq!(
path.to_string_lossy(),
"/home/user/.config/pmo/paradise/history.db"
);
}
}

View File

@@ -5,7 +5,6 @@
//! a scaffolding of the final behaviour; commands sent to the worker are
//! logged but not yet executing the full download/buffering pipeline.
use super::config::RadioParadiseConfig;
use super::history::HistoryBackend;
use super::playlist::{PlaylistEntry, SharedPlaylist};
use super::worker::{ParadiseWorker, WorkerCommand};
@@ -128,7 +127,7 @@ pub struct ParadiseChannel {
struct ParadiseChannelInner {
descriptor: ChannelDescriptor,
client: RadioParadiseClient,
config: Arc<RadioParadiseConfig>,
history_max_tracks: usize,
playlist: SharedPlaylist,
history: Arc<dyn HistoryBackend>,
cache_manager: Arc<SourceCacheManager>,
@@ -154,16 +153,16 @@ impl ParadiseChannel {
pub fn new(
descriptor: ChannelDescriptor,
base_client: RadioParadiseClient,
config: Arc<RadioParadiseConfig>,
history_max_tracks: usize,
history: Arc<dyn HistoryBackend>,
cache_manager: Arc<SourceCacheManager>,
) -> Result<Self> {
let client = base_client.clone_with_channel(descriptor.id);
let playlist = SharedPlaylist::new(config.history.max_tracks);
let playlist = SharedPlaylist::new(history_max_tracks);
let (worker, worker_tx) = ParadiseWorker::spawn(
descriptor,
client.clone(),
config.clone(),
history_max_tracks,
playlist.clone(),
history.clone(),
cache_manager.clone(),
@@ -173,7 +172,7 @@ impl ParadiseChannel {
inner: Arc::new(ParadiseChannelInner {
descriptor,
client,
config,
history_max_tracks,
playlist,
history,
cache_manager,
@@ -192,8 +191,8 @@ impl ParadiseChannel {
&self.inner.playlist
}
pub fn config(&self) -> &Arc<RadioParadiseConfig> {
&self.inner.config
pub fn history_max_tracks(&self) -> usize {
self.inner.history_max_tracks
}
pub fn history_backend(&self) -> &Arc<dyn HistoryBackend> {
@@ -297,7 +296,7 @@ impl ParadiseChannel {
if let Err(err) = self
.inner
.history
.truncate(self.inner.config.history.max_tracks)
.truncate(self.inner.history_max_tracks)
.await
{
warn!(

View File

@@ -1,310 +0,0 @@
//! Configuration structures for the Radio Paradise orchestration layer.
//!
//! The YAML schema is described in the functional specification. We expose
//! strongly typed structs with sensible defaults so the rest of the crate can
//! depend on a stable configuration shape irrespective of how the data is
//! loaded (embedded defaults, pmoconfig overrides, tests, etc.).
use serde::{Deserialize, Serialize};
use std::time::Duration;
/// Top-level configuration block.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RadioParadiseConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub channels: Vec<String>,
#[serde(default)]
pub cache: CacheConfig,
#[serde(default)]
pub history: HistoryConfig,
#[serde(default)]
pub activity: ActivityConfig,
#[serde(default)]
pub polling: PollingConfig,
#[serde(default)]
pub stream: StreamConfig,
#[serde(default)]
pub api: ApiConfig,
}
impl Default for RadioParadiseConfig {
fn default() -> Self {
Self {
enabled: true,
channels: vec![
"main".to_string(),
"mellow".to_string(),
"rock".to_string(),
"eclectic".to_string(),
],
cache: CacheConfig::default(),
history: HistoryConfig::default(),
activity: ActivityConfig::default(),
polling: PollingConfig::default(),
stream: StreamConfig::default(),
api: ApiConfig::default(),
}
}
}
/// Cache related parameters.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheConfig {
#[serde(default = "CacheConfig::default_max_blocks")]
pub max_blocks_remembered: usize,
#[serde(default = "CacheConfig::default_track_id_bytes")]
pub track_id_hash_bytes: usize,
}
impl CacheConfig {
const fn default_max_blocks() -> usize {
5
}
const fn default_track_id_bytes() -> usize {
512
}
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
max_blocks_remembered: Self::default_max_blocks(),
track_id_hash_bytes: Self::default_track_id_bytes(),
}
}
}
/// Persisted history tuning.
///
/// Configuration for the SQLite-based history persistence.
/// The database path is managed via `RadioParadiseConfigExt` trait
/// and defaults to a relative path in the config directory.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistoryConfig {
#[serde(default = "HistoryConfig::default_max_tracks")]
pub max_tracks: usize,
#[serde(default = "HistoryConfig::default_database_path")]
pub database_path: String,
}
impl HistoryConfig {
const fn default_max_tracks() -> usize {
100
}
fn default_database_path() -> String {
// Default to a relative path (will be resolved by config_ext)
"paradise_history.db".to_string()
}
}
impl Default for HistoryConfig {
fn default() -> Self {
Self {
max_tracks: Self::default_max_tracks(),
database_path: Self::default_database_path(),
}
}
}
impl RadioParadiseConfig {
pub fn load_from_pmoconfig() -> anyhow::Result<Self> {
let cfg = pmoconfig::get_config();
match cfg.get_value(&["sources", "radio_paradise"]) {
Ok(value) => Ok(serde_yaml::from_value(value).unwrap_or_default()),
Err(_) => Ok(Self::default()),
}
}
}
/// Activity lifecycle tuning.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActivityConfig {
#[serde(default = "ActivityConfig::default_cooling_timeout")]
pub cooling_timeout_seconds: u64,
}
impl ActivityConfig {
const fn default_cooling_timeout() -> u64 {
180
}
pub fn cooling_timeout(&self) -> Duration {
Duration::from_secs(self.cooling_timeout_seconds)
}
}
impl Default for ActivityConfig {
fn default() -> Self {
Self {
cooling_timeout_seconds: Self::default_cooling_timeout(),
}
}
}
/// Polling strategy configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PollingConfig {
#[serde(default = "PollingConfig::default_interval_high")]
pub interval_high_buffer: u64,
#[serde(default = "PollingConfig::default_interval_medium")]
pub interval_medium_buffer: u64,
#[serde(default = "PollingConfig::default_interval_low")]
pub interval_low_buffer: u64,
#[serde(default)]
pub backoff_on_error: PollingBackoffConfig,
}
impl PollingConfig {
const fn default_interval_high() -> u64 {
120
}
const fn default_interval_medium() -> u64 {
60
}
const fn default_interval_low() -> u64 {
20
}
pub fn high_interval(&self) -> Duration {
Duration::from_secs(self.interval_high_buffer)
}
pub fn medium_interval(&self) -> Duration {
Duration::from_secs(self.interval_medium_buffer)
}
pub fn low_interval(&self) -> Duration {
Duration::from_secs(self.interval_low_buffer)
}
}
impl Default for PollingConfig {
fn default() -> Self {
Self {
interval_high_buffer: Self::default_interval_high(),
interval_medium_buffer: Self::default_interval_medium(),
interval_low_buffer: Self::default_interval_low(),
backoff_on_error: PollingBackoffConfig::default(),
}
}
}
/// Backoff policy for API errors.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PollingBackoffConfig {
#[serde(default = "PollingBackoffConfig::default_initial")]
pub initial: u64,
#[serde(default = "PollingBackoffConfig::default_max")]
pub max: u64,
#[serde(default = "PollingBackoffConfig::default_multiplier")]
pub multiplier: f32,
}
impl PollingBackoffConfig {
const fn default_initial() -> u64 {
20
}
const fn default_max() -> u64 {
300
}
const fn default_multiplier() -> f32 {
2.0
}
}
impl Default for PollingBackoffConfig {
fn default() -> Self {
Self {
initial: Self::default_initial(),
max: Self::default_max(),
multiplier: Self::default_multiplier(),
}
}
}
/// Streaming pipeline configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamConfig {
#[serde(default = "StreamConfig::default_metadata_format")]
pub metadata_format: MetadataFormat,
#[serde(default)]
pub enable_gapless: bool,
#[serde(default = "StreamConfig::default_buffer_size")]
pub buffer_size_bytes: usize,
}
impl StreamConfig {
fn default_metadata_format() -> MetadataFormat {
MetadataFormat::Icy
}
const fn default_buffer_size() -> usize {
64 * 1024
}
}
impl Default for StreamConfig {
fn default() -> Self {
Self {
metadata_format: MetadataFormat::Icy,
enable_gapless: true,
buffer_size_bytes: Self::default_buffer_size(),
}
}
}
/// Metadata transport for streaming.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MetadataFormat {
Icy,
#[serde(other)]
None,
}
/// Remote API tuning (timeouts, UA, etc.).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiConfig {
#[serde(default = "ApiConfig::default_base_url")]
pub base_url: String,
#[serde(default = "ApiConfig::default_timeout")]
pub timeout_seconds: u64,
#[serde(default = "ApiConfig::default_user_agent")]
pub user_agent: String,
}
impl ApiConfig {
fn default_base_url() -> String {
"https://api.radioparadise.com".to_string()
}
const fn default_timeout() -> u64 {
30
}
fn default_user_agent() -> String {
"PMO-RadioParadise/1.0".to_string()
}
pub fn timeout(&self) -> Duration {
Duration::from_secs(self.timeout_seconds)
}
}
impl Default for ApiConfig {
fn default() -> Self {
Self {
base_url: Self::default_base_url(),
timeout_seconds: Self::default_timeout(),
user_agent: Self::default_user_agent(),
}
}
}

View File

@@ -0,0 +1,208 @@
//! Constants for Radio Paradise orchestration layer.
//!
//! This module defines all the hardcoded parameters for the Radio Paradise
//! integration. These values are based on empirical testing and Radio Paradise's
//! infrastructure characteristics.
use std::time::Duration;
// ============================================================================
// Activity Lifecycle
// ============================================================================
/// Cooling timeout after all clients disconnect (seconds)
///
/// After the last client disconnects, the channel enters a "cooling" state
/// where it remains active for this duration before shutting down completely.
/// This avoids rapid start/stop cycles if clients reconnect quickly.
///
/// Value: 180 seconds (3 minutes) - good balance between responsiveness and stability
pub const COOLING_TIMEOUT_SECONDS: u64 = 180;
// ============================================================================
// Polling Intervals
// ============================================================================
/// High buffer polling interval (seconds)
///
/// When the playlist buffer has 3+ blocks, poll less frequently to reduce
/// API load and network usage.
///
/// Value: 120 seconds (2 minutes)
pub const POLLING_INTERVAL_HIGH_BUFFER: u64 = 120;
/// Medium buffer polling interval (seconds)
///
/// When the playlist buffer has 2 blocks, poll at moderate frequency.
///
/// Value: 60 seconds (1 minute)
pub const POLLING_INTERVAL_MEDIUM_BUFFER: u64 = 60;
/// Low buffer polling interval (seconds)
///
/// When the playlist buffer has less than 2 blocks, poll frequently to
/// ensure continuous playback.
///
/// Value: 20 seconds
pub const POLLING_INTERVAL_LOW_BUFFER: u64 = 20;
/// Helper to get high buffer polling interval as Duration
pub fn polling_high_interval() -> Duration {
Duration::from_secs(POLLING_INTERVAL_HIGH_BUFFER)
}
/// Helper to get medium buffer polling interval as Duration
pub fn polling_medium_interval() -> Duration {
Duration::from_secs(POLLING_INTERVAL_MEDIUM_BUFFER)
}
/// Helper to get low buffer polling interval as Duration
pub fn polling_low_interval() -> Duration {
Duration::from_secs(POLLING_INTERVAL_LOW_BUFFER)
}
// ============================================================================
// Polling Backoff (on API errors)
// ============================================================================
/// Initial backoff delay on API error (seconds)
///
/// When an API request fails, we wait this duration before retrying.
///
/// Value: 20 seconds
pub const BACKOFF_INITIAL_SECONDS: u64 = 20;
/// Maximum backoff delay (seconds)
///
/// Backoff is capped at this value to avoid waiting too long.
///
/// Value: 300 seconds (5 minutes)
pub const BACKOFF_MAX_SECONDS: u64 = 300;
/// Backoff multiplier
///
/// After each failure, the delay is multiplied by this factor.
/// Example: 20s → 40s → 80s → 160s → 300s (capped)
///
/// Value: 2.0 (exponential backoff)
pub const BACKOFF_MULTIPLIER: f32 = 2.0;
// ============================================================================
// Cache Tuning
// ============================================================================
/// Maximum number of blocks to remember in the worker
///
/// This prevents unbounded memory growth by limiting how many block event IDs
/// we track to avoid re-processing.
///
/// Calculation: (4 channels + 1 buffer) × 3 blocks per channel = 15 blocks
/// Each block is ~20 minutes of audio, so 15 blocks ≈ 5 hours of history
///
/// Value: 15 blocks
pub const MAX_BLOCKS_REMEMBERED: usize = 15;
/// Number of bytes to use for track ID hashing
///
/// Track IDs are constructed by hashing block content and track position.
/// This value defines how much of the FLAC data we read for hashing.
///
/// Value: 512 bytes - sufficient for unique identification without excessive I/O
pub const TRACK_ID_HASH_BYTES: usize = 512;
// ============================================================================
// History
// ============================================================================
/// Default maximum number of tracks to keep in history
///
/// This is used as the default if not configured via pmoconfig.
/// Users can override this value in their configuration.
///
/// Value: 100 tracks - represents ~5-8 hours of playback history
pub const HISTORY_DEFAULT_MAX_TRACKS: usize = 100;
// ============================================================================
// Streaming
// ============================================================================
/// Stream buffer size (bytes)
///
/// Buffer size for audio streaming. 64KB provides good balance between
/// latency and buffering efficiency.
///
/// Value: 64 KB
pub const STREAM_BUFFER_SIZE_BYTES: usize = 64 * 1024;
/// Enable gapless playback
///
/// Radio Paradise blocks are designed for gapless playback - each block
/// transitions seamlessly to the next without audio gaps.
///
/// Value: true (always enabled)
pub const STREAM_GAPLESS: bool = true;
// Note: Metadata format is always ICY (Icecast/SHOUTcast metadata)
// No enum or constant needed as it's the only supported format
// ============================================================================
// API Configuration
// ============================================================================
/// Radio Paradise API base URL
///
/// Base URL for all Radio Paradise API requests.
/// This is hardcoded as Radio Paradise's API endpoint doesn't change.
///
/// Value: https://api.radioparadise.com
pub const API_BASE_URL: &str = "https://api.radioparadise.com";
/// API request timeout (seconds)
///
/// Maximum time to wait for an API response before considering it failed.
///
/// Value: 30 seconds
pub const API_TIMEOUT_SECONDS: u64 = 30;
/// User agent for API requests
///
/// Identifies PMOMusic in HTTP requests to Radio Paradise's servers.
///
/// Value: PMO-RadioParadise/1.0
pub const API_USER_AGENT: &str = "PMO-RadioParadise/1.0";
/// Helper to get API timeout as Duration
pub fn api_timeout() -> Duration {
Duration::from_secs(API_TIMEOUT_SECONDS)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_duration_helpers() {
assert_eq!(polling_high_interval(), Duration::from_secs(120));
assert_eq!(polling_medium_interval(), Duration::from_secs(60));
assert_eq!(polling_low_interval(), Duration::from_secs(20));
assert_eq!(api_timeout(), Duration::from_secs(30));
}
#[test]
fn test_constants_sanity() {
// Polling intervals should be ordered
assert!(POLLING_INTERVAL_LOW_BUFFER < POLLING_INTERVAL_MEDIUM_BUFFER);
assert!(POLLING_INTERVAL_MEDIUM_BUFFER < POLLING_INTERVAL_HIGH_BUFFER);
// Backoff should be reasonable
assert!(BACKOFF_INITIAL_SECONDS < BACKOFF_MAX_SECONDS);
assert!(BACKOFF_MULTIPLIER > 1.0);
// Cache limits should be positive
assert!(MAX_BLOCKS_REMEMBERED > 0);
assert!(TRACK_ID_HASH_BYTES > 0);
// History should be reasonable
assert!(HISTORY_DEFAULT_MAX_TRACKS > 0);
}
}

View File

@@ -3,15 +3,12 @@
//! The worker pushes every completed track into the history backend while
//! keeping the latest entries available for UPnP browsing. We use SQLite
//! for persistent storage with an abstract trait for testability.
use super::config::HistoryConfig;
use crate::models::Song;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::{Arc, Mutex as StdMutex};
use tokio::sync::Mutex;
use tokio::task::spawn_blocking;
/// Serializable record describing a played track.
@@ -59,46 +56,6 @@ pub trait HistoryBackend: Send + Sync {
async fn truncate(&self, keep: usize) -> anyhow::Result<()>;
}
/// Creates a history backend from configuration.
///
/// This always creates a SQLite-based backend using the configured database path.
pub fn history_backend_from_config(
config: &HistoryConfig,
) -> anyhow::Result<Arc<dyn HistoryBackend>> {
let backend = SqliteHistoryBackend::new(&config.database_path)?;
Ok(Arc::new(backend))
}
#[async_trait]
impl HistoryBackend for MemoryHistoryBackend {
async fn append(&self, entry: HistoryEntry) -> anyhow::Result<()> {
let mut entries = self.entries.lock().await;
entries.push(entry);
Ok(())
}
async fn recent(&self, limit: usize) -> anyhow::Result<Vec<HistoryEntry>> {
let entries = self.entries.lock().await;
let total = entries.len();
let start = total.saturating_sub(limit);
Ok(entries[start..].to_vec())
}
async fn len(&self) -> anyhow::Result<usize> {
Ok(self.entries.lock().await.len())
}
async fn truncate(&self, keep: usize) -> anyhow::Result<()> {
let mut entries = self.entries.lock().await;
if entries.len() > keep {
let drop_count = entries.len() - keep;
entries.drain(0..drop_count);
}
Ok(())
}
}
pub struct SqliteHistoryBackend {
conn: Arc<StdMutex<rusqlite::Connection>>,
}
@@ -137,6 +94,7 @@ impl SqliteHistoryBackend {
}
}
#[async_trait]
impl HistoryBackend for SqliteHistoryBackend {
async fn append(&self, entry: HistoryEntry) -> anyhow::Result<()> {
@@ -240,3 +198,21 @@ impl HistoryBackend for SqliteHistoryBackend {
Ok(())
}
}
/// Creates a SQLite history backend with the given database path.
///
/// The database file and parent directories will be created if they don't exist.
///
/// # Arguments
///
/// * `database_path` - Path to the SQLite database file
///
/// # Example
///
/// ```rust,ignore
/// let backend = create_history_backend("/var/lib/pmo/history.db")?;
/// ```
pub fn create_history_backend(database_path: &str) -> anyhow::Result<Arc<dyn HistoryBackend>> {
let backend = SqliteHistoryBackend::new(database_path)?;
Ok(Arc::new(backend))
}

View File

@@ -8,12 +8,12 @@
//! - Shared caches and history storage hooked into existing PMO components.
//!
//! The implementation is split across several submodules to keep concerns
//! isolated (configuration, playlist management, history persistence, etc.).
//! isolated (constants, playlist management, history persistence, etc.).
//! The goal of this scaffolding is to provide a clear, testable surface for
//! the eventual end-to-end integration with the UPnP server and HTTP routes.
mod channel;
mod config;
pub mod constants;
mod history;
mod playlist;
mod worker;
@@ -22,10 +22,7 @@ pub use channel::{
max_channel_id, ChannelDescriptor, ParadiseChannel, ParadiseChannelKind, ParadiseClientStream,
ALL_CHANNELS,
};
pub use config::{
ActivityConfig, ApiConfig, CacheConfig, HistoryConfig, PollingConfig, RadioParadiseConfig,
StreamConfig,
};
pub use history::{history_backend_from_config, HistoryBackend, HistoryEntry};
pub use constants::*; // Export all constants
pub use history::{create_history_backend, HistoryBackend, HistoryEntry};
pub use playlist::PlaylistEntry;
pub use worker::{ParadiseWorker, WorkerCommand};

View File

@@ -5,7 +5,7 @@
//! and ensures fresh content is available according to the specification.
use super::channel::ChannelDescriptor;
use super::config::RadioParadiseConfig;
use super::constants::*;
use super::history::HistoryBackend;
use super::playlist::{PlaylistEntry, SharedPlaylist};
use crate::client::RadioParadiseClient;
@@ -46,7 +46,7 @@ impl ParadiseWorker {
pub fn spawn(
descriptor: ChannelDescriptor,
client: RadioParadiseClient,
config: Arc<RadioParadiseConfig>,
history_max_tracks: usize,
playlist: SharedPlaylist,
history: Arc<dyn HistoryBackend>,
cache_manager: Arc<SourceCacheManager>,
@@ -57,7 +57,7 @@ impl ParadiseWorker {
info!(channel = descriptor.slug, "Starting Radio Paradise worker");
let mut state =
WorkerState::new(descriptor, client, config, playlist, history, cache_manager);
WorkerState::new(descriptor, client, history_max_tracks, playlist, history, cache_manager);
loop {
if let Some(task) = state.scheduled_task.as_mut() {
@@ -141,7 +141,6 @@ impl ParadiseWorker {
struct WorkerState {
descriptor: ChannelDescriptor,
client: RadioParadiseClient,
config: Arc<RadioParadiseConfig>,
playlist: SharedPlaylist,
history: Arc<dyn HistoryBackend>,
cache_manager: Arc<SourceCacheManager>,
@@ -160,7 +159,7 @@ impl WorkerState {
fn new(
descriptor: ChannelDescriptor,
client: RadioParadiseClient,
config: Arc<RadioParadiseConfig>,
_history_max_tracks: usize,
playlist: SharedPlaylist,
history: Arc<dyn HistoryBackend>,
cache_manager: Arc<SourceCacheManager>,
@@ -168,7 +167,6 @@ impl WorkerState {
Self {
descriptor,
client,
config,
playlist,
history,
cache_manager,
@@ -241,9 +239,7 @@ impl WorkerState {
fn on_error(&mut self, err: anyhow::Error) {
warn!(channel = self.descriptor.slug, "Worker error: {err:?}");
let delay = self
.backoff
.next_delay(&self.config.polling.backoff_on_error);
let delay = self.backoff.next_delay();
self.schedule_task(ScheduledTaskKind::Poll, delay);
}
@@ -270,7 +266,7 @@ impl WorkerState {
"Channel entering Cooling state"
);
self.status = ChannelLifecycle::Cooling;
let duration = Duration::from_secs(self.config.activity.cooling_timeout_seconds.max(1));
let duration = Duration::from_secs(COOLING_TIMEOUT_SECONDS.max(1));
self.schedule_task(ScheduledTaskKind::Cooling, duration);
}
@@ -741,11 +737,11 @@ impl WorkerState {
let buffer_len = self.playlist.active_len().await;
let interval = if buffer_len > 3 {
self.config.polling.high_interval()
polling_high_interval()
} else if buffer_len >= 2 {
self.config.polling.medium_interval()
polling_medium_interval()
} else {
self.config.polling.low_interval()
polling_low_interval()
};
self.schedule_task(ScheduledTaskKind::Poll, interval);
@@ -769,7 +765,7 @@ impl WorkerState {
fn record_processed_block(&mut self, event: u64) {
self.processed_blocks.insert(event);
self.recent_blocks.push_back(event);
let max = self.config.cache.max_blocks_remembered.max(1);
let max = MAX_BLOCKS_REMEMBERED.max(1);
while self.recent_blocks.len() > max {
if let Some(ev) = self.recent_blocks.pop_front() {
self.processed_blocks.remove(&ev);
@@ -966,13 +962,13 @@ impl BackoffState {
self.current = None;
}
fn next_delay(&mut self, config: &super::config::PollingBackoffConfig) -> Duration {
fn next_delay(&mut self) -> Duration {
let next = match self.current {
Some(current) => {
let multiplied = (current.as_secs_f32() * config.multiplier).round() as u64;
Duration::from_secs(multiplied.min(config.max))
let multiplied = (current.as_secs_f32() * BACKOFF_MULTIPLIER).round() as u64;
Duration::from_secs(multiplied.min(BACKOFF_MAX_SECONDS))
}
None => Duration::from_secs(config.initial),
None => Duration::from_secs(BACKOFF_INITIAL_SECONDS),
};
self.current = Some(next);
next

View File

@@ -543,8 +543,6 @@ async fn get_channel_status(
})?;
let cache_stats = channel.cache_manager().statistics().await;
let config = channel.config().clone();
let configured = config.channels.iter().any(|slug| slug == descriptor.slug);
let status = ChannelStatusResponse {
channel_id,
@@ -556,8 +554,8 @@ async fn get_channel_status(
update_id,
last_change,
history_entries: history_len,
history_max_tracks: config.history.max_tracks,
configured,
history_max_tracks: channel.history_max_tracks(),
configured: true, // All channels are always available
cache_collection_id: cache_stats.collection_id,
cache_total_tracks: cache_stats.total_tracks,
cache_cached_tracks: cache_stats.cached_tracks,

View File

@@ -7,9 +7,11 @@
use crate::client::RadioParadiseClient;
use crate::paradise::{
history_backend_from_config, ChannelDescriptor, ParadiseChannel, PlaylistEntry,
RadioParadiseConfig, ALL_CHANNELS,
create_history_backend, ChannelDescriptor, ParadiseChannel, PlaylistEntry, ALL_CHANNELS,
};
#[cfg(not(feature = "pmoconfig"))]
use crate::paradise::HISTORY_DEFAULT_MAX_TRACKS;
use anyhow::Result as AnyhowResult;
use pmoaudiocache::Cache as AudioCache;
use pmocovers::Cache as CoverCache;
@@ -76,11 +78,9 @@ impl std::fmt::Debug for RadioParadiseSource {
impl RadioParadiseSource {
#[cfg(feature = "server")]
pub fn from_registry(client: RadioParadiseClient) -> Result<Self> {
let config = Arc::new(RadioParadiseConfig::load_from_pmoconfig().unwrap_or_default());
// Load history configuration from pmoconfig using the config extension trait
#[cfg(feature = "pmoconfig")]
let history_config = {
let (database_path, history_max_tracks) = {
use crate::config_ext::RadioParadiseConfigExt;
let cfg = pmoconfig::get_config();
let database_path = cfg.get_paradise_history_database().map_err(|e| {
@@ -92,16 +92,22 @@ impl RadioParadiseSource {
let max_tracks = cfg.get_paradise_history_size().map_err(|e| {
MusicSourceError::SourceUnavailable(format!("Failed to get history size: {}", e))
})?;
crate::paradise::HistoryConfig {
database_path,
max_tracks,
}
(database_path, max_tracks)
};
#[cfg(not(feature = "pmoconfig"))]
let history_config = config.history.clone();
let (database_path, history_max_tracks) = {
use std::path::PathBuf;
let mut path = PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| ".".to_string()));
path.push(".config");
path.push("pmo");
path.push("paradise");
std::fs::create_dir_all(&path).ok();
path.push("history.db");
(path.to_string_lossy().to_string(), HISTORY_DEFAULT_MAX_TRACKS)
};
let history_backend = history_backend_from_config(&history_config).map_err(|e| {
let history_backend = create_history_backend(&database_path).map_err(|e| {
MusicSourceError::SourceUnavailable(format!(
"Failed to initialize history backend: {}",
e
@@ -117,7 +123,7 @@ impl RadioParadiseSource {
ParadiseChannel::new(
*descriptor,
client.clone(),
config.clone(),
history_max_tracks,
history_backend.clone(),
cache_manager,
)
@@ -146,9 +152,34 @@ impl RadioParadiseSource {
cover_cache: Arc<CoverCache>,
audio_cache: Arc<AudioCache>,
) -> Self {
let config = Arc::new(RadioParadiseConfig::load_from_pmoconfig().unwrap_or_default());
// Load history configuration from pmoconfig using the config extension trait
#[cfg(feature = "pmoconfig")]
let (database_path, history_max_tracks) = {
use crate::config_ext::RadioParadiseConfigExt;
let cfg = pmoconfig::get_config();
let database_path = cfg.get_paradise_history_database().unwrap_or_else(|e| {
panic!("Failed to get history database path: {e}");
});
let max_tracks = cfg.get_paradise_history_size().unwrap_or_else(|e| {
panic!("Failed to get history size: {e}");
});
(database_path, max_tracks)
};
#[cfg(not(feature = "pmoconfig"))]
let (database_path, history_max_tracks) = {
use std::path::PathBuf;
let mut path = PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| ".".to_string()));
path.push(".config");
path.push("pmo");
path.push("paradise");
std::fs::create_dir_all(&path).ok();
path.push("history.db");
(path.to_string_lossy().to_string(), HISTORY_DEFAULT_MAX_TRACKS)
};
let history_backend: Arc<dyn crate::paradise::HistoryBackend> =
history_backend_from_config(&config.history).unwrap_or_else(|err| {
create_history_backend(&database_path).unwrap_or_else(|err| {
panic!("Failed to initialize history backend: {err}");
});
let mut channels = HashMap::new();
@@ -162,7 +193,7 @@ impl RadioParadiseSource {
match ParadiseChannel::new(
*descriptor,
client.clone(),
config.clone(),
history_max_tracks,
history_backend.clone(),
cache_manager,
) {