From 261ac885c1758a790a01df3a98c1e4020373e41c Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sun, 26 Oct 2025 15:16:40 +0100 Subject: [PATCH] =?UTF-8?q?encore=20des=20probl=C3=A8mes=20de=20configurat?= =?UTF-8?q?ion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 47 +++- pmoconfig/src/lib.rs | 5 +- pmoparadise/Cargo.toml | 6 +- pmoparadise/src/config_ext.rs | 188 ++++++++++++---- pmoparadise/src/paradise/channel.rs | 17 +- pmoparadise/src/paradise/config.rs | 310 -------------------------- pmoparadise/src/paradise/constants.rs | 208 +++++++++++++++++ pmoparadise/src/paradise/history.rs | 62 ++---- pmoparadise/src/paradise/mod.rs | 11 +- pmoparadise/src/paradise/worker.rs | 32 ++- pmoparadise/src/pmoserver_ext.rs | 6 +- pmoparadise/src/source.rs | 61 +++-- 12 files changed, 496 insertions(+), 457 deletions(-) delete mode 100644 pmoparadise/src/paradise/config.rs create mode 100644 pmoparadise/src/paradise/constants.rs diff --git a/Cargo.lock b/Cargo.lock index 4b3796a4..6b56e562 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/pmoconfig/src/lib.rs b/pmoconfig/src/lib.rs index d6d657db..5f6699a8 100644 --- a/pmoconfig/src/lib.rs +++ b/pmoconfig/src/lib.rs @@ -461,7 +461,10 @@ impl Config { pub fn get_managed_dir(&self, path: &[&str], default: &str) -> Result { 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) } diff --git a/pmoparadise/Cargo.toml b/pmoparadise/Cargo.toml index 2bb01f41..970b2561 100644 --- a/pmoparadise/Cargo.toml +++ b/pmoparadise/Cargo.toml @@ -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" diff --git a/pmoparadise/src/config_ext.rs b/pmoparadise/src/config_ext.rs index 3fa8f5a1..9a0a79f9 100644 --- a/pmoparadise/src/config_ext.rs +++ b/pmoparadise/src/config_ext.rs @@ -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 `/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: `/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; + + /// 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 `/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; @@ -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 { - // 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 { + 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 { + // 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 { - // 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" + ); } } diff --git a/pmoparadise/src/paradise/channel.rs b/pmoparadise/src/paradise/channel.rs index 0585baa3..a76edb56 100644 --- a/pmoparadise/src/paradise/channel.rs +++ b/pmoparadise/src/paradise/channel.rs @@ -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, + history_max_tracks: usize, playlist: SharedPlaylist, history: Arc, cache_manager: Arc, @@ -154,16 +153,16 @@ impl ParadiseChannel { pub fn new( descriptor: ChannelDescriptor, base_client: RadioParadiseClient, - config: Arc, + history_max_tracks: usize, history: Arc, cache_manager: Arc, ) -> Result { 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 { - &self.inner.config + pub fn history_max_tracks(&self) -> usize { + self.inner.history_max_tracks } pub fn history_backend(&self) -> &Arc { @@ -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!( diff --git a/pmoparadise/src/paradise/config.rs b/pmoparadise/src/paradise/config.rs deleted file mode 100644 index 4316c7aa..00000000 --- a/pmoparadise/src/paradise/config.rs +++ /dev/null @@ -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, - #[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 { - 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(), - } - } -} diff --git a/pmoparadise/src/paradise/constants.rs b/pmoparadise/src/paradise/constants.rs new file mode 100644 index 00000000..d6dd6631 --- /dev/null +++ b/pmoparadise/src/paradise/constants.rs @@ -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); + } +} diff --git a/pmoparadise/src/paradise/history.rs b/pmoparadise/src/paradise/history.rs index 88a14e34..279d6819 100644 --- a/pmoparadise/src/paradise/history.rs +++ b/pmoparadise/src/paradise/history.rs @@ -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> { - 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> { - 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 { - 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>, } @@ -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> { + let backend = SqliteHistoryBackend::new(database_path)?; + Ok(Arc::new(backend)) +} diff --git a/pmoparadise/src/paradise/mod.rs b/pmoparadise/src/paradise/mod.rs index 1826b8b4..9aee345c 100644 --- a/pmoparadise/src/paradise/mod.rs +++ b/pmoparadise/src/paradise/mod.rs @@ -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}; diff --git a/pmoparadise/src/paradise/worker.rs b/pmoparadise/src/paradise/worker.rs index 1cab0ab5..50123668 100644 --- a/pmoparadise/src/paradise/worker.rs +++ b/pmoparadise/src/paradise/worker.rs @@ -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, + history_max_tracks: usize, playlist: SharedPlaylist, history: Arc, cache_manager: Arc, @@ -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, playlist: SharedPlaylist, history: Arc, cache_manager: Arc, @@ -160,7 +159,7 @@ impl WorkerState { fn new( descriptor: ChannelDescriptor, client: RadioParadiseClient, - config: Arc, + _history_max_tracks: usize, playlist: SharedPlaylist, history: Arc, cache_manager: Arc, @@ -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 diff --git a/pmoparadise/src/pmoserver_ext.rs b/pmoparadise/src/pmoserver_ext.rs index df4702ad..7ddf4f17 100644 --- a/pmoparadise/src/pmoserver_ext.rs +++ b/pmoparadise/src/pmoserver_ext.rs @@ -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, diff --git a/pmoparadise/src/source.rs b/pmoparadise/src/source.rs index abc203a6..17b4b2a4 100644 --- a/pmoparadise/src/source.rs +++ b/pmoparadise/src/source.rs @@ -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 { - 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, audio_cache: Arc, ) -> 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 = - 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, ) {