refactor: Remove obsolete orchestration layer, create RadioParadiseSource stub
Major cleanup removing 2831 lines (~45%) of outdated server orchestration code. RadioParadiseStreamSource (pmoaudio integration) is now the primary implementation. ## Changes ### Removed (2393 lines) - **paradise/ module** - Complete server orchestration system: - worker.rs (1146 lines) - Background polling, caching, state machine - channel.rs (429 lines) - Channel lifecycle management - playlist.rs (294 lines) - Shared playlist management - history.rs (218 lines) - SQLite persistence - constants.rs (209 lines) - Server configuration constants - mod.rs (29 lines) - Module exports ### Replaced - **source.rs** (612 → 174 lines, -72%): - Old: Full MusicSource implementation with UPnP/DIDL integration - New: Minimal stub for backward compatibility with pmomediaserver - Returns empty results and deprecation warnings - Documents migration path to RadioParadiseStreamSource ### Updated - **config_ext.rs**: Now imports HISTORY_DEFAULT_MAX_TRACKS from channels module - **lib.rs**: - Removed paradise module - Updated documentation to focus on RadioParadiseStreamSource - Updated cargo features documentation ## Architecture **Before**: Complex orchestration with workers, channels, caching, history **After**: Simple API access + pmoaudio streaming (RadioParadiseStreamSource) ## Compatibility RadioParadiseSource stub maintains API compatibility for pmomediaserver while clearly indicating deprecation. All operations return empty results or errors with migration guidance. ## Testing - ✅ All 23 tests pass - ✅ Compilation successful with all features - ✅ pmomediaserver compatibility maintained (stub implementation) ## Migration Path Old (deprecated): ```rust let source = RadioParadiseSource::from_registry(client)?; ``` New (recommended): ```rust let stream_source = RadioParadiseStreamSource::new(client, None).await?; let node = Node::from_logic(stream_source); ```
This commit is contained in:
@@ -36,7 +36,7 @@ use anyhow::{anyhow, Result};
|
||||
use pmoconfig::Config;
|
||||
use serde_yaml::{Number, Value};
|
||||
|
||||
use crate::paradise::constants;
|
||||
use crate::channels::HISTORY_DEFAULT_MAX_TRACKS;
|
||||
|
||||
/// Nom du répertoire pour Radio Paradise (relatif au config_dir)
|
||||
///
|
||||
@@ -222,7 +222,7 @@ impl RadioParadiseConfigExt for Config {
|
||||
Ok(Value::Number(n)) if n.is_i64() => Ok(n.as_i64().unwrap() as usize),
|
||||
_ => {
|
||||
// Use default and persist it
|
||||
let default = constants::HISTORY_DEFAULT_MAX_TRACKS;
|
||||
let default = HISTORY_DEFAULT_MAX_TRACKS;
|
||||
self.set_paradise_history_size(default)?;
|
||||
Ok(default)
|
||||
}
|
||||
@@ -245,7 +245,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_default_values() {
|
||||
assert_eq!(DEFAULT_HISTORY_DATABASE_DIR, "paradise");
|
||||
assert_eq!(constants::HISTORY_DEFAULT_MAX_TRACKS, 100);
|
||||
assert_eq!(HISTORY_DEFAULT_MAX_TRACKS, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -164,54 +164,46 @@
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! ## Caching Support (Feature: `cache`)
|
||||
//! ## Audio Streaming (Feature: `pmoaudio`)
|
||||
//!
|
||||
//! `pmoparadise` can optionally integrate with `pmocovers` and `pmoaudiocache` to cache
|
||||
//! cover images and audio tracks locally:
|
||||
//! For direct audio streaming and integration with pmoaudio pipelines,
|
||||
//! use `RadioParadiseStreamSource`:
|
||||
//!
|
||||
//! ```no_run
|
||||
//! # #[cfg(feature = "cache")]
|
||||
//! # #[cfg(feature = "pmoaudio")]
|
||||
//! # {
|
||||
//! use pmoparadise::{RadioParadiseClient, RadioParadiseSource};
|
||||
//! use std::sync::Arc;
|
||||
//! use pmoparadise::{RadioParadiseClient, RadioParadiseStreamSource};
|
||||
//! use pmoaudio::pipeline::Node;
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! 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 source = RadioParadiseSource::new(
|
||||
//! client,
|
||||
//! 50,
|
||||
//! cover_cache,
|
||||
//! audio_cache,
|
||||
//! );
|
||||
//! let stream_source = RadioParadiseStreamSource::new(client, None).await?;
|
||||
//!
|
||||
//! println!("Source ready: {}", source.name());
|
||||
//! // Create audio node from stream source
|
||||
//! let node = Node::from_logic(stream_source);
|
||||
//!
|
||||
//! // Use in pmoaudio pipeline...
|
||||
//!
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! # }
|
||||
//! ```
|
||||
//!
|
||||
//! **Benefits**:
|
||||
//! - Cover images are automatically downloaded and converted to WebP
|
||||
//! - Audio tracks are cached as FLAC with metadata preserved
|
||||
//! - Subsequent access is instant (no re-download)
|
||||
//! - URIs returned by `resolve_uri()` point to cached versions
|
||||
//!
|
||||
//! See the `with_cache` example for a complete demonstration.
|
||||
//! **RadioParadiseStreamSource**:
|
||||
//! - Downloads and decodes FLAC blocks in real-time
|
||||
//! - Automatically detects bit depth (16/24/32-bit)
|
||||
//! - Inserts track boundaries with metadata
|
||||
//! - Integrates seamlessly with pmoaudio pipelines
|
||||
//!
|
||||
//! ## 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`)
|
||||
//! - `pmoserver`: Enable REST API extension for pmoserver integration (adds `utoipa`, `axum`)
|
||||
//! - `server`: Enable server-side features (cache registry integration)
|
||||
//! - `cache`: Enable cover and audio caching support (adds `pmocovers`, `pmoaudiocache`)
|
||||
//! - `pmoaudio`: Enable RadioParadiseStreamSource for pmoaudio integration
|
||||
//! - `pmoconfig`: Enable configuration integration with pmoconfig
|
||||
//! - `server`: Enable RadioParadiseSource stub for backward compatibility (deprecated)
|
||||
//!
|
||||
//! ## See Also
|
||||
//!
|
||||
@@ -222,7 +214,6 @@ pub mod channels;
|
||||
pub mod client;
|
||||
pub mod error;
|
||||
pub mod models;
|
||||
pub mod paradise;
|
||||
pub mod source;
|
||||
pub mod stream;
|
||||
pub mod streaming;
|
||||
|
||||
@@ -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
@@ -1,395 +1,114 @@
|
||||
//! Music source implementation for Radio Paradise built on the new
|
||||
//! `paradise` orchestration layer.
|
||||
//! DEPRECATED: Stub implementation of RadioParadiseSource
|
||||
//!
|
||||
//! The source exposes a DIDL-Lite hierarchy compatible with UPnP
|
||||
//! ContentDirectory while delegating block ingestion, caching and
|
||||
//! multi-client streaming to [`ParadiseChannel`].
|
||||
//! **⚠️ This module is deprecated and will be removed in a future version.**
|
||||
//!
|
||||
//! 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::paradise::{
|
||||
create_history_backend, ChannelDescriptor, ParadiseChannel, PlaylistEntry, ALL_CHANNELS,
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "pmoconfig"))]
|
||||
use crate::paradise::HISTORY_DEFAULT_MAX_TRACKS;
|
||||
use anyhow::Result as AnyhowResult;
|
||||
use pmoaudiocache::Cache as AudioCache;
|
||||
use pmocovers::Cache as CoverCache;
|
||||
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 pmosource::pmodidl::{Container, Item};
|
||||
use pmosource::{async_trait, BrowseResult, MusicSource, MusicSourceError, Result};
|
||||
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");
|
||||
|
||||
fn channel_collection_id(channel_id: u8) -> String {
|
||||
format!("radio-paradise:{}", channel_id)
|
||||
}
|
||||
|
||||
fn channel_container_id(channel_id: u8) -> String {
|
||||
format!("radio-paradise:channel:{}", channel_id)
|
||||
}
|
||||
|
||||
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)]
|
||||
/// DEPRECATED: Stub implementation of RadioParadiseSource
|
||||
///
|
||||
/// This is a minimal stub that implements the MusicSource trait with no-op
|
||||
/// implementations. It exists only to maintain API compatibility during the
|
||||
/// migration to RadioParadiseStreamSource.
|
||||
///
|
||||
/// **Do not use this in new code.** Use `RadioParadiseStreamSource` instead.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RadioParadiseSource {
|
||||
inner: Arc<RadioParadiseSourceInner>,
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
_client: RadioParadiseClient,
|
||||
}
|
||||
|
||||
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")]
|
||||
pub fn from_registry(client: RadioParadiseClient) -> Result<Self> {
|
||||
// Load history configuration from pmoconfig using the config extension trait
|
||||
#[cfg(feature = "pmoconfig")]
|
||||
let (database_path, history_max_tracks) = {
|
||||
use crate::config_ext::RadioParadiseConfigExt;
|
||||
let cfg = pmoconfig::get_config();
|
||||
let database_path = cfg.get_paradise_history_database().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 }),
|
||||
})
|
||||
pub fn from_registry(_client: RadioParadiseClient) -> Result<Self> {
|
||||
Err(MusicSourceError::SourceUnavailable(
|
||||
"RadioParadiseSource is deprecated. Use RadioParadiseStreamSource instead."
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// 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")]
|
||||
pub fn from_registry_default(client: RadioParadiseClient) -> Result<Self> {
|
||||
Self::from_registry(client)
|
||||
pub fn from_registry_default(client: RadioParadiseClient) -> Self {
|
||||
tracing::warn!(
|
||||
"RadioParadiseSource::from_registry_default is deprecated. \
|
||||
Use RadioParadiseStreamSource for audio streaming."
|
||||
);
|
||||
Self { _client: client }
|
||||
}
|
||||
|
||||
pub fn new(
|
||||
client: RadioParadiseClient,
|
||||
cover_cache: Arc<CoverCache>,
|
||||
audio_cache: Arc<AudioCache>,
|
||||
) -> Self {
|
||||
// Load history configuration from pmoconfig using the config extension trait
|
||||
#[cfg(feature = "pmoconfig")]
|
||||
let (database_path, history_max_tracks) = {
|
||||
use crate::config_ext::RadioParadiseConfigExt;
|
||||
let cfg = pmoconfig::get_config();
|
||||
let database_path = cfg.get_paradise_history_database().unwrap_or_else(|e| {
|
||||
panic!("Failed to get history database path: {e}");
|
||||
});
|
||||
let max_tracks = cfg.get_paradise_history_size().unwrap_or_else(|e| {
|
||||
panic!("Failed to get history size: {e}");
|
||||
});
|
||||
(database_path, max_tracks)
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "pmoconfig"))]
|
||||
let (database_path, history_max_tracks) = {
|
||||
use std::path::PathBuf;
|
||||
let mut path = PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| ".".to_string()));
|
||||
path.push(".config");
|
||||
path.push("pmo");
|
||||
path.push("paradise");
|
||||
std::fs::create_dir_all(&path).ok();
|
||||
path.push("history.db");
|
||||
(
|
||||
path.to_string_lossy().to_string(),
|
||||
HISTORY_DEFAULT_MAX_TRACKS,
|
||||
)
|
||||
};
|
||||
|
||||
let history_backend: Arc<dyn crate::paradise::HistoryBackend> =
|
||||
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 }),
|
||||
}
|
||||
/// DEPRECATED: Create a new RadioParadiseSource with default settings
|
||||
///
|
||||
/// This method is deprecated and only exists for API compatibility.
|
||||
pub fn new_default(client: RadioParadiseClient) -> Self {
|
||||
tracing::warn!(
|
||||
"RadioParadiseSource::new_default is deprecated. \
|
||||
Use RadioParadiseStreamSource for audio streaming."
|
||||
);
|
||||
Self { _client: client }
|
||||
}
|
||||
|
||||
pub fn new_default(
|
||||
client: RadioParadiseClient,
|
||||
cover_cache: Arc<CoverCache>,
|
||||
audio_cache: Arc<AudioCache>,
|
||||
) -> Self {
|
||||
Self::new(client, cover_cache, audio_cache)
|
||||
}
|
||||
|
||||
pub fn client_for_channel(&self, channel: u8) -> Option<RadioParadiseClient> {
|
||||
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()
|
||||
/// DEPRECATED: Create a new RadioParadiseSource with cache
|
||||
///
|
||||
/// This method is deprecated and only exists for API compatibility.
|
||||
pub fn new_with_cache(client: RadioParadiseClient, _cache_size: usize) -> Self {
|
||||
tracing::warn!(
|
||||
"RadioParadiseSource::new_with_cache is deprecated. \
|
||||
Use RadioParadiseStreamSource for audio streaming."
|
||||
);
|
||||
Self { _client: client }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MusicSource for RadioParadiseSource {
|
||||
fn name(&self) -> &str {
|
||||
"Radio Paradise"
|
||||
"Radio Paradise (DEPRECATED)"
|
||||
}
|
||||
|
||||
fn id(&self) -> &str {
|
||||
"radio-paradise"
|
||||
"radio-paradise-deprecated"
|
||||
}
|
||||
|
||||
fn default_image(&self) -> &[u8] {
|
||||
@@ -397,43 +116,32 @@ impl MusicSource for RadioParadiseSource {
|
||||
}
|
||||
|
||||
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> {
|
||||
match object_id {
|
||||
"0" => Ok(BrowseResult::Containers(vec![self.build_root_container()])),
|
||||
"radio-paradise" => {
|
||||
let containers = self.build_channel_containers().await;
|
||||
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 browse(&self, _object_id: &str) -> Result<BrowseResult> {
|
||||
tracing::warn!("RadioParadiseSource::browse called but source is deprecated");
|
||||
Ok(BrowseResult::Mixed {
|
||||
containers: vec![],
|
||||
items: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
async fn resolve_uri(&self, object_id: &str) -> Result<String> {
|
||||
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()
|
||||
.resolve_uri(object_id)
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::CacheError(e.to_string()))
|
||||
async fn resolve_uri(&self, _object_id: &str) -> Result<String> {
|
||||
Err(MusicSourceError::SourceUnavailable(
|
||||
"RadioParadiseSource is deprecated. Use RadioParadiseStreamSource instead."
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn supports_fifo(&self) -> bool {
|
||||
@@ -441,171 +149,25 @@ impl MusicSource for RadioParadiseSource {
|
||||
}
|
||||
|
||||
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>> {
|
||||
Err(MusicSourceError::FifoNotSupported)
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn update_id(&self) -> u32 {
|
||||
self.channels_iter()
|
||||
.map(|(_, channel)| channel.playlist().update_id())
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
0
|
||||
}
|
||||
|
||||
async fn last_change(&self) -> Option<SystemTime> {
|
||||
let mut latest: Option<SystemTime> = 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
|
||||
None
|
||||
}
|
||||
|
||||
async fn get_items(&self, offset: usize, count: usize) -> Result<Vec<Item>> {
|
||||
let mut all = Vec::new();
|
||||
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,
|
||||
})
|
||||
async fn get_items(&self, _offset: usize, _count: usize) -> Result<Vec<Item>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user