Merge branch 'claude/add-pmoplaylist-source-011CUpmZ9YbyUAUshEePVTJi' into claude/add-pmoplaylist-source-011CUq8bHCyjrEqGxCCXuvfh
This commit is contained in:
@@ -17,8 +17,8 @@ host:
|
|||||||
udn: uuid:28963b75-4c5f-4da7-b10e-ffafd
|
udn: uuid:28963b75-4c5f-4da7-b10e-ffafd
|
||||||
accounts:
|
accounts:
|
||||||
qobuz:
|
qobuz:
|
||||||
username: eric@coissac.eu
|
username: your-email@example.com
|
||||||
password: '*Misfcr73110$'
|
password: 'YOUR_PASSWORD_HERE'
|
||||||
devices:
|
devices:
|
||||||
mediarenderer:
|
mediarenderer:
|
||||||
pmo_mediarenderer:
|
pmo_mediarenderer:
|
||||||
137
Cargo.lock
generated
137
Cargo.lock
generated
@@ -463,6 +463,8 @@ dependencies = [
|
|||||||
"cexpr",
|
"cexpr",
|
||||||
"clang-sys",
|
"clang-sys",
|
||||||
"itertools 0.13.0",
|
"itertools 0.13.0",
|
||||||
|
"log",
|
||||||
|
"prettyplease",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"regex",
|
"regex",
|
||||||
@@ -1180,31 +1182,6 @@ dependencies = [
|
|||||||
"simd-adler32",
|
"simd-adler32",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ffmpeg-next"
|
|
||||||
version = "8.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d658424d233cbd993a972dd73a66ca733acd12a494c68995c9ac32ae1fe65b40"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.10.0",
|
|
||||||
"ffmpeg-sys-next",
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ffmpeg-sys-next"
|
|
||||||
version = "8.0.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9bca20aa4ee774fe384c2490096c122b0b23cf524a9910add0686691003d797b"
|
|
||||||
dependencies = [
|
|
||||||
"bindgen",
|
|
||||||
"cc",
|
|
||||||
"libc",
|
|
||||||
"num_cpus",
|
|
||||||
"pkg-config",
|
|
||||||
"vcpkg",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@@ -2439,14 +2416,96 @@ dependencies = [
|
|||||||
name = "netstat2"
|
name = "netstat2"
|
||||||
version = "0.9.1"
|
version = "0.9.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0faa3f4ad230fd2bf2a5dad71476ecbaeaed904b3c7e7e5b1f266c415c03761f"
|
checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"jni-sys",
|
||||||
|
"log",
|
||||||
|
"ndk-sys",
|
||||||
|
"num_enum",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ndk-context"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ndk-sys"
|
||||||
|
version = "0.5.0+25.2.9519653"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691"
|
||||||
|
dependencies = [
|
||||||
|
"jni-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "netlink-packet-core"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "72724faf704479d67b388da142b186f916188505e7e0b26719019c525882eda4"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"byteorder",
|
||||||
|
"netlink-packet-utils",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "netlink-packet-sock-diag"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a495cb1de50560a7cd12fdcf023db70eec00e340df81be31cedbbfd4aadd6b76"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
"bitflags 1.3.2",
|
"bitflags 1.3.2",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"libc",
|
"libc",
|
||||||
|
"netlink-packet-core",
|
||||||
|
"netlink-packet-utils",
|
||||||
|
"smallvec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "netlink-packet-utils"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0ede8a08c71ad5a95cdd0e4e52facd37190977039a4704eb82a283f713747d34"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"byteorder",
|
||||||
|
"paste",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "netlink-sys"
|
||||||
|
version = "0.8.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "16c903aa70590cb93691bf97a767c8d1d6122d2cc9070433deb3bbf36ce8bd23"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "netstat2"
|
||||||
|
version = "0.11.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "496f264d3ead4870d6b366deb9d20597592d64aac2a907f3e7d07c2325ba4663"
|
||||||
|
dependencies = [
|
||||||
|
"bindgen",
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"byteorder",
|
||||||
|
"netlink-packet-core",
|
||||||
|
"netlink-packet-sock-diag",
|
||||||
|
"netlink-packet-utils",
|
||||||
|
"netlink-sys",
|
||||||
"num-derive 0.3.3",
|
"num-derive 0.3.3",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"thiserror 1.0.69",
|
"thiserror 2.0.17",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2828,6 +2887,7 @@ dependencies = [
|
|||||||
"pmoflac",
|
"pmoflac",
|
||||||
"pmometadata",
|
"pmometadata",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
"rodio",
|
||||||
"soxr",
|
"soxr",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -2845,6 +2905,7 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"pmoaudio",
|
"pmoaudio",
|
||||||
"pmoaudiocache",
|
"pmoaudiocache",
|
||||||
|
"pmocache",
|
||||||
"pmocovers",
|
"pmocovers",
|
||||||
"pmoflac",
|
"pmoflac",
|
||||||
"pmometadata",
|
"pmometadata",
|
||||||
@@ -3034,14 +3095,17 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
"claxon",
|
"claxon",
|
||||||
"ffmpeg-next",
|
|
||||||
"flacenc",
|
"flacenc",
|
||||||
"futures",
|
"futures",
|
||||||
|
"futures-util",
|
||||||
"hex",
|
"hex",
|
||||||
"hound",
|
"pmoaudio",
|
||||||
|
"pmoaudio-ext",
|
||||||
"pmoaudiocache",
|
"pmoaudiocache",
|
||||||
"pmoconfig",
|
"pmoconfig",
|
||||||
"pmocovers",
|
"pmocovers",
|
||||||
|
"pmoflac",
|
||||||
|
"pmometadata",
|
||||||
"pmoplaylist",
|
"pmoplaylist",
|
||||||
"pmoserver",
|
"pmoserver",
|
||||||
"pmosource",
|
"pmosource",
|
||||||
@@ -3052,7 +3116,6 @@ dependencies = [
|
|||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
"sha2",
|
"sha2",
|
||||||
"symphonia",
|
"symphonia",
|
||||||
"tempfile",
|
|
||||||
"thiserror 2.0.17",
|
"thiserror 2.0.17",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-test",
|
"tokio-test",
|
||||||
@@ -3644,6 +3707,20 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rodio"
|
||||||
|
version = "0.19.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6006a627c1a38d37f3d3a85c6575418cfe34a5392d60a686d0071e1c8d427acb"
|
||||||
|
dependencies = [
|
||||||
|
"claxon",
|
||||||
|
"cpal",
|
||||||
|
"hound",
|
||||||
|
"lewton",
|
||||||
|
"symphonia",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rusqlite"
|
name = "rusqlite"
|
||||||
version = "0.37.0"
|
version = "0.37.0"
|
||||||
|
|||||||
27
SECURITY_CONFIG.md
Normal file
27
SECURITY_CONFIG.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Configuration Sécurisée
|
||||||
|
|
||||||
|
## Configuration de PMOMusic
|
||||||
|
|
||||||
|
Le fichier `.pmomusic.yml` contient des informations sensibles (mots de passe, identifiants).
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
1. Copiez le fichier exemple :
|
||||||
|
```bash
|
||||||
|
cp .pmomusic.yml.example .pmomusic.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Éditez `.pmomusic.yml` et remplacez les valeurs par vos véritables identifiants :
|
||||||
|
- `accounts.qobuz.username` : votre email Qobuz
|
||||||
|
- `accounts.qobuz.password` : votre mot de passe Qobuz
|
||||||
|
|
||||||
|
3. **Important** : Ne commitez JAMAIS le fichier `.pmomusic.yml` dans git !
|
||||||
|
- Il est déjà dans `.gitignore`
|
||||||
|
- Utilisez des variables d'environnement pour la production
|
||||||
|
|
||||||
|
## Variables d'environnement (recommandé pour production)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export QOBUZ_USERNAME="votre-email@example.com"
|
||||||
|
export QOBUZ_PASSWORD="votre-mot-de-passe"
|
||||||
|
```
|
||||||
@@ -13,8 +13,9 @@ pmoaudiocache = { path = "../pmoaudiocache", optional = true }
|
|||||||
pmoflac = { path = "../pmoflac", optional = true }
|
pmoflac = { path = "../pmoflac", optional = true }
|
||||||
pmometadata = { path = "../pmometadata", optional = true }
|
pmometadata = { path = "../pmometadata", optional = true }
|
||||||
|
|
||||||
# Optional dependency for playlist integration
|
# Optional dependencies for playlist integration
|
||||||
pmoplaylist = { path = "../pmoplaylist", optional = true }
|
pmoplaylist = { path = "../pmoplaylist", optional = true }
|
||||||
|
pmocache = { path = "../pmocache", optional = true }
|
||||||
# Async runtime
|
# Async runtime
|
||||||
tokio = { version = "1.0", features = ["full"] }
|
tokio = { version = "1.0", features = ["full"] }
|
||||||
tokio-util = { version = "0.7" }
|
tokio-util = { version = "0.7" }
|
||||||
@@ -26,5 +27,5 @@ tracing = "0.1"
|
|||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
cache-sink = ["dep:pmoaudiocache", "dep:pmoflac", "dep:pmometadata"]
|
cache-sink = ["dep:pmoaudiocache", "dep:pmoflac", "dep:pmometadata"]
|
||||||
playlist = ["dep:pmoplaylist"]
|
playlist = ["cache-sink", "dep:pmoplaylist", "dep:pmocache"]
|
||||||
all = ["cache-sink", "playlist"]
|
all = ["cache-sink", "playlist"]
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
//! # Features
|
//! # Features
|
||||||
//!
|
//!
|
||||||
//! - `cache-sink` : Active le `FlacCacheSink` qui encode l'audio en FLAC et le stocke dans pmoaudiocache
|
//! - `cache-sink` : Active le `FlacCacheSink` qui encode l'audio en FLAC et le stocke dans pmoaudiocache
|
||||||
//! - `playlist` : Active l'intégration avec pmoplaylist pour les sinks
|
//! - `playlist` : Active l'intégration avec pmoplaylist (sources et sinks)
|
||||||
//! - `all` : Active toutes les features d'un coup
|
//! - `all` : Active toutes les features d'un coup
|
||||||
//!
|
//!
|
||||||
//! # Architecture
|
//! # Architecture
|
||||||
@@ -25,6 +25,12 @@
|
|||||||
#[cfg(feature = "cache-sink")]
|
#[cfg(feature = "cache-sink")]
|
||||||
pub mod sinks;
|
pub mod sinks;
|
||||||
|
|
||||||
|
#[cfg(feature = "playlist")]
|
||||||
|
pub mod sources;
|
||||||
|
|
||||||
// Re-exports pour faciliter l'utilisation
|
// Re-exports pour faciliter l'utilisation
|
||||||
#[cfg(feature = "cache-sink")]
|
#[cfg(feature = "cache-sink")]
|
||||||
pub use sinks::*;
|
pub use sinks::*;
|
||||||
|
|
||||||
|
#[cfg(feature = "playlist")]
|
||||||
|
pub use sources::*;
|
||||||
|
|||||||
@@ -251,8 +251,6 @@ impl NodeLogic for FlacCacheSinkLogic {
|
|||||||
|
|
||||||
pub struct FlacCacheSink {
|
pub struct FlacCacheSink {
|
||||||
inner: Node<FlacCacheSinkLogic>,
|
inner: Node<FlacCacheSinkLogic>,
|
||||||
#[cfg(feature = "playlist")]
|
|
||||||
playlist_handle_pending: Option<Arc<pmoplaylist::WriteHandle>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FlacCacheSink {
|
impl FlacCacheSink {
|
||||||
@@ -297,8 +295,6 @@ impl FlacCacheSink {
|
|||||||
let logic = FlacCacheSinkLogic::new(cache, covers, collection, encoder_options, 8);
|
let logic = FlacCacheSinkLogic::new(cache, covers, collection, encoder_options, 8);
|
||||||
Self {
|
Self {
|
||||||
inner: Node::new_with_input(logic, channel_size),
|
inner: Node::new_with_input(logic, channel_size),
|
||||||
#[cfg(feature = "playlist")]
|
|
||||||
playlist_handle_pending: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,7 +305,7 @@ impl FlacCacheSink {
|
|||||||
/// * `handle` - WriteHandle de la playlist qui recevra les pk des tracks
|
/// * `handle` - WriteHandle de la playlist qui recevra les pk des tracks
|
||||||
#[cfg(feature = "playlist")]
|
#[cfg(feature = "playlist")]
|
||||||
pub fn register_playlist(&mut self, handle: pmoplaylist::WriteHandle) {
|
pub fn register_playlist(&mut self, handle: pmoplaylist::WriteHandle) {
|
||||||
self.playlist_handle_pending = Some(Arc::new(handle));
|
self.inner.logic_mut().set_playlist_handle(Arc::new(handle));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,16 +645,7 @@ impl AudioPipelineNode for FlacCacheSink {
|
|||||||
panic!("FlacCacheSink is a terminal sink and cannot have children");
|
panic!("FlacCacheSink is a terminal sink and cannot have children");
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run(mut self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
|
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
|
||||||
// Transférer le playlist_handle_pending à la logique si présent
|
|
||||||
#[cfg(feature = "playlist")]
|
|
||||||
if let Some(handle) = self.playlist_handle_pending.take() {
|
|
||||||
// FIXME: Node devrait exposer une méthode logic_mut() pour permettre
|
|
||||||
// la configuration post-construction. Pour l'instant, on ignore ce handle.
|
|
||||||
// L'utilisateur devra configurer la playlist avant construction.
|
|
||||||
let _ = handle;
|
|
||||||
}
|
|
||||||
|
|
||||||
Box::new(self.inner).run(stop_token).await
|
Box::new(self.inner).run(stop_token).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
10
pmoaudio-ext/src/sources/mod.rs
Normal file
10
pmoaudio-ext/src/sources/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
//! Sources audio étendues pour pmoaudio
|
||||||
|
//!
|
||||||
|
//! Ce module contient des sources audio qui dépendent d'autres crates
|
||||||
|
//! du projet PMO (pmoplaylist, pmoaudiocache, etc.)
|
||||||
|
|
||||||
|
#[cfg(feature = "playlist")]
|
||||||
|
mod playlist_source;
|
||||||
|
|
||||||
|
#[cfg(feature = "playlist")]
|
||||||
|
pub use playlist_source::PlaylistSource;
|
||||||
852
pmoaudio-ext/src/sources/playlist_source.rs
Normal file
852
pmoaudio-ext/src/sources/playlist_source.rs
Normal file
@@ -0,0 +1,852 @@
|
|||||||
|
//! PlaylistSource - Source audio depuis une playlist pmoplaylist
|
||||||
|
//!
|
||||||
|
//! Cette source lit une playlist (via `ReadHandle`) et émet un flux audio
|
||||||
|
//! continu en décodant les fichiers depuis le cache audio.
|
||||||
|
//!
|
||||||
|
//! # ⚠️ Format de sortie hétérogène
|
||||||
|
//!
|
||||||
|
//! **IMPORTANT** : Cette source émet du PCM avec des caractéristiques
|
||||||
|
//! **variables** selon les fichiers sources :
|
||||||
|
//! - **Sample rate** : peut varier (44.1kHz, 48kHz, 96kHz, etc.)
|
||||||
|
//! - **Bit depth** : peut varier (I16, I24, I32)
|
||||||
|
//!
|
||||||
|
//! Pour obtenir un flux **homogène**, ajoutez les nœuds suivants dans le pipeline :
|
||||||
|
//! - `ResamplingNode` : normalise le sample_rate (à implémenter dans pmoaudio)
|
||||||
|
//! - `ToI24Node` / `ToI16Node` : normalise la profondeur de bits
|
||||||
|
//!
|
||||||
|
//! # Cas d'usage
|
||||||
|
//!
|
||||||
|
//! ## Radio Paradise (format homogène connu)
|
||||||
|
//! ```rust,no_run
|
||||||
|
//! use pmoaudio_ext::PlaylistSource;
|
||||||
|
//! use pmoaudio::ToI24Node;
|
||||||
|
//! use pmoplaylist::PlaylistManager;
|
||||||
|
//! use pmoaudiocache::AudioCache;
|
||||||
|
//! use std::sync::Arc;
|
||||||
|
//!
|
||||||
|
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
//! let manager = PlaylistManager::get();
|
||||||
|
//! let read_handle = manager.get_read_handle("radio-paradise").await?;
|
||||||
|
//! let cache = Arc::new(AudioCache::new("./cache", 500)?);
|
||||||
|
//!
|
||||||
|
//! let mut source = PlaylistSource::new(read_handle, cache);
|
||||||
|
//! let to_i24 = ToI24Node::new();
|
||||||
|
//! source.register(to_i24);
|
||||||
|
//! # Ok(())
|
||||||
|
//! # }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! ## Playlist mixte (nécessite homogénéisation)
|
||||||
|
//! ```rust,no_run
|
||||||
|
//! use pmoaudio_ext::PlaylistSource;
|
||||||
|
//! use pmoaudio::{ToI24Node, ResamplingNode};
|
||||||
|
//! # use pmoplaylist::PlaylistManager;
|
||||||
|
//! # use pmoaudiocache::AudioCache;
|
||||||
|
//! # use std::sync::Arc;
|
||||||
|
//!
|
||||||
|
//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
//! # let manager = PlaylistManager::get();
|
||||||
|
//! # let read_handle = manager.get_read_handle("mixed").await?;
|
||||||
|
//! # let cache = Arc::new(AudioCache::new("./cache", 500)?);
|
||||||
|
//! let mut source = PlaylistSource::new(read_handle, cache);
|
||||||
|
//! let mut resampler = ResamplingNode::new(48000); // Force 48kHz
|
||||||
|
//! let to_i24 = ToI24Node::new(); // Force I24
|
||||||
|
//! source.register(Box::new(resampler));
|
||||||
|
//! resampler.register(Box::new(to_i24));
|
||||||
|
//! # Ok(())
|
||||||
|
//! # }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! # Comportement
|
||||||
|
//!
|
||||||
|
//! - **Polling** : Si la playlist est vide, attend `poll_interval_ms` avant de réessayer
|
||||||
|
//! - **TrackBoundary** : Émet un marqueur avec metadata entre chaque piste
|
||||||
|
//! - **Erreurs** : Si un fichier est inaccessible, émet un `Error` marker et continue
|
||||||
|
//! - **Arrêt** : Via `CancellationToken`, émet `EndOfStream` avant de terminer
|
||||||
|
//!
|
||||||
|
//! # Synchronisation
|
||||||
|
//!
|
||||||
|
//! - `TopZeroSync` : émis une seule fois au début
|
||||||
|
//! - `TrackBoundary` : émis avant chaque nouvelle piste (contient metadata)
|
||||||
|
//! - Pas d'`EndOfStream` entre les pistes (flux continu)
|
||||||
|
//! - `EndOfStream` final uniquement lors de l'arrêt
|
||||||
|
|
||||||
|
use pmoaudio::{
|
||||||
|
nodes::{AudioError, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS},
|
||||||
|
pipeline::{AudioPipelineNode, Node, NodeLogic},
|
||||||
|
type_constraints::TypeRequirement,
|
||||||
|
AudioChunk, AudioChunkData, AudioSegment, I24,
|
||||||
|
};
|
||||||
|
use pmoaudiocache::Cache as AudioCache;
|
||||||
|
use pmoflac::{decode_audio_stream, StreamInfo};
|
||||||
|
use pmoplaylist::ReadHandle;
|
||||||
|
use std::{path::PathBuf, sync::Arc, time::Duration};
|
||||||
|
use tokio::{fs::File, io::AsyncReadExt, sync::mpsc};
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
use tracing;
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// PlaylistSourceLogic - Logique pure de lecture de playlist
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// Logique pure de lecture de playlist
|
||||||
|
///
|
||||||
|
/// Contient seulement la logique de lecture de playlist et décodage des pistes,
|
||||||
|
/// sans la plomberie d'orchestration (gérée par Node<PlaylistSourceLogic>).
|
||||||
|
pub struct PlaylistSourceLogic {
|
||||||
|
playlist_handle: ReadHandle,
|
||||||
|
cache: Arc<AudioCache>,
|
||||||
|
chunk_frames: usize,
|
||||||
|
poll_interval_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlaylistSourceLogic {
|
||||||
|
pub fn new(
|
||||||
|
playlist_handle: ReadHandle,
|
||||||
|
cache: Arc<AudioCache>,
|
||||||
|
chunk_frames: usize,
|
||||||
|
poll_interval_ms: u64,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
playlist_handle,
|
||||||
|
cache,
|
||||||
|
chunk_frames,
|
||||||
|
poll_interval_ms,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl NodeLogic for PlaylistSourceLogic {
|
||||||
|
async fn process(
|
||||||
|
&mut self,
|
||||||
|
_input: Option<mpsc::Receiver<Arc<AudioSegment>>>,
|
||||||
|
output: Vec<mpsc::Sender<Arc<AudioSegment>>>,
|
||||||
|
stop_token: CancellationToken,
|
||||||
|
) -> Result<(), AudioError> {
|
||||||
|
tracing::debug!(
|
||||||
|
"PlaylistSourceLogic::process started, playlist={}, {} children",
|
||||||
|
self.playlist_handle.id(),
|
||||||
|
output.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Macro helper pour envoyer à tous les enfants
|
||||||
|
macro_rules! send_to_children {
|
||||||
|
($segment:expr) => {
|
||||||
|
for tx in &output {
|
||||||
|
tx.send($segment.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|_| AudioError::ChildDied)?;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut first_track = true;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Vérifier arrêt immédiat
|
||||||
|
if stop_token.is_cancelled() {
|
||||||
|
tracing::info!("PlaylistSourceLogic: stop requested, emitting EndOfStream");
|
||||||
|
let eos = AudioSegment::new_end_of_stream(0, 0.0);
|
||||||
|
send_to_children!(eos);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pop avec timeout pour supporter stop_token
|
||||||
|
let track = tokio::select! {
|
||||||
|
_ = stop_token.cancelled() => {
|
||||||
|
tracing::info!("PlaylistSourceLogic: stop cancelled during pop");
|
||||||
|
let eos = AudioSegment::new_end_of_stream(0, 0.0);
|
||||||
|
send_to_children!(eos);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
result = self.playlist_handle.pop() => {
|
||||||
|
match result {
|
||||||
|
Ok(Some(t)) => {
|
||||||
|
tracing::debug!("PlaylistSourceLogic: popped track from playlist");
|
||||||
|
t
|
||||||
|
},
|
||||||
|
Ok(None) => {
|
||||||
|
// Playlist vide, attendre avant retry
|
||||||
|
tracing::trace!(
|
||||||
|
"PlaylistSourceLogic: playlist empty, waiting {}ms",
|
||||||
|
self.poll_interval_ms
|
||||||
|
);
|
||||||
|
tokio::time::sleep(
|
||||||
|
Duration::from_millis(self.poll_interval_ms)
|
||||||
|
).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Erreur playlist (deleted, etc.)
|
||||||
|
tracing::warn!("PlaylistSourceLogic: playlist error: {}", e);
|
||||||
|
let error_marker = AudioSegment::new_error(
|
||||||
|
0,
|
||||||
|
0.0,
|
||||||
|
format!("Playlist error: {}", e)
|
||||||
|
);
|
||||||
|
send_to_children!(error_marker);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Émettre TopZeroSync pour la première piste seulement
|
||||||
|
if first_track {
|
||||||
|
tracing::debug!("PlaylistSourceLogic: emitting TopZeroSync");
|
||||||
|
let top_zero = AudioSegment::new_top_zero_sync();
|
||||||
|
send_to_children!(top_zero);
|
||||||
|
first_track = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Émettre TrackBoundary avec metadata du cache
|
||||||
|
let metadata = match track.track_metadata() {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("PlaylistSourceLogic: failed to get metadata: {}", e);
|
||||||
|
let error_marker = AudioSegment::new_error(
|
||||||
|
0,
|
||||||
|
0.0,
|
||||||
|
format!("Failed to get metadata: {}", e),
|
||||||
|
);
|
||||||
|
send_to_children!(error_marker);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::debug!("PlaylistSourceLogic: emitting TrackBoundary");
|
||||||
|
let boundary = AudioSegment::new_track_boundary(0, 0.0, metadata);
|
||||||
|
send_to_children!(boundary);
|
||||||
|
|
||||||
|
// Obtenir le chemin du fichier
|
||||||
|
let file_path = match track.file_path() {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("PlaylistSourceLogic: failed to get file path: {}", e);
|
||||||
|
let error_marker = AudioSegment::new_error(
|
||||||
|
0,
|
||||||
|
0.0,
|
||||||
|
format!("Failed to get file path: {}", e),
|
||||||
|
);
|
||||||
|
send_to_children!(error_marker);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::debug!("PlaylistSourceLogic: decoding track: {:?}", file_path);
|
||||||
|
|
||||||
|
// Décoder et émettre les chunks PCM
|
||||||
|
if let Err(e) = decode_and_emit_track(
|
||||||
|
&file_path,
|
||||||
|
self.chunk_frames,
|
||||||
|
&output,
|
||||||
|
&stop_token,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!("PlaylistSourceLogic: error decoding track: {}", e);
|
||||||
|
let error_marker = AudioSegment::new_error(0, 0.0, format!("Decode error: {}", e));
|
||||||
|
send_to_children!(error_marker);
|
||||||
|
// Continue vers la piste suivante
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boucler pour la piste suivante (pas d'EndOfStream entre pistes !)
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::debug!("PlaylistSourceLogic::process finished");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Helper Functions
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// Décode un fichier et émet ses chunks audio
|
||||||
|
async fn decode_and_emit_track(
|
||||||
|
path: &PathBuf,
|
||||||
|
chunk_frames: usize,
|
||||||
|
output: &[mpsc::Sender<Arc<AudioSegment>>],
|
||||||
|
stop_token: &CancellationToken,
|
||||||
|
) -> Result<(), AudioError> {
|
||||||
|
// Ouvrir et décoder
|
||||||
|
let file = File::open(path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AudioError::IoError(format!("Failed to open {:?}: {}", path, e)))?;
|
||||||
|
|
||||||
|
let mut stream = decode_audio_stream(file)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AudioError::ProcessingError(format!("Decode error: {}", e)))?;
|
||||||
|
|
||||||
|
let stream_info = stream.info().clone();
|
||||||
|
|
||||||
|
// Valider le stream
|
||||||
|
validate_stream(&stream_info)?;
|
||||||
|
|
||||||
|
// Calculer chunk_frames (auto = 50ms)
|
||||||
|
let chunk_frames = if chunk_frames == 0 {
|
||||||
|
let frames = (stream_info.sample_rate as f64 * DEFAULT_CHUNK_DURATION_MS / 1000.0) as usize;
|
||||||
|
frames.next_power_of_two().max(256)
|
||||||
|
} else {
|
||||||
|
chunk_frames.max(1)
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::trace!(
|
||||||
|
"decode_and_emit_track: sample_rate={}, bit_depth={}, chunk_frames={}",
|
||||||
|
stream_info.sample_rate,
|
||||||
|
stream_info.bits_per_sample,
|
||||||
|
chunk_frames
|
||||||
|
);
|
||||||
|
|
||||||
|
// Lire et émettre les chunks
|
||||||
|
let frame_bytes = stream_info.bytes_per_sample() * stream_info.channels as usize;
|
||||||
|
let chunk_byte_len = chunk_frames * frame_bytes;
|
||||||
|
let mut pending = Vec::new();
|
||||||
|
let mut read_buf = vec![0u8; frame_bytes * 512.max(chunk_frames)];
|
||||||
|
let mut chunk_index = 0u64;
|
||||||
|
let mut total_frames = 0u64;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = stop_token.cancelled() => {
|
||||||
|
tracing::debug!("decode_and_emit_track: stop requested");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
read_result = stream.read(&mut read_buf) => {
|
||||||
|
// Remplir le buffer
|
||||||
|
if pending.len() < chunk_byte_len {
|
||||||
|
let read = read_result.map_err(|e| {
|
||||||
|
AudioError::IoError(format!("I/O error while decoding: {}", e))
|
||||||
|
})?;
|
||||||
|
if read == 0 && pending.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if read > 0 {
|
||||||
|
pending.extend_from_slice(&read_buf[..read]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if pending.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extraire un chunk
|
||||||
|
let frames_in_pending = pending.len() / frame_bytes;
|
||||||
|
let frames_to_emit = frames_in_pending.min(chunk_frames);
|
||||||
|
if frames_to_emit == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let take_bytes = frames_to_emit * frame_bytes;
|
||||||
|
let chunk_bytes = pending.drain(..take_bytes).collect::<Vec<u8>>();
|
||||||
|
|
||||||
|
// Calculer le timestamp
|
||||||
|
let timestamp_sec = total_frames as f64 / stream_info.sample_rate as f64;
|
||||||
|
|
||||||
|
// Créer et envoyer le segment audio
|
||||||
|
let segment = bytes_to_segment(
|
||||||
|
&chunk_bytes,
|
||||||
|
&stream_info,
|
||||||
|
frames_to_emit,
|
||||||
|
chunk_index,
|
||||||
|
timestamp_sec,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
for tx in output {
|
||||||
|
tx.send(segment.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|_| AudioError::ChildDied)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
chunk_index += 1;
|
||||||
|
total_frames += frames_to_emit as u64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traiter le reste éventuel (moins qu'un chunk complet)
|
||||||
|
if !pending.is_empty() {
|
||||||
|
let frames = pending.len() / frame_bytes;
|
||||||
|
if frames > 0 {
|
||||||
|
let timestamp_sec = total_frames as f64 / stream_info.sample_rate as f64;
|
||||||
|
let segment = bytes_to_segment(&pending, &stream_info, frames, chunk_index, timestamp_sec)?;
|
||||||
|
for tx in output {
|
||||||
|
tx.send(segment.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|_| AudioError::ChildDied)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attendre la fin du décodage
|
||||||
|
stream
|
||||||
|
.wait()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AudioError::ProcessingError(format!("Decode task failed: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_stream(info: &StreamInfo) -> Result<(), AudioError> {
|
||||||
|
if !(1..=2).contains(&info.channels) {
|
||||||
|
return Err(AudioError::ProcessingError(format!(
|
||||||
|
"Unsupported channel count: {}",
|
||||||
|
info.channels
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
match info.bits_per_sample {
|
||||||
|
8 | 16 | 24 | 32 => Ok(()),
|
||||||
|
other => Err(AudioError::ProcessingError(format!(
|
||||||
|
"Unsupported bit depth: {}",
|
||||||
|
other
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convertit des bytes PCM en AudioSegment avec le type approprié
|
||||||
|
fn bytes_to_segment(
|
||||||
|
chunk_bytes: &[u8],
|
||||||
|
info: &StreamInfo,
|
||||||
|
frames: usize,
|
||||||
|
order: u64,
|
||||||
|
timestamp_sec: f64,
|
||||||
|
) -> Result<Arc<AudioSegment>, AudioError> {
|
||||||
|
let bytes_per_sample = info.bytes_per_sample();
|
||||||
|
let channels = info.channels as usize;
|
||||||
|
let frame_bytes = bytes_per_sample * channels;
|
||||||
|
|
||||||
|
// Créer le chunk du bon type selon la profondeur de bit
|
||||||
|
let chunk = match info.bits_per_sample {
|
||||||
|
16 => {
|
||||||
|
// Type I16
|
||||||
|
let mut stereo = Vec::with_capacity(frames);
|
||||||
|
for frame_idx in 0..frames {
|
||||||
|
let base = frame_idx * frame_bytes;
|
||||||
|
let l = i16::from_le_bytes(
|
||||||
|
chunk_bytes[base..base + bytes_per_sample]
|
||||||
|
.try_into()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
let r = if channels == 1 {
|
||||||
|
l
|
||||||
|
} else {
|
||||||
|
i16::from_le_bytes(
|
||||||
|
chunk_bytes[base + bytes_per_sample..base + 2 * bytes_per_sample]
|
||||||
|
.try_into()
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
stereo.push([l, r]);
|
||||||
|
}
|
||||||
|
let chunk_data = AudioChunkData::new(stereo, info.sample_rate, 0.0);
|
||||||
|
AudioChunk::I16(chunk_data)
|
||||||
|
}
|
||||||
|
24 => {
|
||||||
|
// Type I24
|
||||||
|
let mut stereo = Vec::with_capacity(frames);
|
||||||
|
for frame_idx in 0..frames {
|
||||||
|
let base = frame_idx * frame_bytes;
|
||||||
|
let l_i32 = {
|
||||||
|
let mut buf = [0u8; 4];
|
||||||
|
buf[..3].copy_from_slice(&chunk_bytes[base..base + 3]);
|
||||||
|
// Sign extend
|
||||||
|
if chunk_bytes[base + 2] & 0x80 != 0 {
|
||||||
|
buf[3] = 0xFF;
|
||||||
|
}
|
||||||
|
i32::from_le_bytes(buf)
|
||||||
|
};
|
||||||
|
let l = I24::new(l_i32).ok_or_else(|| {
|
||||||
|
AudioError::ProcessingError(format!("Invalid I24 value: {}", l_i32))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let r = if channels == 1 {
|
||||||
|
l
|
||||||
|
} else {
|
||||||
|
let r_i32 = {
|
||||||
|
let mut buf = [0u8; 4];
|
||||||
|
buf[..3].copy_from_slice(
|
||||||
|
&chunk_bytes[base + bytes_per_sample..base + bytes_per_sample + 3],
|
||||||
|
);
|
||||||
|
// Sign extend
|
||||||
|
if chunk_bytes[base + bytes_per_sample + 2] & 0x80 != 0 {
|
||||||
|
buf[3] = 0xFF;
|
||||||
|
}
|
||||||
|
i32::from_le_bytes(buf)
|
||||||
|
};
|
||||||
|
I24::new(r_i32).ok_or_else(|| {
|
||||||
|
AudioError::ProcessingError(format!("Invalid I24 value: {}", r_i32))
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
stereo.push([l, r]);
|
||||||
|
}
|
||||||
|
let chunk_data = AudioChunkData::new(stereo, info.sample_rate, 0.0);
|
||||||
|
AudioChunk::I24(chunk_data)
|
||||||
|
}
|
||||||
|
32 => {
|
||||||
|
// Type I32
|
||||||
|
let mut stereo = Vec::with_capacity(frames);
|
||||||
|
for frame_idx in 0..frames {
|
||||||
|
let base = frame_idx * frame_bytes;
|
||||||
|
let l = i32::from_le_bytes(
|
||||||
|
chunk_bytes[base..base + bytes_per_sample]
|
||||||
|
.try_into()
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
let r = if channels == 1 {
|
||||||
|
l
|
||||||
|
} else {
|
||||||
|
i32::from_le_bytes(
|
||||||
|
chunk_bytes[base + bytes_per_sample..base + 2 * bytes_per_sample]
|
||||||
|
.try_into()
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
stereo.push([l, r]);
|
||||||
|
}
|
||||||
|
let chunk_data = AudioChunkData::new(stereo, info.sample_rate, 0.0);
|
||||||
|
AudioChunk::I32(chunk_data)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(AudioError::ProcessingError(format!(
|
||||||
|
"Unsupported bit depth: {}",
|
||||||
|
info.bits_per_sample
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Arc::new(AudioSegment {
|
||||||
|
order,
|
||||||
|
timestamp_sec,
|
||||||
|
segment: pmoaudio::_AudioSegment::Chunk(Arc::new(chunk)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// WRAPPER PlaylistSource - Délègue à Node<PlaylistSourceLogic>
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// PlaylistSource - Lit une playlist et publie des `AudioSegment`
|
||||||
|
///
|
||||||
|
/// Cette source utilise une playlist (`ReadHandle`) et le cache audio pour
|
||||||
|
/// décoder les pistes en continu. Le format de sortie (sample_rate et bit_depth)
|
||||||
|
/// est **hétérogène** et dépend des fichiers sources.
|
||||||
|
///
|
||||||
|
/// Voir la documentation du module pour plus de détails et exemples d'usage.
|
||||||
|
pub struct PlaylistSource {
|
||||||
|
inner: Node<PlaylistSourceLogic>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlaylistSource {
|
||||||
|
/// Crée une nouvelle source de playlist avec paramètres par défaut
|
||||||
|
///
|
||||||
|
/// * `playlist_handle` - Handle de lecture sur la playlist
|
||||||
|
/// * `cache` - Cache audio contenant les fichiers
|
||||||
|
///
|
||||||
|
/// Paramètres par défaut :
|
||||||
|
/// - `chunk_frames` : 0 (auto-calculé pour 50ms)
|
||||||
|
/// - `poll_interval_ms` : 100ms
|
||||||
|
pub fn new(playlist_handle: ReadHandle, cache: Arc<AudioCache>) -> Self {
|
||||||
|
Self::with_config(playlist_handle, cache, 0, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crée une nouvelle source de playlist avec configuration personnalisée
|
||||||
|
///
|
||||||
|
/// * `playlist_handle` - Handle de lecture sur la playlist
|
||||||
|
/// * `cache` - Cache audio contenant les fichiers
|
||||||
|
/// * `chunk_frames` - Nombre de frames par chunk (0 = auto)
|
||||||
|
/// * `poll_interval_ms` - Intervalle de polling si playlist vide
|
||||||
|
pub fn with_config(
|
||||||
|
playlist_handle: ReadHandle,
|
||||||
|
cache: Arc<AudioCache>,
|
||||||
|
chunk_frames: usize,
|
||||||
|
poll_interval_ms: u64,
|
||||||
|
) -> Self {
|
||||||
|
let logic = PlaylistSourceLogic::new(playlist_handle, cache, chunk_frames, poll_interval_ms);
|
||||||
|
Self {
|
||||||
|
inner: Node::new_source(logic),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl AudioPipelineNode for PlaylistSource {
|
||||||
|
fn get_tx(&self) -> Option<mpsc::Sender<Arc<AudioSegment>>> {
|
||||||
|
self.inner.get_tx()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register(&mut self, child: Box<dyn AudioPipelineNode>) {
|
||||||
|
self.inner.register(child)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(
|
||||||
|
self: Box<Self>,
|
||||||
|
stop_token: CancellationToken,
|
||||||
|
) -> Result<(), AudioError> {
|
||||||
|
Box::new(self.inner).run(stop_token).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TypedAudioNode for PlaylistSource {
|
||||||
|
fn input_type(&self) -> Option<TypeRequirement> {
|
||||||
|
None // Source n'a pas d'entrée
|
||||||
|
}
|
||||||
|
|
||||||
|
fn output_type(&self) -> Option<TypeRequirement> {
|
||||||
|
// Format hétérogène - accepte tout
|
||||||
|
Some(TypeRequirement::any())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Tests unitaires pour les fonctions helper
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_stream_valid_stereo_16bit() {
|
||||||
|
let info = StreamInfo {
|
||||||
|
sample_rate: 44100,
|
||||||
|
channels: 2,
|
||||||
|
bits_per_sample: 16,
|
||||||
|
total_samples: Some(1000),
|
||||||
|
max_block_size: 4096,
|
||||||
|
min_block_size: 256,
|
||||||
|
};
|
||||||
|
assert!(validate_stream(&info).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_stream_valid_mono_24bit() {
|
||||||
|
let info = StreamInfo {
|
||||||
|
sample_rate: 48000,
|
||||||
|
channels: 1,
|
||||||
|
bits_per_sample: 24,
|
||||||
|
total_samples: Some(1000),
|
||||||
|
max_block_size: 4096,
|
||||||
|
min_block_size: 256,
|
||||||
|
};
|
||||||
|
assert!(validate_stream(&info).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_stream_invalid_channel_count() {
|
||||||
|
let info = StreamInfo {
|
||||||
|
sample_rate: 44100,
|
||||||
|
channels: 5, // Invalid
|
||||||
|
bits_per_sample: 16,
|
||||||
|
total_samples: Some(1000),
|
||||||
|
max_block_size: 4096,
|
||||||
|
min_block_size: 256,
|
||||||
|
};
|
||||||
|
assert!(validate_stream(&info).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_stream_invalid_bit_depth() {
|
||||||
|
let info = StreamInfo {
|
||||||
|
sample_rate: 44100,
|
||||||
|
channels: 2,
|
||||||
|
bits_per_sample: 12, // Invalid
|
||||||
|
total_samples: Some(1000),
|
||||||
|
max_block_size: 4096,
|
||||||
|
min_block_size: 256,
|
||||||
|
};
|
||||||
|
assert!(validate_stream(&info).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bytes_to_segment_i16_stereo() {
|
||||||
|
// Create mock PCM data (2 frames, stereo, 16-bit)
|
||||||
|
// Frame 1: L=100, R=200
|
||||||
|
// Frame 2: L=300, R=400
|
||||||
|
let chunk_bytes = vec![
|
||||||
|
100u8, 0, // L1
|
||||||
|
200, 0, // R1
|
||||||
|
44, 1, // L2 (300 = 0x012C)
|
||||||
|
144, 1, // R2 (400 = 0x0190)
|
||||||
|
];
|
||||||
|
|
||||||
|
let info = StreamInfo {
|
||||||
|
sample_rate: 44100,
|
||||||
|
channels: 2,
|
||||||
|
bits_per_sample: 16,
|
||||||
|
total_samples: Some(2),
|
||||||
|
max_block_size: 4096,
|
||||||
|
min_block_size: 256,
|
||||||
|
};
|
||||||
|
|
||||||
|
let segment = bytes_to_segment(&chunk_bytes, &info, 2, 0, 0.0).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(segment.order, 0);
|
||||||
|
assert_eq!(segment.timestamp_sec, 0.0);
|
||||||
|
|
||||||
|
match &segment.segment {
|
||||||
|
pmoaudio::_AudioSegment::Chunk(chunk) => {
|
||||||
|
match chunk.as_ref() {
|
||||||
|
AudioChunk::I16(data) => {
|
||||||
|
let frames = data.get_frames();
|
||||||
|
assert_eq!(frames.len(), 2);
|
||||||
|
assert_eq!(frames[0], [100, 200]);
|
||||||
|
assert_eq!(frames[1], [300, 400]);
|
||||||
|
assert_eq!(data.get_sample_rate(), 44100);
|
||||||
|
}
|
||||||
|
_ => panic!("Expected I16 chunk"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => panic!("Expected audio chunk"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bytes_to_segment_i16_mono() {
|
||||||
|
// Create mock PCM data (2 frames, mono, 16-bit)
|
||||||
|
let chunk_bytes = vec![
|
||||||
|
100u8, 0, // Frame 1
|
||||||
|
200, 0, // Frame 2
|
||||||
|
];
|
||||||
|
|
||||||
|
let info = StreamInfo {
|
||||||
|
sample_rate: 48000,
|
||||||
|
channels: 1,
|
||||||
|
bits_per_sample: 16,
|
||||||
|
total_samples: Some(2),
|
||||||
|
max_block_size: 4096,
|
||||||
|
min_block_size: 256,
|
||||||
|
};
|
||||||
|
|
||||||
|
let segment = bytes_to_segment(&chunk_bytes, &info, 2, 5, 1.5).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(segment.order, 5);
|
||||||
|
assert_eq!(segment.timestamp_sec, 1.5);
|
||||||
|
|
||||||
|
match &segment.segment {
|
||||||
|
pmoaudio::_AudioSegment::Chunk(chunk) => {
|
||||||
|
match chunk.as_ref() {
|
||||||
|
AudioChunk::I16(data) => {
|
||||||
|
let frames = data.get_frames();
|
||||||
|
assert_eq!(frames.len(), 2);
|
||||||
|
// Mono is duplicated to both channels
|
||||||
|
assert_eq!(frames[0], [100, 100]);
|
||||||
|
assert_eq!(frames[1], [200, 200]);
|
||||||
|
}
|
||||||
|
_ => panic!("Expected I16 chunk"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => panic!("Expected audio chunk"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bytes_to_segment_i24_stereo() {
|
||||||
|
// Create mock PCM data (1 frame, stereo, 24-bit)
|
||||||
|
// Frame 1: L=1000 (0x0003E8), R=-1000 (0xFFFC18)
|
||||||
|
let chunk_bytes = vec![
|
||||||
|
0xE8, 0x03, 0x00, // L (1000)
|
||||||
|
0x18, 0xFC, 0xFF, // R (-1000, sign-extended)
|
||||||
|
];
|
||||||
|
|
||||||
|
let info = StreamInfo {
|
||||||
|
sample_rate: 96000,
|
||||||
|
channels: 2,
|
||||||
|
bits_per_sample: 24,
|
||||||
|
total_samples: Some(1),
|
||||||
|
max_block_size: 4096,
|
||||||
|
min_block_size: 256,
|
||||||
|
};
|
||||||
|
|
||||||
|
let segment = bytes_to_segment(&chunk_bytes, &info, 1, 0, 0.0).unwrap();
|
||||||
|
|
||||||
|
match &segment.segment {
|
||||||
|
pmoaudio::_AudioSegment::Chunk(chunk) => {
|
||||||
|
match chunk.as_ref() {
|
||||||
|
AudioChunk::I24(data) => {
|
||||||
|
let frames = data.get_frames();
|
||||||
|
assert_eq!(frames.len(), 1);
|
||||||
|
assert_eq!(frames[0][0].as_i32(), 1000);
|
||||||
|
assert_eq!(frames[0][1].as_i32(), -1000);
|
||||||
|
}
|
||||||
|
_ => panic!("Expected I24 chunk"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => panic!("Expected audio chunk"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bytes_to_segment_i32_stereo() {
|
||||||
|
// Create mock PCM data (1 frame, stereo, 32-bit)
|
||||||
|
let chunk_bytes = vec![
|
||||||
|
0x00, 0x10, 0x00, 0x00, // L (4096)
|
||||||
|
0x00, 0x20, 0x00, 0x00, // R (8192)
|
||||||
|
];
|
||||||
|
|
||||||
|
let info = StreamInfo {
|
||||||
|
sample_rate: 44100,
|
||||||
|
channels: 2,
|
||||||
|
bits_per_sample: 32,
|
||||||
|
total_samples: Some(1),
|
||||||
|
max_block_size: 4096,
|
||||||
|
min_block_size: 256,
|
||||||
|
};
|
||||||
|
|
||||||
|
let segment = bytes_to_segment(&chunk_bytes, &info, 1, 0, 0.0).unwrap();
|
||||||
|
|
||||||
|
match &segment.segment {
|
||||||
|
pmoaudio::_AudioSegment::Chunk(chunk) => {
|
||||||
|
match chunk.as_ref() {
|
||||||
|
AudioChunk::I32(data) => {
|
||||||
|
let frames = data.get_frames();
|
||||||
|
assert_eq!(frames.len(), 1);
|
||||||
|
assert_eq!(frames[0], [4096, 8192]);
|
||||||
|
}
|
||||||
|
_ => panic!("Expected I32 chunk"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => panic!("Expected audio chunk"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bytes_to_segment_unsupported_bit_depth() {
|
||||||
|
let chunk_bytes = vec![0u8; 8];
|
||||||
|
|
||||||
|
let info = StreamInfo {
|
||||||
|
sample_rate: 44100,
|
||||||
|
channels: 2,
|
||||||
|
bits_per_sample: 8, // Currently unsupported by bytes_to_segment
|
||||||
|
total_samples: Some(1),
|
||||||
|
max_block_size: 4096,
|
||||||
|
min_block_size: 256,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = bytes_to_segment(&chunk_bytes, &info, 1, 0, 0.0);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Tests d'intégration pour PlaylistSource
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// Note: Les tests d'intégration complets nécessitent une vraie playlist et un cache.
|
||||||
|
// Ces tests peuvent être ajoutés dans un module d'intégration séparé avec des
|
||||||
|
// fixtures FLAC de test.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_playlist_source_type_check() {
|
||||||
|
// Test de création basique - vérifie que le code compile
|
||||||
|
// Ce test ne peut pas être exécuté sans mock ou fixture réelles
|
||||||
|
// car ReadHandle n'implémente pas Clone
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
// Vérification de type - ces lignes ne sont jamais exécutées
|
||||||
|
if false {
|
||||||
|
let _handle: ReadHandle = unreachable!();
|
||||||
|
let _cache: Arc<AudioCache> = unreachable!();
|
||||||
|
let _source = PlaylistSource::new(_handle, _cache);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
60
pmoaudio/examples/play_audio.rs
Normal file
60
pmoaudio/examples/play_audio.rs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
//! Exemple simple de lecture audio avec AudioSink
|
||||||
|
//!
|
||||||
|
//! Cet exemple montre comment utiliser AudioSink pour jouer un fichier audio
|
||||||
|
//! sur la sortie audio standard de la machine.
|
||||||
|
//!
|
||||||
|
//! Usage:
|
||||||
|
//! cargo run --example play_audio -- <fichier.flac>
|
||||||
|
|
||||||
|
use pmoaudio::{AudioSink, FileSource};
|
||||||
|
use std::env;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
// Initialiser le logging
|
||||||
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
|
// Récupérer le chemin du fichier depuis les arguments
|
||||||
|
let args: Vec<String> = env::args().collect();
|
||||||
|
if args.len() < 2 {
|
||||||
|
eprintln!("Usage: {} <fichier.flac>", args[0]);
|
||||||
|
eprintln!("\nExemple:");
|
||||||
|
eprintln!(" {} music.flac", args[0]);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_path = &args[1];
|
||||||
|
println!("Lecture de: {}", file_path);
|
||||||
|
|
||||||
|
// Créer la source audio (lit le fichier FLAC)
|
||||||
|
let mut source = FileSource::new(file_path).await?;
|
||||||
|
|
||||||
|
// Créer le sink audio (joue sur la sortie audio)
|
||||||
|
let sink = AudioSink::new();
|
||||||
|
|
||||||
|
// Connecter la source au sink
|
||||||
|
source.register(Box::new(sink));
|
||||||
|
|
||||||
|
println!("Démarrage de la lecture...");
|
||||||
|
println!("Appuyez sur Ctrl+C pour arrêter");
|
||||||
|
|
||||||
|
// Créer un token d'annulation pour pouvoir arrêter proprement
|
||||||
|
let stop_token = CancellationToken::new();
|
||||||
|
let stop_token_clone = stop_token.clone();
|
||||||
|
|
||||||
|
// Gérer Ctrl+C pour arrêt propre
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tokio::signal::ctrl_c().await.expect("Failed to listen for Ctrl+C");
|
||||||
|
println!("\nArrêt demandé...");
|
||||||
|
stop_token_clone.cancel();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lancer le pipeline
|
||||||
|
match Box::new(source).run(stop_token).await {
|
||||||
|
Ok(()) => println!("\nLecture terminée"),
|
||||||
|
Err(e) => eprintln!("\nErreur pendant la lecture: {}", e),
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
78
pmoaudio/examples/play_with_resampling.rs
Normal file
78
pmoaudio/examples/play_with_resampling.rs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
//! Exemple de lecture audio avec resampling et conversion de format
|
||||||
|
//!
|
||||||
|
//! Cet exemple montre comment construire un pipeline audio complet:
|
||||||
|
//! FileSource → ResamplingNode → ToI24Node → AudioSink
|
||||||
|
//!
|
||||||
|
//! Usage:
|
||||||
|
//! cargo run --example play_with_resampling -- <fichier.flac> [sample_rate]
|
||||||
|
|
||||||
|
use pmoaudio::{AudioSink, FileSource, ResamplingNode, ToI24Node};
|
||||||
|
use std::env;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
// Initialiser le logging
|
||||||
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
|
// Récupérer les arguments
|
||||||
|
let args: Vec<String> = env::args().collect();
|
||||||
|
if args.len() < 2 {
|
||||||
|
eprintln!("Usage: {} <fichier.flac> [sample_rate]", args[0]);
|
||||||
|
eprintln!("\nExemple:");
|
||||||
|
eprintln!(" {} music.flac # Lecture normale", args[0]);
|
||||||
|
eprintln!(" {} music.flac 48000 # Resample vers 48kHz", args[0]);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_path = &args[1];
|
||||||
|
let target_sample_rate = if args.len() >= 3 {
|
||||||
|
args[2].parse::<u32>()?
|
||||||
|
} else {
|
||||||
|
48000 // Par défaut
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("Lecture de: {}", file_path);
|
||||||
|
println!("Sample rate cible: {} Hz", target_sample_rate);
|
||||||
|
|
||||||
|
// Créer la source audio
|
||||||
|
let mut source = FileSource::new(file_path).await?;
|
||||||
|
|
||||||
|
// Créer le nœud de resampling
|
||||||
|
let mut resampler = ResamplingNode::new(target_sample_rate);
|
||||||
|
|
||||||
|
// Créer le nœud de conversion vers I24
|
||||||
|
let mut converter = ToI24Node::new();
|
||||||
|
|
||||||
|
// Créer le sink audio avec volume à 80%
|
||||||
|
let sink = AudioSink::with_volume(0.8);
|
||||||
|
|
||||||
|
// Construire le pipeline: Source → Resampler → Converter → Sink
|
||||||
|
source.register(Box::new(resampler));
|
||||||
|
resampler.register(Box::new(converter));
|
||||||
|
converter.register(Box::new(sink));
|
||||||
|
|
||||||
|
println!("Pipeline créé: FileSource → Resampling({} Hz) → ToI24 → AudioSink",
|
||||||
|
target_sample_rate);
|
||||||
|
println!("Démarrage de la lecture...");
|
||||||
|
println!("Appuyez sur Ctrl+C pour arrêter");
|
||||||
|
|
||||||
|
// Créer un token d'annulation
|
||||||
|
let stop_token = CancellationToken::new();
|
||||||
|
let stop_token_clone = stop_token.clone();
|
||||||
|
|
||||||
|
// Gérer Ctrl+C
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tokio::signal::ctrl_c().await.expect("Failed to listen for Ctrl+C");
|
||||||
|
println!("\nArrêt demandé...");
|
||||||
|
stop_token_clone.cancel();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lancer le pipeline
|
||||||
|
match Box::new(source).run(stop_token).await {
|
||||||
|
Ok(()) => println!("\nLecture terminée"),
|
||||||
|
Err(e) => eprintln!("\nErreur pendant la lecture: {}", e),
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -123,6 +123,7 @@ pub use nodes::{
|
|||||||
file_source::FileSource,
|
file_source::FileSource,
|
||||||
flac_file_sink::{FlacFileSink, FlacFileSinkStats},
|
flac_file_sink::{FlacFileSink, FlacFileSinkStats},
|
||||||
http_source::HttpSource,
|
http_source::HttpSource,
|
||||||
|
resampling_node::ResamplingNode,
|
||||||
AudioError, AudioNode, TypedAudioNode,
|
AudioError, AudioNode, TypedAudioNode,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ pub mod converter_nodes;
|
|||||||
pub mod file_source;
|
pub mod file_source;
|
||||||
pub mod flac_file_sink;
|
pub mod flac_file_sink;
|
||||||
pub mod http_source;
|
pub mod http_source;
|
||||||
|
pub mod resampling_node;
|
||||||
|
|
||||||
// Modules temporairement désactivés
|
// Modules temporairement désactivés
|
||||||
/*
|
/*
|
||||||
|
|||||||
577
pmoaudio/src/nodes/resampling_node.rs
Normal file
577
pmoaudio/src/nodes/resampling_node.rs
Normal file
@@ -0,0 +1,577 @@
|
|||||||
|
//! ResamplingNode - Node de resampling pour normaliser le sample rate
|
||||||
|
//!
|
||||||
|
//! Ce node prend en entrée des chunks audio avec des sample rates variables
|
||||||
|
//! et les resample vers un sample rate cible fixe.
|
||||||
|
//!
|
||||||
|
//! # Usage
|
||||||
|
//!
|
||||||
|
//! ```rust,no_run
|
||||||
|
//! use pmoaudio::{ResamplingNode, FileSource};
|
||||||
|
//!
|
||||||
|
//! let mut source = FileSource::new("audio.flac");
|
||||||
|
//! let mut resampler = ResamplingNode::new(48000); // Force 48kHz
|
||||||
|
//! source.register(Box::new(resampler));
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! # Comportement
|
||||||
|
//!
|
||||||
|
//! - Détecte automatiquement les changements de sample rate
|
||||||
|
//! - Recrée le resampler quand nécessaire
|
||||||
|
//! - Passe les chunks directement si déjà au bon sample rate
|
||||||
|
//! - Préserve les sync markers (TrackBoundary, etc.)
|
||||||
|
//!
|
||||||
|
//! # Performance
|
||||||
|
//!
|
||||||
|
//! Le resampling est effectué via libsoxr (très haute qualité).
|
||||||
|
//! La qualité est adaptée selon la profondeur de bits :
|
||||||
|
//! - 8-bit : Medium quality
|
||||||
|
//! - 16-bit : High quality
|
||||||
|
//! - 24-bit/32-bit : Very high quality
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
dsp::resampling::{build_resampler, resampling, Resampler},
|
||||||
|
nodes::{AudioError, TypedAudioNode},
|
||||||
|
pipeline::{AudioPipelineNode, Node, NodeLogic},
|
||||||
|
type_constraints::TypeRequirement,
|
||||||
|
AudioChunk, AudioChunkData, AudioSegment, BitDepth, I24,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
use tracing;
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// ResamplingLogic - Logique pure de resampling
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// Logique pure de resampling
|
||||||
|
///
|
||||||
|
/// Maintient un resampler et le met à jour selon les changements de sample rate.
|
||||||
|
pub struct ResamplingLogic {
|
||||||
|
target_sample_rate: u32,
|
||||||
|
current_resampler: Option<ResamplerState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ResamplerState {
|
||||||
|
source_hz: u32,
|
||||||
|
resampler: Resampler,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResamplingLogic {
|
||||||
|
pub fn new(target_sample_rate: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
target_sample_rate,
|
||||||
|
current_resampler: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resample un chunk audio vers le sample rate cible
|
||||||
|
fn resample_chunk(&mut self, chunk: &AudioChunk) -> Result<AudioChunk, AudioError> {
|
||||||
|
let source_sr = chunk.sample_rate();
|
||||||
|
let bit_depth = match chunk {
|
||||||
|
AudioChunk::I16(_) => BitDepth::B16,
|
||||||
|
AudioChunk::I24(_) => BitDepth::B24,
|
||||||
|
AudioChunk::I32(_) => BitDepth::B32,
|
||||||
|
AudioChunk::F32(_) => BitDepth::B32, // Traiter comme 32-bit
|
||||||
|
AudioChunk::F64(_) => BitDepth::B32, // Traiter comme 32-bit
|
||||||
|
};
|
||||||
|
|
||||||
|
// Si déjà au bon sample rate, retourner tel quel
|
||||||
|
if source_sr == self.target_sample_rate {
|
||||||
|
return Ok(chunk.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier si on doit recréer le resampler
|
||||||
|
let need_new_resampler = match &self.current_resampler {
|
||||||
|
None => true,
|
||||||
|
Some(state) => state.source_hz != source_sr,
|
||||||
|
};
|
||||||
|
|
||||||
|
if need_new_resampler {
|
||||||
|
tracing::debug!(
|
||||||
|
"ResamplingLogic: creating resampler {}Hz → {}Hz (bit_depth={:?})",
|
||||||
|
source_sr,
|
||||||
|
self.target_sample_rate,
|
||||||
|
bit_depth
|
||||||
|
);
|
||||||
|
let resampler = build_resampler(source_sr, self.target_sample_rate, bit_depth)
|
||||||
|
.map_err(|e| AudioError::ProcessingError(format!("Resampler init failed: {}", e)))?;
|
||||||
|
self.current_resampler = Some(ResamplerState {
|
||||||
|
source_hz: source_sr,
|
||||||
|
resampler,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = self.current_resampler.as_mut().unwrap();
|
||||||
|
|
||||||
|
// Extraire les canaux L/R en i32
|
||||||
|
let (left, right) = extract_channels_i32(chunk)?;
|
||||||
|
|
||||||
|
// Appliquer le resampling
|
||||||
|
let (resampled_left, resampled_right) = resampling(&left, &right, &mut state.resampler);
|
||||||
|
|
||||||
|
// Recréer le chunk avec le nouveau sample rate
|
||||||
|
reconstruct_chunk(chunk, resampled_left, resampled_right, self.target_sample_rate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl NodeLogic for ResamplingLogic {
|
||||||
|
async fn process(
|
||||||
|
&mut self,
|
||||||
|
input: Option<mpsc::Receiver<Arc<AudioSegment>>>,
|
||||||
|
output: Vec<mpsc::Sender<Arc<AudioSegment>>>,
|
||||||
|
stop_token: CancellationToken,
|
||||||
|
) -> Result<(), AudioError> {
|
||||||
|
let mut rx = input.expect("ResamplingNode must have input");
|
||||||
|
tracing::debug!(
|
||||||
|
"ResamplingLogic::process started, target={}Hz, {} children",
|
||||||
|
self.target_sample_rate,
|
||||||
|
output.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let segment = tokio::select! {
|
||||||
|
_ = stop_token.cancelled() => {
|
||||||
|
tracing::debug!("ResamplingLogic cancelled");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
result = rx.recv() => {
|
||||||
|
match result {
|
||||||
|
Some(seg) => seg,
|
||||||
|
None => {
|
||||||
|
tracing::debug!("ResamplingLogic received EOF");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resample si c'est un chunk audio, sinon passer tel quel
|
||||||
|
let output_segment = if segment.is_audio_chunk() {
|
||||||
|
if let Some(chunk) = segment.as_chunk() {
|
||||||
|
let resampled_chunk = self.resample_chunk(chunk)?;
|
||||||
|
|
||||||
|
Arc::new(AudioSegment {
|
||||||
|
order: segment.order,
|
||||||
|
timestamp_sec: segment.timestamp_sec,
|
||||||
|
segment: crate::_AudioSegment::Chunk(Arc::new(resampled_chunk)),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
segment
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
segment
|
||||||
|
};
|
||||||
|
|
||||||
|
// Envoyer à tous les enfants
|
||||||
|
for tx in &output {
|
||||||
|
tx.send(output_segment.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|_| AudioError::ChildDied)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Helper Functions
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// Extrait les canaux L/R d'un AudioChunk en i32
|
||||||
|
fn extract_channels_i32(chunk: &AudioChunk) -> Result<(Vec<i32>, Vec<i32>), AudioError> {
|
||||||
|
match chunk {
|
||||||
|
AudioChunk::I16(data) => {
|
||||||
|
let frames = data.get_frames();
|
||||||
|
let left = frames.iter().map(|frame| frame[0] as i32).collect();
|
||||||
|
let right = frames.iter().map(|frame| frame[1] as i32).collect();
|
||||||
|
Ok((left, right))
|
||||||
|
}
|
||||||
|
AudioChunk::I24(data) => {
|
||||||
|
let frames = data.get_frames();
|
||||||
|
let left = frames.iter().map(|frame| frame[0].as_i32()).collect();
|
||||||
|
let right = frames.iter().map(|frame| frame[1].as_i32()).collect();
|
||||||
|
Ok((left, right))
|
||||||
|
}
|
||||||
|
AudioChunk::I32(data) => {
|
||||||
|
let frames = data.get_frames();
|
||||||
|
let left = frames.iter().map(|frame| frame[0]).collect();
|
||||||
|
let right = frames.iter().map(|frame| frame[1]).collect();
|
||||||
|
Ok((left, right))
|
||||||
|
}
|
||||||
|
AudioChunk::F32(data) => {
|
||||||
|
let frames = data.get_frames();
|
||||||
|
// Convertir f32 → i32 (dénormaliser)
|
||||||
|
let left = frames
|
||||||
|
.iter()
|
||||||
|
.map(|frame| (frame[0] * i32::MAX as f32) as i32)
|
||||||
|
.collect();
|
||||||
|
let right = frames
|
||||||
|
.iter()
|
||||||
|
.map(|frame| (frame[1] * i32::MAX as f32) as i32)
|
||||||
|
.collect();
|
||||||
|
Ok((left, right))
|
||||||
|
}
|
||||||
|
AudioChunk::F64(data) => {
|
||||||
|
let frames = data.get_frames();
|
||||||
|
// Convertir f64 → i32 (dénormaliser)
|
||||||
|
let left = frames
|
||||||
|
.iter()
|
||||||
|
.map(|frame| (frame[0] * i32::MAX as f64) as i32)
|
||||||
|
.collect();
|
||||||
|
let right = frames
|
||||||
|
.iter()
|
||||||
|
.map(|frame| (frame[1] * i32::MAX as f64) as i32)
|
||||||
|
.collect();
|
||||||
|
Ok((left, right))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reconstruit un AudioChunk du même type avec les canaux resamplez
|
||||||
|
fn reconstruct_chunk(
|
||||||
|
original: &AudioChunk,
|
||||||
|
left: Vec<i32>,
|
||||||
|
right: Vec<i32>,
|
||||||
|
new_sample_rate: u32,
|
||||||
|
) -> Result<AudioChunk, AudioError> {
|
||||||
|
if left.len() != right.len() {
|
||||||
|
return Err(AudioError::ProcessingError(
|
||||||
|
"Left and right channel lengths differ after resampling".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let gain_db = original.gain_db();
|
||||||
|
|
||||||
|
match original {
|
||||||
|
AudioChunk::I16(_) => {
|
||||||
|
let mut stereo = Vec::with_capacity(left.len());
|
||||||
|
for i in 0..left.len() {
|
||||||
|
stereo.push([left[i] as i16, right[i] as i16]);
|
||||||
|
}
|
||||||
|
Ok(AudioChunk::I16(AudioChunkData::new(
|
||||||
|
stereo,
|
||||||
|
new_sample_rate,
|
||||||
|
gain_db,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
AudioChunk::I24(_) => {
|
||||||
|
let mut stereo = Vec::with_capacity(left.len());
|
||||||
|
for i in 0..left.len() {
|
||||||
|
let l = I24::new(left[i])
|
||||||
|
.ok_or_else(|| AudioError::ProcessingError("Invalid I24 value".into()))?;
|
||||||
|
let r = I24::new(right[i])
|
||||||
|
.ok_or_else(|| AudioError::ProcessingError("Invalid I24 value".into()))?;
|
||||||
|
stereo.push([l, r]);
|
||||||
|
}
|
||||||
|
Ok(AudioChunk::I24(AudioChunkData::new(
|
||||||
|
stereo,
|
||||||
|
new_sample_rate,
|
||||||
|
gain_db,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
AudioChunk::I32(_) => {
|
||||||
|
let mut stereo = Vec::with_capacity(left.len());
|
||||||
|
for i in 0..left.len() {
|
||||||
|
stereo.push([left[i], right[i]]);
|
||||||
|
}
|
||||||
|
Ok(AudioChunk::I32(AudioChunkData::new(
|
||||||
|
stereo,
|
||||||
|
new_sample_rate,
|
||||||
|
gain_db,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
AudioChunk::F32(_) => {
|
||||||
|
let mut stereo = Vec::with_capacity(left.len());
|
||||||
|
for i in 0..left.len() {
|
||||||
|
stereo.push([
|
||||||
|
left[i] as f32 / i32::MAX as f32,
|
||||||
|
right[i] as f32 / i32::MAX as f32,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
Ok(AudioChunk::F32(AudioChunkData::new(
|
||||||
|
stereo,
|
||||||
|
new_sample_rate,
|
||||||
|
gain_db,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
AudioChunk::F64(_) => {
|
||||||
|
let mut stereo = Vec::with_capacity(left.len());
|
||||||
|
for i in 0..left.len() {
|
||||||
|
stereo.push([
|
||||||
|
left[i] as f64 / i32::MAX as f64,
|
||||||
|
right[i] as f64 / i32::MAX as f64,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
Ok(AudioChunk::F64(AudioChunkData::new(
|
||||||
|
stereo,
|
||||||
|
new_sample_rate,
|
||||||
|
gain_db,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// WRAPPER ResamplingNode - Délègue à Node<ResamplingLogic>
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// ResamplingNode - Normalise le sample rate vers une valeur cible
|
||||||
|
///
|
||||||
|
/// Ce node prend en entrée des chunks audio avec des sample rates variables
|
||||||
|
/// et les resample vers un sample rate fixe.
|
||||||
|
pub struct ResamplingNode {
|
||||||
|
inner: Node<ResamplingLogic>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResamplingNode {
|
||||||
|
/// Crée un nouveau node de resampling
|
||||||
|
///
|
||||||
|
/// * `target_sample_rate` - Sample rate de sortie en Hz (ex: 48000)
|
||||||
|
pub fn new(target_sample_rate: u32) -> Box<dyn AudioPipelineNode> {
|
||||||
|
Self::with_channel_size(target_sample_rate, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crée un nouveau node de resampling avec taille de canal personnalisée
|
||||||
|
///
|
||||||
|
/// * `target_sample_rate` - Sample rate de sortie en Hz
|
||||||
|
/// * `channel_size` - Taille du canal de communication
|
||||||
|
pub fn with_channel_size(
|
||||||
|
target_sample_rate: u32,
|
||||||
|
channel_size: usize,
|
||||||
|
) -> Box<dyn AudioPipelineNode> {
|
||||||
|
let logic = ResamplingLogic::new(target_sample_rate);
|
||||||
|
Box::new(Self {
|
||||||
|
inner: Node::new_with_input(logic, channel_size),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl AudioPipelineNode for ResamplingNode {
|
||||||
|
fn get_tx(&self) -> Option<mpsc::Sender<Arc<AudioSegment>>> {
|
||||||
|
self.inner.get_tx()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register(&mut self, child: Box<dyn AudioPipelineNode>) {
|
||||||
|
self.inner.register(child)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(
|
||||||
|
self: Box<Self>,
|
||||||
|
stop_token: CancellationToken,
|
||||||
|
) -> Result<(), AudioError> {
|
||||||
|
Box::new(self.inner).run(stop_token).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TypedAudioNode for ResamplingNode {
|
||||||
|
fn input_type(&self) -> Option<TypeRequirement> {
|
||||||
|
// Accepte n'importe quel type
|
||||||
|
Some(TypeRequirement::any())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn output_type(&self) -> Option<TypeRequirement> {
|
||||||
|
// Produit le même type que l'entrée (mais sample rate changé)
|
||||||
|
Some(TypeRequirement::any())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{AudioChunk, AudioChunkData, SyncMarker};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_channels_i16() {
|
||||||
|
let chunk = AudioChunk::I16(AudioChunkData::new(
|
||||||
|
vec![[100, 200], [300, 400]],
|
||||||
|
48000,
|
||||||
|
0.0,
|
||||||
|
));
|
||||||
|
|
||||||
|
let (left, right) = extract_channels_i32(&chunk).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(left, vec![100i32, 300i32]);
|
||||||
|
assert_eq!(right, vec![200i32, 400i32]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_channels_i24() {
|
||||||
|
let chunk = AudioChunk::I24(AudioChunkData::new(
|
||||||
|
vec![
|
||||||
|
[I24::new(1_000_000).unwrap(), I24::new(2_000_000).unwrap()],
|
||||||
|
[I24::new(3_000_000).unwrap(), I24::new(4_000_000).unwrap()],
|
||||||
|
],
|
||||||
|
48000,
|
||||||
|
0.0,
|
||||||
|
));
|
||||||
|
|
||||||
|
let (left, right) = extract_channels_i32(&chunk).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(left, vec![1_000_000i32, 3_000_000i32]);
|
||||||
|
assert_eq!(right, vec![2_000_000i32, 4_000_000i32]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reconstruct_chunk_i16() {
|
||||||
|
let original = AudioChunk::I16(AudioChunkData::new(
|
||||||
|
vec![[100, 200]],
|
||||||
|
44100,
|
||||||
|
0.0,
|
||||||
|
));
|
||||||
|
|
||||||
|
let left = vec![100i32, 300i32];
|
||||||
|
let right = vec![200i32, 400i32];
|
||||||
|
|
||||||
|
let result = reconstruct_chunk(&original, left, right, 48000).unwrap();
|
||||||
|
|
||||||
|
if let AudioChunk::I16(data) = result {
|
||||||
|
assert_eq!(data.get_sample_rate(), 48000);
|
||||||
|
let frames = data.get_frames();
|
||||||
|
assert_eq!(frames.len(), 2);
|
||||||
|
assert_eq!(frames[0], [100i16, 200i16]);
|
||||||
|
assert_eq!(frames[1], [300i16, 400i16]);
|
||||||
|
} else {
|
||||||
|
panic!("Expected I16 chunk");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reconstruct_chunk_i24() {
|
||||||
|
let original = AudioChunk::I24(AudioChunkData::new(
|
||||||
|
vec![[I24::new(1_000_000).unwrap(), I24::new(2_000_000).unwrap()]],
|
||||||
|
44100,
|
||||||
|
0.0,
|
||||||
|
));
|
||||||
|
|
||||||
|
let left = vec![1_000_000i32, 3_000_000i32];
|
||||||
|
let right = vec![2_000_000i32, 4_000_000i32];
|
||||||
|
|
||||||
|
let result = reconstruct_chunk(&original, left, right, 48000).unwrap();
|
||||||
|
|
||||||
|
if let AudioChunk::I24(data) = result {
|
||||||
|
assert_eq!(data.get_sample_rate(), 48000);
|
||||||
|
let frames = data.get_frames();
|
||||||
|
assert_eq!(frames.len(), 2);
|
||||||
|
assert_eq!(frames[0][0].as_i32(), 1_000_000);
|
||||||
|
assert_eq!(frames[0][1].as_i32(), 2_000_000);
|
||||||
|
} else {
|
||||||
|
panic!("Expected I24 chunk");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resample_chunk_no_change_if_same_rate() {
|
||||||
|
let mut logic = ResamplingLogic::new(48000);
|
||||||
|
|
||||||
|
let chunk = AudioChunk::I16(AudioChunkData::new(
|
||||||
|
vec![[100, 200], [300, 400]],
|
||||||
|
48000, // Déjà à 48kHz
|
||||||
|
0.0,
|
||||||
|
));
|
||||||
|
|
||||||
|
let result = logic.resample_chunk(&chunk).unwrap();
|
||||||
|
|
||||||
|
// Doit retourner le même chunk sans resampling
|
||||||
|
if let AudioChunk::I16(data) = result {
|
||||||
|
assert_eq!(data.get_sample_rate(), 48000);
|
||||||
|
assert_eq!(data.get_frames().len(), 2);
|
||||||
|
} else {
|
||||||
|
panic!("Expected I16 chunk");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_resampling_logic_passes_sync_markers() {
|
||||||
|
let mut logic = ResamplingLogic::new(48000);
|
||||||
|
|
||||||
|
let (input_tx, input_rx) = mpsc::channel(10);
|
||||||
|
let (output_tx, mut output_rx) = mpsc::channel(10);
|
||||||
|
let stop_token = CancellationToken::new();
|
||||||
|
|
||||||
|
// Créer un TrackBoundary
|
||||||
|
let metadata = Arc::new(tokio::sync::RwLock::new(
|
||||||
|
pmometadata::MemoryTrackMetadata::new()
|
||||||
|
));
|
||||||
|
let boundary = AudioSegment::new_track_boundary(0, 0.0, metadata);
|
||||||
|
|
||||||
|
// Envoyer le boundary
|
||||||
|
input_tx.send(boundary.clone()).await.unwrap();
|
||||||
|
drop(input_tx);
|
||||||
|
|
||||||
|
// Lancer le traitement
|
||||||
|
tokio::spawn(async move {
|
||||||
|
logic
|
||||||
|
.process(Some(input_rx), vec![output_tx], stop_token)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Vérifier que le boundary passe tel quel
|
||||||
|
let result = output_rx.recv().await.unwrap();
|
||||||
|
assert!(result.as_sync_marker().is_some());
|
||||||
|
|
||||||
|
if let Some(marker) = result.as_sync_marker() {
|
||||||
|
assert!(matches!(**marker, SyncMarker::TrackBoundary { .. }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_resampling_node_integration() {
|
||||||
|
// Test d'intégration complet avec ResamplingNode
|
||||||
|
let (input_tx, input_rx) = mpsc::channel(10);
|
||||||
|
let (output_tx, mut output_rx) = mpsc::channel(10);
|
||||||
|
let stop_token = CancellationToken::new();
|
||||||
|
|
||||||
|
let mut logic = ResamplingLogic::new(48000);
|
||||||
|
|
||||||
|
// Créer un chunk à 44.1kHz
|
||||||
|
let chunk_44k = AudioChunk::I16(AudioChunkData::new(
|
||||||
|
vec![[1000, 2000]; 100], // 100 frames
|
||||||
|
44100,
|
||||||
|
0.0,
|
||||||
|
));
|
||||||
|
|
||||||
|
let segment = Arc::new(AudioSegment {
|
||||||
|
order: 0,
|
||||||
|
timestamp_sec: 0.0,
|
||||||
|
segment: crate::_AudioSegment::Chunk(Arc::new(chunk_44k)),
|
||||||
|
});
|
||||||
|
|
||||||
|
input_tx.send(segment).await.unwrap();
|
||||||
|
drop(input_tx);
|
||||||
|
|
||||||
|
// Lancer le traitement
|
||||||
|
tokio::spawn(async move {
|
||||||
|
logic
|
||||||
|
.process(Some(input_rx), vec![output_tx], stop_token)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Vérifier le résultat
|
||||||
|
let result = output_rx.recv().await.unwrap();
|
||||||
|
assert!(result.is_audio_chunk());
|
||||||
|
|
||||||
|
if let Some(chunk) = result.as_chunk() {
|
||||||
|
// Le chunk doit être I16 (même type)
|
||||||
|
assert!(matches!(chunk.as_ref(), AudioChunk::I16(_)));
|
||||||
|
|
||||||
|
// Le sample rate doit être 48000
|
||||||
|
assert_eq!(chunk.sample_rate(), 48000);
|
||||||
|
|
||||||
|
// Le nombre de frames doit avoir changé (ratio ~1.088)
|
||||||
|
// 100 frames @ 44.1kHz ≈ 109 frames @ 48kHz
|
||||||
|
if let AudioChunk::I16(data) = chunk.as_ref() {
|
||||||
|
let frames = data.get_frames().len();
|
||||||
|
assert!(frames >= 105 && frames <= 115, "Expected ~109 frames, got {}", frames);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
panic!("Expected audio chunk");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -482,6 +482,15 @@ impl<L: NodeLogic> Node<L> {
|
|||||||
pub fn logic(&self) -> &L {
|
pub fn logic(&self) -> &L {
|
||||||
&self.logic
|
&self.logic
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Retourne une référence mutable vers la logique métier du nœud
|
||||||
|
///
|
||||||
|
/// Permet de configurer la logique après construction mais avant run().
|
||||||
|
/// Utile pour définir des options qui ne peuvent pas être connues
|
||||||
|
/// au moment de la construction du nœud.
|
||||||
|
pub fn logic_mut(&mut self) -> &mut L {
|
||||||
|
&mut self.logic
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use sha1::{Digest, Sha1};
|
|
||||||
use std::{
|
use std::{
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
|
|||||||
@@ -51,12 +51,8 @@ symphonia = { version = "0.5", features = ["all"] }
|
|||||||
# Audio decoding - claxon for FLAC streaming
|
# Audio decoding - claxon for FLAC streaming
|
||||||
claxon = "0.4"
|
claxon = "0.4"
|
||||||
|
|
||||||
# FFmpeg for progressive streaming (decoding + encoding) - optional
|
# pmoaudio-ext with playlist support (optional for examples)
|
||||||
ffmpeg-next = { version = "8.0", optional = true }
|
pmoaudio-ext = { path = "../pmoaudio-ext", optional = true, features = ["playlist"] }
|
||||||
|
|
||||||
# Per-track feature dependencies
|
|
||||||
hound = { version = "3.5", optional = true }
|
|
||||||
tempfile = { version = "3.8", optional = true }
|
|
||||||
|
|
||||||
# Common music source traits
|
# Common music source traits
|
||||||
pmosource = { path = "../pmosource" }
|
pmosource = { path = "../pmosource" }
|
||||||
@@ -74,12 +70,16 @@ pmoserver = { path = "../pmoserver", optional = true }
|
|||||||
utoipa = { version = "5.4.0", optional = true }
|
utoipa = { version = "5.4.0", optional = true }
|
||||||
axum = { version = "0.8.4", optional = true }
|
axum = { version = "0.8.4", optional = true }
|
||||||
|
|
||||||
|
# pmoaudio node support
|
||||||
|
pmoaudio = { path = "../pmoaudio", optional = true }
|
||||||
|
pmoflac = { path = "../pmoflac", optional = true }
|
||||||
|
pmometadata = { path = "../pmometadata", optional = true }
|
||||||
|
futures-util = { version = "0.3", optional = true }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["metadata-only", "pmoconfig"]
|
default = ["metadata-only", "pmoconfig"]
|
||||||
# Mode métadonnées seules (pas de décodage FLAC)
|
# Mode métadonnées seules (pas de décodage FLAC)
|
||||||
metadata-only = []
|
metadata-only = []
|
||||||
# Active l'extraction par-track (WAV export, etc.)
|
|
||||||
per-track = ["dep:hound", "dep:tempfile"]
|
|
||||||
# Active l'API REST pmoserver
|
# Active l'API REST pmoserver
|
||||||
pmoserver = ["dep:pmoserver", "dep:utoipa", "dep:axum", "server"]
|
pmoserver = ["dep:pmoserver", "dep:utoipa", "dep:axum", "server"]
|
||||||
# Feature pour activer le support serveur (cache registry)
|
# Feature pour activer le support serveur (cache registry)
|
||||||
@@ -88,8 +88,10 @@ server = ["pmosource/server", "pmoconfig"]
|
|||||||
pmoconfig = ["dep:pmoconfig"]
|
pmoconfig = ["dep:pmoconfig"]
|
||||||
# Feature cache (deprecated - toujours actif maintenant)
|
# Feature cache (deprecated - toujours actif maintenant)
|
||||||
cache = []
|
cache = []
|
||||||
# Active le streaming progressif avec FFmpeg (latence réduite)
|
# Active le support pmoaudio node (RadioParadiseStreamSource)
|
||||||
ffmpeg = ["dep:ffmpeg-next"]
|
pmoaudio = ["dep:pmoaudio", "dep:pmoflac", "dep:pmometadata", "dep:futures-util"]
|
||||||
|
# Active le support complet avec playlist (pour les exemples avancés)
|
||||||
|
full = ["pmoaudio", "dep:pmoaudio-ext"]
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
# Tests
|
# Tests
|
||||||
@@ -104,17 +106,3 @@ pmoaudiocache = { path = "../pmoaudiocache" }
|
|||||||
[[example]]
|
[[example]]
|
||||||
name = "now_playing"
|
name = "now_playing"
|
||||||
path = "examples/now_playing.rs"
|
path = "examples/now_playing.rs"
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "stream_block"
|
|
||||||
path = "examples/stream_block.rs"
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "extract_track"
|
|
||||||
path = "examples/extract_track.rs"
|
|
||||||
required-features = ["per-track"]
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "with_cache"
|
|
||||||
path = "examples/with_cache.rs"
|
|
||||||
required-features = ["cache"]
|
|
||||||
|
|||||||
244
pmoparadise/RADIO_PARADISE_STREAM_SOURCE.md
Normal file
244
pmoparadise/RADIO_PARADISE_STREAM_SOURCE.md
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
# RadioParadiseStreamSource - Documentation Technique
|
||||||
|
|
||||||
|
## Vue d'ensemble
|
||||||
|
|
||||||
|
`RadioParadiseStreamSource` est un nœud source pour `pmoaudio` qui télécharge et décode les blocs FLAC de Radio Paradise en temps réel, avec gestion automatique des transitions entre pistes (TrackBoundary).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Pattern Node<L>
|
||||||
|
|
||||||
|
Suit l'architecture séparée logique/pipeline de `pmoaudio` :
|
||||||
|
|
||||||
|
```
|
||||||
|
RadioParadiseStreamSource (wrapper)
|
||||||
|
└── Node<RadioParadiseStreamSourceLogic>
|
||||||
|
└── RadioParadiseStreamSourceLogic (logique métier)
|
||||||
|
```
|
||||||
|
|
||||||
|
### RadioParadiseStreamSourceLogic
|
||||||
|
|
||||||
|
Responsabilités :
|
||||||
|
- **File d'attente** : `VecDeque<EventId>` pour les blocks à télécharger
|
||||||
|
- **Cache anti-redondance** : `VecDeque<EventId>` pour 10 blocs récents (FIFO)
|
||||||
|
- **Téléchargement** : Fetch bloc FLAC (bitrate=4 uniquement)
|
||||||
|
- **Décodage** : Stream FLAC via `pmoflac::decode_audio_stream`
|
||||||
|
- **Timing** : Calcul précis pour insertion TrackBoundary
|
||||||
|
|
||||||
|
## Flux d'exécution
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ 1. Attente block ID (timeout 3s) │
|
||||||
|
│ └─> VecDeque::pop_front() │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ 2. Vérification cache │
|
||||||
|
│ └─> VecDeque::contains(&event_id) │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ 3. Téléchargement métadonnées │
|
||||||
|
│ └─> client.get_block(event_id) │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ 4. Téléchargement FLAC (bitrate=4) │
|
||||||
|
│ └─> client.download_block_file(&block, 4) │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ 5. Décodage streaming │
|
||||||
|
│ └─> pmoflac::decode_audio_stream(reader) │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ 6. Découpage en chunks │
|
||||||
|
│ └─> pcm_to_audio_chunk(pcm, sr, bps) │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ 7. Insertion TrackBoundary (timing sample-based) │
|
||||||
|
│ └─> elapsed_ms = (total_samples * 1000) / sr │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Timing TrackBoundary
|
||||||
|
|
||||||
|
### Algorithme
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let elapsed_ms = (total_samples * 1000) / sample_rate as u64;
|
||||||
|
|
||||||
|
if elapsed_ms >= song.elapsed {
|
||||||
|
// Envoyer TrackBoundary AVANT le chunk (même order)
|
||||||
|
send_track_boundary(*order, song, block).await;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exemple concret
|
||||||
|
|
||||||
|
Bloc FLAC contenant 3 chansons :
|
||||||
|
- Song 0 : `elapsed = 0ms`
|
||||||
|
- Song 1 : `elapsed = 180000ms` (3min)
|
||||||
|
- Song 2 : `elapsed = 420000ms` (7min)
|
||||||
|
|
||||||
|
Timeline :
|
||||||
|
```
|
||||||
|
0ms 180000ms 420000ms
|
||||||
|
│ │ │
|
||||||
|
Song 0 TrackBoundary TrackBoundary
|
||||||
|
└─> Song 1 └─> Song 2
|
||||||
|
```
|
||||||
|
|
||||||
|
## SyncMarker Order
|
||||||
|
|
||||||
|
**Règle** : TrackBoundary a le **même order** que le chunk suivant.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// TrackBoundary order = 42
|
||||||
|
AudioSegment::new_sync(42, SyncMarker::TrackBoundary { ... })
|
||||||
|
|
||||||
|
// Chunk suivant order = 42
|
||||||
|
AudioSegment::new_audio(42, AudioChunk::I16(...))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gestion du cache
|
||||||
|
|
||||||
|
### Stratégie FIFO simple
|
||||||
|
|
||||||
|
```rust
|
||||||
|
const RECENT_BLOCKS_CACHE_SIZE: usize = 10;
|
||||||
|
|
||||||
|
fn mark_block_downloaded(&mut self, event_id: EventId) {
|
||||||
|
// Retirer tous les éléments excédentaires (garantit <= CACHE_SIZE)
|
||||||
|
while self.recent_blocks.len() >= RECENT_BLOCKS_CACHE_SIZE {
|
||||||
|
self.recent_blocks.pop_front();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Puis ajouter le nouveau bloc
|
||||||
|
self.recent_blocks.push_back(event_id);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Avantages VecDeque** :
|
||||||
|
- ✅ Ordre FIFO garanti (le plus ancien est toujours retiré)
|
||||||
|
- ✅ Simple et prévisible
|
||||||
|
- ✅ Robuste : `while` garantit exactement 10 éléments max, même en cas d'état anormal
|
||||||
|
- ✅ Ne dépasse jamais la capacité pré-allouée (retire avant d'ajouter)
|
||||||
|
- ✅ Pour 10 éléments, `contains()` en O(n) reste très performant
|
||||||
|
|
||||||
|
## Support FLAC
|
||||||
|
|
||||||
|
### Formats supportés
|
||||||
|
|
||||||
|
- **16-bit** : `AudioChunk::I16`
|
||||||
|
- **24-bit** : `AudioChunk::I24`
|
||||||
|
|
||||||
|
### Conversion PCM
|
||||||
|
|
||||||
|
```rust
|
||||||
|
match bits_per_sample {
|
||||||
|
16 => {
|
||||||
|
let samples: Vec<i16> = pcm_data
|
||||||
|
.chunks_exact(2)
|
||||||
|
.map(|chunk| i16::from_le_bytes([chunk[0], chunk[1]]))
|
||||||
|
.collect();
|
||||||
|
AudioChunk::I16(...)
|
||||||
|
}
|
||||||
|
24 => {
|
||||||
|
let samples: Vec<I24> = pcm_data
|
||||||
|
.chunks_exact(3)
|
||||||
|
.map(|chunk| {
|
||||||
|
let value = i32::from_le_bytes([chunk[0], chunk[1], chunk[2], 0]) >> 8;
|
||||||
|
I24::from_i32(value)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
AudioChunk::I24(...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Métadonnées
|
||||||
|
|
||||||
|
### TrackMetadata
|
||||||
|
|
||||||
|
Champs extraits de `Song` :
|
||||||
|
- `title` : Titre de la chanson
|
||||||
|
- `artist` : Artiste
|
||||||
|
- `album` : Album (optionnel)
|
||||||
|
- `year` : Année (optionnel)
|
||||||
|
- `cover_url` : URL de la pochette (async via tokio::spawn)
|
||||||
|
|
||||||
|
### Gestion asynchrone du cover
|
||||||
|
|
||||||
|
```rust
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Ok(mut meta) = metadata_clone.write().await {
|
||||||
|
let _ = meta.set_cover_url(Some(cover_url)).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Publique
|
||||||
|
|
||||||
|
### Création
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn new(client: RadioParadiseClient, chunk_duration_ms: u32) -> Self
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn push_block_id(&mut self, event_id: EventId)
|
||||||
|
```
|
||||||
|
|
||||||
|
Ajoute un block ID à télécharger dans la file d'attente.
|
||||||
|
|
||||||
|
### Exécution
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError>
|
||||||
|
```
|
||||||
|
|
||||||
|
Hérite de `AudioPipelineNode`.
|
||||||
|
|
||||||
|
## Exemple d'utilisation
|
||||||
|
|
||||||
|
Voir `examples/radio_paradise_stream.rs` pour :
|
||||||
|
- Utilisation basique
|
||||||
|
- Intégration avec nowplaying stream
|
||||||
|
- Connexion à un sink
|
||||||
|
|
||||||
|
## Constantes
|
||||||
|
|
||||||
|
```rust
|
||||||
|
const BLOCK_ID_TIMEOUT_SECS: u64 = 3; // Timeout attente nouveau block
|
||||||
|
const RECENT_BLOCKS_CACHE_SIZE: usize = 10; // Taille cache anti-redondance
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dépendances
|
||||||
|
|
||||||
|
- `pmoaudio` : Pipeline audio, types AudioChunk/AudioSegment
|
||||||
|
- `pmoflac` : Décodage FLAC streaming
|
||||||
|
- `pmometadata` : Métadonnées pistes
|
||||||
|
- `futures-util` : StreamExt pour le décodage
|
||||||
|
- `tokio` : Runtime async
|
||||||
|
- `tokio-util` : StreamReader, CancellationToken
|
||||||
|
|
||||||
|
## Feature gate
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[features]
|
||||||
|
pmoaudio = ["dep:pmoaudio", "dep:pmoflac", "dep:pmometadata", "dep:futures-util"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Activer avec : `cargo build -p pmoparadise --features pmoaudio`
|
||||||
205
pmoparadise/examples/download_block.rs
Normal file
205
pmoparadise/examples/download_block.rs
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
//! Télécharge un bloc complet de Radio Paradise et sauvegarde toutes les pistes en FLAC
|
||||||
|
//!
|
||||||
|
//! Ce programme démontre l'utilisation de la chaîne :
|
||||||
|
//! 1. RadioParadiseStreamSource - Télécharge et décode un bloc FLAC de Radio Paradise
|
||||||
|
//! 2. FlacFileSink - Sauvegarde automatiquement chaque piste dans un fichier FLAC séparé
|
||||||
|
//!
|
||||||
|
//! La nouvelle architecture AudioPipelineNode permet de :
|
||||||
|
//! - Télécharger et décoder automatiquement les blocs FLAC de Radio Paradise
|
||||||
|
//! - Détecter les limites de pistes (TrackBoundary)
|
||||||
|
//! - Sauvegarder automatiquement chaque piste dans un fichier séparé
|
||||||
|
//! - Gérer proprement l'arrêt du pipeline avec un CancellationToken
|
||||||
|
//!
|
||||||
|
//! Usage:
|
||||||
|
//! cargo run --example download_block -- <channel_id>
|
||||||
|
//!
|
||||||
|
//! Exemple:
|
||||||
|
//! cargo run --example download_block -- 0 # Main Mix
|
||||||
|
//! cargo run --example download_block -- 1 # Mellow Mix
|
||||||
|
//! cargo run --example download_block -- 2 # Rock Mix
|
||||||
|
//! cargo run --example download_block -- 3 # World/Etc Mix
|
||||||
|
|
||||||
|
use pmoaudio::{AudioPipelineNode, FlacFileSink};
|
||||||
|
use pmoparadise::{RadioParadiseClient, RadioParadiseStreamSource};
|
||||||
|
use std::env;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
// Initialiser tracing pour le debug
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::from_default_env()
|
||||||
|
.add_directive(tracing::Level::INFO.into()),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
// Récupérer les arguments
|
||||||
|
let args: Vec<String> = env::args().collect();
|
||||||
|
if args.len() != 2 {
|
||||||
|
eprintln!("Usage: {} <channel_id>", args[0]);
|
||||||
|
eprintln!();
|
||||||
|
eprintln!("Downloads a complete Radio Paradise block and saves all tracks as FLAC files.");
|
||||||
|
eprintln!();
|
||||||
|
eprintln!("Channel IDs:");
|
||||||
|
eprintln!(" 0 - Main Mix (eclectic, diverse mix)");
|
||||||
|
eprintln!(" 1 - Mellow Mix (smooth, chilled music)");
|
||||||
|
eprintln!(" 2 - Rock Mix (classic & modern rock)");
|
||||||
|
eprintln!(" 3 - World/Etc Mix (global sounds)");
|
||||||
|
eprintln!();
|
||||||
|
eprintln!("Example:");
|
||||||
|
eprintln!(" {} 0 # Download Main Mix", args[0]);
|
||||||
|
eprintln!(" {} 2 # Download Rock Mix", args[0]);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let channel_id: u8 = match args[1].parse() {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(_) => {
|
||||||
|
eprintln!("Error: channel_id must be a number between 0 and 3");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if channel_id > 3 {
|
||||||
|
eprintln!("Error: channel_id must be between 0 and 3");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("=== Radio Paradise Block Downloader ===");
|
||||||
|
println!();
|
||||||
|
println!("Channel ID: {}", channel_id);
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// Créer le client Radio Paradise pour le channel spécifié
|
||||||
|
println!("Fetching current block metadata...");
|
||||||
|
let client = RadioParadiseClient::builder()
|
||||||
|
.channel(channel_id)
|
||||||
|
.build()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Récupérer le bloc actuel
|
||||||
|
let block = client.get_block(None).await?;
|
||||||
|
|
||||||
|
println!("Block Information:");
|
||||||
|
println!(" Event ID: {}", block.event);
|
||||||
|
println!(" Songs: {}", block.song_count());
|
||||||
|
println!(
|
||||||
|
" Duration: {:.1} minutes",
|
||||||
|
block.length as f64 / 60000.0
|
||||||
|
);
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// Afficher la liste des pistes
|
||||||
|
println!("Tracklist:");
|
||||||
|
for (index, song) in block.songs_ordered() {
|
||||||
|
println!(
|
||||||
|
" {:2}. {} - {} ({})",
|
||||||
|
index + 1,
|
||||||
|
song.artist,
|
||||||
|
song.title,
|
||||||
|
song.album.as_deref().unwrap_or("Unknown Album")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// Créer le répertoire de sortie
|
||||||
|
let output_dir = format!("./rp_channel_{}block{}", channel_id, block.event);
|
||||||
|
std::fs::create_dir_all(&output_dir)?;
|
||||||
|
println!("Output directory: {}", output_dir);
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// Créer le pipeline: RadioParadiseStreamSource → FlacFileSink
|
||||||
|
let mut source = RadioParadiseStreamSource::new(client);
|
||||||
|
|
||||||
|
// Ajouter le bloc à télécharger
|
||||||
|
source.push_block_id(block.event);
|
||||||
|
|
||||||
|
// Créer le sink qui sauvegarde chaque piste dans un fichier séparé
|
||||||
|
let base_path = format!("{}/track.flac", output_dir);
|
||||||
|
let sink = FlacFileSink::new(&base_path);
|
||||||
|
|
||||||
|
// Construire la chaîne: source → sink
|
||||||
|
source.register(Box::new(sink));
|
||||||
|
|
||||||
|
// Créer un token d'arrêt
|
||||||
|
let stop_token = CancellationToken::new();
|
||||||
|
|
||||||
|
// Gérer Ctrl+C pour arrêt propre
|
||||||
|
let stop_token_clone = stop_token.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tokio::signal::ctrl_c().await.ok();
|
||||||
|
println!("\n\nReceived Ctrl+C, stopping...");
|
||||||
|
stop_token_clone.cancel();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lancer tout le pipeline
|
||||||
|
println!("Downloading and processing block...");
|
||||||
|
println!("Press Ctrl+C to stop.");
|
||||||
|
println!();
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
|
let result = Box::new(source).run(stop_token).await;
|
||||||
|
|
||||||
|
let elapsed = start.elapsed();
|
||||||
|
|
||||||
|
// Vérifier le résultat
|
||||||
|
match result {
|
||||||
|
Ok(()) => {
|
||||||
|
println!();
|
||||||
|
println!(
|
||||||
|
"✓ Download completed successfully in {:.2}s",
|
||||||
|
elapsed.as_secs_f64()
|
||||||
|
);
|
||||||
|
println!(" Output directory: {}", output_dir);
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// Afficher les fichiers créés
|
||||||
|
let entries = std::fs::read_dir(&output_dir)?;
|
||||||
|
let mut files: Vec<_> = entries
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.filter(|e| {
|
||||||
|
e.path()
|
||||||
|
.extension()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.map(|s| s == "flac")
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
files.sort_by_key(|e| e.path());
|
||||||
|
|
||||||
|
println!("Files created:");
|
||||||
|
for (i, entry) in files.iter().enumerate() {
|
||||||
|
let path = entry.path();
|
||||||
|
let metadata = std::fs::metadata(&path)?;
|
||||||
|
let size_mb = metadata.len() as f64 / (1024.0 * 1024.0);
|
||||||
|
println!(
|
||||||
|
" {:2}. {} ({:.2} MB)",
|
||||||
|
i + 1,
|
||||||
|
path.file_name().unwrap().to_string_lossy(),
|
||||||
|
size_mb
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// Calculer la taille totale
|
||||||
|
let total_size: u64 = files
|
||||||
|
.iter()
|
||||||
|
.filter_map(|e| std::fs::metadata(e.path()).ok())
|
||||||
|
.map(|m| m.len())
|
||||||
|
.sum();
|
||||||
|
println!(
|
||||||
|
"Total size: {:.2} MB",
|
||||||
|
total_size as f64 / (1024.0 * 1024.0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!();
|
||||||
|
eprintln!("✗ Download error: {}", e);
|
||||||
|
eprintln!();
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
//! Example: Extract individual tracks from a FLAC block (requires `per-track` feature)
|
|
||||||
//!
|
|
||||||
//! This example demonstrates:
|
|
||||||
//! - Per-track extraction from FLAC blocks
|
|
||||||
//! - Exporting tracks to WAV files
|
|
||||||
//! - Alternative player-based seeking (recommended)
|
|
||||||
//!
|
|
||||||
//! **Warning**: This approach downloads and decodes entire blocks.
|
|
||||||
//! For most use cases, player-based seeking is more efficient.
|
|
||||||
//!
|
|
||||||
//! Run with: cargo run --example extract_track --features per-track
|
|
||||||
|
|
||||||
#[cfg(feature = "per-track")]
|
|
||||||
use pmoparadise::{RadioParadiseClient, Result};
|
|
||||||
#[cfg(feature = "per-track")]
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
#[cfg(feature = "per-track")]
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() -> Result<()> {
|
|
||||||
// Initialize logging
|
|
||||||
#[cfg(feature = "logging")]
|
|
||||||
tracing_subscriber::fmt::init();
|
|
||||||
|
|
||||||
println!("Radio Paradise - Per-Track Extraction Demo");
|
|
||||||
println!("===========================================\n");
|
|
||||||
|
|
||||||
println!("WARNING: This feature downloads entire blocks (50-100MB)");
|
|
||||||
println!(" and performs CPU-intensive FLAC decoding.");
|
|
||||||
println!(" For most use cases, player-based seeking is better.\n");
|
|
||||||
|
|
||||||
// Create client
|
|
||||||
let client = RadioParadiseClient::new().await?;
|
|
||||||
|
|
||||||
// Get current block
|
|
||||||
let block = client.get_block(None).await?;
|
|
||||||
|
|
||||||
println!("Block Information:");
|
|
||||||
println!(" Event: {}", block.event);
|
|
||||||
println!(" Songs: {}", block.song_count());
|
|
||||||
println!(" URL: {}\n", block.url);
|
|
||||||
|
|
||||||
// Display all tracks
|
|
||||||
println!("Available Tracks:");
|
|
||||||
for (index, song) in block.songs_ordered() {
|
|
||||||
println!(
|
|
||||||
" {}. {} - {} ({:.1}s)",
|
|
||||||
index,
|
|
||||||
song.artist,
|
|
||||||
song.title,
|
|
||||||
song.duration as f64 / 1000.0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
println!();
|
|
||||||
|
|
||||||
// Extract first track
|
|
||||||
let track_index = 0;
|
|
||||||
if let Some((_, song)) = block.songs_ordered().first() {
|
|
||||||
println!("Extracting Track {}:", track_index);
|
|
||||||
println!(" Artist: {}", song.artist);
|
|
||||||
println!(" Title: {}", song.title);
|
|
||||||
println!(" Album: {}\n", song.album);
|
|
||||||
|
|
||||||
println!("Downloading and decoding... (this may take a while)");
|
|
||||||
|
|
||||||
// Open track stream
|
|
||||||
let mut track_stream = client.open_track_stream(&block, track_index).await?;
|
|
||||||
|
|
||||||
println!("Track Metadata:");
|
|
||||||
println!(" Sample Rate: {} Hz", track_stream.metadata.sample_rate);
|
|
||||||
println!(" Channels: {}", track_stream.metadata.channels);
|
|
||||||
println!(
|
|
||||||
" Bits Per Sample: {}",
|
|
||||||
track_stream.metadata.bits_per_sample
|
|
||||||
);
|
|
||||||
println!(" Total Samples: {}", track_stream.metadata.total_samples);
|
|
||||||
println!();
|
|
||||||
|
|
||||||
// Export to WAV
|
|
||||||
let output_path = Path::new("track.wav");
|
|
||||||
println!("Exporting to {:?}...", output_path);
|
|
||||||
track_stream.export_wav(output_path)?;
|
|
||||||
println!("✓ Export complete!\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show alternative: player-based seeking
|
|
||||||
println!("RECOMMENDED ALTERNATIVE: Player-Based Seeking");
|
|
||||||
println!("=============================================\n");
|
|
||||||
|
|
||||||
for (index, song) in block.songs_ordered().into_iter().take(3) {
|
|
||||||
let (start, duration) = client.track_position_seconds(&block, index)?;
|
|
||||||
println!("Track {}: {} - {}", index, song.artist, song.title);
|
|
||||||
println!(" mpv command:");
|
|
||||||
println!(
|
|
||||||
" mpv --start={:.3} --length={:.3} '{}'",
|
|
||||||
start, duration, block.url
|
|
||||||
);
|
|
||||||
println!(" ffmpeg command (extract to file):");
|
|
||||||
println!(
|
|
||||||
" ffmpeg -ss {:.3} -t {:.3} -i '{}' -c copy track_{}.flac",
|
|
||||||
start, duration, block.url, index
|
|
||||||
);
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("These methods are much more efficient as they:");
|
|
||||||
println!(" - Don't download the entire block");
|
|
||||||
println!(" - Use the player's optimized seeking");
|
|
||||||
println!(" - Start playback immediately");
|
|
||||||
println!(" - Preserve original quality (with -c copy)");
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(feature = "per-track"))]
|
|
||||||
fn main() {
|
|
||||||
eprintln!("ERROR: This example requires the 'per-track' feature.");
|
|
||||||
eprintln!("Run with: cargo run --example extract_track --features per-track");
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
276
pmoparadise/examples/play_and_cache.rs
Normal file
276
pmoparadise/examples/play_and_cache.rs
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
//! Télécharge un bloc Radio Paradise, le cache, et le joue en même temps
|
||||||
|
//!
|
||||||
|
//! Ce programme démontre l'utilisation complète de la chaîne :
|
||||||
|
//! 1. RadioParadiseStreamSource - Télécharge et décode un bloc FLAC
|
||||||
|
//! 2. FlacCacheSink - Cache chaque piste en FLAC et alimente une playlist
|
||||||
|
//! 3. PlaylistSource - Lit la playlist pendant le téléchargement
|
||||||
|
//! 4. AudioSink - Joue l'audio sur la sortie standard
|
||||||
|
//!
|
||||||
|
//! Architecture :
|
||||||
|
//! ```text
|
||||||
|
//! Pipeline 1 (Download & Cache):
|
||||||
|
//! RadioParadiseStreamSource → FlacCacheSink (avec playlist abonnée)
|
||||||
|
//!
|
||||||
|
//! Pipeline 2 (Playback):
|
||||||
|
//! PlaylistSource (lit la playlist) → AudioSink (joue l'audio)
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Usage:
|
||||||
|
//! cargo run --example play_and_cache --features full -- <channel_id>
|
||||||
|
//!
|
||||||
|
//! Exemple:
|
||||||
|
//! cargo run --example play_and_cache --features full -- 0 # Main Mix
|
||||||
|
//! cargo run --example play_and_cache --features full -- 2 # Rock Mix
|
||||||
|
|
||||||
|
use pmoaudio::{AudioPipelineNode, AudioSink};
|
||||||
|
use pmoaudio_ext::{FlacCacheSink, PlaylistSource};
|
||||||
|
use pmoaudiocache::Cache as AudioCache;
|
||||||
|
use pmocovers::Cache as CoverCache;
|
||||||
|
use pmoparadise::{RadioParadiseClient, RadioParadiseStreamSource};
|
||||||
|
use pmoplaylist::Manager as PlaylistManager;
|
||||||
|
use std::env;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
// Initialiser tracing avec beaucoup de logs
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::from_default_env()
|
||||||
|
.add_directive(tracing::Level::DEBUG.into())
|
||||||
|
.add_directive("pmoaudio=debug".parse()?)
|
||||||
|
.add_directive("pmoaudio_ext=debug".parse()?)
|
||||||
|
.add_directive("pmoplaylist=debug".parse()?)
|
||||||
|
.add_directive("pmoparadise=debug".parse()?)
|
||||||
|
.add_directive("pmoaudiocache=debug".parse()?)
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
tracing::info!("=== Radio Paradise Play & Cache ===");
|
||||||
|
|
||||||
|
// Récupérer les arguments
|
||||||
|
let args: Vec<String> = env::args().collect();
|
||||||
|
if args.len() != 2 {
|
||||||
|
eprintln!("Usage: {} <channel_id>", args[0]);
|
||||||
|
eprintln!();
|
||||||
|
eprintln!("Downloads a Radio Paradise block, caches it, and plays it simultaneously.");
|
||||||
|
eprintln!();
|
||||||
|
eprintln!("Channel IDs:");
|
||||||
|
eprintln!(" 0 - Main Mix (eclectic, diverse mix)");
|
||||||
|
eprintln!(" 1 - Mellow Mix (smooth, chilled music)");
|
||||||
|
eprintln!(" 2 - Rock Mix (classic & modern rock)");
|
||||||
|
eprintln!(" 3 - World/Etc Mix (global sounds)");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let channel_id: u8 = match args[1].parse() {
|
||||||
|
Ok(id) if id <= 3 => id,
|
||||||
|
_ => {
|
||||||
|
eprintln!("Error: channel_id must be a number between 0 and 3");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!("Channel ID: {}", channel_id);
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Initialiser les caches et le gestionnaire de playlist
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
let base_dir = std::env::var("PMO_CONFIG_DIR").unwrap_or_else(|_| "/tmp/pmomusic_test".to_string());
|
||||||
|
std::fs::create_dir_all(&base_dir)?;
|
||||||
|
|
||||||
|
tracing::info!("Initializing caches in: {}", base_dir);
|
||||||
|
|
||||||
|
// Créer le cache audio
|
||||||
|
let audio_cache_dir = format!("{}/audio_cache", base_dir);
|
||||||
|
std::fs::create_dir_all(&audio_cache_dir)?;
|
||||||
|
let audio_cache = Arc::new(AudioCache::new(
|
||||||
|
&audio_cache_dir,
|
||||||
|
1000, // 1000 MB limit
|
||||||
|
)?);
|
||||||
|
tracing::debug!("Audio cache initialized at: {}", audio_cache_dir);
|
||||||
|
|
||||||
|
// Créer le cache de covers
|
||||||
|
let cover_cache_dir = format!("{}/cover_cache", base_dir);
|
||||||
|
std::fs::create_dir_all(&cover_cache_dir)?;
|
||||||
|
let cover_cache = Arc::new(CoverCache::new(
|
||||||
|
&cover_cache_dir,
|
||||||
|
100, // 100 MB limit
|
||||||
|
)?);
|
||||||
|
tracing::debug!("Cover cache initialized at: {}", cover_cache_dir);
|
||||||
|
|
||||||
|
// Utiliser le gestionnaire de playlist singleton
|
||||||
|
tracing::info!("Getting playlist manager...");
|
||||||
|
let playlist_manager = pmoplaylist::PlaylistManager();
|
||||||
|
tracing::debug!("Playlist manager obtained");
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Créer la playlist pour ce channel
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
let playlist_id = format!("radio-paradise-ch{}", channel_id);
|
||||||
|
tracing::info!("Creating playlist: {}", playlist_id);
|
||||||
|
|
||||||
|
// Créer la playlist (ou la vider si elle existe)
|
||||||
|
let mut writer = playlist_manager.create_persistent_playlist(playlist_id.clone()).await?;
|
||||||
|
writer.set_title(format!("Radio Paradise - Channel {}", channel_id)).await?;
|
||||||
|
writer.flush().await?; // Vider la playlist si elle existait
|
||||||
|
tracing::debug!("Playlist created and flushed");
|
||||||
|
|
||||||
|
// Créer le reader pour la lecture
|
||||||
|
let reader = playlist_manager.get_read_handle(&playlist_id).await?;
|
||||||
|
tracing::debug!("Read handle created");
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Récupérer les infos du bloc à télécharger
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
tracing::info!("Fetching current block metadata...");
|
||||||
|
let client = RadioParadiseClient::builder()
|
||||||
|
.channel(channel_id)
|
||||||
|
.build()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let block = client.get_block(None).await?;
|
||||||
|
|
||||||
|
tracing::info!("Block Information:");
|
||||||
|
tracing::info!(" Event ID: {}", block.event);
|
||||||
|
tracing::info!(" Songs: {}", block.song_count());
|
||||||
|
tracing::info!(" Duration: {:.1} minutes", block.length as f64 / 60000.0);
|
||||||
|
tracing::info!("");
|
||||||
|
|
||||||
|
tracing::info!("Tracklist:");
|
||||||
|
for (index, song) in block.songs_ordered() {
|
||||||
|
tracing::info!(
|
||||||
|
" {:2}. {} - {} ({})",
|
||||||
|
index + 1,
|
||||||
|
song.artist,
|
||||||
|
song.title,
|
||||||
|
song.album.as_deref().unwrap_or("Unknown Album")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
tracing::info!("");
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Pipeline 1: Téléchargement et cache
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
tracing::info!("Creating download pipeline...");
|
||||||
|
|
||||||
|
// Créer la source Radio Paradise
|
||||||
|
let mut download_source = RadioParadiseStreamSource::new(client);
|
||||||
|
download_source.push_block_id(block.event);
|
||||||
|
tracing::debug!("RadioParadiseStreamSource created with block {}", block.event);
|
||||||
|
|
||||||
|
// Créer le sink de cache FLAC
|
||||||
|
let mut cache_sink = FlacCacheSink::new(audio_cache.clone(), cover_cache.clone());
|
||||||
|
cache_sink.register_playlist(writer);
|
||||||
|
tracing::debug!("FlacCacheSink created and registered with playlist");
|
||||||
|
|
||||||
|
// Connecter source → sink
|
||||||
|
download_source.register(Box::new(cache_sink));
|
||||||
|
tracing::info!("Download pipeline connected: RadioParadiseStreamSource → FlacCacheSink");
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Pipeline 2: Lecture depuis la playlist
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
tracing::info!("Creating playback pipeline...");
|
||||||
|
|
||||||
|
// Créer la source playlist
|
||||||
|
let mut playlist_source = PlaylistSource::new(reader, audio_cache.clone());
|
||||||
|
tracing::debug!("PlaylistSource created");
|
||||||
|
|
||||||
|
// Créer le sink audio avec volume à 80%
|
||||||
|
let audio_sink = AudioSink::with_volume(0.8);
|
||||||
|
tracing::debug!("AudioSink created with volume 0.8");
|
||||||
|
|
||||||
|
// Connecter playlist → audio
|
||||||
|
playlist_source.register(Box::new(audio_sink));
|
||||||
|
tracing::info!("Playback pipeline connected: PlaylistSource → AudioSink");
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Lancer les deux pipelines en parallèle
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
tracing::info!("");
|
||||||
|
tracing::info!("========================================");
|
||||||
|
tracing::info!("Starting both pipelines...");
|
||||||
|
tracing::info!("Pipeline 1: Downloading and caching");
|
||||||
|
tracing::info!("Pipeline 2: Playing from playlist");
|
||||||
|
tracing::info!("========================================");
|
||||||
|
tracing::info!("");
|
||||||
|
|
||||||
|
let stop_token = CancellationToken::new();
|
||||||
|
let stop_token_download = stop_token.clone();
|
||||||
|
let stop_token_playback = stop_token.clone();
|
||||||
|
|
||||||
|
// Gérer Ctrl+C
|
||||||
|
let stop_token_ctrl_c = stop_token.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tokio::signal::ctrl_c().await.ok();
|
||||||
|
tracing::warn!("Received Ctrl+C, stopping...");
|
||||||
|
stop_token_ctrl_c.cancel();
|
||||||
|
});
|
||||||
|
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
|
// Lancer les deux pipelines en parallèle
|
||||||
|
let download_handle = tokio::spawn(async move {
|
||||||
|
tracing::info!("[DOWNLOAD] Pipeline starting...");
|
||||||
|
let result = Box::new(download_source).run(stop_token_download).await;
|
||||||
|
match &result {
|
||||||
|
Ok(()) => tracing::info!("[DOWNLOAD] Pipeline completed successfully"),
|
||||||
|
Err(e) => tracing::error!("[DOWNLOAD] Pipeline error: {}", e),
|
||||||
|
}
|
||||||
|
result
|
||||||
|
});
|
||||||
|
|
||||||
|
let playback_handle = tokio::spawn(async move {
|
||||||
|
// Attendre un peu que le premier track soit disponible
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||||
|
tracing::info!("[PLAYBACK] Pipeline starting...");
|
||||||
|
let result = Box::new(playlist_source).run(stop_token_playback).await;
|
||||||
|
match &result {
|
||||||
|
Ok(()) => tracing::info!("[PLAYBACK] Pipeline completed successfully"),
|
||||||
|
Err(e) => tracing::error!("[PLAYBACK] Pipeline error: {}", e),
|
||||||
|
}
|
||||||
|
result
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attendre les deux pipelines
|
||||||
|
let (download_result, playback_result) = tokio::join!(download_handle, playback_handle);
|
||||||
|
|
||||||
|
let elapsed = start.elapsed();
|
||||||
|
|
||||||
|
// Vérifier les résultats
|
||||||
|
match (download_result, playback_result) {
|
||||||
|
(Ok(Ok(())), Ok(Ok(()))) => {
|
||||||
|
tracing::info!("");
|
||||||
|
tracing::info!("========================================");
|
||||||
|
tracing::info!("✓ Both pipelines completed successfully");
|
||||||
|
tracing::info!(" Total time: {:.2}s", elapsed.as_secs_f64());
|
||||||
|
tracing::info!("========================================");
|
||||||
|
}
|
||||||
|
(download_res, playback_res) => {
|
||||||
|
tracing::error!("");
|
||||||
|
tracing::error!("========================================");
|
||||||
|
if let Err(e) = download_res {
|
||||||
|
tracing::error!("✗ Download pipeline error: {:?}", e);
|
||||||
|
} else if let Ok(Err(e)) = download_res {
|
||||||
|
tracing::error!("✗ Download pipeline error: {}", e);
|
||||||
|
}
|
||||||
|
if let Err(e) = playback_res {
|
||||||
|
tracing::error!("✗ Playback pipeline error: {:?}", e);
|
||||||
|
} else if let Ok(Err(e)) = playback_res {
|
||||||
|
tracing::error!("✗ Playback pipeline error: {}", e);
|
||||||
|
}
|
||||||
|
tracing::error!("========================================");
|
||||||
|
return Err("Pipeline error".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
//! Example showing how to access and save the Radio Paradise source image
|
|
||||||
//!
|
|
||||||
//! This example demonstrates:
|
|
||||||
//! - Getting source information via the MusicSource trait
|
|
||||||
//! - Accessing the embedded WebP image
|
|
||||||
//! - Optionally saving it to a file
|
|
||||||
|
|
||||||
use pmoaudiocache::cache as audio_cache;
|
|
||||||
use pmocovers::cache as covers_cache;
|
|
||||||
use pmoparadise::{RadioParadiseClient, RadioParadiseSource};
|
|
||||||
use pmosource::MusicSource;
|
|
||||||
use std::fs;
|
|
||||||
use std::io::Write;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
// Create the client and source
|
|
||||||
let client = RadioParadiseClient::new().await?;
|
|
||||||
|
|
||||||
// Build lightweight caches under the system temp dir for this example
|
|
||||||
let base_dir = std::env::temp_dir().join(format!(
|
|
||||||
"pmoparadise_show_source_image_{}",
|
|
||||||
std::process::id()
|
|
||||||
));
|
|
||||||
let covers_dir = base_dir.join("covers");
|
|
||||||
let audio_dir = base_dir.join("audio");
|
|
||||||
std::fs::create_dir_all(&covers_dir)?;
|
|
||||||
std::fs::create_dir_all(&audio_dir)?;
|
|
||||||
|
|
||||||
let cover_cache = Arc::new(covers_cache::new_cache(
|
|
||||||
covers_dir.to_string_lossy().as_ref(),
|
|
||||||
32,
|
|
||||||
)?);
|
|
||||||
let audio_cache = Arc::new(audio_cache::new_cache(
|
|
||||||
audio_dir.to_string_lossy().as_ref(),
|
|
||||||
32,
|
|
||||||
)?);
|
|
||||||
|
|
||||||
let source = RadioParadiseSource::new_default(client, cover_cache, audio_cache);
|
|
||||||
|
|
||||||
// Display source information
|
|
||||||
println!("Music Source Information");
|
|
||||||
println!("========================");
|
|
||||||
println!("Name: {}", source.name());
|
|
||||||
println!("ID: {}", source.id());
|
|
||||||
println!("Image MIME type: {}", source.default_image_mime_type());
|
|
||||||
|
|
||||||
// Get the embedded image
|
|
||||||
let image_data = source.default_image();
|
|
||||||
println!("Embedded image size: {} bytes", image_data.len());
|
|
||||||
|
|
||||||
// Verify WebP format
|
|
||||||
if image_data.len() >= 12 {
|
|
||||||
let is_webp = &image_data[0..4] == b"RIFF" && &image_data[8..12] == b"WEBP";
|
|
||||||
println!("Valid WebP format: {}", is_webp);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional: save to file
|
|
||||||
if std::env::args().any(|arg| arg == "--save") {
|
|
||||||
let filename = format!("{}_default.webp", source.id());
|
|
||||||
let mut file = fs::File::create(&filename)?;
|
|
||||||
file.write_all(image_data)?;
|
|
||||||
println!("\nImage saved to: {}", filename);
|
|
||||||
println!("You can view it with: open {}", filename);
|
|
||||||
} else {
|
|
||||||
println!("\nTo save the image to disk, run with: --save");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
//! Example: Stream a Radio Paradise block with prefetching
|
|
||||||
//!
|
|
||||||
//! This example demonstrates:
|
|
||||||
//! - Streaming block audio data
|
|
||||||
//! - Writing to a file or piping to a player
|
|
||||||
//! - Prefetching the next block for gapless playback
|
|
||||||
//! - Continuous playback loop
|
|
||||||
//!
|
|
||||||
//! Run with: cargo run --example stream_block
|
|
||||||
//!
|
|
||||||
//! To play directly with mpv:
|
|
||||||
//! cargo run --example stream_block | mpv --no-cache --demuxer=+lavf -
|
|
||||||
|
|
||||||
use futures::StreamExt;
|
|
||||||
use pmoparadise::{RadioParadiseClient, Result};
|
|
||||||
use std::io::Write;
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() -> Result<()> {
|
|
||||||
// Initialize logging (optional)
|
|
||||||
#[cfg(feature = "logging")]
|
|
||||||
tracing_subscriber::fmt::init();
|
|
||||||
|
|
||||||
eprintln!("Radio Paradise - Block Streaming Demo");
|
|
||||||
eprintln!("======================================\n");
|
|
||||||
|
|
||||||
// Create client
|
|
||||||
let mut client = RadioParadiseClient::builder().build().await?;
|
|
||||||
|
|
||||||
eprintln!("Client configured for FLAC streaming\n");
|
|
||||||
|
|
||||||
// Get current block
|
|
||||||
let current_block = client.get_block(None).await?;
|
|
||||||
|
|
||||||
eprintln!("Current Block:");
|
|
||||||
eprintln!(" Event: {}", current_block.event);
|
|
||||||
eprintln!(" Songs: {}", current_block.song_count());
|
|
||||||
eprintln!(
|
|
||||||
" Duration: {:.1} minutes",
|
|
||||||
current_block.length as f64 / 60000.0
|
|
||||||
);
|
|
||||||
eprintln!(" URL: {}\n", current_block.url);
|
|
||||||
|
|
||||||
// Display tracklist
|
|
||||||
eprintln!("Tracklist:");
|
|
||||||
for (index, song) in current_block.songs_ordered() {
|
|
||||||
eprintln!(" {}. {} - {}", index + 1, song.artist, song.title);
|
|
||||||
}
|
|
||||||
eprintln!();
|
|
||||||
|
|
||||||
// Prefetch next block in advance
|
|
||||||
eprintln!("Prefetching next block...");
|
|
||||||
client.prefetch_next(¤t_block).await?;
|
|
||||||
eprintln!(
|
|
||||||
"Next block prefetched: {}\n",
|
|
||||||
client.next_block_url().unwrap()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Stream the block
|
|
||||||
eprintln!("Streaming block... (writing to stdout)");
|
|
||||||
eprintln!("Tip: Pipe to a player like: cargo run --example stream_block | mpv -\n");
|
|
||||||
|
|
||||||
let mut stream = client.stream_block_from_metadata(¤t_block).await?;
|
|
||||||
let mut total_bytes = 0u64;
|
|
||||||
let mut stdout = std::io::stdout();
|
|
||||||
|
|
||||||
while let Some(chunk_result) = stream.next().await {
|
|
||||||
let chunk = chunk_result?;
|
|
||||||
total_bytes += chunk.len() as u64;
|
|
||||||
|
|
||||||
// Write to stdout (can be piped to a player)
|
|
||||||
stdout.write_all(&chunk)?;
|
|
||||||
stdout.flush()?;
|
|
||||||
|
|
||||||
// Progress indicator (to stderr so it doesn't interfere with piped audio)
|
|
||||||
if total_bytes % (1024 * 1024) == 0 {
|
|
||||||
eprintln!(
|
|
||||||
" Downloaded: {:.1} MB",
|
|
||||||
total_bytes as f64 / 1024.0 / 1024.0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
eprintln!("\nBlock streaming complete!");
|
|
||||||
eprintln!(
|
|
||||||
"Total downloaded: {:.2} MB",
|
|
||||||
total_bytes as f64 / 1024.0 / 1024.0
|
|
||||||
);
|
|
||||||
|
|
||||||
// In a real application, you would now:
|
|
||||||
// 1. Get the next block using prefetched metadata
|
|
||||||
// 2. Stream it seamlessly
|
|
||||||
// 3. Prefetch the following block
|
|
||||||
// 4. Repeat for continuous playback
|
|
||||||
|
|
||||||
eprintln!("\nFor continuous playback, you would now stream the next block:");
|
|
||||||
eprintln!(" Event: {}", current_block.end_event);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
//! Test progressive streaming implementation
|
|
||||||
//!
|
|
||||||
//! This example tests the streaming implementation and measures performance
|
|
||||||
//!
|
|
||||||
//! Run with:
|
|
||||||
//! ```bash
|
|
||||||
//! RUST_LOG=info cargo run --example test_streaming
|
|
||||||
//! ```
|
|
||||||
|
|
||||||
use pmoparadise::RadioParadiseClient;
|
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
// Initialize tracing with timestamps
|
|
||||||
tracing_subscriber::fmt()
|
|
||||||
.with_target(false)
|
|
||||||
.with_thread_ids(false)
|
|
||||||
.with_level(true)
|
|
||||||
.init();
|
|
||||||
|
|
||||||
println!("🎵 Testing Progressive FLAC Streaming");
|
|
||||||
println!("=====================================\n");
|
|
||||||
|
|
||||||
// Create the Radio Paradise client
|
|
||||||
println!("📡 Connecting to Radio Paradise...");
|
|
||||||
let client = RadioParadiseClient::new().await?;
|
|
||||||
println!("✅ Connected!\n");
|
|
||||||
|
|
||||||
// Get current block
|
|
||||||
println!("🎧 Fetching current block metadata...");
|
|
||||||
let block = client.get_block(None).await?;
|
|
||||||
|
|
||||||
println!("\n📊 Block Information:");
|
|
||||||
println!(" Event ID: {}", block.event);
|
|
||||||
println!(" Songs: {}", block.song_count());
|
|
||||||
println!(" Duration: ~{} seconds\n", block.length / 1000);
|
|
||||||
|
|
||||||
// List songs
|
|
||||||
println!("🎵 Songs in this block:");
|
|
||||||
for (idx, song) in block.songs_ordered() {
|
|
||||||
println!(
|
|
||||||
" {}. {} - {} ({}s at {}s)",
|
|
||||||
idx + 1,
|
|
||||||
song.artist,
|
|
||||||
song.title,
|
|
||||||
song.duration / 1000,
|
|
||||||
song.elapsed / 1000
|
|
||||||
);
|
|
||||||
}
|
|
||||||
println!();
|
|
||||||
|
|
||||||
// Now test the streaming decoder
|
|
||||||
println!("⚡ Starting progressive streaming test...");
|
|
||||||
println!(" (This will download and decode the block progressively)");
|
|
||||||
println!();
|
|
||||||
|
|
||||||
let start_time = Instant::now();
|
|
||||||
let block_url = block.url.parse()?;
|
|
||||||
let http_stream = client.stream_block(&block_url).await?;
|
|
||||||
|
|
||||||
use pmoparadise::streaming::StreamingPCMDecoder;
|
|
||||||
|
|
||||||
// Decode in a blocking task
|
|
||||||
let decode_task = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<(u64, usize)>> {
|
|
||||||
let mut decoder = StreamingPCMDecoder::new(http_stream)?;
|
|
||||||
|
|
||||||
println!(
|
|
||||||
" 🎼 Stream info: {}Hz, {} channels, {} bits",
|
|
||||||
decoder.sample_rate(),
|
|
||||||
decoder.channels(),
|
|
||||||
decoder.bits_per_sample()
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut chunk_times = Vec::new();
|
|
||||||
let mut chunk_count = 0;
|
|
||||||
|
|
||||||
while let Some(chunk) = decoder.decode_chunk()? {
|
|
||||||
chunk_count += 1;
|
|
||||||
chunk_times.push((chunk.position_ms, chunk.samples.len()));
|
|
||||||
|
|
||||||
if chunk_count % 50 == 0 {
|
|
||||||
println!(
|
|
||||||
" 📦 Chunk {} at {}ms ({} samples)",
|
|
||||||
chunk_count,
|
|
||||||
chunk.position_ms,
|
|
||||||
chunk.samples.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(chunk_times)
|
|
||||||
});
|
|
||||||
|
|
||||||
let chunk_times = decode_task
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!("Join error: {}", e))??;
|
|
||||||
let total_time = start_time.elapsed();
|
|
||||||
|
|
||||||
println!("\n✅ Streaming Complete!");
|
|
||||||
println!("\n📈 Performance Metrics:");
|
|
||||||
println!(" Total chunks decoded: {}", chunk_times.len());
|
|
||||||
println!(" Total time: {:.2}s", total_time.as_secs_f64());
|
|
||||||
|
|
||||||
if let Some((first_pos, _)) = chunk_times.first() {
|
|
||||||
println!(" First chunk at: {}ms", first_pos);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some((last_pos, _)) = chunk_times.last() {
|
|
||||||
println!(
|
|
||||||
" Last chunk at: {}ms (~{:.1}s)",
|
|
||||||
last_pos,
|
|
||||||
last_pos / 1000
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("\n💡 Analysis:");
|
|
||||||
println!(" With the old approach (download all first):");
|
|
||||||
println!(" - Would need to wait for full download (~12-16s)");
|
|
||||||
println!(" - Then decode all samples");
|
|
||||||
println!(" - Total: ~15-20s before first track");
|
|
||||||
println!();
|
|
||||||
println!(" With progressive streaming:");
|
|
||||||
println!(" - First chunks arrive in ~2-3s");
|
|
||||||
println!(" - First track (3min) ready in ~6-8s");
|
|
||||||
println!(" - Improvement: ~2x faster! ⚡");
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
//! Example demonstrating Radio Paradise with cache support
|
|
||||||
//!
|
|
||||||
//! This example shows how to use the RadioParadiseSource with pmocovers
|
|
||||||
//! and pmoaudiocache to cache both cover images and audio tracks.
|
|
||||||
//!
|
|
||||||
//! Run with:
|
|
||||||
//! ```bash
|
|
||||||
//! cargo run --example with_cache --features cache
|
|
||||||
//! ```
|
|
||||||
|
|
||||||
use pmoaudiocache::AudioCache;
|
|
||||||
use pmocovers::Cache as CoverCache;
|
|
||||||
use pmoparadise::{RadioParadiseClient, RadioParadiseSource};
|
|
||||||
use pmosource::MusicSource;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::time::{sleep, Duration};
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
// Initialize tracing
|
|
||||||
tracing_subscriber::fmt::init();
|
|
||||||
|
|
||||||
println!("🎵 Radio Paradise with Cache Support");
|
|
||||||
println!("=====================================\n");
|
|
||||||
|
|
||||||
// Create the Radio Paradise client
|
|
||||||
println!("📡 Connecting to Radio Paradise...");
|
|
||||||
let client = RadioParadiseClient::new().await?;
|
|
||||||
println!("✅ Connected!\n");
|
|
||||||
|
|
||||||
// Initialize caches
|
|
||||||
println!("💾 Initializing caches...");
|
|
||||||
let cover_cache = Arc::new(CoverCache::new("./cache/covers", 500)?);
|
|
||||||
let audio_cache = Arc::new(AudioCache::new("./cache/audio", 100)?);
|
|
||||||
println!("✅ Caches initialized!\n");
|
|
||||||
|
|
||||||
// Create the source with caching enabled
|
|
||||||
let source = RadioParadiseSource::new_with_cache(
|
|
||||||
client.clone(),
|
|
||||||
"http://localhost:8080",
|
|
||||||
50,
|
|
||||||
Some(cover_cache.clone()),
|
|
||||||
Some(audio_cache.clone()),
|
|
||||||
);
|
|
||||||
|
|
||||||
println!("📻 Source: {}", source.name());
|
|
||||||
println!("🆔 ID: {}", source.id());
|
|
||||||
println!("📝 Supports FIFO: {}\n", source.supports_fifo());
|
|
||||||
|
|
||||||
// Fetch current playing information
|
|
||||||
println!("🎧 Fetching current track information...");
|
|
||||||
let now_playing = client.now_playing().await?;
|
|
||||||
let block = Arc::new(now_playing.block.clone());
|
|
||||||
|
|
||||||
println!("\n🎵 Now Playing:");
|
|
||||||
println!(" Event: {}", block.event);
|
|
||||||
if let Some(song) = &now_playing.current_song {
|
|
||||||
println!(" Title: {}", song.title);
|
|
||||||
println!(" Artist: {}", song.artist);
|
|
||||||
println!(" Album: {}", song.album);
|
|
||||||
}
|
|
||||||
println!();
|
|
||||||
|
|
||||||
// Add current song to the source
|
|
||||||
println!("➕ Adding current track to FIFO with caching...");
|
|
||||||
if let Some(song) = &now_playing.current_song {
|
|
||||||
source
|
|
||||||
.add_song(
|
|
||||||
block.clone(),
|
|
||||||
song,
|
|
||||||
now_playing.current_song_index.unwrap_or(0),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
println!("✅ Track added and caching started!");
|
|
||||||
println!(" - Cover image will be cached to: ./cache/covers/");
|
|
||||||
println!(" - Audio will be cached to: ./cache/audio/\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait a bit for caching to start
|
|
||||||
println!("⏳ Waiting for cache operations to complete...");
|
|
||||||
sleep(Duration::from_secs(5)).await;
|
|
||||||
|
|
||||||
// Get items from FIFO
|
|
||||||
println!("\n📋 Items in FIFO:");
|
|
||||||
let items = source.get_items(0, 10).await?;
|
|
||||||
for (i, item) in items.iter().enumerate() {
|
|
||||||
println!(
|
|
||||||
" {}. {} - {}",
|
|
||||||
i + 1,
|
|
||||||
item.artist.as_deref().unwrap_or("Unknown"),
|
|
||||||
item.title
|
|
||||||
);
|
|
||||||
|
|
||||||
// Show resolved URI (will use cached version if available)
|
|
||||||
if let Ok(uri) = source.resolve_uri(&item.id).await {
|
|
||||||
println!(" URI: {}", uri);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("\n✨ Example complete!");
|
|
||||||
println!("\n💡 Tips:");
|
|
||||||
println!(" - Run the example again to see faster loading from cache");
|
|
||||||
println!(" - Check ./cache/covers/ for cached cover images");
|
|
||||||
println!(" - Check ./cache/audio/ for cached FLAC files");
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
143
pmoparadise/src/channels.rs
Normal file
143
pmoparadise/src/channels.rs
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
//! Radio Paradise channel definitions
|
||||||
|
//!
|
||||||
|
//! This module defines the available Radio Paradise channels and their metadata.
|
||||||
|
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
/// Logical identifier for a Radio Paradise channel.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum ParadiseChannelKind {
|
||||||
|
Main,
|
||||||
|
Mellow,
|
||||||
|
Rock,
|
||||||
|
Eclectic,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ParadiseChannelKind {
|
||||||
|
pub const fn id(self) -> u8 {
|
||||||
|
match self {
|
||||||
|
Self::Main => 0,
|
||||||
|
Self::Mellow => 1,
|
||||||
|
Self::Rock => 2,
|
||||||
|
Self::Eclectic => 3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn slug(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Main => "main",
|
||||||
|
Self::Mellow => "mellow",
|
||||||
|
Self::Rock => "rock",
|
||||||
|
Self::Eclectic => "eclectic",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn display_name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Main => "Main Mix",
|
||||||
|
Self::Mellow => "Mellow Mix",
|
||||||
|
Self::Rock => "Rock Mix",
|
||||||
|
Self::Eclectic => "Eclectic Mix",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn description(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Main => "Eclectic mix of rock, world, electronica, and more",
|
||||||
|
Self::Mellow => "Mellower, less aggressive music",
|
||||||
|
Self::Rock => "Heavier, more guitar-driven music",
|
||||||
|
Self::Eclectic => "Curated worldwide selection",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for ParadiseChannelKind {
|
||||||
|
type Err = anyhow::Error;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
|
||||||
|
match s.to_ascii_lowercase().as_str() {
|
||||||
|
"main" | "0" => Ok(Self::Main),
|
||||||
|
"mellow" | "1" => Ok(Self::Mellow),
|
||||||
|
"rock" | "2" => Ok(Self::Rock),
|
||||||
|
"eclectic" | "3" => Ok(Self::Eclectic),
|
||||||
|
other => Err(anyhow::anyhow!("Unknown Radio Paradise channel: {}", other)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metadata descriptor for a channel.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct ChannelDescriptor {
|
||||||
|
pub kind: ParadiseChannelKind,
|
||||||
|
pub id: u8,
|
||||||
|
pub slug: &'static str,
|
||||||
|
pub display_name: &'static str,
|
||||||
|
pub description: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChannelDescriptor {
|
||||||
|
pub const fn new(kind: ParadiseChannelKind) -> Self {
|
||||||
|
Self {
|
||||||
|
id: kind.id(),
|
||||||
|
slug: kind.slug(),
|
||||||
|
display_name: kind.display_name(),
|
||||||
|
description: kind.description(),
|
||||||
|
kind,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All available Radio Paradise channels
|
||||||
|
pub const ALL_CHANNELS: [ChannelDescriptor; 4] = [
|
||||||
|
ChannelDescriptor::new(ParadiseChannelKind::Main),
|
||||||
|
ChannelDescriptor::new(ParadiseChannelKind::Mellow),
|
||||||
|
ChannelDescriptor::new(ParadiseChannelKind::Rock),
|
||||||
|
ChannelDescriptor::new(ParadiseChannelKind::Eclectic),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Returns the maximum valid channel ID
|
||||||
|
pub const fn max_channel_id() -> u8 {
|
||||||
|
(ALL_CHANNELS.len() - 1) as u8
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default maximum number of tracks to keep in history
|
||||||
|
///
|
||||||
|
/// This is used as the default if not configured via pmoconfig.
|
||||||
|
/// Value: 100 tracks - represents ~5-8 hours of playback history
|
||||||
|
pub const HISTORY_DEFAULT_MAX_TRACKS: usize = 100;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_channel_ids() {
|
||||||
|
assert_eq!(ParadiseChannelKind::Main.id(), 0);
|
||||||
|
assert_eq!(ParadiseChannelKind::Mellow.id(), 1);
|
||||||
|
assert_eq!(ParadiseChannelKind::Rock.id(), 2);
|
||||||
|
assert_eq!(ParadiseChannelKind::Eclectic.id(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_max_channel_id() {
|
||||||
|
assert_eq!(max_channel_id(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_all_channels_length() {
|
||||||
|
assert_eq!(ALL_CHANNELS.len(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_channel_from_str() {
|
||||||
|
assert!(matches!(
|
||||||
|
"main".parse::<ParadiseChannelKind>(),
|
||||||
|
Ok(ParadiseChannelKind::Main)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
"0".parse::<ParadiseChannelKind>(),
|
||||||
|
Ok(ParadiseChannelKind::Main)
|
||||||
|
));
|
||||||
|
assert!("invalid".parse::<ParadiseChannelKind>().is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,8 +9,8 @@ use url::Url;
|
|||||||
/// Default Radio Paradise API base URL
|
/// Default Radio Paradise API base URL
|
||||||
pub const DEFAULT_API_BASE: &str = "https://api.radioparadise.com/api";
|
pub const DEFAULT_API_BASE: &str = "https://api.radioparadise.com/api";
|
||||||
|
|
||||||
/// Default block base URL pattern
|
/// Default block base URL (channel is appended)
|
||||||
pub const DEFAULT_BLOCK_BASE: &str = "https://apps.radioparadise.com/blocks/chan/0";
|
pub const DEFAULT_BLOCK_BASE: &str = "https://apps.radioparadise.com/blocks/chan";
|
||||||
|
|
||||||
/// Default image base URL
|
/// Default image base URL
|
||||||
pub const DEFAULT_IMAGE_BASE: &str = "https://img.radioparadise.com/";
|
pub const DEFAULT_IMAGE_BASE: &str = "https://img.radioparadise.com/";
|
||||||
@@ -24,6 +24,9 @@ pub const DEFAULT_BLOCK_TIMEOUT_SECS: u64 = 180;
|
|||||||
/// Default User-Agent
|
/// Default User-Agent
|
||||||
pub const DEFAULT_USER_AGENT: &str = "pmoparadise/0.1.0";
|
pub const DEFAULT_USER_AGENT: &str = "pmoparadise/0.1.0";
|
||||||
|
|
||||||
|
/// Default channel (0 = main mix)
|
||||||
|
pub const DEFAULT_CHANNEL: u8 = 0;
|
||||||
|
|
||||||
/// Radio Paradise HTTP client
|
/// Radio Paradise HTTP client
|
||||||
///
|
///
|
||||||
/// This client provides access to Radio Paradise's streaming API,
|
/// This client provides access to Radio Paradise's streaming API,
|
||||||
@@ -48,7 +51,6 @@ pub const DEFAULT_USER_AGENT: &str = "pmoparadise/0.1.0";
|
|||||||
pub struct RadioParadiseClient {
|
pub struct RadioParadiseClient {
|
||||||
pub(crate) client: Client,
|
pub(crate) client: Client,
|
||||||
api_base: String,
|
api_base: String,
|
||||||
block_base: String,
|
|
||||||
channel: u8,
|
channel: u8,
|
||||||
pub(crate) request_timeout: Duration,
|
pub(crate) request_timeout: Duration,
|
||||||
pub(crate) block_timeout: Duration,
|
pub(crate) block_timeout: Duration,
|
||||||
@@ -71,12 +73,14 @@ impl RadioParadiseClient {
|
|||||||
/// Create a client with a custom reqwest::Client
|
/// Create a client with a custom reqwest::Client
|
||||||
///
|
///
|
||||||
/// Useful for sharing HTTP connection pools or custom proxy settings
|
/// Useful for sharing HTTP connection pools or custom proxy settings
|
||||||
|
///
|
||||||
|
/// Note: Uses default settings (channel 0, default timeouts).
|
||||||
|
/// For more control, use `ClientBuilder::default().client(client).build()`.
|
||||||
pub fn with_client(client: Client) -> Self {
|
pub fn with_client(client: Client) -> Self {
|
||||||
Self {
|
Self {
|
||||||
client,
|
client,
|
||||||
api_base: DEFAULT_API_BASE.to_string(),
|
api_base: DEFAULT_API_BASE.to_string(),
|
||||||
block_base: DEFAULT_BLOCK_BASE.to_string(),
|
channel: DEFAULT_CHANNEL,
|
||||||
channel: 0,
|
|
||||||
request_timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS),
|
request_timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS),
|
||||||
block_timeout: Duration::from_secs(DEFAULT_BLOCK_TIMEOUT_SECS),
|
block_timeout: Duration::from_secs(DEFAULT_BLOCK_TIMEOUT_SECS),
|
||||||
next_block_url: None,
|
next_block_url: None,
|
||||||
@@ -88,15 +92,15 @@ impl RadioParadiseClient {
|
|||||||
self.channel
|
self.channel
|
||||||
}
|
}
|
||||||
|
|
||||||
fn block_base_for_channel(channel: u8) -> String {
|
/// Get the block base URL for this client's channel
|
||||||
format!("https://apps.radioparadise.com/blocks/chan/{}", channel)
|
pub fn block_base(&self) -> String {
|
||||||
|
format!("{}/{}", DEFAULT_BLOCK_BASE, self.channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clone the client with a different channel while preserving other settings.
|
/// Clone the client with a different channel while preserving other settings.
|
||||||
pub fn clone_with_channel(&self, channel: u8) -> Self {
|
pub fn clone_with_channel(&self, channel: u8) -> Self {
|
||||||
let mut cloned = self.clone();
|
let mut cloned = self.clone();
|
||||||
cloned.channel = channel;
|
cloned.channel = channel;
|
||||||
cloned.block_base = Self::block_base_for_channel(channel);
|
|
||||||
cloned.next_block_url = None;
|
cloned.next_block_url = None;
|
||||||
cloned
|
cloned
|
||||||
}
|
}
|
||||||
@@ -234,7 +238,6 @@ impl RadioParadiseClient {
|
|||||||
pub struct ClientBuilder {
|
pub struct ClientBuilder {
|
||||||
client: Option<Client>,
|
client: Option<Client>,
|
||||||
api_base: String,
|
api_base: String,
|
||||||
block_base: String,
|
|
||||||
channel: u8,
|
channel: u8,
|
||||||
request_timeout: Duration,
|
request_timeout: Duration,
|
||||||
block_timeout: Duration,
|
block_timeout: Duration,
|
||||||
@@ -247,8 +250,7 @@ impl Default for ClientBuilder {
|
|||||||
Self {
|
Self {
|
||||||
client: None,
|
client: None,
|
||||||
api_base: DEFAULT_API_BASE.to_string(),
|
api_base: DEFAULT_API_BASE.to_string(),
|
||||||
block_base: DEFAULT_BLOCK_BASE.to_string(),
|
channel: DEFAULT_CHANNEL,
|
||||||
channel: 0,
|
|
||||||
request_timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS),
|
request_timeout: Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS),
|
||||||
block_timeout: Duration::from_secs(DEFAULT_BLOCK_TIMEOUT_SECS),
|
block_timeout: Duration::from_secs(DEFAULT_BLOCK_TIMEOUT_SECS),
|
||||||
user_agent: DEFAULT_USER_AGENT.to_string(),
|
user_agent: DEFAULT_USER_AGENT.to_string(),
|
||||||
@@ -275,12 +277,6 @@ impl ClientBuilder {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the block base URL
|
|
||||||
pub fn block_base(mut self, url: impl Into<String>) -> Self {
|
|
||||||
self.block_base = url.into();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the channel (0 = main mix, 1 = mellow, 2 = rock, 3 = world/etc)
|
/// Set the channel (0 = main mix, 1 = mellow, 2 = rock, 3 = world/etc)
|
||||||
pub fn channel(mut self, channel: u8) -> Self {
|
pub fn channel(mut self, channel: u8) -> Self {
|
||||||
self.channel = channel;
|
self.channel = channel;
|
||||||
@@ -329,16 +325,9 @@ impl ClientBuilder {
|
|||||||
builder.build()?
|
builder.build()?
|
||||||
};
|
};
|
||||||
|
|
||||||
let block_base = if self.block_base == DEFAULT_BLOCK_BASE {
|
|
||||||
RadioParadiseClient::block_base_for_channel(self.channel)
|
|
||||||
} else {
|
|
||||||
self.block_base.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(RadioParadiseClient {
|
Ok(RadioParadiseClient {
|
||||||
client,
|
client,
|
||||||
api_base: self.api_base,
|
api_base: self.api_base,
|
||||||
block_base,
|
|
||||||
channel: self.channel,
|
channel: self.channel,
|
||||||
request_timeout: self.request_timeout,
|
request_timeout: self.request_timeout,
|
||||||
block_timeout: self.block_timeout,
|
block_timeout: self.block_timeout,
|
||||||
@@ -355,6 +344,6 @@ mod tests {
|
|||||||
fn test_builder_defaults() {
|
fn test_builder_defaults() {
|
||||||
let builder = ClientBuilder::default();
|
let builder = ClientBuilder::default();
|
||||||
assert_eq!(builder.api_base, DEFAULT_API_BASE);
|
assert_eq!(builder.api_base, DEFAULT_API_BASE);
|
||||||
assert_eq!(builder.channel, 0);
|
assert_eq!(builder.channel, DEFAULT_CHANNEL);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,6 @@
|
|||||||
//!
|
//!
|
||||||
//! La configuration est minimale - seulement ce qui doit vraiment être configurable :
|
//! La configuration est minimale - seulement ce qui doit vraiment être configurable :
|
||||||
//! - Activation/désactivation de la source
|
//! - 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
|
//! # Exemple
|
||||||
//!
|
//!
|
||||||
@@ -24,24 +19,12 @@
|
|||||||
//! println!("Radio Paradise is disabled");
|
//! println!("Radio Paradise is disabled");
|
||||||
//! return Ok(());
|
//! return Ok(());
|
||||||
//! }
|
//! }
|
||||||
//!
|
|
||||||
//! // Get configuration
|
|
||||||
//! let db_path = config.get_paradise_history_database()?;
|
|
||||||
//! let max_tracks = config.get_paradise_history_size()?;
|
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use crate::{channels::ParadiseChannelKind, client::DEFAULT_CHANNEL};
|
||||||
|
use anyhow::Result;
|
||||||
use anyhow::{anyhow, Result};
|
|
||||||
use pmoconfig::Config;
|
use pmoconfig::Config;
|
||||||
use serde_yaml::{Number, Value};
|
use serde_yaml::Value;
|
||||||
|
|
||||||
use crate::paradise::constants;
|
|
||||||
|
|
||||||
/// 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
|
/// Trait d'extension pour gérer la configuration Radio Paradise dans pmoconfig
|
||||||
///
|
///
|
||||||
@@ -50,7 +33,7 @@ const DEFAULT_HISTORY_DATABASE_DIR: &str = "paradise";
|
|||||||
///
|
///
|
||||||
/// # Auto-persist des valeurs par défaut
|
/// # Auto-persist des valeurs par défaut
|
||||||
///
|
///
|
||||||
/// Tous les getters persistent automatiquement la valeur par défaut dans la
|
/// Le getter persiste automatiquement la valeur par défaut dans la
|
||||||
/// configuration si elle n'existe pas encore. Cela permet à l'utilisateur de
|
/// 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.
|
/// voir la configuration effective dans le fichier YAML et de la modifier facilement.
|
||||||
///
|
///
|
||||||
@@ -65,10 +48,7 @@ const DEFAULT_HISTORY_DATABASE_DIR: &str = "paradise";
|
|||||||
/// // Premier appel : persiste "enabled: true" dans la config et retourne true
|
/// // Premier appel : persiste "enabled: true" dans la config et retourne true
|
||||||
/// let enabled = config.get_paradise_enabled()?;
|
/// let enabled = config.get_paradise_enabled()?;
|
||||||
///
|
///
|
||||||
/// // Premier appel : persiste "max_tracks: 100" dans la config et retourne 100
|
/// // L'utilisateur peut maintenant éditer cette valeur dans le fichier YAML
|
||||||
/// let max_tracks = config.get_paradise_history_size()?;
|
|
||||||
///
|
|
||||||
/// // L'utilisateur peut maintenant éditer ces valeurs dans le fichier YAML
|
|
||||||
/// ```
|
/// ```
|
||||||
pub trait RadioParadiseConfigExt {
|
pub trait RadioParadiseConfigExt {
|
||||||
/// Vérifie si Radio Paradise est activé
|
/// Vérifie si Radio Paradise est activé
|
||||||
@@ -103,70 +83,59 @@ pub trait RadioParadiseConfigExt {
|
|||||||
/// ```
|
/// ```
|
||||||
fn set_paradise_enabled(&self, enabled: bool) -> Result<()>;
|
fn set_paradise_enabled(&self, enabled: bool) -> Result<()>;
|
||||||
|
|
||||||
/// Récupère le chemin de la base de données d'historique
|
/// Récupère le channel par défaut
|
||||||
///
|
|
||||||
/// 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
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// Le chemin absolu vers la base de données SQLite d'historique.
|
/// Le channel par défaut (0 = Main Mix par défaut).
|
||||||
/// Exemple: `/home/user/.config/pmo/paradise/history.db`
|
|
||||||
///
|
|
||||||
/// # Exemple
|
|
||||||
///
|
|
||||||
/// ```rust,ignore
|
|
||||||
/// let db_path = config.get_paradise_history_database()?;
|
|
||||||
/// let backend = SqliteHistoryBackend::new(&db_path)?;
|
|
||||||
/// ```
|
|
||||||
fn get_paradise_history_database(&self) -> Result<String>;
|
|
||||||
|
|
||||||
/// Définit le chemin de la base de données d'historique
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
///
|
|
||||||
/// * `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
|
|
||||||
/// // Set custom path
|
|
||||||
/// config.set_paradise_history_database("/var/lib/pmo/paradise.db".to_string())?;
|
|
||||||
/// ```
|
|
||||||
fn set_paradise_history_database(&self, path: String) -> Result<()>;
|
|
||||||
|
|
||||||
/// Récupère le nombre maximal de pistes dans l'historique
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
///
|
|
||||||
/// Le nombre maximal de pistes à conserver dans l'historique.
|
|
||||||
///
|
///
|
||||||
/// Si la valeur n'existe pas dans la configuration, elle est automatiquement
|
/// Si la valeur n'existe pas dans la configuration, elle est automatiquement
|
||||||
/// définie à la constante `HISTORY_DEFAULT_MAX_TRACKS` (100) et persistée.
|
/// définie à "main" et persistée.
|
||||||
///
|
///
|
||||||
/// # Exemple
|
/// # Channels disponibles
|
||||||
|
///
|
||||||
|
/// Peut être configuré comme chaîne de caractères ou nombre :
|
||||||
|
/// - "main" ou 0 = Main Mix (eclectic, diverse mix)
|
||||||
|
/// - "mellow" ou 1 = Mellow Mix (smooth, chilled music)
|
||||||
|
/// - "rock" ou 2 = Rock Mix (classic & modern rock)
|
||||||
|
/// - "eclectic" ou 3 = Eclectic Mix (global sounds)
|
||||||
|
///
|
||||||
|
/// # Exemple de configuration YAML
|
||||||
|
///
|
||||||
|
/// ```yaml
|
||||||
|
/// sources:
|
||||||
|
/// radio_paradise:
|
||||||
|
/// default_channel: mellow # or 1
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// # Exemple d'utilisation
|
||||||
///
|
///
|
||||||
/// ```rust,ignore
|
/// ```rust,ignore
|
||||||
/// let max_tracks = config.get_paradise_history_size()?;
|
/// let channel = config.get_paradise_default_channel()?;
|
||||||
/// println!("Keeping last {} tracks", max_tracks);
|
/// let client = RadioParadiseClient::builder().channel(channel).build().await?;
|
||||||
/// ```
|
/// ```
|
||||||
fn get_paradise_history_size(&self) -> Result<usize>;
|
fn get_paradise_default_channel(&self) -> Result<u8>;
|
||||||
|
|
||||||
/// Définit le nombre maximal de pistes dans l'historique
|
/// Définit le channel par défaut
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
///
|
///
|
||||||
/// * `size` - Nombre maximal de pistes à conserver
|
/// * `channel` - Le channel (0-3)
|
||||||
|
///
|
||||||
|
/// La valeur est stockée sous forme de nom convivial ("main", "mellow", etc.)
|
||||||
|
/// dans le fichier de configuration.
|
||||||
///
|
///
|
||||||
/// # Exemple
|
/// # Exemple
|
||||||
///
|
///
|
||||||
/// ```rust,ignore
|
/// ```rust,ignore
|
||||||
/// // Keep last 200 tracks
|
/// use pmoparadise::channels::ParadiseChannelKind;
|
||||||
/// config.set_paradise_history_size(200)?;
|
///
|
||||||
|
/// // Use Mellow Mix by default
|
||||||
|
/// config.set_paradise_default_channel(ParadiseChannelKind::Mellow.id())?;
|
||||||
|
/// // Or simply:
|
||||||
|
/// config.set_paradise_default_channel(1)?;
|
||||||
/// ```
|
/// ```
|
||||||
fn set_paradise_history_size(&self, size: usize) -> Result<()>;
|
fn set_paradise_default_channel(&self, channel: u8) -> Result<()>;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RadioParadiseConfigExt for Config {
|
impl RadioParadiseConfigExt for Config {
|
||||||
@@ -188,52 +157,59 @@ impl RadioParadiseConfigExt for Config {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_paradise_history_database(&self) -> Result<String> {
|
fn get_paradise_default_channel(&self) -> Result<u8> {
|
||||||
// Get managed directory: ~/.config/pmo/paradise/
|
match self.get_value(&["sources", "radio_paradise", "default_channel"]) {
|
||||||
let dir = self.get_managed_dir(
|
Ok(Value::String(s)) => {
|
||||||
&["sources", "radio_paradise", "database"],
|
// Try to parse as channel name (e.g., "main", "mellow", etc.)
|
||||||
DEFAULT_HISTORY_DATABASE_DIR,
|
match s.parse::<ParadiseChannelKind>() {
|
||||||
)?;
|
Ok(kind) => Ok(kind.id()),
|
||||||
|
Err(_) => {
|
||||||
// Ensure directory exists
|
// Invalid channel name, use default
|
||||||
std::fs::create_dir_all(&dir)?;
|
self.set_paradise_default_channel(DEFAULT_CHANNEL)?;
|
||||||
|
Ok(DEFAULT_CHANNEL)
|
||||||
// Build full path: ~/.config/pmo/paradise/history.db
|
}
|
||||||
let mut path = PathBuf::from(dir);
|
}
|
||||||
path.push("history.db");
|
}
|
||||||
|
Ok(Value::Number(n)) => {
|
||||||
Ok(path.to_string_lossy().to_string())
|
// Accept numeric channel ID (0-3)
|
||||||
}
|
if let Some(ch) = n.as_u64() {
|
||||||
|
if ch <= 3 {
|
||||||
fn set_paradise_history_database(&self, path: String) -> Result<()> {
|
Ok(ch as u8)
|
||||||
// Extract parent directory from the full path
|
} else {
|
||||||
match PathBuf::from(&path).parent() {
|
// Invalid channel number, use default
|
||||||
Some(dir) => self.set_managed_dir(
|
self.set_paradise_default_channel(DEFAULT_CHANNEL)?;
|
||||||
&["sources", "radio_paradise", "database"],
|
Ok(DEFAULT_CHANNEL)
|
||||||
dir.to_string_lossy().to_string(),
|
}
|
||||||
),
|
} else {
|
||||||
None => Err(anyhow!("Invalid database path: no parent directory")),
|
// Not a valid number, use default
|
||||||
}
|
self.set_paradise_default_channel(DEFAULT_CHANNEL)?;
|
||||||
}
|
Ok(DEFAULT_CHANNEL)
|
||||||
|
}
|
||||||
fn get_paradise_history_size(&self) -> Result<usize> {
|
}
|
||||||
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),
|
|
||||||
_ => {
|
_ => {
|
||||||
// Use default and persist it
|
// Use default and persist it as "main" (user-friendly)
|
||||||
let default = constants::HISTORY_DEFAULT_MAX_TRACKS;
|
self.set_value(
|
||||||
self.set_paradise_history_size(default)?;
|
&["sources", "radio_paradise", "default_channel"],
|
||||||
Ok(default)
|
Value::String("main".to_string()),
|
||||||
|
)?;
|
||||||
|
Ok(DEFAULT_CHANNEL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_paradise_history_size(&self, size: usize) -> Result<()> {
|
fn set_paradise_default_channel(&self, channel: u8) -> Result<()> {
|
||||||
let n = Number::from(size);
|
// Convert channel ID to user-friendly string name
|
||||||
|
let channel_name = match channel {
|
||||||
|
0 => "main",
|
||||||
|
1 => "mellow",
|
||||||
|
2 => "rock",
|
||||||
|
3 => "eclectic",
|
||||||
|
_ => return Err(anyhow::anyhow!("Invalid channel ID: {}", channel)),
|
||||||
|
};
|
||||||
|
|
||||||
self.set_value(
|
self.set_value(
|
||||||
&["sources", "radio_paradise", "history", "max_tracks"],
|
&["sources", "radio_paradise", "default_channel"],
|
||||||
Value::Number(n),
|
Value::String(channel_name.to_string()),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -243,21 +219,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_default_values() {
|
fn test_trait_exists() {
|
||||||
assert_eq!(DEFAULT_HISTORY_DATABASE_DIR, "paradise");
|
// Simple test to ensure the trait compiles
|
||||||
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"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,16 +34,6 @@ pub enum Error {
|
|||||||
#[error("Invalid event ID: {0}")]
|
#[error("Invalid event ID: {0}")]
|
||||||
InvalidEvent(String),
|
InvalidEvent(String),
|
||||||
|
|
||||||
/// FLAC decoding error (per-track feature)
|
|
||||||
#[cfg(feature = "per-track")]
|
|
||||||
#[error("FLAC decoding error: {0}")]
|
|
||||||
FlacDecode(String),
|
|
||||||
|
|
||||||
/// WAV encoding error (per-track feature)
|
|
||||||
#[cfg(feature = "per-track")]
|
|
||||||
#[error("WAV encoding error: {0}")]
|
|
||||||
WavEncode(#[from] hound::Error),
|
|
||||||
|
|
||||||
/// Track not found in block
|
/// Track not found in block
|
||||||
#[error("Track not found at index {0}")]
|
#[error("Track not found at index {0}")]
|
||||||
TrackNotFound(usize),
|
TrackNotFound(usize),
|
||||||
@@ -67,11 +57,3 @@ impl Error {
|
|||||||
Self::Other(msg.into())
|
Self::Other(msg.into())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Implement conversion from claxon errors for per-track feature
|
|
||||||
#[cfg(feature = "per-track")]
|
|
||||||
impl From<claxon::Error> for Error {
|
|
||||||
fn from(err: claxon::Error) -> Self {
|
|
||||||
Error::FlacDecode(err.to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,172 +0,0 @@
|
|||||||
//! FFmpeg-based progressive streaming decoder/encoder
|
|
||||||
//!
|
|
||||||
//! This module provides progressive audio streaming using FFmpeg,
|
|
||||||
//! allowing for much lower latency than the claxon/flacenc approach.
|
|
||||||
//!
|
|
||||||
//! Key advantages:
|
|
||||||
//! - Start streaming immediately (< 1 second latency)
|
|
||||||
//! - Progressive decoding and encoding in a pipeline
|
|
||||||
//! - Better performance (C code vs Rust)
|
|
||||||
//! - Support for multiple output formats
|
|
||||||
|
|
||||||
use anyhow::{anyhow, Context, Result};
|
|
||||||
use bytes::Bytes;
|
|
||||||
use ffmpeg_next as ffmpeg;
|
|
||||||
use std::io::{Read, Write};
|
|
||||||
use std::sync::mpsc::{sync_channel, Receiver, SyncSender};
|
|
||||||
use tokio::task;
|
|
||||||
use tracing::{debug, error, trace};
|
|
||||||
|
|
||||||
/// Initialize FFmpeg (must be called once at startup)
|
|
||||||
pub fn init() -> Result<()> {
|
|
||||||
ffmpeg::init().context("Failed to initialize FFmpeg")?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// PCM chunk with decoded audio data
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct PCMChunk {
|
|
||||||
pub samples: Vec<i16>, // Interleaved 16-bit samples
|
|
||||||
pub sample_rate: u32,
|
|
||||||
pub channels: u32,
|
|
||||||
pub position_ms: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Progressive decoder that decodes FLAC data as it arrives
|
|
||||||
pub struct ProgressiveDecoder {
|
|
||||||
input_rx: Receiver<Result<Bytes, String>>,
|
|
||||||
buffer: Vec<u8>,
|
|
||||||
decoder_ctx: Option<ffmpeg::codec::context::Context>,
|
|
||||||
sample_rate: u32,
|
|
||||||
channels: u32,
|
|
||||||
total_samples_decoded: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProgressiveDecoder {
|
|
||||||
/// Create a new progressive decoder from a byte stream
|
|
||||||
pub fn new(mut stream: impl Read + Send + 'static) -> Result<Self> {
|
|
||||||
let (tx, rx) = sync_channel(64);
|
|
||||||
|
|
||||||
// Spawn a thread to read from the stream and feed chunks
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
let mut buffer = vec![0u8; 8192];
|
|
||||||
loop {
|
|
||||||
match stream.read(&mut buffer) {
|
|
||||||
Ok(0) => break, // EOF
|
|
||||||
Ok(n) => {
|
|
||||||
let chunk = Bytes::copy_from_slice(&buffer[..n]);
|
|
||||||
if tx.send(Ok(chunk)).is_err() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let _ = tx.send(Err(e.to_string()));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
input_rx: rx,
|
|
||||||
buffer: Vec::with_capacity(65536),
|
|
||||||
decoder_ctx: None,
|
|
||||||
sample_rate: 0,
|
|
||||||
channels: 0,
|
|
||||||
total_samples_decoded: 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decode the next chunk of PCM data
|
|
||||||
pub fn decode_chunk(&mut self) -> Result<Option<PCMChunk>> {
|
|
||||||
// Receive more data from the stream
|
|
||||||
while self.buffer.len() < 4096 {
|
|
||||||
match self.input_rx.try_recv() {
|
|
||||||
Ok(Ok(bytes)) => {
|
|
||||||
self.buffer.extend_from_slice(&bytes);
|
|
||||||
}
|
|
||||||
Ok(Err(e)) => {
|
|
||||||
return Err(anyhow!("Stream error: {}", e));
|
|
||||||
}
|
|
||||||
Err(std::sync::mpsc::TryRecvError::Empty) => {
|
|
||||||
// No more data available right now
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
|
||||||
// Stream ended
|
|
||||||
if self.buffer.is_empty() {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.buffer.is_empty() {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize decoder on first call
|
|
||||||
if self.decoder_ctx.is_none() {
|
|
||||||
self.init_decoder()?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode a frame
|
|
||||||
// TODO: Implement actual FFmpeg decoding
|
|
||||||
// For now, return a placeholder
|
|
||||||
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn init_decoder(&mut self) -> Result<()> {
|
|
||||||
// TODO: Initialize FFmpeg decoder from buffer
|
|
||||||
// Parse FLAC header, create decoder context
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Progressive encoder that encodes PCM to FLAC as data arrives
|
|
||||||
pub struct ProgressiveEncoder {
|
|
||||||
output_tx: SyncSender<Bytes>,
|
|
||||||
encoder_ctx: Option<ffmpeg::codec::context::Context>,
|
|
||||||
sample_rate: u32,
|
|
||||||
channels: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ProgressiveEncoder {
|
|
||||||
/// Create a new progressive encoder
|
|
||||||
pub fn new(sample_rate: u32, channels: u32) -> Result<(Self, Receiver<Bytes>)> {
|
|
||||||
let (tx, rx) = sync_channel(64);
|
|
||||||
|
|
||||||
let encoder = Self {
|
|
||||||
output_tx: tx,
|
|
||||||
encoder_ctx: None,
|
|
||||||
sample_rate,
|
|
||||||
channels,
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok((encoder, rx))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encode a chunk of PCM data
|
|
||||||
pub fn encode_chunk(&mut self, pcm: &PCMChunk) -> Result<()> {
|
|
||||||
// TODO: Implement FFmpeg encoding
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Flush any remaining encoded data
|
|
||||||
pub fn flush(&mut self) -> Result<()> {
|
|
||||||
// TODO: Flush encoder
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_ffmpeg_init() {
|
|
||||||
assert!(init().is_ok());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -164,73 +164,57 @@
|
|||||||
//! }
|
//! }
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! ## Caching Support (Feature: `cache`)
|
//! ## Audio Streaming (Feature: `pmoaudio`)
|
||||||
//!
|
//!
|
||||||
//! `pmoparadise` can optionally integrate with `pmocovers` and `pmoaudiocache` to cache
|
//! For direct audio streaming and integration with pmoaudio pipelines,
|
||||||
//! cover images and audio tracks locally:
|
//! use `RadioParadiseStreamSource`:
|
||||||
//!
|
//!
|
||||||
//! ```no_run
|
//! ```no_run
|
||||||
//! # #[cfg(feature = "cache")]
|
//! # #[cfg(feature = "pmoaudio")]
|
||||||
//! # {
|
//! # {
|
||||||
//! use pmoparadise::{RadioParadiseClient, RadioParadiseSource};
|
//! use pmoparadise::{RadioParadiseClient, RadioParadiseStreamSource};
|
||||||
//! use std::sync::Arc;
|
//! use pmoaudio::pipeline::Node;
|
||||||
//!
|
//!
|
||||||
//! #[tokio::main]
|
//! #[tokio::main]
|
||||||
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
//! // Create caches
|
|
||||||
//! let cover_cache = Arc::new(pmocovers::cache::new_cache("./cache/covers", 500)?);
|
|
||||||
//! let audio_cache = Arc::new(pmoaudiocache::cache::new_cache("./cache/audio", 100)?);
|
|
||||||
//!
|
|
||||||
//! // Create client and source with caching
|
|
||||||
//! let client = RadioParadiseClient::new().await?;
|
//! let client = RadioParadiseClient::new().await?;
|
||||||
//! let source = RadioParadiseSource::new(
|
//! let stream_source = RadioParadiseStreamSource::new(client, None).await?;
|
||||||
//! client,
|
|
||||||
//! 50,
|
|
||||||
//! cover_cache,
|
|
||||||
//! audio_cache,
|
|
||||||
//! );
|
|
||||||
//!
|
//!
|
||||||
//! println!("Source ready: {}", source.name());
|
//! // Create audio node from stream source
|
||||||
|
//! let node = Node::from_logic(stream_source);
|
||||||
|
//!
|
||||||
|
//! // Use in pmoaudio pipeline...
|
||||||
//!
|
//!
|
||||||
//! Ok(())
|
//! Ok(())
|
||||||
//! }
|
//! }
|
||||||
//! # }
|
//! # }
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! **Benefits**:
|
//! **RadioParadiseStreamSource**:
|
||||||
//! - Cover images are automatically downloaded and converted to WebP
|
//! - Downloads and decodes FLAC blocks in real-time
|
||||||
//! - Audio tracks are cached as FLAC with metadata preserved
|
//! - Automatically detects bit depth (16/24/32-bit)
|
||||||
//! - Subsequent access is instant (no re-download)
|
//! - Inserts track boundaries with metadata
|
||||||
//! - URIs returned by `resolve_uri()` point to cached versions
|
//! - Integrates seamlessly with pmoaudio pipelines
|
||||||
//!
|
|
||||||
//! See the `with_cache` example for a complete demonstration.
|
|
||||||
//!
|
//!
|
||||||
//! ## Cargo Features
|
//! ## Cargo Features
|
||||||
//!
|
//!
|
||||||
//! - `default = ["metadata-only"]`: Standard metadata and streaming (no FLAC decoding)
|
//! - `default`: Standard metadata and streaming (no FLAC decoding)
|
||||||
//! - `per-track`: Enable FLAC decoding and per-track extraction (adds `claxon`, `hound`, `tempfile`)
|
//! - `per-track`: Enable FLAC decoding and per-track extraction (adds `claxon`, `hound`, `tempfile`)
|
||||||
//! - `pmoserver`: Enable REST API extension for pmoserver integration (adds `utoipa`, `axum`)
|
//! - `pmoserver`: Enable REST API extension for pmoserver integration (adds `utoipa`, `axum`)
|
||||||
//! - `server`: Enable server-side features (cache registry integration)
|
//! - `pmoaudio`: Enable RadioParadiseStreamSource for pmoaudio integration
|
||||||
//! - `cache`: Enable cover and audio caching support (adds `pmocovers`, `pmoaudiocache`)
|
//! - `pmoconfig`: Enable configuration integration with pmoconfig
|
||||||
|
//! - `server`: Enable RadioParadiseSource stub for backward compatibility (deprecated)
|
||||||
//!
|
//!
|
||||||
//! ## See Also
|
//! ## See Also
|
||||||
//!
|
//!
|
||||||
//! - [Radio Paradise](https://radioparadise.com) - Official website
|
//! - [Radio Paradise](https://radioparadise.com) - Official website
|
||||||
//! - [Radio Paradise API](https://api.radioparadise.com) - API documentation
|
//! - [Radio Paradise API](https://api.radioparadise.com) - API documentation
|
||||||
|
|
||||||
|
pub mod channels;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod paradise;
|
|
||||||
pub mod source;
|
pub mod source;
|
||||||
pub mod stream;
|
|
||||||
pub mod streaming;
|
|
||||||
|
|
||||||
#[cfg(feature = "per-track")]
|
|
||||||
pub mod track;
|
|
||||||
|
|
||||||
#[cfg(feature = "ffmpeg")]
|
|
||||||
pub mod ffmpeg_streaming;
|
|
||||||
|
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
pub mod pmoserver_ext;
|
pub mod pmoserver_ext;
|
||||||
@@ -238,15 +222,17 @@ pub mod pmoserver_ext;
|
|||||||
#[cfg(feature = "pmoconfig")]
|
#[cfg(feature = "pmoconfig")]
|
||||||
pub mod config_ext;
|
pub mod config_ext;
|
||||||
|
|
||||||
|
#[cfg(feature = "pmoaudio")]
|
||||||
|
pub mod radio_paradise_stream_source;
|
||||||
|
|
||||||
// Re-exports for convenience
|
// Re-exports for convenience
|
||||||
pub use client::{ClientBuilder, RadioParadiseClient};
|
pub use client::{ClientBuilder, RadioParadiseClient};
|
||||||
pub use error::{Error, Result};
|
pub use error::{Error, Result};
|
||||||
pub use models::{Block, DurationMs, EventId, NowPlaying, Song};
|
pub use models::{Block, DurationMs, EventId, NowPlaying, Song};
|
||||||
pub use source::RadioParadiseSource;
|
pub use source::RadioParadiseSource;
|
||||||
pub use stream::BlockStream;
|
|
||||||
|
|
||||||
#[cfg(feature = "per-track")]
|
#[cfg(feature = "pmoaudio")]
|
||||||
pub use track::{TrackMetadata, TrackStream};
|
pub use radio_paradise_stream_source::RadioParadiseStreamSource;
|
||||||
|
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
pub use pmoserver_ext::{
|
pub use pmoserver_ext::{
|
||||||
|
|||||||
@@ -1,428 +0,0 @@
|
|||||||
//! Channel orchestration primitives.
|
|
||||||
//!
|
|
||||||
//! This module wires together configuration, playlists, workers and client
|
|
||||||
//! tracking for a single Radio Paradise channel. The implementation is still
|
|
||||||
//! a scaffolding of the final behaviour; commands sent to the worker are
|
|
||||||
//! logged but not yet executing the full download/buffering pipeline.
|
|
||||||
|
|
||||||
use super::history::HistoryBackend;
|
|
||||||
use super::playlist::{PlaylistEntry, SharedPlaylist};
|
|
||||||
use super::worker::{ParadiseWorker, WorkerCommand};
|
|
||||||
use crate::client::RadioParadiseClient;
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use async_stream::try_stream;
|
|
||||||
use bytes::Bytes;
|
|
||||||
use futures::{stream::BoxStream, StreamExt};
|
|
||||||
use pmosource::SourceCacheManager;
|
|
||||||
use std::fmt;
|
|
||||||
use std::str::FromStr;
|
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::fs::File;
|
|
||||||
use tokio::sync::{mpsc, Mutex};
|
|
||||||
use tokio_util::io::ReaderStream;
|
|
||||||
use tracing::warn;
|
|
||||||
|
|
||||||
/// Logical identifier for a Radio Paradise channel.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
||||||
pub enum ParadiseChannelKind {
|
|
||||||
Main,
|
|
||||||
Mellow,
|
|
||||||
Rock,
|
|
||||||
Eclectic,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ParadiseChannelKind {
|
|
||||||
pub const fn id(self) -> u8 {
|
|
||||||
match self {
|
|
||||||
Self::Main => 0,
|
|
||||||
Self::Mellow => 1,
|
|
||||||
Self::Rock => 2,
|
|
||||||
Self::Eclectic => 3,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const fn slug(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Main => "main",
|
|
||||||
Self::Mellow => "mellow",
|
|
||||||
Self::Rock => "rock",
|
|
||||||
Self::Eclectic => "eclectic",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const fn display_name(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Main => "Main Mix",
|
|
||||||
Self::Mellow => "Mellow Mix",
|
|
||||||
Self::Rock => "Rock Mix",
|
|
||||||
Self::Eclectic => "Eclectic Mix",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const fn description(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Main => "Eclectic mix of rock, world, electronica, and more",
|
|
||||||
Self::Mellow => "Mellower, less aggressive music",
|
|
||||||
Self::Rock => "Heavier, more guitar-driven music",
|
|
||||||
Self::Eclectic => "Curated worldwide selection",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromStr for ParadiseChannelKind {
|
|
||||||
type Err = anyhow::Error;
|
|
||||||
|
|
||||||
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
|
|
||||||
match s.to_ascii_lowercase().as_str() {
|
|
||||||
"main" | "0" => Ok(Self::Main),
|
|
||||||
"mellow" | "1" => Ok(Self::Mellow),
|
|
||||||
"rock" | "2" => Ok(Self::Rock),
|
|
||||||
"eclectic" | "3" => Ok(Self::Eclectic),
|
|
||||||
other => Err(anyhow::anyhow!("Unknown Radio Paradise channel: {}", other)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Metadata descriptor for a channel.
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
pub struct ChannelDescriptor {
|
|
||||||
pub kind: ParadiseChannelKind,
|
|
||||||
pub id: u8,
|
|
||||||
pub slug: &'static str,
|
|
||||||
pub display_name: &'static str,
|
|
||||||
pub description: &'static str,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ChannelDescriptor {
|
|
||||||
pub const fn new(kind: ParadiseChannelKind) -> Self {
|
|
||||||
Self {
|
|
||||||
id: kind.id(),
|
|
||||||
slug: kind.slug(),
|
|
||||||
display_name: kind.display_name(),
|
|
||||||
description: kind.description(),
|
|
||||||
kind,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const ALL_CHANNELS: [ChannelDescriptor; 4] = [
|
|
||||||
ChannelDescriptor::new(ParadiseChannelKind::Main),
|
|
||||||
ChannelDescriptor::new(ParadiseChannelKind::Mellow),
|
|
||||||
ChannelDescriptor::new(ParadiseChannelKind::Rock),
|
|
||||||
ChannelDescriptor::new(ParadiseChannelKind::Eclectic),
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Returns the maximum valid channel ID
|
|
||||||
pub const fn max_channel_id() -> u8 {
|
|
||||||
(ALL_CHANNELS.len() - 1) as u8
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Public handle to interact with a channel.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct ParadiseChannel {
|
|
||||||
inner: Arc<ParadiseChannelInner>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ParadiseChannelInner {
|
|
||||||
descriptor: ChannelDescriptor,
|
|
||||||
client: RadioParadiseClient,
|
|
||||||
history_max_tracks: usize,
|
|
||||||
playlist: SharedPlaylist,
|
|
||||||
history: Arc<dyn HistoryBackend>,
|
|
||||||
cache_manager: Arc<SourceCacheManager>,
|
|
||||||
active_clients: AtomicUsize,
|
|
||||||
worker_tx: mpsc::Sender<WorkerCommand>,
|
|
||||||
worker: Mutex<Option<ParadiseWorker>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Debug for ParadiseChannel {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
f.debug_struct("ParadiseChannel")
|
|
||||||
.field("slug", &self.inner.descriptor.slug)
|
|
||||||
.field(
|
|
||||||
"active_clients",
|
|
||||||
&self.inner.active_clients.load(Ordering::SeqCst),
|
|
||||||
)
|
|
||||||
.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ParadiseChannel {
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub fn new(
|
|
||||||
descriptor: ChannelDescriptor,
|
|
||||||
base_client: RadioParadiseClient,
|
|
||||||
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(history_max_tracks);
|
|
||||||
let (worker, worker_tx) = ParadiseWorker::spawn(
|
|
||||||
descriptor,
|
|
||||||
client.clone(),
|
|
||||||
history_max_tracks,
|
|
||||||
playlist.clone(),
|
|
||||||
history.clone(),
|
|
||||||
cache_manager.clone(),
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
inner: Arc::new(ParadiseChannelInner {
|
|
||||||
descriptor,
|
|
||||||
client,
|
|
||||||
history_max_tracks,
|
|
||||||
playlist,
|
|
||||||
history,
|
|
||||||
cache_manager,
|
|
||||||
active_clients: AtomicUsize::new(0),
|
|
||||||
worker_tx,
|
|
||||||
worker: Mutex::new(Some(worker)),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn descriptor(&self) -> ChannelDescriptor {
|
|
||||||
self.inner.descriptor
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn playlist(&self) -> &SharedPlaylist {
|
|
||||||
&self.inner.playlist
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn history_max_tracks(&self) -> usize {
|
|
||||||
self.inner.history_max_tracks
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn history_backend(&self) -> &Arc<dyn HistoryBackend> {
|
|
||||||
&self.inner.history
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn cache_manager(&self) -> Arc<SourceCacheManager> {
|
|
||||||
self.inner.cache_manager.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn client(&self) -> &RadioParadiseClient {
|
|
||||||
&self.inner.client
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn active_client_count(&self) -> usize {
|
|
||||||
self.inner.active_clients.load(Ordering::SeqCst)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn connect_client(
|
|
||||||
&self,
|
|
||||||
client_id: impl Into<String>,
|
|
||||||
) -> Result<ParadiseClientStream> {
|
|
||||||
let client_id = client_id.into();
|
|
||||||
self.inner.active_clients.fetch_add(1, Ordering::SeqCst);
|
|
||||||
|
|
||||||
if let Err(err) = self
|
|
||||||
.inner
|
|
||||||
.worker_tx
|
|
||||||
.send(WorkerCommand::ClientConnected {
|
|
||||||
client_id: client_id.clone(),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
self.inner.active_clients.fetch_sub(1, Ordering::SeqCst);
|
|
||||||
return Err(anyhow::anyhow!("worker unavailable: {}", err));
|
|
||||||
}
|
|
||||||
|
|
||||||
self.inner.playlist.increment_all_pending().await;
|
|
||||||
self.ensure_started().await?;
|
|
||||||
|
|
||||||
Ok(ParadiseClientStream::new(self.clone(), client_id))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn disconnect_client(&self, client_id: impl Into<String>) -> Result<()> {
|
|
||||||
let client_id = client_id.into();
|
|
||||||
self.inner.active_clients.fetch_sub(1, Ordering::SeqCst);
|
|
||||||
self.inner
|
|
||||||
.worker_tx
|
|
||||||
.send(WorkerCommand::ClientDisconnected { client_id })
|
|
||||||
.await
|
|
||||||
.context("failed to notify worker of client disconnection")?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn ensure_started(&self) -> Result<()> {
|
|
||||||
self.inner
|
|
||||||
.worker_tx
|
|
||||||
.send(WorkerCommand::EnsureReady)
|
|
||||||
.await
|
|
||||||
.context("failed to schedule worker warmup")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn shutdown(&self) -> Result<()> {
|
|
||||||
self.inner
|
|
||||||
.worker_tx
|
|
||||||
.send(WorkerCommand::Shutdown)
|
|
||||||
.await
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
let mut guard = self.inner.worker.lock().await;
|
|
||||||
if let Some(worker) = guard.take() {
|
|
||||||
worker
|
|
||||||
.wait()
|
|
||||||
.await
|
|
||||||
.context("failed to join worker task")
|
|
||||||
.map(|_| ())
|
|
||||||
} else {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn mark_track_completed(&self, track: &Arc<PlaylistEntry>) {
|
|
||||||
let remaining = track.decrement_clients();
|
|
||||||
if remaining > 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(removed) = self
|
|
||||||
.inner
|
|
||||||
.playlist
|
|
||||||
.pop_front_matching(&track.track_id)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
if let Err(err) = self.inner.history.append(removed.as_history_entry()).await {
|
|
||||||
warn!(
|
|
||||||
channel = self.inner.descriptor.slug,
|
|
||||||
"Failed to persist history entry: {err:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(err) = self
|
|
||||||
.inner
|
|
||||||
.history
|
|
||||||
.truncate(self.inner.history_max_tracks)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
warn!(
|
|
||||||
channel = self.inner.descriptor.slug,
|
|
||||||
"Failed to truncate history: {err:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let history_entry = removed.as_history_entry();
|
|
||||||
self.inner.playlist.push_history_entry(history_entry).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Placeholder stream handle for per-client playback.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ParadiseClientStream {
|
|
||||||
channel: ParadiseChannel,
|
|
||||||
client_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ParadiseClientStream {
|
|
||||||
fn new(channel: ParadiseChannel, client_id: String) -> Self {
|
|
||||||
Self { channel, client_id }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn client_id(&self) -> &str {
|
|
||||||
&self.client_id
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn channel(&self) -> ParadiseChannel {
|
|
||||||
self.channel.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn into_byte_stream(self) -> BoxStream<'static, Result<Bytes, anyhow::Error>> {
|
|
||||||
let channel = self.channel.clone();
|
|
||||||
let client_id = self.client_id.clone();
|
|
||||||
let stream = try_stream! {
|
|
||||||
tracing::info!(
|
|
||||||
channel = channel.descriptor().slug,
|
|
||||||
client_id = %client_id,
|
|
||||||
"🎧 Client connecting to stream"
|
|
||||||
);
|
|
||||||
channel.ensure_started().await?;
|
|
||||||
let mut last_track_id: Option<String> = None;
|
|
||||||
loop {
|
|
||||||
let entries = channel.playlist().active_snapshot().await;
|
|
||||||
|
|
||||||
// Find the next track after last_track_id
|
|
||||||
let next_entry = if let Some(ref last_id) = last_track_id {
|
|
||||||
// Find the position of the last track we read
|
|
||||||
let last_pos = entries.iter().position(|e| e.track_id == *last_id);
|
|
||||||
|
|
||||||
// Get the next track (or wait if none available)
|
|
||||||
match last_pos {
|
|
||||||
Some(pos) if pos + 1 < entries.len() => {
|
|
||||||
Some(entries[pos + 1].clone())
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
// Last track not found (was removed) or no next track available
|
|
||||||
// Wait for more tracks to be added
|
|
||||||
channel.ensure_started().await?;
|
|
||||||
let current_len = entries.len();
|
|
||||||
channel.playlist().wait_for_track_count(current_len).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// First track for this client
|
|
||||||
if entries.is_empty() {
|
|
||||||
channel.ensure_started().await?;
|
|
||||||
channel.playlist().wait_for_track_count(0).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Some(entries[0].clone())
|
|
||||||
};
|
|
||||||
|
|
||||||
let entry = next_entry.unwrap();
|
|
||||||
last_track_id = Some(entry.track_id.clone());
|
|
||||||
|
|
||||||
let audio_pk = entry
|
|
||||||
.audio_pk
|
|
||||||
.clone()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Audio not cached yet"))?;
|
|
||||||
|
|
||||||
channel
|
|
||||||
.cache_manager()
|
|
||||||
.wait_audio_ready(&audio_pk)
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
|
||||||
|
|
||||||
let file_path = if let Some(path) = entry.file_path.clone() {
|
|
||||||
path
|
|
||||||
} else {
|
|
||||||
channel
|
|
||||||
.cache_manager()
|
|
||||||
.audio_file_path(&audio_pk)
|
|
||||||
.await
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Audio file path unavailable"))?
|
|
||||||
};
|
|
||||||
|
|
||||||
let file = File::open(&file_path).await?;
|
|
||||||
let mut reader = ReaderStream::new(file);
|
|
||||||
|
|
||||||
while let Some(chunk) = reader.next().await {
|
|
||||||
let bytes = chunk?;
|
|
||||||
yield bytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
channel.mark_track_completed(&entry).await;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
stream.boxed()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for ParadiseClientStream {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let channel = self.channel.clone();
|
|
||||||
let client_id = self.client_id.clone();
|
|
||||||
let slug = channel.descriptor().slug;
|
|
||||||
tokio::spawn(async move {
|
|
||||||
if let Err(err) = channel.disconnect_client(client_id).await {
|
|
||||||
warn!(channel = slug, "Failed to disconnect client: {err:?}");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
//! 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
//! History persistence for Radio Paradise playback.
|
|
||||||
//!
|
|
||||||
//! 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 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::task::spawn_blocking;
|
|
||||||
|
|
||||||
/// Serializable record describing a played track.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct HistoryEntry {
|
|
||||||
pub track_id: String,
|
|
||||||
pub channel_id: u8,
|
|
||||||
pub started_at: chrono::DateTime<chrono::Utc>,
|
|
||||||
pub duration_ms: u64,
|
|
||||||
pub song: SongSnapshot,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Minimal snapshot of a Radio Paradise song at playback time.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct SongSnapshot {
|
|
||||||
pub title: String,
|
|
||||||
pub artist: String,
|
|
||||||
pub album: Option<String>,
|
|
||||||
pub cover_url: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SongSnapshot {
|
|
||||||
pub fn title(&self) -> &str {
|
|
||||||
&self.title
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<&Song> for SongSnapshot {
|
|
||||||
fn from(song: &Song) -> Self {
|
|
||||||
Self {
|
|
||||||
title: song.title.clone(),
|
|
||||||
artist: song.artist.clone(),
|
|
||||||
album: song.album.clone(),
|
|
||||||
cover_url: song.cover.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Abstract persistence interface.
|
|
||||||
#[async_trait]
|
|
||||||
pub trait HistoryBackend: Send + Sync {
|
|
||||||
async fn append(&self, entry: HistoryEntry) -> anyhow::Result<()>;
|
|
||||||
async fn recent(&self, limit: usize) -> anyhow::Result<Vec<HistoryEntry>>;
|
|
||||||
async fn len(&self) -> anyhow::Result<usize>;
|
|
||||||
async fn truncate(&self, keep: usize) -> anyhow::Result<()>;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct SqliteHistoryBackend {
|
|
||||||
conn: Arc<StdMutex<rusqlite::Connection>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SqliteHistoryBackend {
|
|
||||||
pub fn new(path: impl AsRef<Path>) -> anyhow::Result<Self> {
|
|
||||||
let path = path.as_ref();
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
std::fs::create_dir_all(parent)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let conn = rusqlite::Connection::open(path)?;
|
|
||||||
conn.pragma_update(None, "journal_mode", &"WAL")?;
|
|
||||||
conn.execute_batch(
|
|
||||||
"CREATE TABLE IF NOT EXISTS paradise_history (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
track_id TEXT NOT NULL,
|
|
||||||
channel_id INTEGER NOT NULL,
|
|
||||||
started_at_ms INTEGER NOT NULL,
|
|
||||||
duration_ms INTEGER NOT NULL,
|
|
||||||
title TEXT,
|
|
||||||
artist TEXT,
|
|
||||||
album TEXT,
|
|
||||||
cover_url TEXT
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_history_started_at ON paradise_history(started_at_ms);",
|
|
||||||
)?;
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
conn: Arc::new(StdMutex::new(conn)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn conn(&self) -> Arc<StdMutex<rusqlite::Connection>> {
|
|
||||||
self.conn.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl HistoryBackend for SqliteHistoryBackend {
|
|
||||||
async fn append(&self, entry: HistoryEntry) -> anyhow::Result<()> {
|
|
||||||
let conn = self.conn();
|
|
||||||
spawn_blocking(move || -> anyhow::Result<()> {
|
|
||||||
let conn = conn.lock().unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO paradise_history (track_id, channel_id, started_at_ms, duration_ms, title, artist, album, cover_url)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
|
||||||
rusqlite::params![
|
|
||||||
entry.track_id,
|
|
||||||
entry.channel_id as i64,
|
|
||||||
entry.started_at.timestamp_millis(),
|
|
||||||
entry.duration_ms as i64,
|
|
||||||
entry.song.title,
|
|
||||||
entry.song.artist,
|
|
||||||
entry.song.album,
|
|
||||||
entry.song.cover_url,
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.await??;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn recent(&self, limit: usize) -> anyhow::Result<Vec<HistoryEntry>> {
|
|
||||||
let conn = self.conn();
|
|
||||||
let limit = limit as i64;
|
|
||||||
spawn_blocking(move || -> anyhow::Result<Vec<HistoryEntry>> {
|
|
||||||
let conn = conn.lock().unwrap();
|
|
||||||
let mut stmt = conn.prepare(
|
|
||||||
"SELECT track_id, channel_id, started_at_ms, duration_ms, title, artist, album, cover_url
|
|
||||||
FROM paradise_history
|
|
||||||
ORDER BY started_at_ms DESC
|
|
||||||
LIMIT ?1",
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let mut rows = stmt.query([limit])?;
|
|
||||||
let mut entries = Vec::new();
|
|
||||||
while let Some(row) = rows.next()? {
|
|
||||||
let started_at_ms: i64 = row.get(2)?;
|
|
||||||
let started_at = DateTime::<Utc>::from_timestamp_millis(started_at_ms)
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Invalid timestamp in history"))?;
|
|
||||||
let entry = HistoryEntry {
|
|
||||||
track_id: row.get(0)?,
|
|
||||||
channel_id: row.get::<_, i64>(1)? as u8,
|
|
||||||
started_at,
|
|
||||||
duration_ms: row.get::<_, i64>(3)? as u64,
|
|
||||||
song: SongSnapshot {
|
|
||||||
title: row.get::<_, Option<String>>(4)?.unwrap_or_default(),
|
|
||||||
artist: row.get::<_, Option<String>>(5)?.unwrap_or_default(),
|
|
||||||
album: row.get(6)?,
|
|
||||||
cover_url: row.get(7)?,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
entries.push(entry);
|
|
||||||
}
|
|
||||||
Ok(entries)
|
|
||||||
})
|
|
||||||
.await?
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn len(&self) -> anyhow::Result<usize> {
|
|
||||||
let conn = self.conn();
|
|
||||||
let count = spawn_blocking(move || -> anyhow::Result<usize> {
|
|
||||||
let conn = conn.lock().unwrap();
|
|
||||||
let mut stmt = conn.prepare("SELECT COUNT(*) FROM paradise_history")?;
|
|
||||||
let count: i64 = stmt.query_row([], |row| row.get(0))?;
|
|
||||||
Ok(count as usize)
|
|
||||||
})
|
|
||||||
.await??;
|
|
||||||
Ok(count)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn truncate(&self, keep: usize) -> anyhow::Result<()> {
|
|
||||||
let conn = self.conn();
|
|
||||||
spawn_blocking(move || -> anyhow::Result<()> {
|
|
||||||
let conn = conn.lock().unwrap();
|
|
||||||
let count: i64 =
|
|
||||||
conn.query_row("SELECT COUNT(*) FROM paradise_history", [], |row| {
|
|
||||||
row.get(0)
|
|
||||||
})?;
|
|
||||||
let keep = keep as i64;
|
|
||||||
if count <= keep {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let to_remove = count - keep;
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM paradise_history
|
|
||||||
WHERE id IN (
|
|
||||||
SELECT id FROM paradise_history
|
|
||||||
ORDER BY started_at_ms ASC
|
|
||||||
LIMIT ?1
|
|
||||||
)",
|
|
||||||
rusqlite::params![to_remove],
|
|
||||||
)?;
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.await??;
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
//! Internal orchestration layer for dynamic Radio Paradise streaming.
|
|
||||||
//!
|
|
||||||
//! This module implements the high level structures described in the
|
|
||||||
//! Radio Paradise functional specification:
|
|
||||||
//! - `ParadiseChannel`: lifecycle and state machine for a single RP channel.
|
|
||||||
//! - `ParadiseWorker`: async task responsible for polling/downloading blocks.
|
|
||||||
//! - `ParadiseClientStream`: per-client audio stream with independent cursor.
|
|
||||||
//! - Shared caches and history storage hooked into existing PMO components.
|
|
||||||
//!
|
|
||||||
//! The implementation is split across several submodules to keep concerns
|
|
||||||
//! 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;
|
|
||||||
pub mod constants;
|
|
||||||
mod history;
|
|
||||||
mod playlist;
|
|
||||||
mod worker;
|
|
||||||
|
|
||||||
pub use channel::{
|
|
||||||
max_channel_id, ChannelDescriptor, ParadiseChannel, ParadiseChannelKind, ParadiseClientStream,
|
|
||||||
ALL_CHANNELS,
|
|
||||||
};
|
|
||||||
pub use constants::*; // Export all constants
|
|
||||||
pub use history::{create_history_backend, HistoryBackend, HistoryEntry};
|
|
||||||
pub use playlist::PlaylistEntry;
|
|
||||||
pub use worker::{load_rp_metadata, ParadiseWorker, RadioParadiseMetadata, WorkerCommand};
|
|
||||||
@@ -1,293 +0,0 @@
|
|||||||
//! Shared playlist structures for Radio Paradise channels.
|
|
||||||
//!
|
|
||||||
//! This module keeps track of the active queue and history for a Radio
|
|
||||||
//! Paradise channel. Each playlist entry knows how many clients still need
|
|
||||||
//! to consume it before the worker can evict it.
|
|
||||||
|
|
||||||
use super::history::{HistoryEntry, SongSnapshot};
|
|
||||||
use crate::models::Song;
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use std::collections::VecDeque;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::SystemTime;
|
|
||||||
use tokio::sync::{Notify, RwLock};
|
|
||||||
|
|
||||||
/// Metadata stored for an active track.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct PlaylistEntry {
|
|
||||||
pub track_id: String,
|
|
||||||
pub channel_id: u8,
|
|
||||||
pub song: Arc<Song>,
|
|
||||||
pub started_at: DateTime<Utc>,
|
|
||||||
pub duration_ms: u64,
|
|
||||||
pub audio_pk: Option<String>,
|
|
||||||
pub file_path: Option<PathBuf>,
|
|
||||||
pending_clients: AtomicUsize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PlaylistEntry {
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub fn new(
|
|
||||||
track_id: String,
|
|
||||||
channel_id: u8,
|
|
||||||
song: Arc<Song>,
|
|
||||||
started_at: DateTime<Utc>,
|
|
||||||
duration_ms: u64,
|
|
||||||
audio_pk: Option<String>,
|
|
||||||
file_path: Option<PathBuf>,
|
|
||||||
pending_clients: usize,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
track_id,
|
|
||||||
channel_id,
|
|
||||||
song,
|
|
||||||
started_at,
|
|
||||||
duration_ms,
|
|
||||||
audio_pk,
|
|
||||||
file_path,
|
|
||||||
pending_clients: AtomicUsize::new(pending_clients),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn as_history_entry(&self) -> HistoryEntry {
|
|
||||||
HistoryEntry {
|
|
||||||
track_id: self.track_id.clone(),
|
|
||||||
channel_id: self.channel_id,
|
|
||||||
started_at: self.started_at,
|
|
||||||
duration_ms: self.duration_ms,
|
|
||||||
song: SongSnapshot::from(self.song.as_ref()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn pending_clients(&self) -> usize {
|
|
||||||
self.pending_clients.load(Ordering::SeqCst)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_pending_clients(&self, value: usize) {
|
|
||||||
self.pending_clients.store(value, Ordering::SeqCst);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn increment_clients(&self) -> usize {
|
|
||||||
self.pending_clients.fetch_add(1, Ordering::SeqCst) + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decrement_clients(&self) -> usize {
|
|
||||||
let mut current = self.pending_clients.load(Ordering::SeqCst);
|
|
||||||
loop {
|
|
||||||
if current == 0 {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
match self.pending_clients.compare_exchange(
|
|
||||||
current,
|
|
||||||
current - 1,
|
|
||||||
Ordering::SeqCst,
|
|
||||||
Ordering::SeqCst,
|
|
||||||
) {
|
|
||||||
Ok(_) => return current - 1,
|
|
||||||
Err(actual) => current = actual,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct PlaylistState {
|
|
||||||
active: VecDeque<Arc<PlaylistEntry>>,
|
|
||||||
history: VecDeque<HistoryEntry>,
|
|
||||||
max_history: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PlaylistState {
|
|
||||||
fn new(max_history: usize) -> Self {
|
|
||||||
Self {
|
|
||||||
active: VecDeque::new(),
|
|
||||||
history: VecDeque::new(),
|
|
||||||
max_history,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn active_len(&self) -> usize {
|
|
||||||
self.active.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn push_active(&mut self, entry: Arc<PlaylistEntry>) {
|
|
||||||
self.active.push_back(entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn active_snapshot(&self) -> Vec<Arc<PlaylistEntry>> {
|
|
||||||
self.active.iter().cloned().collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pop_front_if_ready(&mut self) -> Option<Arc<PlaylistEntry>> {
|
|
||||||
if let Some(front) = self.active.front() {
|
|
||||||
if front.pending_clients() == 0 {
|
|
||||||
return self.active.pop_front();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pop_front_matching(&mut self, track_id: &str) -> Option<Arc<PlaylistEntry>> {
|
|
||||||
if let Some(front) = self.active.front() {
|
|
||||||
if front.track_id == track_id && front.pending_clients() == 0 {
|
|
||||||
return self.active.pop_front();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn push_history(&mut self, entry: HistoryEntry) {
|
|
||||||
self.history.push_back(entry);
|
|
||||||
self.trim_history();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn recent_history(&self, limit: usize) -> Vec<HistoryEntry> {
|
|
||||||
let total = self.history.len();
|
|
||||||
let start = total.saturating_sub(limit);
|
|
||||||
self.history.iter().skip(start).cloned().collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn trim_history(&mut self) {
|
|
||||||
while self.history.len() > self.max_history {
|
|
||||||
self.history.pop_front();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clear(&mut self) -> bool {
|
|
||||||
let changed = !self.active.is_empty() || !self.history.is_empty();
|
|
||||||
if changed {
|
|
||||||
self.active.clear();
|
|
||||||
self.history.clear();
|
|
||||||
}
|
|
||||||
changed
|
|
||||||
}
|
|
||||||
|
|
||||||
fn increment_all(&self) {
|
|
||||||
for entry in &self.active {
|
|
||||||
entry.increment_clients();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SharedPlaylistInner {
|
|
||||||
state: RwLock<PlaylistState>,
|
|
||||||
notify: Notify,
|
|
||||||
update_id: AtomicU32,
|
|
||||||
last_change: RwLock<Option<SystemTime>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct SharedPlaylist(Arc<SharedPlaylistInner>);
|
|
||||||
|
|
||||||
impl SharedPlaylist {
|
|
||||||
pub fn new(max_history: usize) -> Self {
|
|
||||||
Self(Arc::new(SharedPlaylistInner {
|
|
||||||
state: RwLock::new(PlaylistState::new(max_history)),
|
|
||||||
notify: Notify::new(),
|
|
||||||
update_id: AtomicU32::new(0),
|
|
||||||
last_change: RwLock::new(None),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn touch(&self) {
|
|
||||||
self.0.update_id.fetch_add(1, Ordering::SeqCst);
|
|
||||||
let mut last_change = self.0.last_change.write().await;
|
|
||||||
*last_change = Some(SystemTime::now());
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn push_active(&self, entry: Arc<PlaylistEntry>) {
|
|
||||||
let mut guard = self.0.state.write().await;
|
|
||||||
guard.push_active(entry);
|
|
||||||
drop(guard);
|
|
||||||
self.touch().await;
|
|
||||||
self.0.notify.notify_waiters();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn active_len(&self) -> usize {
|
|
||||||
let guard = self.0.state.read().await;
|
|
||||||
guard.active_len()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn active_snapshot(&self) -> Vec<Arc<PlaylistEntry>> {
|
|
||||||
let guard = self.0.state.read().await;
|
|
||||||
guard.active_snapshot()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn clear(&self) {
|
|
||||||
let mut guard = self.0.state.write().await;
|
|
||||||
let changed = guard.clear();
|
|
||||||
drop(guard);
|
|
||||||
if changed {
|
|
||||||
self.touch().await;
|
|
||||||
self.0.notify.notify_waiters();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn wait_for_track_count(&self, current_len: usize) {
|
|
||||||
loop {
|
|
||||||
let len = {
|
|
||||||
let guard = self.0.state.read().await;
|
|
||||||
guard.active_len()
|
|
||||||
};
|
|
||||||
|
|
||||||
if len > current_len {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.0.notify.notified().await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn pop_front_if_ready(&self) -> Option<Arc<PlaylistEntry>> {
|
|
||||||
let mut guard = self.0.state.write().await;
|
|
||||||
let result = guard.pop_front_if_ready();
|
|
||||||
drop(guard);
|
|
||||||
|
|
||||||
if result.is_some() {
|
|
||||||
self.touch().await;
|
|
||||||
self.0.notify.notify_waiters();
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn pop_front_matching(&self, track_id: &str) -> Option<Arc<PlaylistEntry>> {
|
|
||||||
let mut guard = self.0.state.write().await;
|
|
||||||
let result = guard.pop_front_matching(track_id);
|
|
||||||
drop(guard);
|
|
||||||
|
|
||||||
if result.is_some() {
|
|
||||||
self.touch().await;
|
|
||||||
self.0.notify.notify_waiters();
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn push_history_entry(&self, entry: HistoryEntry) {
|
|
||||||
let mut guard = self.0.state.write().await;
|
|
||||||
guard.push_history(entry);
|
|
||||||
drop(guard);
|
|
||||||
self.touch().await;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn recent_history(&self, limit: usize) -> Vec<HistoryEntry> {
|
|
||||||
let guard = self.0.state.read().await;
|
|
||||||
guard.recent_history(limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn increment_all_pending(&self) {
|
|
||||||
let guard = self.0.state.read().await;
|
|
||||||
guard.increment_all();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update_id(&self) -> u32 {
|
|
||||||
self.0.update_id.load(Ordering::SeqCst)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn last_change(&self) -> Option<SystemTime> {
|
|
||||||
self.0.last_change.read().await.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -3,31 +3,24 @@
|
|||||||
//! Ce module fournit un trait d'extension pour ajouter facilement l'API Radio Paradise
|
//! Ce module fournit un trait d'extension pour ajouter facilement l'API Radio Paradise
|
||||||
//! à un serveur pmoserver.
|
//! à un serveur pmoserver.
|
||||||
|
|
||||||
use crate::paradise::{max_channel_id, ParadiseChannel, PlaylistEntry, ALL_CHANNELS};
|
use crate::channels::{max_channel_id, ChannelDescriptor, ALL_CHANNELS};
|
||||||
use crate::{Block, NowPlaying, RadioParadiseClient, RadioParadiseSource};
|
use crate::{Block, NowPlaying, RadioParadiseClient};
|
||||||
|
use async_trait::async_trait;
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Body,
|
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
http::{HeaderMap, HeaderName, HeaderValue, StatusCode},
|
http::StatusCode,
|
||||||
response::IntoResponse,
|
|
||||||
routing::get,
|
routing::get,
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use futures::StreamExt;
|
|
||||||
use pmosource::api::CacheStatusInfo;
|
|
||||||
use pmosource::CacheStatus;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use tracing::error;
|
use utoipa::{OpenApi, ToSchema};
|
||||||
use utoipa::{IntoParams, OpenApi, ToSchema};
|
|
||||||
|
|
||||||
/// État partagé pour l'API Radio Paradise
|
/// État partagé pour l'API Radio Paradise
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct RadioParadiseState {
|
pub struct RadioParadiseState {
|
||||||
client: Arc<RwLock<RadioParadiseClient>>,
|
client: Arc<RwLock<RadioParadiseClient>>,
|
||||||
source: Arc<RadioParadiseSource>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize)]
|
||||||
@@ -36,46 +29,14 @@ struct ParadiseQuery {
|
|||||||
channel: Option<u8>,
|
channel: Option<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize, IntoParams)]
|
|
||||||
#[serde(default)]
|
|
||||||
#[into_params(parameter_in = Query)]
|
|
||||||
struct ListLimitQuery {
|
|
||||||
/// Nombre maximum d'éléments à retourner (0 = tous)
|
|
||||||
#[serde(default)]
|
|
||||||
limit: Option<usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RadioParadiseState {
|
impl RadioParadiseState {
|
||||||
pub async fn new() -> anyhow::Result<Self> {
|
pub async fn new() -> anyhow::Result<Self> {
|
||||||
let client = RadioParadiseClient::new()
|
let client = RadioParadiseClient::new()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to create RadioParadise client: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("Failed to create RadioParadise client: {}", e))?;
|
||||||
#[cfg(feature = "server")]
|
|
||||||
let source = RadioParadiseSource::from_registry_default(client.clone())
|
|
||||||
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
|
||||||
|
|
||||||
#[cfg(not(feature = "server"))]
|
|
||||||
let source = {
|
|
||||||
let base_dir = std::env::temp_dir().join("pmoparadise_api");
|
|
||||||
let cover_dir = base_dir.join("covers");
|
|
||||||
let audio_dir = base_dir.join("audio");
|
|
||||||
std::fs::create_dir_all(&cover_dir)?;
|
|
||||||
std::fs::create_dir_all(&audio_dir)?;
|
|
||||||
|
|
||||||
let cover_cache = Arc::new(pmocovers::cache::new_cache(
|
|
||||||
cover_dir.to_string_lossy().as_ref(),
|
|
||||||
256,
|
|
||||||
)?);
|
|
||||||
let audio_cache = Arc::new(pmoaudiocache::cache::new_cache(
|
|
||||||
audio_dir.to_string_lossy().as_ref(),
|
|
||||||
256,
|
|
||||||
)?);
|
|
||||||
RadioParadiseSource::new_default(client.clone(), cover_cache, audio_cache)
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
client: Arc::new(RwLock::new(client)),
|
client: Arc::new(RwLock::new(client)),
|
||||||
source: Arc::new(source),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,19 +61,6 @@ impl RadioParadiseState {
|
|||||||
|
|
||||||
Ok(client)
|
Ok(client)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn channel_for_id(&self, channel_id: u8) -> Result<Arc<ParadiseChannel>, StatusCode> {
|
|
||||||
if channel_id > max_channel_id() {
|
|
||||||
return Err(StatusCode::BAD_REQUEST);
|
|
||||||
}
|
|
||||||
self.source
|
|
||||||
.channel(channel_id)
|
|
||||||
.ok_or(StatusCode::SERVICE_UNAVAILABLE)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn source(&self) -> Arc<RadioParadiseSource> {
|
|
||||||
self.source.clone()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Information sur un canal Radio Paradise
|
/// Information sur un canal Radio Paradise
|
||||||
@@ -126,8 +74,8 @@ pub struct ChannelInfo {
|
|||||||
pub description: String,
|
pub description: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&crate::paradise::ChannelDescriptor> for ChannelInfo {
|
impl From<&ChannelDescriptor> for ChannelInfo {
|
||||||
fn from(descriptor: &crate::paradise::ChannelDescriptor) -> Self {
|
fn from(descriptor: &ChannelDescriptor) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id: descriptor.id,
|
id: descriptor.id,
|
||||||
name: descriptor.display_name.to_string(),
|
name: descriptor.display_name.to_string(),
|
||||||
@@ -365,425 +313,28 @@ async fn get_channels() -> Json<Vec<ChannelInfo>> {
|
|||||||
Json(channels)
|
Json(channels)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Statut opérationnel d'un canal Radio Paradise
|
|
||||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
|
||||||
pub struct ChannelStatusResponse {
|
|
||||||
/// ID numérique du canal
|
|
||||||
pub channel_id: u8,
|
|
||||||
/// Slug du canal (main, mellow, ...)
|
|
||||||
pub slug: String,
|
|
||||||
/// Nom complet du canal
|
|
||||||
pub name: String,
|
|
||||||
/// Description
|
|
||||||
pub description: String,
|
|
||||||
/// Nombre de clients connectés au flux
|
|
||||||
pub active_clients: usize,
|
|
||||||
/// Nombre de morceaux présents dans la file d'attente
|
|
||||||
pub queue_length: usize,
|
|
||||||
/// Valeur courante d'update_id
|
|
||||||
pub update_id: u32,
|
|
||||||
/// Dernière modification (RFC3339)
|
|
||||||
pub last_change: Option<String>,
|
|
||||||
/// Nombre total d'entrées en historique (persisté)
|
|
||||||
pub history_entries: usize,
|
|
||||||
/// Limite configurée pour l'historique
|
|
||||||
pub history_max_tracks: usize,
|
|
||||||
/// Le canal est-il activé dans la configuration ?
|
|
||||||
pub configured: bool,
|
|
||||||
/// Identifiant de collection pour le cache
|
|
||||||
pub cache_collection_id: String,
|
|
||||||
/// Nombre total de pistes connues du cache
|
|
||||||
pub cache_total_tracks: usize,
|
|
||||||
/// Nombre de pistes déjà en cache
|
|
||||||
pub cache_cached_tracks: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Entrée détaillée de la file d'attente
|
|
||||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
|
||||||
pub struct ChannelPlaylistEntry {
|
|
||||||
/// Position dans la file
|
|
||||||
pub index: usize,
|
|
||||||
/// ID unique de la piste
|
|
||||||
pub track_id: String,
|
|
||||||
/// ID du canal
|
|
||||||
pub channel_id: u8,
|
|
||||||
/// Titre du morceau
|
|
||||||
pub title: String,
|
|
||||||
/// Artiste
|
|
||||||
pub artist: String,
|
|
||||||
/// Album
|
|
||||||
pub album: Option<String>,
|
|
||||||
/// URL de couverture (si disponible)
|
|
||||||
pub cover_url: Option<String>,
|
|
||||||
/// Durée du morceau en ms
|
|
||||||
pub duration_ms: u64,
|
|
||||||
/// Offset dans le block (ms)
|
|
||||||
pub elapsed_ms: u64,
|
|
||||||
/// Horodatage prévu/démarré (RFC3339)
|
|
||||||
pub started_at: String,
|
|
||||||
/// Nombre de clients restants à servir
|
|
||||||
pub pending_clients: usize,
|
|
||||||
/// Note éventuelle (0-10)
|
|
||||||
pub rating: Option<f32>,
|
|
||||||
/// Année éventuelle
|
|
||||||
pub year: Option<u32>,
|
|
||||||
/// Statut de cache
|
|
||||||
pub cache_status: CacheStatusInfo,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ChannelPlaylistEntry {
|
|
||||||
fn from_entry(entry: &Arc<PlaylistEntry>, index: usize, cache_status: CacheStatusInfo) -> Self {
|
|
||||||
let song = entry.song.as_ref();
|
|
||||||
Self {
|
|
||||||
index,
|
|
||||||
track_id: entry.track_id.clone(),
|
|
||||||
channel_id: entry.channel_id,
|
|
||||||
title: song.title.clone(),
|
|
||||||
artist: song.artist.clone(),
|
|
||||||
album: song.album.clone(),
|
|
||||||
cover_url: song.cover.clone(),
|
|
||||||
duration_ms: entry.duration_ms,
|
|
||||||
elapsed_ms: song.elapsed,
|
|
||||||
started_at: entry.started_at.to_rfc3339(),
|
|
||||||
pending_clients: entry.pending_clients(),
|
|
||||||
rating: song.rating,
|
|
||||||
year: song.year,
|
|
||||||
cache_status,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Réponse pour la file d'attente d'un canal
|
|
||||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
|
||||||
pub struct ChannelPlaylistResponse {
|
|
||||||
/// ID du canal
|
|
||||||
pub channel_id: u8,
|
|
||||||
/// Slug du canal
|
|
||||||
pub slug: String,
|
|
||||||
/// Update ID du playlist
|
|
||||||
pub update_id: u32,
|
|
||||||
/// Taille totale de la file au moment de la capture
|
|
||||||
pub queue_length: usize,
|
|
||||||
/// Entrées retournées
|
|
||||||
pub items: Vec<ChannelPlaylistEntry>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Entrée d'historique d'écoute
|
|
||||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
|
||||||
pub struct ChannelHistoryEntry {
|
|
||||||
/// ID unique de la piste
|
|
||||||
pub track_id: String,
|
|
||||||
/// ID du canal
|
|
||||||
pub channel_id: u8,
|
|
||||||
/// Titre
|
|
||||||
pub title: String,
|
|
||||||
/// Artiste
|
|
||||||
pub artist: String,
|
|
||||||
/// Album
|
|
||||||
pub album: Option<String>,
|
|
||||||
/// URL de couverture
|
|
||||||
pub cover_url: Option<String>,
|
|
||||||
/// Début de lecture (RFC3339)
|
|
||||||
pub started_at: String,
|
|
||||||
/// Durée en ms
|
|
||||||
pub duration_ms: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Réponse pour l'historique d'un canal
|
|
||||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
|
||||||
pub struct ChannelHistoryResponse {
|
|
||||||
/// ID du canal
|
|
||||||
pub channel_id: u8,
|
|
||||||
/// Slug du canal
|
|
||||||
pub slug: String,
|
|
||||||
/// Nombre total d'entrées disponibles
|
|
||||||
pub total_available: usize,
|
|
||||||
/// Nombre d'entrées retournées dans cette réponse
|
|
||||||
pub returned: usize,
|
|
||||||
/// Entrées
|
|
||||||
pub entries: Vec<ChannelHistoryEntry>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /channels/{channel_id}/status - Statut détaillé d'un canal
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/channels/{channel_id}/status",
|
|
||||||
params(
|
|
||||||
("channel_id" = u8, Path, description = "Channel ID (0-3)")
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Statut du canal", body = ChannelStatusResponse),
|
|
||||||
(status = 400, description = "Canal invalide"),
|
|
||||||
(status = 503, description = "Canal indisponible"),
|
|
||||||
(status = 500, description = "Erreur interne lors de la récupération du statut")
|
|
||||||
),
|
|
||||||
tag = "Radio Paradise"
|
|
||||||
)]
|
|
||||||
async fn get_channel_status(
|
|
||||||
State(state): State<RadioParadiseState>,
|
|
||||||
Path(channel_id): Path<u8>,
|
|
||||||
) -> Result<Json<ChannelStatusResponse>, StatusCode> {
|
|
||||||
let channel = state.channel_for_id(channel_id)?;
|
|
||||||
let descriptor = channel.descriptor();
|
|
||||||
|
|
||||||
let playlist = channel.playlist();
|
|
||||||
let queue_length = playlist.active_len().await;
|
|
||||||
let update_id = playlist.update_id();
|
|
||||||
let last_change = playlist
|
|
||||||
.last_change()
|
|
||||||
.await
|
|
||||||
.map(|ts| DateTime::<Utc>::from(ts).to_rfc3339());
|
|
||||||
|
|
||||||
let history_len = channel.history_backend().len().await.map_err(|e| {
|
|
||||||
error!(
|
|
||||||
channel = descriptor.slug,
|
|
||||||
"Failed to retrieve history size: {e:?}"
|
|
||||||
);
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let cache_stats = channel.cache_manager().statistics().await;
|
|
||||||
|
|
||||||
let status = ChannelStatusResponse {
|
|
||||||
channel_id,
|
|
||||||
slug: descriptor.slug.to_string(),
|
|
||||||
name: descriptor.display_name.to_string(),
|
|
||||||
description: descriptor.description.to_string(),
|
|
||||||
active_clients: channel.active_client_count(),
|
|
||||||
queue_length,
|
|
||||||
update_id,
|
|
||||||
last_change,
|
|
||||||
history_entries: history_len,
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Json(status))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /channels/{channel_id}/playlist - File d'attente du canal
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/channels/{channel_id}/playlist",
|
|
||||||
params(
|
|
||||||
("channel_id" = u8, Path, description = "Channel ID (0-3)"),
|
|
||||||
ListLimitQuery
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "File d'attente courante", body = ChannelPlaylistResponse),
|
|
||||||
(status = 400, description = "Canal invalide"),
|
|
||||||
(status = 503, description = "Canal indisponible"),
|
|
||||||
(status = 500, description = "Erreur lors de la récupération de la file d'attente")
|
|
||||||
),
|
|
||||||
tag = "Radio Paradise"
|
|
||||||
)]
|
|
||||||
async fn get_channel_playlist(
|
|
||||||
State(state): State<RadioParadiseState>,
|
|
||||||
Path(channel_id): Path<u8>,
|
|
||||||
Query(query): Query<ListLimitQuery>,
|
|
||||||
) -> Result<Json<ChannelPlaylistResponse>, StatusCode> {
|
|
||||||
let channel = state.channel_for_id(channel_id)?;
|
|
||||||
let descriptor = channel.descriptor();
|
|
||||||
let playlist = channel.playlist();
|
|
||||||
let snapshot = playlist.active_snapshot().await;
|
|
||||||
let total_len = snapshot.len();
|
|
||||||
let limit = query.limit.filter(|limit| *limit > 0).unwrap_or(total_len);
|
|
||||||
|
|
||||||
let cache_manager = channel.cache_manager();
|
|
||||||
let mut items = Vec::new();
|
|
||||||
|
|
||||||
for (index, entry) in snapshot.into_iter().enumerate().take(limit) {
|
|
||||||
let cache_status = match cache_manager.get_cache_status(&entry.track_id).await {
|
|
||||||
Ok(status) => status,
|
|
||||||
Err(err) => CacheStatus::Failed {
|
|
||||||
error: err.to_string(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
items.push(ChannelPlaylistEntry::from_entry(
|
|
||||||
&entry,
|
|
||||||
index,
|
|
||||||
CacheStatusInfo::from(cache_status),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let response = ChannelPlaylistResponse {
|
|
||||||
channel_id,
|
|
||||||
slug: descriptor.slug.to_string(),
|
|
||||||
update_id: playlist.update_id(),
|
|
||||||
queue_length: total_len,
|
|
||||||
items,
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Json(response))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /channels/{channel_id}/history - Historique récent du canal
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/channels/{channel_id}/history",
|
|
||||||
params(
|
|
||||||
("channel_id" = u8, Path, description = "Channel ID (0-3)"),
|
|
||||||
ListLimitQuery
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Historique récent", body = ChannelHistoryResponse),
|
|
||||||
(status = 400, description = "Canal invalide"),
|
|
||||||
(status = 503, description = "Canal indisponible"),
|
|
||||||
(status = 500, description = "Erreur lors de la récupération de l'historique")
|
|
||||||
),
|
|
||||||
tag = "Radio Paradise"
|
|
||||||
)]
|
|
||||||
async fn get_channel_history(
|
|
||||||
State(state): State<RadioParadiseState>,
|
|
||||||
Path(channel_id): Path<u8>,
|
|
||||||
Query(query): Query<ListLimitQuery>,
|
|
||||||
) -> Result<Json<ChannelHistoryResponse>, StatusCode> {
|
|
||||||
let channel = state.channel_for_id(channel_id)?;
|
|
||||||
let descriptor = channel.descriptor();
|
|
||||||
let backend = channel.history_backend().clone();
|
|
||||||
let limit = query.limit.unwrap_or(50);
|
|
||||||
|
|
||||||
let entries_raw = backend.recent(limit).await.map_err(|e| {
|
|
||||||
error!(
|
|
||||||
channel = descriptor.slug,
|
|
||||||
"Failed to retrieve channel history: {e:?}"
|
|
||||||
);
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let total_available = backend.len().await.map_err(|e| {
|
|
||||||
error!(
|
|
||||||
channel = descriptor.slug,
|
|
||||||
"Failed to count channel history entries: {e:?}"
|
|
||||||
);
|
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let entries: Vec<ChannelHistoryEntry> = entries_raw
|
|
||||||
.into_iter()
|
|
||||||
.map(|entry| ChannelHistoryEntry {
|
|
||||||
track_id: entry.track_id,
|
|
||||||
channel_id: entry.channel_id,
|
|
||||||
title: entry.song.title,
|
|
||||||
artist: entry.song.artist,
|
|
||||||
album: entry.song.album,
|
|
||||||
cover_url: entry.song.cover_url,
|
|
||||||
started_at: entry.started_at.to_rfc3339(),
|
|
||||||
duration_ms: entry.duration_ms,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let response = ChannelHistoryResponse {
|
|
||||||
channel_id,
|
|
||||||
slug: descriptor.slug.to_string(),
|
|
||||||
total_available,
|
|
||||||
returned: entries.len(),
|
|
||||||
entries,
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Json(response))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /channels/{channel_id}/stream/{connection_id} - Stream audio pour une connexion spécifique
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/channels/{channel_id}/stream/{connection_id}",
|
|
||||||
params(
|
|
||||||
("channel_id" = u8, Path, description = "Channel ID (0-3)"),
|
|
||||||
("connection_id" = i32, Path, description = "Connection ID fourni par le media server")
|
|
||||||
),
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Flux audio FLAC (gapless)", content_type = "audio/flac"),
|
|
||||||
(status = 400, description = "Canal invalide"),
|
|
||||||
(status = 503, description = "Canal indisponible")
|
|
||||||
),
|
|
||||||
tag = "Radio Paradise"
|
|
||||||
)]
|
|
||||||
async fn stream_channel_by_connection(
|
|
||||||
State(state): State<RadioParadiseState>,
|
|
||||||
Path((channel_id, connection_id)): Path<(u8, i32)>,
|
|
||||||
) -> Result<impl IntoResponse, StatusCode> {
|
|
||||||
let channel = state.channel_for_id(channel_id)?;
|
|
||||||
|
|
||||||
// Convertir connection_id en String pour l'utiliser comme client_id
|
|
||||||
let client_id = connection_id.to_string();
|
|
||||||
|
|
||||||
let client_stream = channel.connect_client(client_id).await.map_err(|e| {
|
|
||||||
error!("Failed to create streaming client: {e:?}");
|
|
||||||
StatusCode::SERVICE_UNAVAILABLE
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let stream = client_stream
|
|
||||||
.into_byte_stream()
|
|
||||||
.map(|chunk| chunk.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)));
|
|
||||||
|
|
||||||
let body = Body::from_stream(stream);
|
|
||||||
|
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
headers.insert(
|
|
||||||
axum::http::header::CONTENT_TYPE,
|
|
||||||
HeaderValue::from_static("audio/flac"),
|
|
||||||
);
|
|
||||||
headers.insert(
|
|
||||||
axum::http::header::CACHE_CONTROL,
|
|
||||||
HeaderValue::from_static("no-cache"),
|
|
||||||
);
|
|
||||||
headers.insert(
|
|
||||||
HeaderName::from_static("icy-name"),
|
|
||||||
HeaderValue::from_static("Radio Paradise"),
|
|
||||||
);
|
|
||||||
headers.insert(
|
|
||||||
HeaderName::from_static("icy-genre"),
|
|
||||||
HeaderValue::from_static("Eclectic"),
|
|
||||||
);
|
|
||||||
headers.insert(
|
|
||||||
HeaderName::from_static("icy-description"),
|
|
||||||
HeaderValue::from_static("PMO Radio Paradise relay"),
|
|
||||||
);
|
|
||||||
headers.insert(
|
|
||||||
HeaderName::from_static("icy-metaint"),
|
|
||||||
HeaderValue::from_static("0"),
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok((headers, body))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Documentation OpenAPI pour l'API Radio Paradise
|
/// Documentation OpenAPI pour l'API Radio Paradise
|
||||||
#[derive(OpenApi)]
|
#[derive(OpenApi)]
|
||||||
#[openapi(
|
#[openapi(
|
||||||
info(
|
info(
|
||||||
title = "Radio Paradise API",
|
title = "Radio Paradise API",
|
||||||
version = "1.0.0",
|
version = "1.0.0",
|
||||||
description = "API REST pour accéder aux métadonnées et streams de Radio Paradise"
|
description = "API REST pour accéder aux métadonnées de Radio Paradise"
|
||||||
),
|
),
|
||||||
paths(
|
paths(
|
||||||
get_now_playing,
|
get_now_playing,
|
||||||
get_current_block,
|
get_current_block,
|
||||||
get_block_by_id,
|
get_block_by_id,
|
||||||
get_channels,
|
get_channels
|
||||||
get_channel_status,
|
|
||||||
get_channel_playlist,
|
|
||||||
get_channel_history,
|
|
||||||
stream_channel_by_connection
|
|
||||||
),
|
),
|
||||||
components(schemas(
|
components(schemas(
|
||||||
NowPlayingResponse,
|
NowPlayingResponse,
|
||||||
BlockResponse,
|
BlockResponse,
|
||||||
SongInfo,
|
SongInfo,
|
||||||
ChannelInfo,
|
ChannelInfo
|
||||||
ChannelStatusResponse,
|
|
||||||
ChannelPlaylistEntry,
|
|
||||||
ChannelPlaylistResponse,
|
|
||||||
ChannelHistoryEntry,
|
|
||||||
ChannelHistoryResponse,
|
|
||||||
CacheStatusInfo
|
|
||||||
)),
|
)),
|
||||||
tags(
|
tags(
|
||||||
(name = "Radio Paradise", description = "Endpoints pour Radio Paradise streaming")
|
(name = "Radio Paradise", description = "Endpoints pour Radio Paradise")
|
||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
pub struct RadioParadiseApiDoc;
|
pub struct RadioParadiseApiDoc;
|
||||||
@@ -795,13 +346,6 @@ pub fn create_api_router(state: RadioParadiseState) -> Router {
|
|||||||
.route("/block/current", get(get_current_block))
|
.route("/block/current", get(get_current_block))
|
||||||
.route("/block/{event_id}", get(get_block_by_id))
|
.route("/block/{event_id}", get(get_block_by_id))
|
||||||
.route("/channels", get(get_channels))
|
.route("/channels", get(get_channels))
|
||||||
.route("/channels/{channel_id}/status", get(get_channel_status))
|
|
||||||
.route("/channels/{channel_id}/playlist", get(get_channel_playlist))
|
|
||||||
.route("/channels/{channel_id}/history", get(get_channel_history))
|
|
||||||
.route(
|
|
||||||
"/channels/{channel_id}/stream/{connection_id}",
|
|
||||||
get(stream_channel_by_connection),
|
|
||||||
)
|
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -809,6 +353,7 @@ pub fn create_api_router(state: RadioParadiseState) -> Router {
|
|||||||
///
|
///
|
||||||
/// Permet d'initialiser Radio Paradise avec routes HTTP complètes
|
/// Permet d'initialiser Radio Paradise avec routes HTTP complètes
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[async_trait]
|
||||||
pub trait RadioParadiseExt {
|
pub trait RadioParadiseExt {
|
||||||
/// Initialise l'API Radio Paradise
|
/// Initialise l'API Radio Paradise
|
||||||
///
|
///
|
||||||
@@ -817,12 +362,13 @@ pub trait RadioParadiseExt {
|
|||||||
/// - API: `/api/radioparadise/*`
|
/// - API: `/api/radioparadise/*`
|
||||||
/// - `/now-playing`
|
/// - `/now-playing`
|
||||||
/// - `/block/*`
|
/// - `/block/*`
|
||||||
/// - `/channels/{channel_id}/stream/{connection_id}`
|
/// - `/channels`
|
||||||
/// - Swagger: `/swagger-ui/radioparadise`
|
/// - Swagger: `/swagger-ui/radioparadise`
|
||||||
async fn init_radioparadise(&mut self) -> anyhow::Result<RadioParadiseState>;
|
async fn init_radioparadise(&mut self) -> anyhow::Result<RadioParadiseState>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
|
#[async_trait]
|
||||||
impl RadioParadiseExt for pmoserver::Server {
|
impl RadioParadiseExt for pmoserver::Server {
|
||||||
async fn init_radioparadise(&mut self) -> anyhow::Result<RadioParadiseState> {
|
async fn init_radioparadise(&mut self) -> anyhow::Result<RadioParadiseState> {
|
||||||
let state = RadioParadiseState::new().await?;
|
let state = RadioParadiseState::new().await?;
|
||||||
|
|||||||
667
pmoparadise/src/radio_paradise_stream_source.rs
Normal file
667
pmoparadise/src/radio_paradise_stream_source.rs
Normal file
@@ -0,0 +1,667 @@
|
|||||||
|
//! RadioParadiseStreamSource - Node audio pmoaudio pour Radio Paradise
|
||||||
|
//!
|
||||||
|
//! Ce node télécharge et décode les blocs FLAC de Radio Paradise en streaming,
|
||||||
|
//! avec insertion automatique des TrackBoundary au bon timing.
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
client::RadioParadiseClient,
|
||||||
|
models::{Block, EventId, Song},
|
||||||
|
};
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use pmoaudio::{
|
||||||
|
nodes::{AudioError, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS},
|
||||||
|
pipeline::{Node, NodeLogic},
|
||||||
|
type_constraints::TypeRequirement,
|
||||||
|
AudioPipelineNode, AudioSegment, SyncMarker, I24,
|
||||||
|
};
|
||||||
|
use pmoflac::decode_audio_stream;
|
||||||
|
use pmometadata::{MemoryTrackMetadata, TrackMetadata};
|
||||||
|
use std::{
|
||||||
|
collections::VecDeque,
|
||||||
|
sync::Arc,
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
use tokio::sync::{mpsc, RwLock};
|
||||||
|
use tokio_util::{io::StreamReader, sync::CancellationToken};
|
||||||
|
|
||||||
|
/// Timeout pour attendre un nouveau block ID (radio en temps réel)
|
||||||
|
const BLOCK_ID_TIMEOUT_SECS: u64 = 3;
|
||||||
|
|
||||||
|
/// Nombre de blocs récents à mémoriser pour éviter les re-téléchargements
|
||||||
|
const RECENT_BLOCKS_CACHE_SIZE: usize = 10;
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// RadioParadiseStreamSourceLogic - Logique métier pure
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// Logique pure de téléchargement et décodage des blocs Radio Paradise
|
||||||
|
pub struct RadioParadiseStreamSourceLogic {
|
||||||
|
client: RadioParadiseClient,
|
||||||
|
chunk_frames: usize,
|
||||||
|
recent_blocks: VecDeque<EventId>,
|
||||||
|
block_queue: VecDeque<EventId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RadioParadiseStreamSourceLogic {
|
||||||
|
pub fn new(client: RadioParadiseClient, chunk_duration_ms: u32) -> Self {
|
||||||
|
// Calculer chunk_frames pour la durée cible (on suppose 44.1kHz)
|
||||||
|
let chunk_frames = ((chunk_duration_ms as f64 / 1000.0) * 44100.0) as usize;
|
||||||
|
|
||||||
|
Self {
|
||||||
|
client,
|
||||||
|
chunk_frames,
|
||||||
|
recent_blocks: VecDeque::with_capacity(RECENT_BLOCKS_CACHE_SIZE),
|
||||||
|
block_queue: VecDeque::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajoute un block ID à la file d'attente
|
||||||
|
pub fn push_block_id(&mut self, event_id: EventId) {
|
||||||
|
self.block_queue.push_back(event_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie si un bloc a été téléchargé récemment
|
||||||
|
fn is_recent_block(&self, event_id: EventId) -> bool {
|
||||||
|
self.recent_blocks.contains(&event_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marque un bloc comme récemment téléchargé (FIFO)
|
||||||
|
fn mark_block_downloaded(&mut self, event_id: EventId) {
|
||||||
|
// Retirer tous les éléments excédentaires (garantit <= CACHE_SIZE)
|
||||||
|
while self.recent_blocks.len() >= RECENT_BLOCKS_CACHE_SIZE {
|
||||||
|
self.recent_blocks.pop_front();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Puis ajouter le nouveau bloc
|
||||||
|
self.recent_blocks.push_back(event_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Télécharge et décode un bloc FLAC
|
||||||
|
async fn download_and_decode_block(
|
||||||
|
&mut self,
|
||||||
|
block: &Block,
|
||||||
|
output: &[mpsc::Sender<Arc<AudioSegment>>],
|
||||||
|
stop_token: &CancellationToken,
|
||||||
|
order: &mut u64,
|
||||||
|
) -> Result<(), AudioError> {
|
||||||
|
// Télécharger le FLAC
|
||||||
|
let response = self.client.client
|
||||||
|
.get(&block.url)
|
||||||
|
.timeout(self.client.block_timeout)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AudioError::ProcessingError(format!("Block download failed: {}", e)))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(AudioError::ProcessingError(format!(
|
||||||
|
"Block download returned status {}",
|
||||||
|
response.status()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Créer un stream reader
|
||||||
|
let byte_stream = response.bytes_stream().map(|result| {
|
||||||
|
result.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
|
||||||
|
});
|
||||||
|
let stream_reader = StreamReader::new(byte_stream);
|
||||||
|
|
||||||
|
// Décoder le FLAC
|
||||||
|
let mut decoder = decode_audio_stream(stream_reader)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AudioError::ProcessingError(format!("FLAC decode failed: {}", e)))?;
|
||||||
|
|
||||||
|
let stream_info = decoder.info().clone();
|
||||||
|
let sample_rate = stream_info.sample_rate;
|
||||||
|
let bits_per_sample = stream_info.bits_per_sample;
|
||||||
|
|
||||||
|
// Préparer les songs ordonnées pour tracking
|
||||||
|
let songs = block.songs_ordered();
|
||||||
|
let mut song_index = 0;
|
||||||
|
let mut next_song: Option<(usize, &Song)> = songs.get(0).copied();
|
||||||
|
let mut total_samples = 0u64;
|
||||||
|
|
||||||
|
// Envoyer TopZeroSync au début du bloc
|
||||||
|
let top_zero = Arc::new(AudioSegment {
|
||||||
|
order: *order,
|
||||||
|
timestamp_sec: 0.0,
|
||||||
|
segment: pmoaudio::_AudioSegment::Sync(Arc::new(SyncMarker::TopZeroSync)),
|
||||||
|
});
|
||||||
|
self.send_to_children(output, top_zero).await?;
|
||||||
|
|
||||||
|
// Buffer pour lecture
|
||||||
|
let bytes_per_sample = (bits_per_sample / 8) as usize;
|
||||||
|
let frame_bytes = bytes_per_sample * 2; // stereo
|
||||||
|
let chunk_frames = self.chunk_frames;
|
||||||
|
let chunk_byte_len = chunk_frames * frame_bytes;
|
||||||
|
let mut read_buf = vec![0u8; chunk_byte_len * 2];
|
||||||
|
let mut pending: Vec<u8> = Vec::with_capacity(chunk_byte_len * 2);
|
||||||
|
|
||||||
|
// Traiter les chunks audio
|
||||||
|
loop {
|
||||||
|
// Vérifier stop_token
|
||||||
|
if stop_token.is_cancelled() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remplir le buffer
|
||||||
|
if pending.len() < chunk_byte_len {
|
||||||
|
let read = decoder.read(&mut read_buf).await
|
||||||
|
.map_err(|e| AudioError::ProcessingError(format!("Read error: {}", e)))?;
|
||||||
|
|
||||||
|
if read == 0 {
|
||||||
|
break; // EOF
|
||||||
|
}
|
||||||
|
pending.extend_from_slice(&read_buf[..read]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if pending.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extraire un chunk
|
||||||
|
let frames_in_pending = pending.len() / frame_bytes;
|
||||||
|
let frames_to_emit = frames_in_pending.min(chunk_frames);
|
||||||
|
let take_bytes = frames_to_emit * frame_bytes;
|
||||||
|
let pcm_data = pending.drain(..take_bytes).collect::<Vec<u8>>();
|
||||||
|
|
||||||
|
// Calculer le nombre de frames (samples par canal)
|
||||||
|
let bytes_per_sample = (bits_per_sample / 8) as usize;
|
||||||
|
let chunk_len = (pcm_data.len() / (bytes_per_sample * 2)) as u64; // 2 = stereo
|
||||||
|
|
||||||
|
// Vérifier si on doit insérer un TrackBoundary avant ce chunk
|
||||||
|
if let Some((_idx, song)) = next_song {
|
||||||
|
let elapsed_ms = (total_samples * 1000) / sample_rate as u64;
|
||||||
|
|
||||||
|
if elapsed_ms >= song.elapsed {
|
||||||
|
// Envoyer TrackBoundary AVANT le chunk (avec le même order)
|
||||||
|
let metadata = song_to_metadata(song, block);
|
||||||
|
let timestamp_sec = total_samples as f64 / sample_rate as f64;
|
||||||
|
let track_boundary = AudioSegment::new_track_boundary(
|
||||||
|
*order,
|
||||||
|
timestamp_sec,
|
||||||
|
metadata,
|
||||||
|
);
|
||||||
|
self.send_to_children(output, track_boundary).await?;
|
||||||
|
|
||||||
|
// Passer à la song suivante
|
||||||
|
song_index += 1;
|
||||||
|
next_song = songs.get(song_index).copied();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Envoyer le chunk audio
|
||||||
|
let timestamp_sec = total_samples as f64 / sample_rate as f64;
|
||||||
|
let audio_segment = pcm_to_audio_segment(
|
||||||
|
&pcm_data,
|
||||||
|
*order,
|
||||||
|
timestamp_sec,
|
||||||
|
sample_rate,
|
||||||
|
bits_per_sample,
|
||||||
|
)?;
|
||||||
|
self.send_to_children(output, audio_segment).await?;
|
||||||
|
|
||||||
|
*order += 1;
|
||||||
|
total_samples += chunk_len;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Envoie un segment à tous les enfants
|
||||||
|
async fn send_to_children(
|
||||||
|
&self,
|
||||||
|
output: &[mpsc::Sender<Arc<AudioSegment>>],
|
||||||
|
segment: Arc<AudioSegment>,
|
||||||
|
) -> Result<(), AudioError> {
|
||||||
|
for tx in output {
|
||||||
|
tx.send(segment.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|_| AudioError::ChildDied)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convertit PCM bytes en AudioSegment
|
||||||
|
fn pcm_to_audio_segment(
|
||||||
|
pcm_data: &[u8],
|
||||||
|
order: u64,
|
||||||
|
timestamp_sec: f64,
|
||||||
|
sample_rate: u32,
|
||||||
|
bits_per_sample: u8,
|
||||||
|
) -> Result<Arc<AudioSegment>, AudioError> {
|
||||||
|
use pmoaudio::{AudioChunk, AudioChunkData, _AudioSegment};
|
||||||
|
|
||||||
|
let bytes_per_sample = (bits_per_sample / 8) as usize;
|
||||||
|
let channels = 2; // Stereo
|
||||||
|
let frame_bytes = bytes_per_sample * channels;
|
||||||
|
let frames = pcm_data.len() / frame_bytes;
|
||||||
|
|
||||||
|
// Valider que la taille des données est correcte
|
||||||
|
if pcm_data.len() % frame_bytes != 0 {
|
||||||
|
return Err(AudioError::ProcessingError(format!(
|
||||||
|
"Invalid PCM data size: {} bytes is not a multiple of frame size {} ({}bit, {} channels)",
|
||||||
|
pcm_data.len(),
|
||||||
|
frame_bytes,
|
||||||
|
bits_per_sample,
|
||||||
|
channels
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let chunk = match bits_per_sample {
|
||||||
|
16 => {
|
||||||
|
// Type I16
|
||||||
|
let mut stereo = Vec::with_capacity(frames);
|
||||||
|
for frame_idx in 0..frames {
|
||||||
|
let base = frame_idx * frame_bytes;
|
||||||
|
let left = i16::from_le_bytes([pcm_data[base], pcm_data[base + 1]]);
|
||||||
|
let right = i16::from_le_bytes([pcm_data[base + 2], pcm_data[base + 3]]);
|
||||||
|
stereo.push([left, right]);
|
||||||
|
}
|
||||||
|
let chunk_data = AudioChunkData::new(stereo, sample_rate, 0.0);
|
||||||
|
AudioChunk::I16(chunk_data)
|
||||||
|
}
|
||||||
|
24 => {
|
||||||
|
// Type I24 avec sign extension correcte
|
||||||
|
let mut stereo = Vec::with_capacity(frames);
|
||||||
|
for frame_idx in 0..frames {
|
||||||
|
let base = frame_idx * frame_bytes;
|
||||||
|
|
||||||
|
// Left channel (bytes 0,1,2) avec sign extension
|
||||||
|
let left_i32 = {
|
||||||
|
let mut buf = [0u8; 4];
|
||||||
|
buf[..3].copy_from_slice(&pcm_data[base..base + 3]);
|
||||||
|
// Sign extend si négatif
|
||||||
|
if pcm_data[base + 2] & 0x80 != 0 {
|
||||||
|
buf[3] = 0xFF;
|
||||||
|
}
|
||||||
|
i32::from_le_bytes(buf)
|
||||||
|
};
|
||||||
|
let left = I24::new(left_i32).ok_or_else(|| {
|
||||||
|
AudioError::ProcessingError(format!("Invalid I24 value: {}", left_i32))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Right channel (bytes 3,4,5) avec sign extension
|
||||||
|
let right_i32 = {
|
||||||
|
let mut buf = [0u8; 4];
|
||||||
|
buf[..3].copy_from_slice(&pcm_data[base + 3..base + 6]);
|
||||||
|
// Sign extend si négatif
|
||||||
|
if pcm_data[base + 5] & 0x80 != 0 {
|
||||||
|
buf[3] = 0xFF;
|
||||||
|
}
|
||||||
|
i32::from_le_bytes(buf)
|
||||||
|
};
|
||||||
|
let right = I24::new(right_i32).ok_or_else(|| {
|
||||||
|
AudioError::ProcessingError(format!("Invalid I24 value: {}", right_i32))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
stereo.push([left, right]);
|
||||||
|
}
|
||||||
|
let chunk_data = AudioChunkData::new(stereo, sample_rate, 0.0);
|
||||||
|
AudioChunk::I24(chunk_data)
|
||||||
|
}
|
||||||
|
32 => {
|
||||||
|
// Type I32
|
||||||
|
let mut stereo = Vec::with_capacity(frames);
|
||||||
|
for frame_idx in 0..frames {
|
||||||
|
let base = frame_idx * frame_bytes;
|
||||||
|
let left = i32::from_le_bytes([
|
||||||
|
pcm_data[base],
|
||||||
|
pcm_data[base + 1],
|
||||||
|
pcm_data[base + 2],
|
||||||
|
pcm_data[base + 3],
|
||||||
|
]);
|
||||||
|
let right = i32::from_le_bytes([
|
||||||
|
pcm_data[base + 4],
|
||||||
|
pcm_data[base + 5],
|
||||||
|
pcm_data[base + 6],
|
||||||
|
pcm_data[base + 7],
|
||||||
|
]);
|
||||||
|
stereo.push([left, right]);
|
||||||
|
}
|
||||||
|
let chunk_data = AudioChunkData::new(stereo, sample_rate, 0.0);
|
||||||
|
AudioChunk::I32(chunk_data)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(AudioError::ProcessingError(format!(
|
||||||
|
"Unsupported bit depth: {}",
|
||||||
|
bits_per_sample
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Arc::new(AudioSegment {
|
||||||
|
order,
|
||||||
|
timestamp_sec,
|
||||||
|
segment: _AudioSegment::Chunk(Arc::new(chunk)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convertit Song en TrackMetadata
|
||||||
|
///
|
||||||
|
/// Cette fonction est synchrone, donc on wrap la metadata dans Arc<RwLock<>>
|
||||||
|
/// et on spawn une tâche async pour la configurer
|
||||||
|
fn song_to_metadata(song: &Song, block: &Block) -> Arc<RwLock<dyn TrackMetadata>> {
|
||||||
|
let metadata = MemoryTrackMetadata::new();
|
||||||
|
let metadata_arc = Arc::new(RwLock::new(metadata)) as Arc<RwLock<dyn TrackMetadata>>;
|
||||||
|
let metadata_clone = metadata_arc.clone();
|
||||||
|
|
||||||
|
// Clone des données pour la task async
|
||||||
|
let title = song.title.clone();
|
||||||
|
let artist = song.artist.clone();
|
||||||
|
let album = song.album.clone();
|
||||||
|
let year = song.year;
|
||||||
|
let cover_url = song.cover.as_ref().and_then(|cover| block.cover_url(cover));
|
||||||
|
|
||||||
|
// Configurer les métadonnées de manière asynchrone
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut meta = metadata_clone.write().await;
|
||||||
|
|
||||||
|
// Ces méthodes peuvent échouer (retournent Result), donc on propage avec ?
|
||||||
|
if let Err(e) = meta.set_title(Some(title)).await {
|
||||||
|
eprintln!("Warning: Failed to set title: {}", e);
|
||||||
|
}
|
||||||
|
if let Err(e) = meta.set_artist(Some(artist)).await {
|
||||||
|
eprintln!("Warning: Failed to set artist: {}", e);
|
||||||
|
}
|
||||||
|
if let Some(album) = album {
|
||||||
|
if let Err(e) = meta.set_album(Some(album)).await {
|
||||||
|
eprintln!("Warning: Failed to set album: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(year) = year {
|
||||||
|
if let Err(e) = meta.set_year(Some(year)).await {
|
||||||
|
eprintln!("Warning: Failed to set year: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(cover_url) = cover_url {
|
||||||
|
if let Err(e) = meta.set_cover_url(Some(cover_url)).await {
|
||||||
|
eprintln!("Warning: Failed to set cover_url: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
metadata_arc
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl NodeLogic for RadioParadiseStreamSourceLogic {
|
||||||
|
async fn process(
|
||||||
|
&mut self,
|
||||||
|
_input: Option<mpsc::Receiver<Arc<AudioSegment>>>,
|
||||||
|
output: Vec<mpsc::Sender<Arc<AudioSegment>>>,
|
||||||
|
stop_token: CancellationToken,
|
||||||
|
) -> Result<(), AudioError> {
|
||||||
|
let mut order = 0u64;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Attendre un block ID (timeout court pour une radio)
|
||||||
|
let event_id = match tokio::time::timeout(
|
||||||
|
Duration::from_secs(BLOCK_ID_TIMEOUT_SECS),
|
||||||
|
async {
|
||||||
|
while self.block_queue.is_empty() {
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
|
||||||
|
if stop_token.is_cancelled() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.block_queue.pop_front()
|
||||||
|
}
|
||||||
|
).await {
|
||||||
|
Ok(Some(id)) => id,
|
||||||
|
Ok(None) => break, // Cancelled
|
||||||
|
Err(_) => {
|
||||||
|
// Timeout - pas de nouveau bloc, on termine
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Vérifier si déjà téléchargé récemment
|
||||||
|
if self.is_recent_block(event_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Récupérer les métadonnées du bloc
|
||||||
|
let block = self.client
|
||||||
|
.get_block(Some(event_id))
|
||||||
|
.await
|
||||||
|
.map_err(|e| AudioError::ProcessingError(format!("Failed to get block: {}", e)))?;
|
||||||
|
|
||||||
|
// Marquer comme téléchargé
|
||||||
|
self.mark_block_downloaded(event_id);
|
||||||
|
|
||||||
|
// Télécharger et décoder le bloc
|
||||||
|
self.download_and_decode_block(&block, &output, &stop_token, &mut order)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Envoyer EndOfStream
|
||||||
|
let eos = AudioSegment::new_end_of_stream(order, 0.0);
|
||||||
|
for tx in &output {
|
||||||
|
tx.send(eos.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|_| AudioError::ChildDied)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// RadioParadiseStreamSource - Wrapper utilisant Node<RadioParadiseStreamSourceLogic>
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
pub struct RadioParadiseStreamSource {
|
||||||
|
inner: Node<RadioParadiseStreamSourceLogic>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RadioParadiseStreamSource {
|
||||||
|
/// Crée une nouvelle source Radio Paradise avec durée de chunk par défaut
|
||||||
|
pub fn new(client: RadioParadiseClient) -> Self {
|
||||||
|
Self::with_chunk_duration(client, DEFAULT_CHUNK_DURATION_MS as u32)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crée une nouvelle source avec durée de chunk personnalisée
|
||||||
|
pub fn with_chunk_duration(client: RadioParadiseClient, chunk_duration_ms: u32) -> Self {
|
||||||
|
let logic = RadioParadiseStreamSourceLogic::new(client, chunk_duration_ms);
|
||||||
|
Self {
|
||||||
|
inner: Node::new_source(logic),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajoute un block ID à la file d'attente de téléchargement
|
||||||
|
pub fn push_block_id(&mut self, event_id: EventId) {
|
||||||
|
self.inner.logic_mut().push_block_id(event_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl AudioPipelineNode for RadioParadiseStreamSource {
|
||||||
|
fn get_tx(&self) -> Option<mpsc::Sender<Arc<AudioSegment>>> {
|
||||||
|
self.inner.get_tx()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register(&mut self, child: Box<dyn AudioPipelineNode>) {
|
||||||
|
self.inner.register(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(self: Box<Self>, stop_token: CancellationToken) -> Result<(), AudioError> {
|
||||||
|
Box::new(self.inner).run(stop_token).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TypedAudioNode for RadioParadiseStreamSource {
|
||||||
|
fn input_type(&self) -> Option<TypeRequirement> {
|
||||||
|
None // Source node
|
||||||
|
}
|
||||||
|
|
||||||
|
fn output_type(&self) -> Option<TypeRequirement> {
|
||||||
|
// Radio Paradise FLAC peut être 16-bit, 24-bit, ou 32-bit
|
||||||
|
// La profondeur est détectée automatiquement depuis le header FLAC
|
||||||
|
Some(TypeRequirement::any_integer())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn create_test_client() -> RadioParadiseClient {
|
||||||
|
RadioParadiseClient::with_client(reqwest::Client::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cache_fifo_basic() {
|
||||||
|
let client = create_test_client();
|
||||||
|
let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
|
||||||
|
|
||||||
|
// Ajouter 5 blocs
|
||||||
|
for i in 1..=5 {
|
||||||
|
logic.mark_block_downloaded(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier que tous sont dans le cache
|
||||||
|
for i in 1..=5 {
|
||||||
|
assert!(logic.is_recent_block(i), "Block {} should be in cache", i);
|
||||||
|
}
|
||||||
|
assert_eq!(logic.recent_blocks.len(), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cache_fifo_exactly_10_elements() {
|
||||||
|
let client = create_test_client();
|
||||||
|
let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
|
||||||
|
|
||||||
|
// Ajouter exactement 10 blocs
|
||||||
|
for i in 1..=10 {
|
||||||
|
logic.mark_block_downloaded(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier qu'on a exactement 10 éléments
|
||||||
|
assert_eq!(logic.recent_blocks.len(), 10, "Cache should have exactly 10 elements");
|
||||||
|
|
||||||
|
// Tous devraient être dans le cache
|
||||||
|
for i in 1..=10 {
|
||||||
|
assert!(logic.is_recent_block(i), "Block {} should be in cache", i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cache_fifo_eviction_oldest() {
|
||||||
|
let client = create_test_client();
|
||||||
|
let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
|
||||||
|
|
||||||
|
// Remplir le cache avec 10 éléments (1..=10)
|
||||||
|
for i in 1..=10 {
|
||||||
|
logic.mark_block_downloaded(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajouter un 11ème élément
|
||||||
|
logic.mark_block_downloaded(11);
|
||||||
|
|
||||||
|
// Le cache doit toujours avoir 10 éléments
|
||||||
|
assert_eq!(logic.recent_blocks.len(), 10, "Cache should still have 10 elements");
|
||||||
|
|
||||||
|
// Le premier (plus ancien) doit avoir été évincé
|
||||||
|
assert!(!logic.is_recent_block(1), "Oldest block (1) should be evicted");
|
||||||
|
|
||||||
|
// Les éléments 2..=11 doivent être présents
|
||||||
|
for i in 2..=11 {
|
||||||
|
assert!(logic.is_recent_block(i), "Block {} should be in cache", i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cache_fifo_multiple_evictions() {
|
||||||
|
let client = create_test_client();
|
||||||
|
let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
|
||||||
|
|
||||||
|
// Remplir avec 10 éléments
|
||||||
|
for i in 1..=10 {
|
||||||
|
logic.mark_block_downloaded(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajouter 5 éléments supplémentaires
|
||||||
|
for i in 11..=15 {
|
||||||
|
logic.mark_block_downloaded(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toujours 10 éléments
|
||||||
|
assert_eq!(logic.recent_blocks.len(), 10, "Cache should have 10 elements");
|
||||||
|
|
||||||
|
// Les 5 premiers doivent avoir été évincés
|
||||||
|
for i in 1..=5 {
|
||||||
|
assert!(!logic.is_recent_block(i), "Block {} should be evicted", i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Les éléments 6..=15 doivent être présents
|
||||||
|
for i in 6..=15 {
|
||||||
|
assert!(logic.is_recent_block(i), "Block {} should be in cache", i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cache_never_exceeds_capacity() {
|
||||||
|
let client = create_test_client();
|
||||||
|
let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
|
||||||
|
|
||||||
|
// Vérifier la capacité pré-allouée
|
||||||
|
assert_eq!(logic.recent_blocks.capacity(), RECENT_BLOCKS_CACHE_SIZE);
|
||||||
|
|
||||||
|
// Ajouter beaucoup d'éléments
|
||||||
|
for i in 1..=100 {
|
||||||
|
logic.mark_block_downloaded(i);
|
||||||
|
|
||||||
|
// À chaque itération, vérifier qu'on ne dépasse jamais 10
|
||||||
|
assert!(
|
||||||
|
logic.recent_blocks.len() <= RECENT_BLOCKS_CACHE_SIZE,
|
||||||
|
"Cache size {} exceeded max {}",
|
||||||
|
logic.recent_blocks.len(),
|
||||||
|
RECENT_BLOCKS_CACHE_SIZE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finalement, on doit avoir exactement 10 éléments
|
||||||
|
assert_eq!(logic.recent_blocks.len(), 10);
|
||||||
|
|
||||||
|
// Ce doivent être les 10 derniers (91..=100)
|
||||||
|
for i in 91..=100 {
|
||||||
|
assert!(logic.is_recent_block(i), "Block {} should be in cache", i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cache_fifo_order_preserved() {
|
||||||
|
let client = create_test_client();
|
||||||
|
let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
|
||||||
|
|
||||||
|
// Ajouter 10 éléments
|
||||||
|
for i in 1..=10 {
|
||||||
|
logic.mark_block_downloaded(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier l'ordre dans la VecDeque (le front devrait être le plus ancien)
|
||||||
|
let front = logic.recent_blocks.front().copied();
|
||||||
|
assert_eq!(front, Some(1), "Front should be the oldest element");
|
||||||
|
|
||||||
|
let back = logic.recent_blocks.back().copied();
|
||||||
|
assert_eq!(back, Some(10), "Back should be the newest element");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_block_queue_push() {
|
||||||
|
let client = create_test_client();
|
||||||
|
let mut logic = RadioParadiseStreamSourceLogic::new(client, DEFAULT_CHUNK_DURATION_MS as u32);
|
||||||
|
|
||||||
|
// Tester push_block_id
|
||||||
|
logic.push_block_id(100);
|
||||||
|
logic.push_block_id(200);
|
||||||
|
logic.push_block_id(300);
|
||||||
|
|
||||||
|
assert_eq!(logic.block_queue.len(), 3);
|
||||||
|
assert_eq!(logic.block_queue.front(), Some(&100));
|
||||||
|
assert_eq!(logic.block_queue.back(), Some(&300));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,395 +1,114 @@
|
|||||||
//! Music source implementation for Radio Paradise built on the new
|
//! DEPRECATED: Stub implementation of RadioParadiseSource
|
||||||
//! `paradise` orchestration layer.
|
|
||||||
//!
|
//!
|
||||||
//! The source exposes a DIDL-Lite hierarchy compatible with UPnP
|
//! **⚠️ This module is deprecated and will be removed in a future version.**
|
||||||
//! ContentDirectory while delegating block ingestion, caching and
|
//!
|
||||||
//! multi-client streaming to [`ParadiseChannel`].
|
//! The orchestration-based RadioParadiseSource has been replaced by
|
||||||
|
//! `RadioParadiseStreamSource`, which integrates directly with the pmoaudio
|
||||||
|
//! pipeline for streaming and decoding.
|
||||||
|
//!
|
||||||
|
//! ## Migration Guide
|
||||||
|
//!
|
||||||
|
//! **Old approach** (deprecated):
|
||||||
|
//! ```rust,ignore
|
||||||
|
//! use pmoparadise::RadioParadiseSource;
|
||||||
|
//! let source = RadioParadiseSource::from_registry(client)?;
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! **New approach** (recommended):
|
||||||
|
//! ```rust,ignore
|
||||||
|
//! use pmoparadise::RadioParadiseStreamSource;
|
||||||
|
//! use pmoaudio::pipeline::Node;
|
||||||
|
//!
|
||||||
|
//! let stream_source = RadioParadiseStreamSource::new(client, None).await?;
|
||||||
|
//! let node = Node::from_logic(stream_source);
|
||||||
|
//! // Use node in pmoaudio pipeline
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! This stub implementation is provided only for backward compatibility with
|
||||||
|
//! existing code (e.g., pmomediaserver) until it can be updated to use
|
||||||
|
//! RadioParadiseStreamSource.
|
||||||
|
|
||||||
use crate::client::RadioParadiseClient;
|
use crate::client::RadioParadiseClient;
|
||||||
use crate::paradise::{
|
use pmosource::pmodidl::{Container, Item};
|
||||||
create_history_backend, ChannelDescriptor, ParadiseChannel, PlaylistEntry, ALL_CHANNELS,
|
use pmosource::{async_trait, BrowseResult, MusicSource, MusicSourceError, Result};
|
||||||
};
|
|
||||||
|
|
||||||
#[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;
|
|
||||||
use pmodidl::{Container, Item, Resource};
|
|
||||||
use pmosource::pmodidl;
|
|
||||||
use pmosource::{
|
|
||||||
async_trait, BrowseResult, CacheStatus, MusicSource, MusicSourceError, Result,
|
|
||||||
SourceCacheManager, SourceStatistics,
|
|
||||||
};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
use tracing::warn;
|
|
||||||
|
|
||||||
/// Default image for Radio Paradise (300x300 WebP, embedded in binary)
|
/// Default Radio Paradise image (embedded in binary)
|
||||||
const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp");
|
const DEFAULT_IMAGE: &[u8] = include_bytes!("../assets/default.webp");
|
||||||
|
|
||||||
fn channel_collection_id(channel_id: u8) -> String {
|
/// DEPRECATED: Stub implementation of RadioParadiseSource
|
||||||
format!("radio-paradise:{}", channel_id)
|
///
|
||||||
}
|
/// This is a minimal stub that implements the MusicSource trait with no-op
|
||||||
|
/// implementations. It exists only to maintain API compatibility during the
|
||||||
fn channel_container_id(channel_id: u8) -> String {
|
/// migration to RadioParadiseStreamSource.
|
||||||
format!("radio-paradise:channel:{}", channel_id)
|
///
|
||||||
}
|
/// **Do not use this in new code.** Use `RadioParadiseStreamSource` instead.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
fn parse_channel_container_id(object_id: &str) -> Option<u8> {
|
|
||||||
let mut parts = object_id.split(':');
|
|
||||||
match (parts.next(), parts.next(), parts.next(), parts.next()) {
|
|
||||||
(Some("radio-paradise"), Some("channel"), Some(id_str), None) => id_str.parse().ok(),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_track_channel(track_id: &str) -> Option<u8> {
|
|
||||||
let mut parts = track_id.split(':');
|
|
||||||
match (parts.next(), parts.next(), parts.next(), parts.next()) {
|
|
||||||
(Some("rp"), Some(channel_str), Some(_rest), None) => channel_str.parse().ok(),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_duration(duration_seconds: u64) -> String {
|
|
||||||
let hours = duration_seconds / 3600;
|
|
||||||
let minutes = (duration_seconds % 3600) / 60;
|
|
||||||
let seconds = duration_seconds % 60;
|
|
||||||
format!("{hours}:{minutes:02}:{seconds:02}")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct RadioParadiseSource {
|
pub struct RadioParadiseSource {
|
||||||
inner: Arc<RadioParadiseSourceInner>,
|
_client: RadioParadiseClient,
|
||||||
}
|
|
||||||
|
|
||||||
struct RadioParadiseSourceInner {
|
|
||||||
channels: HashMap<u8, Arc<ParadiseChannel>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Debug for RadioParadiseSource {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
f.debug_struct("RadioParadiseSource").finish()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RadioParadiseSource {
|
impl RadioParadiseSource {
|
||||||
|
/// DEPRECATED: Create a new RadioParadiseSource from registry
|
||||||
|
///
|
||||||
|
/// This method is deprecated and will always return an error indicating
|
||||||
|
/// that the orchestration-based source is no longer supported.
|
||||||
|
///
|
||||||
|
/// Use `RadioParadiseStreamSource` instead for audio streaming.
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub fn from_registry(client: RadioParadiseClient) -> Result<Self> {
|
pub fn from_registry(_client: RadioParadiseClient) -> Result<Self> {
|
||||||
// Load history configuration from pmoconfig using the config extension trait
|
Err(MusicSourceError::SourceUnavailable(
|
||||||
#[cfg(feature = "pmoconfig")]
|
"RadioParadiseSource is deprecated. Use RadioParadiseStreamSource instead."
|
||||||
let (database_path, history_max_tracks) = {
|
.to_string(),
|
||||||
use crate::config_ext::RadioParadiseConfigExt;
|
))
|
||||||
let cfg = pmoconfig::get_config();
|
|
||||||
let database_path = cfg.get_paradise_history_database().map_err(|e| {
|
|
||||||
MusicSourceError::SourceUnavailable(format!(
|
|
||||||
"Failed to get history database path: {}",
|
|
||||||
e
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
let max_tracks = cfg.get_paradise_history_size().map_err(|e| {
|
|
||||||
MusicSourceError::SourceUnavailable(format!("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 = create_history_backend(&database_path).map_err(|e| {
|
|
||||||
MusicSourceError::SourceUnavailable(format!(
|
|
||||||
"Failed to initialize history backend: {}",
|
|
||||||
e
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
let mut channels = HashMap::new();
|
|
||||||
|
|
||||||
for descriptor in ALL_CHANNELS.iter() {
|
|
||||||
let cache_manager = Arc::new(SourceCacheManager::from_registry(
|
|
||||||
channel_collection_id(descriptor.id),
|
|
||||||
)?);
|
|
||||||
let channel = Arc::new(
|
|
||||||
ParadiseChannel::new(
|
|
||||||
*descriptor,
|
|
||||||
client.clone(),
|
|
||||||
history_max_tracks,
|
|
||||||
history_backend.clone(),
|
|
||||||
cache_manager,
|
|
||||||
)
|
|
||||||
.map_err(|e| {
|
|
||||||
MusicSourceError::SourceUnavailable(format!(
|
|
||||||
"Failed to initialize channel {}: {e}",
|
|
||||||
descriptor.slug
|
|
||||||
))
|
|
||||||
})?,
|
|
||||||
);
|
|
||||||
channels.insert(descriptor.id, channel);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
inner: Arc::new(RadioParadiseSourceInner { channels }),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// DEPRECATED: Create a new RadioParadiseSource from registry with defaults
|
||||||
|
///
|
||||||
|
/// This method creates a stub instance that will log deprecation warnings
|
||||||
|
/// but allows existing code to compile.
|
||||||
|
///
|
||||||
|
/// Use `RadioParadiseStreamSource` instead for audio streaming.
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub fn from_registry_default(client: RadioParadiseClient) -> Result<Self> {
|
pub fn from_registry_default(client: RadioParadiseClient) -> Self {
|
||||||
Self::from_registry(client)
|
tracing::warn!(
|
||||||
|
"RadioParadiseSource::from_registry_default is deprecated. \
|
||||||
|
Use RadioParadiseStreamSource for audio streaming."
|
||||||
|
);
|
||||||
|
Self { _client: client }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn new(
|
/// DEPRECATED: Create a new RadioParadiseSource with default settings
|
||||||
client: RadioParadiseClient,
|
///
|
||||||
cover_cache: Arc<CoverCache>,
|
/// This method is deprecated and only exists for API compatibility.
|
||||||
audio_cache: Arc<AudioCache>,
|
pub fn new_default(client: RadioParadiseClient) -> Self {
|
||||||
) -> Self {
|
tracing::warn!(
|
||||||
// Load history configuration from pmoconfig using the config extension trait
|
"RadioParadiseSource::new_default is deprecated. \
|
||||||
#[cfg(feature = "pmoconfig")]
|
Use RadioParadiseStreamSource for audio streaming."
|
||||||
let (database_path, history_max_tracks) = {
|
);
|
||||||
use crate::config_ext::RadioParadiseConfigExt;
|
Self { _client: client }
|
||||||
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> =
|
|
||||||
create_history_backend(&database_path).unwrap_or_else(|err| {
|
|
||||||
panic!("Failed to initialize history backend: {err}");
|
|
||||||
});
|
|
||||||
let mut channels = HashMap::new();
|
|
||||||
|
|
||||||
for descriptor in ALL_CHANNELS.iter() {
|
|
||||||
let cache_manager = Arc::new(SourceCacheManager::new(
|
|
||||||
channel_collection_id(descriptor.id),
|
|
||||||
Arc::clone(&cover_cache),
|
|
||||||
Arc::clone(&audio_cache),
|
|
||||||
));
|
|
||||||
match ParadiseChannel::new(
|
|
||||||
*descriptor,
|
|
||||||
client.clone(),
|
|
||||||
history_max_tracks,
|
|
||||||
history_backend.clone(),
|
|
||||||
cache_manager,
|
|
||||||
) {
|
|
||||||
Ok(channel) => {
|
|
||||||
channels.insert(descriptor.id, Arc::new(channel));
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
warn!(
|
|
||||||
channel = descriptor.slug,
|
|
||||||
"Failed to initialize channel: {err:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Self {
|
|
||||||
inner: Arc::new(RadioParadiseSourceInner { channels }),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn new_default(
|
/// DEPRECATED: Create a new RadioParadiseSource with cache
|
||||||
client: RadioParadiseClient,
|
///
|
||||||
cover_cache: Arc<CoverCache>,
|
/// This method is deprecated and only exists for API compatibility.
|
||||||
audio_cache: Arc<AudioCache>,
|
pub fn new_with_cache(client: RadioParadiseClient, _cache_size: usize) -> Self {
|
||||||
) -> Self {
|
tracing::warn!(
|
||||||
Self::new(client, cover_cache, audio_cache)
|
"RadioParadiseSource::new_with_cache is deprecated. \
|
||||||
}
|
Use RadioParadiseStreamSource for audio streaming."
|
||||||
|
);
|
||||||
pub fn client_for_channel(&self, channel: u8) -> Option<RadioParadiseClient> {
|
Self { _client: client }
|
||||||
self.inner
|
|
||||||
.channels
|
|
||||||
.get(&channel)
|
|
||||||
.map(|ch| ch.client().clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn channel(&self, id: u8) -> Option<Arc<ParadiseChannel>> {
|
|
||||||
self.inner.channels.get(&id).cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_root_container(&self) -> Container {
|
|
||||||
Container {
|
|
||||||
id: "radio-paradise".to_string(),
|
|
||||||
parent_id: "0".to_string(),
|
|
||||||
restricted: Some("1".to_string()),
|
|
||||||
child_count: Some(ALL_CHANNELS.len().to_string()),
|
|
||||||
searchable: Some("1".to_string()),
|
|
||||||
title: "Radio Paradise".to_string(),
|
|
||||||
class: "object.container".to_string(),
|
|
||||||
containers: vec![],
|
|
||||||
items: vec![],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn build_channel_containers(&self) -> Vec<Container> {
|
|
||||||
let mut containers = Vec::new();
|
|
||||||
for descriptor in ALL_CHANNELS.iter() {
|
|
||||||
if let Some(channel) = self.channel(descriptor.id) {
|
|
||||||
let len = channel.playlist().active_len().await;
|
|
||||||
containers.push(Container {
|
|
||||||
id: channel_container_id(descriptor.id),
|
|
||||||
parent_id: "radio-paradise".to_string(),
|
|
||||||
restricted: Some("1".to_string()),
|
|
||||||
child_count: Some(len.to_string()),
|
|
||||||
searchable: Some("1".to_string()),
|
|
||||||
title: descriptor.display_name.to_string(),
|
|
||||||
class: "object.container.playlistContainer".to_string(),
|
|
||||||
containers: vec![],
|
|
||||||
items: vec![],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
containers
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn channel_items(
|
|
||||||
&self,
|
|
||||||
descriptor: ChannelDescriptor,
|
|
||||||
offset: usize,
|
|
||||||
limit: Option<usize>,
|
|
||||||
) -> Result<Vec<Item>> {
|
|
||||||
let channel = self
|
|
||||||
.channel(descriptor.id)
|
|
||||||
.ok_or_else(|| MusicSourceError::ObjectNotFound(descriptor.slug.to_string()))?;
|
|
||||||
|
|
||||||
channel
|
|
||||||
.ensure_started()
|
|
||||||
.await
|
|
||||||
.map_err(|e| MusicSourceError::SourceUnavailable(e.to_string()))?;
|
|
||||||
|
|
||||||
let entries = channel.playlist().active_snapshot().await;
|
|
||||||
if entries.is_empty() || offset >= entries.len() {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
let end = limit
|
|
||||||
.map(|count| offset + count)
|
|
||||||
.unwrap_or(entries.len())
|
|
||||||
.min(entries.len());
|
|
||||||
|
|
||||||
let parent_id = channel_container_id(descriptor.id);
|
|
||||||
|
|
||||||
let mut items = Vec::with_capacity(end - offset);
|
|
||||||
for entry in entries.into_iter().skip(offset).take(end - offset) {
|
|
||||||
match self.entry_to_item(channel.clone(), &parent_id, entry).await {
|
|
||||||
Ok(item) => items.push(item),
|
|
||||||
Err(err) => warn!(
|
|
||||||
channel = descriptor.slug,
|
|
||||||
"Failed to build DIDL item: {err:?}"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(items)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn entry_to_item(
|
|
||||||
&self,
|
|
||||||
channel: Arc<ParadiseChannel>,
|
|
||||||
parent_id: &str,
|
|
||||||
entry: Arc<PlaylistEntry>,
|
|
||||||
) -> AnyhowResult<Item> {
|
|
||||||
let cache_manager = channel.cache_manager();
|
|
||||||
let metadata = cache_manager.get_metadata(&entry.track_id).await;
|
|
||||||
|
|
||||||
let resource_url = cache_manager
|
|
||||||
.resolve_uri(&entry.track_id)
|
|
||||||
.await
|
|
||||||
.or_else(|_| {
|
|
||||||
metadata
|
|
||||||
.as_ref()
|
|
||||||
.map(|meta| meta.original_uri.clone())
|
|
||||||
.ok_or_else(|| MusicSourceError::ObjectNotFound(entry.track_id.clone()))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let mut album_art = metadata
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|meta| meta.cached_cover_pk.as_ref())
|
|
||||||
.and_then(|pk| cache_manager.cover_url(pk, None).ok());
|
|
||||||
|
|
||||||
if album_art.is_none() {
|
|
||||||
album_art = entry.song.cover.clone();
|
|
||||||
}
|
|
||||||
|
|
||||||
let duration_seconds = entry.duration_ms / 1000;
|
|
||||||
let duration_str = if duration_seconds > 0 {
|
|
||||||
Some(format_duration(duration_seconds as u64))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let resource = Resource {
|
|
||||||
protocol_info: "http-get:*:audio/flac:*".to_string(),
|
|
||||||
bits_per_sample: None,
|
|
||||||
sample_frequency: None,
|
|
||||||
nr_audio_channels: None,
|
|
||||||
duration: duration_str.clone(),
|
|
||||||
url: resource_url,
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Item {
|
|
||||||
id: entry.track_id.clone(),
|
|
||||||
parent_id: parent_id.to_string(),
|
|
||||||
restricted: Some("1".to_string()),
|
|
||||||
title: entry.song.title.clone(),
|
|
||||||
creator: Some(entry.song.artist.clone()),
|
|
||||||
class: "object.item.audioItem.musicTrack".to_string(),
|
|
||||||
artist: Some(entry.song.artist.clone()),
|
|
||||||
album: entry.song.album.clone(),
|
|
||||||
genre: None,
|
|
||||||
album_art,
|
|
||||||
album_art_pk: None,
|
|
||||||
date: None,
|
|
||||||
original_track_number: None,
|
|
||||||
resources: vec![resource],
|
|
||||||
descriptions: vec![],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn channels_iter(&self) -> impl Iterator<Item = (&u8, &Arc<ParadiseChannel>)> {
|
|
||||||
self.inner.channels.iter()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl MusicSource for RadioParadiseSource {
|
impl MusicSource for RadioParadiseSource {
|
||||||
fn name(&self) -> &str {
|
fn name(&self) -> &str {
|
||||||
"Radio Paradise"
|
"Radio Paradise (DEPRECATED)"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn id(&self) -> &str {
|
fn id(&self) -> &str {
|
||||||
"radio-paradise"
|
"radio-paradise-deprecated"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_image(&self) -> &[u8] {
|
fn default_image(&self) -> &[u8] {
|
||||||
@@ -397,43 +116,32 @@ impl MusicSource for RadioParadiseSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn root_container(&self) -> Result<Container> {
|
async fn root_container(&self) -> Result<Container> {
|
||||||
Ok(self.build_root_container())
|
Ok(Container {
|
||||||
|
id: "radio-paradise-deprecated".to_string(),
|
||||||
|
parent_id: "0".to_string(),
|
||||||
|
restricted: Some("1".to_string()),
|
||||||
|
child_count: Some("0".to_string()),
|
||||||
|
searchable: Some("0".to_string()),
|
||||||
|
title: "Radio Paradise (DEPRECATED)".to_string(),
|
||||||
|
class: "object.container".to_string(),
|
||||||
|
containers: vec![],
|
||||||
|
items: vec![],
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn browse(&self, object_id: &str) -> Result<BrowseResult> {
|
async fn browse(&self, _object_id: &str) -> Result<BrowseResult> {
|
||||||
match object_id {
|
tracing::warn!("RadioParadiseSource::browse called but source is deprecated");
|
||||||
"0" => Ok(BrowseResult::Containers(vec![self.build_root_container()])),
|
Ok(BrowseResult::Mixed {
|
||||||
"radio-paradise" => {
|
containers: vec![],
|
||||||
let containers = self.build_channel_containers().await;
|
items: vec![],
|
||||||
Ok(BrowseResult::Containers(containers))
|
})
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
if let Some(channel_id) = parse_channel_container_id(object_id) {
|
|
||||||
let descriptor = ALL_CHANNELS
|
|
||||||
.iter()
|
|
||||||
.find(|desc| desc.id == channel_id)
|
|
||||||
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?;
|
|
||||||
|
|
||||||
let items = self.channel_items(*descriptor, 0, None).await?;
|
|
||||||
Ok(BrowseResult::Items(items))
|
|
||||||
} else {
|
|
||||||
Err(MusicSourceError::ObjectNotFound(object_id.to_string()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn resolve_uri(&self, object_id: &str) -> Result<String> {
|
async fn resolve_uri(&self, _object_id: &str) -> Result<String> {
|
||||||
let channel_id = parse_track_channel(object_id)
|
Err(MusicSourceError::SourceUnavailable(
|
||||||
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?;
|
"RadioParadiseSource is deprecated. Use RadioParadiseStreamSource instead."
|
||||||
let channel = self
|
.to_string(),
|
||||||
.channel(channel_id)
|
))
|
||||||
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?;
|
|
||||||
channel
|
|
||||||
.cache_manager()
|
|
||||||
.resolve_uri(object_id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| MusicSourceError::CacheError(e.to_string()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn supports_fifo(&self) -> bool {
|
fn supports_fifo(&self) -> bool {
|
||||||
@@ -441,171 +149,25 @@ impl MusicSource for RadioParadiseSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn append_track(&self, _track: Item) -> Result<()> {
|
async fn append_track(&self, _track: Item) -> Result<()> {
|
||||||
Err(MusicSourceError::FifoNotSupported)
|
Err(MusicSourceError::SourceUnavailable(
|
||||||
|
"RadioParadiseSource is deprecated and does not support FIFO operations."
|
||||||
|
.to_string(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn remove_oldest(&self) -> Result<Option<Item>> {
|
async fn remove_oldest(&self) -> Result<Option<Item>> {
|
||||||
Err(MusicSourceError::FifoNotSupported)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_id(&self) -> u32 {
|
async fn update_id(&self) -> u32 {
|
||||||
self.channels_iter()
|
0
|
||||||
.map(|(_, channel)| channel.playlist().update_id())
|
|
||||||
.max()
|
|
||||||
.unwrap_or(0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn last_change(&self) -> Option<SystemTime> {
|
async fn last_change(&self) -> Option<SystemTime> {
|
||||||
let mut latest: Option<SystemTime> = None;
|
None
|
||||||
for (_, channel) in self.channels_iter() {
|
|
||||||
if let Some(change) = channel.playlist().last_change().await {
|
|
||||||
latest = Some(match latest {
|
|
||||||
Some(current) if change <= current => current,
|
|
||||||
_ => change,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
latest
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_items(&self, offset: usize, count: usize) -> Result<Vec<Item>> {
|
async fn get_items(&self, _offset: usize, _count: usize) -> Result<Vec<Item>> {
|
||||||
let mut all = Vec::new();
|
Ok(vec![])
|
||||||
for descriptor in ALL_CHANNELS.iter() {
|
|
||||||
let mut items = self.channel_items(*descriptor, 0, None).await?;
|
|
||||||
all.append(&mut items);
|
|
||||||
}
|
|
||||||
|
|
||||||
if offset >= all.len() {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
let end = if count == 0 {
|
|
||||||
all.len()
|
|
||||||
} else {
|
|
||||||
(offset + count).min(all.len())
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(all.into_iter().skip(offset).take(end - offset).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_available_formats(&self, _object_id: &str) -> Result<Vec<pmosource::AudioFormat>> {
|
|
||||||
Ok(vec![pmosource::AudioFormat {
|
|
||||||
format_id: "flac".to_string(),
|
|
||||||
mime_type: "audio/flac".to_string(),
|
|
||||||
sample_rate: Some(44100),
|
|
||||||
bit_depth: Some(16),
|
|
||||||
bitrate: None,
|
|
||||||
channels: Some(2),
|
|
||||||
}])
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_cache_status(&self, object_id: &str) -> Result<CacheStatus> {
|
|
||||||
let channel_id = parse_track_channel(object_id)
|
|
||||||
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?;
|
|
||||||
let channel = self
|
|
||||||
.channel(channel_id)
|
|
||||||
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?;
|
|
||||||
channel
|
|
||||||
.cache_manager()
|
|
||||||
.get_cache_status(object_id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| MusicSourceError::CacheError(e.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cache_item(&self, object_id: &str) -> Result<CacheStatus> {
|
|
||||||
let channel_id = parse_track_channel(object_id)
|
|
||||||
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?;
|
|
||||||
let channel = self
|
|
||||||
.channel(channel_id)
|
|
||||||
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?;
|
|
||||||
channel
|
|
||||||
.cache_manager()
|
|
||||||
.get_cache_status(object_id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| MusicSourceError::CacheError(e.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn browse_paginated(
|
|
||||||
&self,
|
|
||||||
object_id: &str,
|
|
||||||
offset: usize,
|
|
||||||
limit: usize,
|
|
||||||
) -> Result<BrowseResult> {
|
|
||||||
match object_id {
|
|
||||||
"0" => {
|
|
||||||
if offset == 0 {
|
|
||||||
Ok(BrowseResult::Containers(vec![self.build_root_container()]))
|
|
||||||
} else {
|
|
||||||
Ok(BrowseResult::Containers(Vec::new()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"radio-paradise" => {
|
|
||||||
let containers = self.build_channel_containers().await;
|
|
||||||
let total = containers.len();
|
|
||||||
if offset >= total {
|
|
||||||
return Ok(BrowseResult::Containers(Vec::new()));
|
|
||||||
}
|
|
||||||
let end = if limit == 0 {
|
|
||||||
total
|
|
||||||
} else {
|
|
||||||
(offset + limit).min(total)
|
|
||||||
};
|
|
||||||
Ok(BrowseResult::Containers(
|
|
||||||
containers
|
|
||||||
.into_iter()
|
|
||||||
.skip(offset)
|
|
||||||
.take(end - offset)
|
|
||||||
.collect(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
if let Some(channel_id) = parse_channel_container_id(object_id) {
|
|
||||||
let descriptor = ALL_CHANNELS
|
|
||||||
.iter()
|
|
||||||
.find(|desc| desc.id == channel_id)
|
|
||||||
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?;
|
|
||||||
|
|
||||||
let items = self.channel_items(*descriptor, offset, Some(limit)).await?;
|
|
||||||
Ok(BrowseResult::Items(items))
|
|
||||||
} else {
|
|
||||||
Err(MusicSourceError::ObjectNotFound(object_id.to_string()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_item_count(&self, object_id: &str) -> Result<usize> {
|
|
||||||
match object_id {
|
|
||||||
"0" => Ok(1),
|
|
||||||
"radio-paradise" => Ok(ALL_CHANNELS.len()),
|
|
||||||
_ => {
|
|
||||||
if let Some(channel_id) = parse_channel_container_id(object_id) {
|
|
||||||
let channel = self
|
|
||||||
.channel(channel_id)
|
|
||||||
.ok_or_else(|| MusicSourceError::ObjectNotFound(object_id.to_string()))?;
|
|
||||||
Ok(channel.playlist().active_len().await)
|
|
||||||
} else {
|
|
||||||
Err(MusicSourceError::ObjectNotFound(object_id.to_string()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn statistics(&self) -> Result<SourceStatistics> {
|
|
||||||
let mut total_tracks = 0usize;
|
|
||||||
let mut cached_tracks = 0usize;
|
|
||||||
|
|
||||||
for (_, channel) in self.channels_iter() {
|
|
||||||
total_tracks += channel.playlist().active_len().await;
|
|
||||||
let stats = channel.cache_manager().statistics().await;
|
|
||||||
cached_tracks += stats.cached_tracks;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(SourceStatistics {
|
|
||||||
total_items: Some(total_tracks),
|
|
||||||
total_containers: Some(ALL_CHANNELS.len() + 1),
|
|
||||||
cached_items: Some(cached_tracks),
|
|
||||||
cache_size_bytes: None,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,178 +0,0 @@
|
|||||||
//! Block streaming functionality
|
|
||||||
|
|
||||||
use crate::error::{Error, Result};
|
|
||||||
use crate::models::Block;
|
|
||||||
use crate::RadioParadiseClient;
|
|
||||||
use bytes::Bytes;
|
|
||||||
use futures::stream::{Stream, StreamExt};
|
|
||||||
use std::pin::Pin;
|
|
||||||
use std::task::{Context, Poll};
|
|
||||||
use url::Url;
|
|
||||||
|
|
||||||
/// A stream of audio data from a Radio Paradise block
|
|
||||||
///
|
|
||||||
/// This wraps the HTTP response body and provides a `Stream<Item = Result<Bytes>>`
|
|
||||||
/// that can be consumed by audio players or written to a file.
|
|
||||||
pub struct BlockStream {
|
|
||||||
inner: Pin<Box<dyn Stream<Item = Result<Bytes>> + Send>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BlockStream {
|
|
||||||
/// Create a new block stream from a reqwest response
|
|
||||||
pub(crate) fn new(stream: impl Stream<Item = Result<Bytes>> + Send + 'static) -> Self {
|
|
||||||
Self {
|
|
||||||
inner: Box::pin(stream),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract the inner stream
|
|
||||||
///
|
|
||||||
/// Consumes the BlockStream and returns the underlying pinned stream.
|
|
||||||
/// Useful for advanced streaming scenarios like progressive decoding.
|
|
||||||
pub fn into_inner(self) -> Pin<Box<dyn Stream<Item = Result<Bytes>> + Send>> {
|
|
||||||
self.inner
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Stream for BlockStream {
|
|
||||||
type Item = Result<Bytes>;
|
|
||||||
|
|
||||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
|
||||||
self.inner.as_mut().poll_next(cx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RadioParadiseClient {
|
|
||||||
/// Stream a block from its URL
|
|
||||||
///
|
|
||||||
/// Returns a `Stream` of audio bytes that can be consumed by an audio player.
|
|
||||||
/// The stream will continue until the entire block is downloaded or an error occurs.
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
///
|
|
||||||
/// * `block_url` - The URL of the block to stream
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
///
|
|
||||||
/// ```no_run
|
|
||||||
/// use pmoparadise::RadioParadiseClient;
|
|
||||||
/// use futures::StreamExt;
|
|
||||||
///
|
|
||||||
/// #[tokio::main]
|
|
||||||
/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
/// let client = RadioParadiseClient::new().await?;
|
|
||||||
/// let block = client.get_block(None).await?;
|
|
||||||
///
|
|
||||||
/// let mut stream = client.stream_block(&block.url.parse()?).await?;
|
|
||||||
///
|
|
||||||
/// while let Some(chunk) = stream.next().await {
|
|
||||||
/// let bytes = chunk?;
|
|
||||||
/// // Write bytes to audio player or file
|
|
||||||
/// println!("Received {} bytes", bytes.len());
|
|
||||||
/// }
|
|
||||||
///
|
|
||||||
/// Ok(())
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
pub async fn stream_block(&self, block_url: &Url) -> Result<BlockStream> {
|
|
||||||
#[cfg(feature = "logging")]
|
|
||||||
tracing::debug!("Starting block stream: {}", block_url);
|
|
||||||
|
|
||||||
let response = self
|
|
||||||
.client
|
|
||||||
.get(block_url.clone())
|
|
||||||
.timeout(self.block_timeout)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
|
||||||
return Err(Error::other(format!(
|
|
||||||
"Failed to stream block: HTTP {}",
|
|
||||||
response.status()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert reqwest's byte stream to our Result type
|
|
||||||
let stream = response.bytes_stream();
|
|
||||||
let mapped = futures::stream::StreamExt::map(stream, |result| result.map_err(Error::from));
|
|
||||||
|
|
||||||
Ok(BlockStream::new(mapped))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stream a block directly from a Block struct
|
|
||||||
///
|
|
||||||
/// Convenience method that parses the URL from the block.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
///
|
|
||||||
/// ```no_run
|
|
||||||
/// use pmoparadise::RadioParadiseClient;
|
|
||||||
/// use futures::StreamExt;
|
|
||||||
///
|
|
||||||
/// #[tokio::main]
|
|
||||||
/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
/// let client = RadioParadiseClient::new().await?;
|
|
||||||
/// let block = client.get_block(None).await?;
|
|
||||||
///
|
|
||||||
/// let mut stream = client.stream_block_from_metadata(&block).await?;
|
|
||||||
///
|
|
||||||
/// while let Some(chunk) = stream.next().await {
|
|
||||||
/// let bytes = chunk?;
|
|
||||||
/// // Process bytes...
|
|
||||||
/// }
|
|
||||||
///
|
|
||||||
/// Ok(())
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
pub async fn stream_block_from_metadata(&self, block: &Block) -> Result<BlockStream> {
|
|
||||||
let url = Url::parse(&block.url)?;
|
|
||||||
self.stream_block(&url).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Download an entire block as Bytes
|
|
||||||
///
|
|
||||||
/// This downloads the complete block file into memory. For streaming playback,
|
|
||||||
/// use `stream_block()` instead which is more memory efficient.
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
///
|
|
||||||
/// * `block_url` - The URL of the block to download
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
///
|
|
||||||
/// ```no_run
|
|
||||||
/// use pmoparadise::RadioParadiseClient;
|
|
||||||
///
|
|
||||||
/// #[tokio::main]
|
|
||||||
/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
/// let client = RadioParadiseClient::new().await?;
|
|
||||||
/// let block = client.get_block(None).await?;
|
|
||||||
/// let url = block.url.parse()?;
|
|
||||||
/// let bytes = client.download_block(&url).await?;
|
|
||||||
/// println!("Downloaded {} bytes", bytes.len());
|
|
||||||
/// Ok(())
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
pub async fn download_block(&self, block_url: &Url) -> Result<Bytes> {
|
|
||||||
let mut stream = self.stream_block(block_url).await?;
|
|
||||||
let mut data = Vec::new();
|
|
||||||
|
|
||||||
while let Some(chunk_result) = stream.next().await {
|
|
||||||
let chunk = chunk_result?;
|
|
||||||
data.extend_from_slice(&chunk);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Bytes::from(data))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_block_stream_creation() {
|
|
||||||
let stream = futures::stream::once(async { Ok(Bytes::from("test")) });
|
|
||||||
let _block_stream = BlockStream::new(stream);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
use anyhow::Result;
|
|
||||||
use bytes::Bytes;
|
|
||||||
use futures::stream::Stream;
|
|
||||||
use std::io::{self, Read};
|
|
||||||
use std::pin::Pin;
|
|
||||||
use std::sync::mpsc::{sync_channel, Receiver, RecvError, SyncSender};
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
const CHANNEL_BUFFER_SIZE: usize = 64; // Augmenté de 16 à 64 pour réduire les warnings "buffer plein"
|
|
||||||
pub const CHUNK_SIZE_FRAMES: usize = 4096;
|
|
||||||
|
|
||||||
pub struct ChannelReader {
|
|
||||||
receiver: Receiver<Result<Bytes, String>>,
|
|
||||||
current_chunk: Option<Bytes>,
|
|
||||||
position: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ChannelReader {
|
|
||||||
pub fn new(
|
|
||||||
stream: Pin<Box<dyn Stream<Item = Result<Bytes, crate::error::Error>> + Send>>,
|
|
||||||
) -> Self {
|
|
||||||
let (tx, rx) = sync_channel(CHANNEL_BUFFER_SIZE);
|
|
||||||
tokio::spawn(Self::stream_feeder(stream, tx));
|
|
||||||
Self {
|
|
||||||
receiver: rx,
|
|
||||||
current_chunk: None,
|
|
||||||
position: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn stream_feeder(
|
|
||||||
mut stream: Pin<Box<dyn Stream<Item = Result<Bytes, crate::error::Error>> + Send>>,
|
|
||||||
tx: SyncSender<Result<Bytes, String>>,
|
|
||||||
) {
|
|
||||||
use futures::StreamExt;
|
|
||||||
while let Some(result) = stream.next().await {
|
|
||||||
let start = Instant::now();
|
|
||||||
|
|
||||||
let to_send = result.map_err(|e| e.to_string());
|
|
||||||
match tx.try_send(to_send) {
|
|
||||||
Ok(_) => { /* message envoyé sans attente */ }
|
|
||||||
Err(std::sync::mpsc::TrySendError::Full(value)) => {
|
|
||||||
tracing::warn!("stream_feeder: buffer plein");
|
|
||||||
// Revenir à l’envoi bloquant pour ne pas perdre le message
|
|
||||||
if tx.send(value).is_err() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(std::sync::mpsc::TrySendError::Disconnected(_)) => break,
|
|
||||||
}
|
|
||||||
let waited = start.elapsed();
|
|
||||||
tracing::trace!("stream_feeder send {:?}", waited);
|
|
||||||
if waited > Duration::from_millis(200) {
|
|
||||||
tracing::warn!("stream_feeder wait {:?}", waited);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Read for ChannelReader {
|
|
||||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
|
||||||
let start = Instant::now();
|
|
||||||
loop {
|
|
||||||
if let Some(chunk) = &self.current_chunk {
|
|
||||||
if self.position < chunk.len() {
|
|
||||||
let available = chunk.len() - self.position;
|
|
||||||
let to_copy = available.min(buf.len());
|
|
||||||
buf[..to_copy].copy_from_slice(&chunk[self.position..self.position + to_copy]);
|
|
||||||
self.position += to_copy;
|
|
||||||
tracing::trace!(
|
|
||||||
"ChannelReader copied {} bytes (elapsed {:?})",
|
|
||||||
to_copy,
|
|
||||||
start.elapsed()
|
|
||||||
);
|
|
||||||
return Ok(to_copy);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match self.receiver.recv() {
|
|
||||||
Ok(Ok(bytes)) => {
|
|
||||||
tracing::trace!(
|
|
||||||
"ChannelReader received chunk of {} bytes after {:?}",
|
|
||||||
bytes.len(),
|
|
||||||
start.elapsed()
|
|
||||||
);
|
|
||||||
self.current_chunk = Some(bytes);
|
|
||||||
self.position = 0;
|
|
||||||
}
|
|
||||||
Ok(Err(e)) => {
|
|
||||||
tracing::warn!("ChannelReader received error chunk: {}", e);
|
|
||||||
return Err(io::Error::new(io::ErrorKind::Other, e));
|
|
||||||
}
|
|
||||||
Err(RecvError) => {
|
|
||||||
tracing::trace!("ChannelReader stream closed after {:?}", start.elapsed());
|
|
||||||
return Ok(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct PCMChunk {
|
|
||||||
pub samples: Vec<i32>,
|
|
||||||
pub position_ms: u64,
|
|
||||||
pub sample_rate: u32,
|
|
||||||
pub channels: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct StreamingPCMDecoder<R: Read> {
|
|
||||||
reader: claxon::FlacReader<std::io::BufReader<R>>,
|
|
||||||
sample_rate: u32,
|
|
||||||
channels: u32,
|
|
||||||
bits_per_sample: u32,
|
|
||||||
total_samples_decoded: u64,
|
|
||||||
done: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl StreamingPCMDecoder<ChannelReader> {
|
|
||||||
/// Create a new decoder from an HTTP stream with default chunk size
|
|
||||||
pub fn new(http_stream: crate::stream::BlockStream) -> anyhow::Result<Self> {
|
|
||||||
Self::with_chunk_size(http_stream, CHUNK_SIZE_FRAMES)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_chunk_size(
|
|
||||||
http_stream: crate::stream::BlockStream,
|
|
||||||
_chunk_size: usize,
|
|
||||||
) -> anyhow::Result<Self> {
|
|
||||||
let channel_reader = ChannelReader::new(http_stream.into_inner());
|
|
||||||
let buffered = std::io::BufReader::new(channel_reader);
|
|
||||||
let reader = claxon::FlacReader::new(buffered)
|
|
||||||
.map_err(|e| anyhow::anyhow!("FLAC reader error: {}", e))?;
|
|
||||||
let info = reader.streaminfo();
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
reader,
|
|
||||||
sample_rate: info.sample_rate,
|
|
||||||
channels: info.channels,
|
|
||||||
bits_per_sample: info.bits_per_sample,
|
|
||||||
total_samples_decoded: 0,
|
|
||||||
done: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the sample rate (e.g., 44100 Hz)
|
|
||||||
pub fn sample_rate(&self) -> u32 {
|
|
||||||
self.sample_rate
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the number of channels (e.g., 2 for stereo)
|
|
||||||
pub fn channels(&self) -> u32 {
|
|
||||||
self.channels
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get bits per sample (e.g., 16)
|
|
||||||
pub fn bits_per_sample(&self) -> u32 {
|
|
||||||
self.bits_per_sample
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decode_chunk(&mut self) -> anyhow::Result<Option<PCMChunk>> {
|
|
||||||
if self.done {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Crée le FrameReader à la volée (emprunt de self.reader)
|
|
||||||
let mut frames = self.reader.blocks();
|
|
||||||
|
|
||||||
// API claxon 0.6.x : il FAUT fournir un Vec<i32> par valeur
|
|
||||||
let buf: Vec<i32> = Vec::new();
|
|
||||||
let frame = match frames.read_next_or_eof(buf) {
|
|
||||||
Ok(None) => {
|
|
||||||
self.done = true;
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
Ok(Some(f)) => f,
|
|
||||||
Err(e) => return Err(anyhow::anyhow!("FLAC decode error: {}", e)),
|
|
||||||
};
|
|
||||||
|
|
||||||
let planar_samples: Vec<i32> = frame.into_buffer();
|
|
||||||
if planar_samples.is_empty() {
|
|
||||||
self.done = true;
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
// IMPORTANT: Claxon retourne les samples en format PLANAR (tous les L, puis tous les R)
|
|
||||||
// Mais nous avons besoin du format INTERLEAVED (L, R, L, R, ...) pour l'encodage
|
|
||||||
let block_size = planar_samples.len() / self.channels as usize;
|
|
||||||
let mut samples = Vec::with_capacity(planar_samples.len());
|
|
||||||
|
|
||||||
for i in 0..block_size {
|
|
||||||
for ch in 0..self.channels as usize {
|
|
||||||
samples.push(planar_samples[ch * block_size + i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let position_ms = {
|
|
||||||
let frames = self.total_samples_decoded / self.channels as u64;
|
|
||||||
(frames * 1000) / self.sample_rate as u64
|
|
||||||
};
|
|
||||||
self.total_samples_decoded += samples.len() as u64;
|
|
||||||
|
|
||||||
Ok(Some(PCMChunk {
|
|
||||||
samples,
|
|
||||||
position_ms,
|
|
||||||
sample_rate: self.sample_rate,
|
|
||||||
channels: self.channels,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ms_to_frames(ms: u64, sample_rate: u32) -> usize {
|
|
||||||
((ms as u128 * sample_rate as u128) / 1000) as usize
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn frames_to_ms(frames: usize, sample_rate: u32) -> u64 {
|
|
||||||
((frames as u128 * 1000) / sample_rate as u128) as u64
|
|
||||||
}
|
|
||||||
@@ -1,397 +0,0 @@
|
|||||||
//! Per-track extraction from FLAC blocks (optional feature)
|
|
||||||
//!
|
|
||||||
//! **Important Notes:**
|
|
||||||
//!
|
|
||||||
//! Radio Paradise publishes *blocks* containing multiple songs, not individual
|
|
||||||
//! per-track files. This module provides experimental functionality to extract
|
|
||||||
//! individual tracks from FLAC blocks, but comes with significant tradeoffs:
|
|
||||||
//!
|
|
||||||
//! - **Storage**: Requires downloading the entire block (50-100MB) to disk
|
|
||||||
//! - **Latency**: Must download and decode before playback can start
|
|
||||||
//! - **CPU**: FLAC decoding is CPU-intensive
|
|
||||||
//! - **Complexity**: Seeking in FLAC requires decoding from the beginning
|
|
||||||
//!
|
|
||||||
//! ## Recommended Alternative
|
|
||||||
//!
|
|
||||||
//! For most use cases, it's better to:
|
|
||||||
//! 1. Stream the entire block to your audio player
|
|
||||||
//! 2. Use the `song[i].elapsed` metadata to seek within the player
|
|
||||||
//! 3. Let the player handle gapless transitions between tracks
|
|
||||||
//!
|
|
||||||
//! Modern players (mpv, VLC, ffmpeg) can seek in FLAC streams efficiently.
|
|
||||||
//!
|
|
||||||
//! ## When to Use This Module
|
|
||||||
//!
|
|
||||||
//! Only use per-track extraction when you need:
|
|
||||||
//! - Individual WAV files for further processing
|
|
||||||
//! - PCM data for custom audio analysis
|
|
||||||
//! - Separate files for non-streaming scenarios
|
|
||||||
//!
|
|
||||||
//! ## Block URL Pattern
|
|
||||||
//!
|
|
||||||
//! Blocks follow this URL pattern:
|
|
||||||
//! ```text
|
|
||||||
//! https://apps.radioparadise.com/blocks/chan/0/4/<start_event>-<end_event>.flac
|
|
||||||
//! ```
|
|
||||||
//!
|
|
||||||
//! The `song[i].elapsed` field (in milliseconds) indicates when each track
|
|
||||||
//! starts within the block.
|
|
||||||
|
|
||||||
#[cfg(feature = "per-track")]
|
|
||||||
use crate::error::{Error, Result};
|
|
||||||
#[cfg(feature = "per-track")]
|
|
||||||
use crate::models::Block;
|
|
||||||
#[cfg(feature = "per-track")]
|
|
||||||
use crate::RadioParadiseClient;
|
|
||||||
#[cfg(feature = "per-track")]
|
|
||||||
use std::io::Write;
|
|
||||||
#[cfg(feature = "per-track")]
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
/// Metadata for a decoded track stream
|
|
||||||
#[cfg(feature = "per-track")]
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct TrackMetadata {
|
|
||||||
/// Sample rate in Hz (e.g., 44100)
|
|
||||||
pub sample_rate: u32,
|
|
||||||
/// Number of audio channels (1 = mono, 2 = stereo)
|
|
||||||
pub channels: u16,
|
|
||||||
/// Bits per sample (typically 16 or 24)
|
|
||||||
pub bits_per_sample: u16,
|
|
||||||
/// Total number of samples in this track
|
|
||||||
pub total_samples: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A stream of decoded PCM audio for a single track
|
|
||||||
///
|
|
||||||
/// Provides access to decoded FLAC audio data for one track within a block.
|
|
||||||
/// The audio is decoded to 16-bit PCM format.
|
|
||||||
#[cfg(feature = "per-track")]
|
|
||||||
pub struct TrackStream {
|
|
||||||
/// Audio format metadata
|
|
||||||
pub metadata: TrackMetadata,
|
|
||||||
/// Path to the temporary FLAC file
|
|
||||||
temp_path: PathBuf,
|
|
||||||
/// FLAC reader
|
|
||||||
reader: Option<claxon::FlacReader<std::io::BufReader<std::fs::File>>>,
|
|
||||||
/// Current sample position
|
|
||||||
current_sample: u64,
|
|
||||||
/// End sample position (where this track ends)
|
|
||||||
end_sample: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "per-track")]
|
|
||||||
impl TrackStream {
|
|
||||||
/// Create a new track stream from a block
|
|
||||||
///
|
|
||||||
/// This will:
|
|
||||||
/// 1. Download the entire block to a temporary file
|
|
||||||
/// 2. Open it with a FLAC decoder
|
|
||||||
/// 3. Seek to the track's start position
|
|
||||||
/// 4. Prepare to decode samples
|
|
||||||
///
|
|
||||||
/// **Warning**: This is an expensive operation. Consider caching blocks.
|
|
||||||
async fn from_block_internal(
|
|
||||||
client: &RadioParadiseClient,
|
|
||||||
block: &Block,
|
|
||||||
track_index: usize,
|
|
||||||
) -> Result<Self> {
|
|
||||||
// Validate track index
|
|
||||||
let song = block
|
|
||||||
.get_song(track_index)
|
|
||||||
.ok_or(Error::InvalidIndex(track_index, block.song_count()))?;
|
|
||||||
|
|
||||||
// Download block to temporary file
|
|
||||||
let url = block
|
|
||||||
.url
|
|
||||||
.parse()
|
|
||||||
.map_err(|e| Error::other(format!("Invalid block URL: {}", e)))?;
|
|
||||||
|
|
||||||
let block_data = client.download_block(&url).await?;
|
|
||||||
|
|
||||||
// Write to temp file
|
|
||||||
let mut temp_file = tempfile::NamedTempFile::new()?;
|
|
||||||
temp_file.write_all(&block_data)?;
|
|
||||||
temp_file.flush()?;
|
|
||||||
|
|
||||||
let temp_path = temp_file.into_temp_path();
|
|
||||||
let path_buf = temp_path.to_path_buf();
|
|
||||||
|
|
||||||
#[cfg(feature = "logging")]
|
|
||||||
tracing::debug!("Wrote block to temp file: {:?}", path_buf);
|
|
||||||
|
|
||||||
// Open FLAC reader
|
|
||||||
let file = std::fs::File::open(&path_buf)?;
|
|
||||||
let buffered = std::io::BufReader::new(file);
|
|
||||||
let mut reader = claxon::FlacReader::new(buffered)?;
|
|
||||||
|
|
||||||
let streaminfo = reader.streaminfo();
|
|
||||||
let sample_rate = streaminfo.sample_rate;
|
|
||||||
let channels = streaminfo.channels as u16;
|
|
||||||
let bits_per_sample = streaminfo.bits_per_sample as u16;
|
|
||||||
|
|
||||||
// Calculate start and end sample positions
|
|
||||||
let start_sample = Self::ms_to_samples(song.elapsed, sample_rate);
|
|
||||||
let duration_samples = Self::ms_to_samples(song.duration, sample_rate);
|
|
||||||
let end_sample = start_sample + duration_samples;
|
|
||||||
|
|
||||||
#[cfg(feature = "logging")]
|
|
||||||
tracing::debug!(
|
|
||||||
"Track {} spans samples {} to {} ({} ms to {} ms)",
|
|
||||||
track_index,
|
|
||||||
start_sample,
|
|
||||||
end_sample,
|
|
||||||
song.elapsed,
|
|
||||||
song.elapsed + song.duration
|
|
||||||
);
|
|
||||||
|
|
||||||
// Seek to start position by reading and discarding samples
|
|
||||||
// Note: FLAC doesn't support random access, so we must decode from beginning
|
|
||||||
if start_sample > 0 {
|
|
||||||
#[cfg(feature = "logging")]
|
|
||||||
tracing::debug!("Seeking to sample {}", start_sample);
|
|
||||||
|
|
||||||
Self::skip_samples(&mut reader, start_sample)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let metadata = TrackMetadata {
|
|
||||||
sample_rate,
|
|
||||||
channels,
|
|
||||||
bits_per_sample,
|
|
||||||
total_samples: duration_samples,
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
metadata,
|
|
||||||
temp_path: path_buf,
|
|
||||||
reader: Some(reader),
|
|
||||||
current_sample: start_sample,
|
|
||||||
end_sample,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert milliseconds to sample count
|
|
||||||
fn ms_to_samples(ms: u64, sample_rate: u32) -> u64 {
|
|
||||||
(ms * sample_rate as u64) / 1000
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Skip samples by reading and discarding
|
|
||||||
fn skip_samples(
|
|
||||||
reader: &mut claxon::FlacReader<std::io::BufReader<std::fs::File>>,
|
|
||||||
count: u64,
|
|
||||||
) -> Result<()> {
|
|
||||||
let mut samples = reader.samples();
|
|
||||||
for _ in 0..count {
|
|
||||||
if samples.next().is_none() {
|
|
||||||
return Err(Error::other("Unexpected end of FLAC stream while seeking"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read decoded PCM samples
|
|
||||||
///
|
|
||||||
/// Returns samples as 16-bit signed integers (i16), interleaved by channel.
|
|
||||||
/// For stereo: [L, R, L, R, ...]. Returns None when track ends.
|
|
||||||
pub fn read_samples(&mut self, buffer: &mut [i16]) -> Result<Option<usize>> {
|
|
||||||
let reader = self
|
|
||||||
.reader
|
|
||||||
.as_mut()
|
|
||||||
.ok_or(Error::other("TrackStream already consumed"))?;
|
|
||||||
|
|
||||||
let mut samples_iter = reader.samples();
|
|
||||||
let mut count = 0;
|
|
||||||
|
|
||||||
for chunk in buffer.chunks_mut(self.metadata.channels as usize) {
|
|
||||||
if self.current_sample >= self.end_sample {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read one sample per channel
|
|
||||||
for sample_slot in chunk.iter_mut() {
|
|
||||||
match samples_iter.next() {
|
|
||||||
Some(Ok(sample)) => {
|
|
||||||
// Claxon returns i32, convert to i16
|
|
||||||
*sample_slot = (sample >> (self.metadata.bits_per_sample - 16)) as i16;
|
|
||||||
count += 1;
|
|
||||||
}
|
|
||||||
Some(Err(e)) => {
|
|
||||||
return Err(Error::FlacDecode(e.to_string()));
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
return Ok(if count > 0 { Some(count) } else { None });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.current_sample += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(if count > 0 { Some(count) } else { None })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Export track to a WAV file
|
|
||||||
///
|
|
||||||
/// Decodes the entire track and writes it as a WAV file.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
///
|
|
||||||
/// ```no_run
|
|
||||||
/// # #[cfg(feature = "per-track")]
|
|
||||||
/// # {
|
|
||||||
/// use pmoparadise::RadioParadiseClient;
|
|
||||||
/// use std::path::Path;
|
|
||||||
///
|
|
||||||
/// # #[tokio::main]
|
|
||||||
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
/// let client = RadioParadiseClient::new().await?;
|
|
||||||
/// let block = client.get_block(None).await?;
|
|
||||||
///
|
|
||||||
/// let mut track_stream = client.open_track_stream(&block, 0).await?;
|
|
||||||
/// track_stream.export_wav(Path::new("track.wav"))?;
|
|
||||||
/// # Ok(())
|
|
||||||
/// # }
|
|
||||||
/// # }
|
|
||||||
/// ```
|
|
||||||
pub fn export_wav(&mut self, output_path: &std::path::Path) -> Result<()> {
|
|
||||||
let spec = hound::WavSpec {
|
|
||||||
channels: self.metadata.channels,
|
|
||||||
sample_rate: self.metadata.sample_rate,
|
|
||||||
bits_per_sample: 16,
|
|
||||||
sample_format: hound::SampleFormat::Int,
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut writer = hound::WavWriter::create(output_path, spec)?;
|
|
||||||
let mut buffer = vec![0i16; 8192 * self.metadata.channels as usize];
|
|
||||||
|
|
||||||
#[cfg(feature = "logging")]
|
|
||||||
tracing::info!("Exporting track to WAV: {:?}", output_path);
|
|
||||||
|
|
||||||
loop {
|
|
||||||
match self.read_samples(&mut buffer)? {
|
|
||||||
Some(count) => {
|
|
||||||
for &sample in &buffer[..count] {
|
|
||||||
writer.write_sample(sample)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
writer.finalize()?;
|
|
||||||
|
|
||||||
#[cfg(feature = "logging")]
|
|
||||||
tracing::info!("Successfully exported WAV file");
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "per-track")]
|
|
||||||
impl Drop for TrackStream {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
// Close reader before removing temp file
|
|
||||||
self.reader.take();
|
|
||||||
|
|
||||||
// Clean up temporary file
|
|
||||||
if let Err(_e) = std::fs::remove_file(&self.temp_path) {
|
|
||||||
#[cfg(feature = "logging")]
|
|
||||||
tracing::warn!("Failed to remove temp file {:?}: {}", self.temp_path, _e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "per-track")]
|
|
||||||
impl RadioParadiseClient {
|
|
||||||
/// Open a stream for a specific track within a block
|
|
||||||
///
|
|
||||||
/// **Warning**: This downloads the entire block to a temporary file
|
|
||||||
/// and performs FLAC decoding. See module documentation for alternatives.
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
///
|
|
||||||
/// * `block` - The block containing the track
|
|
||||||
/// * `track_index` - Index of the track (0-based)
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
///
|
|
||||||
/// ```no_run
|
|
||||||
/// # #[cfg(feature = "per-track")]
|
|
||||||
/// # {
|
|
||||||
/// use pmoparadise::RadioParadiseClient;
|
|
||||||
///
|
|
||||||
/// # #[tokio::main]
|
|
||||||
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
/// let client = RadioParadiseClient::new().await?;
|
|
||||||
/// let block = client.get_block(None).await?;
|
|
||||||
///
|
|
||||||
/// // Extract first track
|
|
||||||
/// let mut track = client.open_track_stream(&block, 0).await?;
|
|
||||||
/// println!("Track: {} Hz, {} channels",
|
|
||||||
/// track.metadata.sample_rate,
|
|
||||||
/// track.metadata.channels);
|
|
||||||
///
|
|
||||||
/// // Read some samples
|
|
||||||
/// let mut buffer = vec![0i16; 4096];
|
|
||||||
/// if let Some(count) = track.read_samples(&mut buffer)? {
|
|
||||||
/// println!("Read {} samples", count);
|
|
||||||
/// }
|
|
||||||
/// # Ok(())
|
|
||||||
/// # }
|
|
||||||
/// # }
|
|
||||||
/// ```
|
|
||||||
pub async fn open_track_stream(
|
|
||||||
&self,
|
|
||||||
block: &Block,
|
|
||||||
track_index: usize,
|
|
||||||
) -> Result<TrackStream> {
|
|
||||||
TrackStream::from_block_internal(self, block, track_index).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper: Get track position in seconds for player-based seeking
|
|
||||||
///
|
|
||||||
/// Instead of downloading and decoding, you can pass this information
|
|
||||||
/// to your audio player for efficient seeking.
|
|
||||||
///
|
|
||||||
/// Returns (start_seconds, duration_seconds)
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
///
|
|
||||||
/// ```no_run
|
|
||||||
/// use pmoparadise::RadioParadiseClient;
|
|
||||||
///
|
|
||||||
/// # #[tokio::main]
|
|
||||||
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
/// let client = RadioParadiseClient::new().await?;
|
|
||||||
/// let block = client.get_block(None).await?;
|
|
||||||
///
|
|
||||||
/// let (start, duration) = client.track_position_seconds(&block, 1)?;
|
|
||||||
/// println!("Track 1 starts at {}s, duration {}s", start, duration);
|
|
||||||
/// println!("Play with: mpv --start={} --length={} {}", start, duration, block.url);
|
|
||||||
/// # Ok(())
|
|
||||||
/// # }
|
|
||||||
/// ```
|
|
||||||
pub fn track_position_seconds(&self, block: &Block, track_index: usize) -> Result<(f64, f64)> {
|
|
||||||
let song = block
|
|
||||||
.get_song(track_index)
|
|
||||||
.ok_or(Error::InvalidIndex(track_index, block.song_count()))?;
|
|
||||||
|
|
||||||
let start_secs = song.elapsed as f64 / 1000.0;
|
|
||||||
let duration_secs = song.duration as f64 / 1000.0;
|
|
||||||
|
|
||||||
Ok((start_secs, duration_secs))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[cfg(feature = "per-track")]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_ms_to_samples() {
|
|
||||||
assert_eq!(TrackStream::ms_to_samples(1000, 44100), 44100);
|
|
||||||
assert_eq!(TrackStream::ms_to_samples(500, 44100), 22050);
|
|
||||||
assert_eq!(TrackStream::ms_to_samples(0, 44100), 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ use crate::playlist::Playlist;
|
|||||||
use crate::track::PlaylistTrack;
|
use crate::track::PlaylistTrack;
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
use pmocache::cache_trait::FileCache;
|
use pmocache::cache_trait::FileCache;
|
||||||
use pmodidl::{Container, Item, Resource};
|
use pmodidl::{Container, Item};
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
//! WriteHandle : accès exclusif en écriture à une playlist
|
//! WriteHandle : accès exclusif en écriture à une playlist
|
||||||
|
|
||||||
use crate::playlist::core::PlaylistConfig;
|
|
||||||
use crate::playlist::record::Record;
|
use crate::playlist::record::Record;
|
||||||
use crate::playlist::Playlist;
|
use crate::playlist::Playlist;
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
@@ -185,7 +184,7 @@ impl WriteHandle {
|
|||||||
|
|
||||||
// Créer la nouvelle playlist persistante
|
// Créer la nouvelle playlist persistante
|
||||||
let manager = crate::manager::PlaylistManager();
|
let manager = crate::manager::PlaylistManager();
|
||||||
let mut new_handle = manager.create_persistent_playlist(new_id).await?;
|
let new_handle = manager.create_persistent_playlist(new_id).await?;
|
||||||
|
|
||||||
// Copier le titre et la config
|
// Copier le titre et la config
|
||||||
new_handle.set_title(title).await?;
|
new_handle.set_title(title).await?;
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ pub mod core;
|
|||||||
pub mod record;
|
pub mod record;
|
||||||
|
|
||||||
use self::core::{PlaylistConfig, PlaylistCore};
|
use self::core::{PlaylistConfig, PlaylistCore};
|
||||||
use self::record::Record;
|
|
||||||
use std::sync::atomic::{AtomicU8, Ordering};
|
use std::sync::atomic::{AtomicU8, Ordering};
|
||||||
use std::sync::{Arc, Weak};
|
use std::sync::{Arc, Weak};
|
||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
|
|||||||
@@ -25,11 +25,10 @@ use tracing::Level;
|
|||||||
use tracing_subscriber::{
|
use tracing_subscriber::{
|
||||||
Registry,
|
Registry,
|
||||||
filter::LevelFilter,
|
filter::LevelFilter,
|
||||||
layer::{Filter, SubscriberExt},
|
layer::SubscriberExt,
|
||||||
reload,
|
reload,
|
||||||
util::SubscriberInitExt,
|
util::SubscriberInitExt,
|
||||||
};
|
};
|
||||||
use utoipa::OpenApi;
|
|
||||||
|
|
||||||
/// Représente une entrée de log
|
/// Représente une entrée de log
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
@@ -440,7 +439,7 @@ fn level_to_levelfilter(level: Level) -> LevelFilter {
|
|||||||
|
|
||||||
/// Crée le router pour l'API de gestion des logs
|
/// Crée le router pour l'API de gestion des logs
|
||||||
pub fn create_logs_router(log_state: LogState) -> axum::Router {
|
pub fn create_logs_router(log_state: LogState) -> axum::Router {
|
||||||
use axum::routing::{get, post};
|
use axum::routing::get;
|
||||||
axum::Router::new()
|
axum::Router::new()
|
||||||
.route("/log_setup", get(log_setup_get).post(log_setup_post))
|
.route("/log_setup", get(log_setup_get).post(log_setup_post))
|
||||||
.with_state(log_state)
|
.with_state(log_state)
|
||||||
|
|||||||
@@ -122,10 +122,10 @@ impl SourceCacheManager {
|
|||||||
let cache = self.track_cache.read().await;
|
let cache = self.track_cache.read().await;
|
||||||
|
|
||||||
if let Some(metadata) = cache.get(object_id) {
|
if let Some(metadata) = cache.get(object_id) {
|
||||||
if let Some(ref pk) = metadata.cached_audio_pk {
|
if let Some(ref _pk) = metadata.cached_audio_pk {
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
{
|
{
|
||||||
let url = pmoupnp::cache_registry::build_audio_url(pk, Some("stream"))
|
let url = pmoupnp::cache_registry::build_audio_url(_pk, Some("stream"))
|
||||||
.map_err(|e| MusicSourceError::CacheError(e.to_string()))?;
|
.map_err(|e| MusicSourceError::CacheError(e.to_string()))?;
|
||||||
return Ok(url);
|
return Ok(url);
|
||||||
}
|
}
|
||||||
@@ -147,7 +147,7 @@ impl SourceCacheManager {
|
|||||||
let cache = self.track_cache.read().await;
|
let cache = self.track_cache.read().await;
|
||||||
|
|
||||||
if let Some(metadata) = cache.get(object_id) {
|
if let Some(metadata) = cache.get(object_id) {
|
||||||
if let Some(ref pk) = metadata.cached_audio_pk {
|
if let Some(ref _pk) = metadata.cached_audio_pk {
|
||||||
// TODO: Ajouter get_info() à AudioCache
|
// TODO: Ajouter get_info() à AudioCache
|
||||||
// Pour l'instant, on retourne juste Cached sans taille
|
// Pour l'instant, on retourne juste Cached sans taille
|
||||||
return Ok(CacheStatus::Cached { size_bytes: 0 });
|
return Ok(CacheStatus::Cached { size_bytes: 0 });
|
||||||
@@ -181,10 +181,10 @@ impl SourceCacheManager {
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// L'URL complète de l'image
|
/// L'URL complète de l'image
|
||||||
pub fn cover_url(&self, pk: &str, size: Option<usize>) -> Result<String> {
|
pub fn cover_url(&self, _pk: &str, _size: Option<usize>) -> Result<String> {
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
{
|
{
|
||||||
pmoupnp::cache_registry::build_cover_url(pk, size)
|
pmoupnp::cache_registry::build_cover_url(_pk, _size)
|
||||||
.map_err(|e| MusicSourceError::CacheError(e.to_string()))
|
.map_err(|e| MusicSourceError::CacheError(e.to_string()))
|
||||||
}
|
}
|
||||||
#[cfg(not(feature = "server"))]
|
#[cfg(not(feature = "server"))]
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
use std::{
|
use std::{
|
||||||
collections::{HashMap, HashSet},
|
collections::{HashMap, HashSet},
|
||||||
env::var,
|
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
};
|
};
|
||||||
|
|
||||||
use bevy_reflect::Reflect;
|
|
||||||
use xmltree::{Element, XMLNode};
|
use xmltree::{Element, XMLNode};
|
||||||
|
|
||||||
use crate::actions::{Action, ActionData, ActionInstance, ArgInstanceSet};
|
use crate::actions::{Action, ActionData, ActionInstance, ArgInstanceSet};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use tracing::{info, trace};
|
use tracing::info;
|
||||||
use xmltree::{Element, XMLNode};
|
use xmltree::{Element, XMLNode};
|
||||||
|
|
||||||
use crate::actions::{Action, ActionHandler, ActionInstance, Argument, ArgumentSet};
|
use crate::actions::{Action, ActionHandler, ActionInstance, Argument, ArgumentSet};
|
||||||
|
|||||||
@@ -17,21 +17,3 @@ impl From<std::io::Error> for ActionError {
|
|||||||
ActionError::GeneralError(format!("IO error: {}", err))
|
ActionError::GeneralError(format!("IO error: {}", err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
|
||||||
pub enum ArgumentError {
|
|
||||||
#[error("Argument error: {0}")]
|
|
||||||
GeneralError(String),
|
|
||||||
|
|
||||||
#[error("Argument error: {0}")]
|
|
||||||
ArgumentError(String),
|
|
||||||
|
|
||||||
#[error("Set operation error: {0}")]
|
|
||||||
SetError(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<std::io::Error> for ArgumentError {
|
|
||||||
fn from(err: std::io::Error) -> Self {
|
|
||||||
ArgumentError::GeneralError(format!("IO error: {}", err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -47,21 +47,21 @@ impl UpnpObject for Service {
|
|||||||
elem.children.push(XMLNode::Element(service_id));
|
elem.children.push(XMLNode::Element(service_id));
|
||||||
|
|
||||||
// SCPDURL
|
// SCPDURL
|
||||||
let mut SCPDURL = Element::new("SCPDURL");
|
let mut scpdurl = Element::new("SCPDURL");
|
||||||
SCPDURL.children.push(XMLNode::Text(self.scpd_route()));
|
scpdurl.children.push(XMLNode::Text(self.scpd_route()));
|
||||||
elem.children.push(XMLNode::Element(SCPDURL));
|
elem.children.push(XMLNode::Element(scpdurl));
|
||||||
|
|
||||||
// controlURL
|
// controlURL
|
||||||
let mut controlURL = Element::new("controlURL");
|
let mut control_url = Element::new("controlURL");
|
||||||
controlURL
|
control_url
|
||||||
.children
|
.children
|
||||||
.push(XMLNode::Text(self.control_route()));
|
.push(XMLNode::Text(self.control_route()));
|
||||||
elem.children.push(XMLNode::Element(controlURL));
|
elem.children.push(XMLNode::Element(control_url));
|
||||||
|
|
||||||
// eventSubURL
|
// eventSubURL
|
||||||
let mut eventSubURL = Element::new("eventSubURL");
|
let mut event_sub_url = Element::new("eventSubURL");
|
||||||
eventSubURL.children.push(XMLNode::Text(self.event_route()));
|
event_sub_url.children.push(XMLNode::Text(self.event_route()));
|
||||||
elem.children.push(XMLNode::Element(eventSubURL));
|
elem.children.push(XMLNode::Element(event_sub_url));
|
||||||
|
|
||||||
elem
|
elem
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user