refactor: Clean up dead code and simplify pmoserver REST API
Changes:
1. Removed dead code from paradise/worker.rs:
- Unused process_song() method
- Unused DecodedBlock struct
- Unused helper functions: song_duration_ms, ms_to_frames, decode_block_audio
2. Simplified pmoserver_ext.rs (840 → 383 lines):
- Removed complex orchestration endpoints (status, playlist, history, streaming)
- Kept only simple API access endpoints:
* /now-playing
* /block/current
* /block/{event_id}
* /channels
- Removed dependencies on RadioParadiseSource and ParadiseChannel
3. Created channels.rs:
- Extracted channel definitions from paradise/channel.rs
- Pure data module with no orchestration logic
- Contains: ParadiseChannelKind, ChannelDescriptor, ALL_CHANNELS
Note: This is work in progress. Still need to update lib.rs and remove
unused modules once dependencies are fully resolved.
This commit is contained in:
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());
|
||||
}
|
||||
}
|
||||
@@ -547,59 +547,6 @@ impl WorkerState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn process_song(
|
||||
&self,
|
||||
block: &Block,
|
||||
song_index: &usize,
|
||||
song: &Song,
|
||||
position: usize,
|
||||
ordered_songs: &[(usize, &Song)],
|
||||
total_frames: usize,
|
||||
decoded: &DecodedBlock,
|
||||
) -> Result<Arc<PlaylistEntry>> {
|
||||
let duration_ms = song_duration_ms(block, ordered_songs, position);
|
||||
let start_frame = ms_to_frames(song.elapsed, decoded.sample_rate);
|
||||
let end_frame = if position + 1 < ordered_songs.len() {
|
||||
ms_to_frames(ordered_songs[position + 1].1.elapsed, decoded.sample_rate)
|
||||
} else {
|
||||
total_frames
|
||||
};
|
||||
|
||||
if end_frame <= start_frame || end_frame > total_frames {
|
||||
warn!(
|
||||
channel = self.descriptor.slug,
|
||||
song_index = song_index,
|
||||
"Invalid frame range for song, skipping"
|
||||
);
|
||||
return Err(anyhow!("Invalid frame range"));
|
||||
}
|
||||
|
||||
let channels = decoded.channels;
|
||||
let start = start_frame * channels;
|
||||
let end = end_frame * channels;
|
||||
let slice = decoded
|
||||
.samples
|
||||
.get(start..end)
|
||||
.ok_or_else(|| anyhow!("Sample slice out of bounds"))?;
|
||||
|
||||
let track_samples = slice.to_vec();
|
||||
encode_song_to_cache(
|
||||
Arc::clone(&self.cache_manager),
|
||||
self.descriptor.id,
|
||||
self.descriptor.slug,
|
||||
block.clone(),
|
||||
*song_index,
|
||||
song.clone(),
|
||||
track_samples,
|
||||
decoded.sample_rate,
|
||||
decoded.channels,
|
||||
decoded.bits_per_sample,
|
||||
self.active_clients,
|
||||
duration_ms,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Stocke les métadonnées Radio Paradise pour un fichier audio caché
|
||||
///
|
||||
/// Cette fonction persiste toutes les métadonnées RP dans la base de données
|
||||
@@ -855,134 +802,6 @@ impl BackoffState {
|
||||
}
|
||||
}
|
||||
|
||||
struct DecodedBlock {
|
||||
samples: Vec<i32>,
|
||||
channels: usize,
|
||||
sample_rate: u32,
|
||||
bits_per_sample: u32,
|
||||
}
|
||||
|
||||
fn song_duration_ms(block: &Block, ordered: &[(usize, &Song)], position: usize) -> u64 {
|
||||
let song = ordered[position].1;
|
||||
if song.duration > 0 {
|
||||
return song.duration;
|
||||
}
|
||||
|
||||
if let Some((_, next_song)) = ordered.get(position + 1) {
|
||||
return next_song.elapsed.saturating_sub(song.elapsed);
|
||||
}
|
||||
|
||||
block.length.saturating_sub(song.elapsed)
|
||||
}
|
||||
|
||||
fn ms_to_frames(ms: u64, sample_rate: u32) -> usize {
|
||||
((ms as u128 * sample_rate as u128) / 1000) as usize
|
||||
}
|
||||
|
||||
fn decode_block_audio(data: Vec<u8>) -> anyhow::Result<DecodedBlock> {
|
||||
use symphonia::core::audio::SampleBuffer;
|
||||
use symphonia::core::codecs::{DecoderOptions, CODEC_TYPE_NULL};
|
||||
use symphonia::core::errors::Error as SymphoniaError;
|
||||
use symphonia::core::formats::FormatOptions;
|
||||
use symphonia::core::io::MediaSourceStream;
|
||||
use symphonia::core::meta::MetadataOptions;
|
||||
use symphonia::core::probe::Hint;
|
||||
|
||||
let cursor = std::io::Cursor::new(data);
|
||||
let mss = MediaSourceStream::new(Box::new(cursor), Default::default());
|
||||
|
||||
let hint = Hint::new();
|
||||
let probed = symphonia::default::get_probe()
|
||||
.format(
|
||||
&hint,
|
||||
mss,
|
||||
&FormatOptions::default(),
|
||||
&MetadataOptions::default(),
|
||||
)
|
||||
.map_err(|e| anyhow!("Failed to probe format: {e}"))?;
|
||||
|
||||
let mut format = probed.format;
|
||||
|
||||
let track = format
|
||||
.tracks()
|
||||
.iter()
|
||||
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
|
||||
.ok_or_else(|| anyhow!("No audio track found"))?;
|
||||
|
||||
let mut decoder = symphonia::default::get_codecs()
|
||||
.make(&track.codec_params, &DecoderOptions::default())
|
||||
.map_err(|e| anyhow!("Failed to create decoder: {e}"))?;
|
||||
|
||||
let channels = track
|
||||
.codec_params
|
||||
.channels
|
||||
.ok_or_else(|| anyhow!("Missing channel info"))?
|
||||
.count();
|
||||
|
||||
let sample_rate = track
|
||||
.codec_params
|
||||
.sample_rate
|
||||
.ok_or_else(|| anyhow!("Missing sample rate"))?;
|
||||
|
||||
let bits_per_sample = track.codec_params.bits_per_sample.unwrap_or(16);
|
||||
|
||||
let mut samples_i32 = Vec::new();
|
||||
let track_id = track.id;
|
||||
|
||||
loop {
|
||||
let packet = match format.next_packet() {
|
||||
Ok(packet) => packet,
|
||||
Err(SymphoniaError::ResetRequired) => {
|
||||
decoder.reset();
|
||||
continue;
|
||||
}
|
||||
Err(SymphoniaError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
|
||||
break;
|
||||
}
|
||||
Err(e) => return Err(anyhow!("Decode error: {e}")),
|
||||
};
|
||||
|
||||
if packet.track_id() != track_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
match decoder.decode(&packet) {
|
||||
Ok(decoded) => {
|
||||
let spec = *decoded.spec();
|
||||
let duration = decoded.capacity() as u64;
|
||||
let mut sample_buf = SampleBuffer::<i32>::new(duration, spec);
|
||||
sample_buf.copy_interleaved_ref(decoded);
|
||||
samples_i32.extend_from_slice(sample_buf.samples());
|
||||
}
|
||||
Err(SymphoniaError::DecodeError(_)) => continue,
|
||||
Err(e) => return Err(anyhow!("Decode error: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
if samples_i32.is_empty() {
|
||||
return Err(anyhow!("No samples decoded"));
|
||||
}
|
||||
|
||||
let (normalized_samples, target_bits): (Vec<i32>, u32) = match bits_per_sample {
|
||||
0..=16 => {
|
||||
let samples = samples_i32.iter().map(|&s| (s >> 16) as i32).collect();
|
||||
(samples, 16)
|
||||
}
|
||||
17..=24 => {
|
||||
let samples = samples_i32.iter().map(|&s| (s >> 8) as i32).collect();
|
||||
(samples, 24)
|
||||
}
|
||||
_ => (samples_i32, 32),
|
||||
};
|
||||
|
||||
Ok(DecodedBlock {
|
||||
samples: normalized_samples,
|
||||
channels,
|
||||
sample_rate,
|
||||
bits_per_sample: target_bits,
|
||||
})
|
||||
}
|
||||
|
||||
fn compute_track_id_for_descriptor(descriptor_id: u8, block: &Block, song_index: usize) -> String {
|
||||
format!(
|
||||
"rp:{}:event_{}_song_{}",
|
||||
|
||||
@@ -3,31 +3,23 @@
|
||||
//! Ce module fournit un trait d'extension pour ajouter facilement l'API Radio Paradise
|
||||
//! à un serveur pmoserver.
|
||||
|
||||
use crate::paradise::{max_channel_id, ParadiseChannel, PlaylistEntry, ALL_CHANNELS};
|
||||
use crate::{Block, NowPlaying, RadioParadiseClient, RadioParadiseSource};
|
||||
use crate::paradise::{max_channel_id, ALL_CHANNELS};
|
||||
use crate::{Block, NowPlaying, RadioParadiseClient};
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Path, Query, State},
|
||||
http::{HeaderMap, HeaderName, HeaderValue, StatusCode},
|
||||
response::IntoResponse,
|
||||
http::StatusCode,
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::StreamExt;
|
||||
use pmosource::api::CacheStatusInfo;
|
||||
use pmosource::CacheStatus;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::error;
|
||||
use utoipa::{IntoParams, OpenApi, ToSchema};
|
||||
use utoipa::{OpenApi, ToSchema};
|
||||
|
||||
/// État partagé pour l'API Radio Paradise
|
||||
#[derive(Clone)]
|
||||
pub struct RadioParadiseState {
|
||||
client: Arc<RwLock<RadioParadiseClient>>,
|
||||
source: Arc<RadioParadiseSource>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
@@ -36,46 +28,14 @@ struct ParadiseQuery {
|
||||
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 {
|
||||
pub async fn new() -> anyhow::Result<Self> {
|
||||
let client = RadioParadiseClient::new()
|
||||
.await
|
||||
.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 {
|
||||
client: Arc::new(RwLock::new(client)),
|
||||
source: Arc::new(source),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -100,19 +60,6 @@ impl RadioParadiseState {
|
||||
|
||||
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
|
||||
@@ -365,425 +312,28 @@ async fn get_channels() -> Json<Vec<ChannelInfo>> {
|
||||
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
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
info(
|
||||
title = "Radio Paradise API",
|
||||
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(
|
||||
get_now_playing,
|
||||
get_current_block,
|
||||
get_block_by_id,
|
||||
get_channels,
|
||||
get_channel_status,
|
||||
get_channel_playlist,
|
||||
get_channel_history,
|
||||
stream_channel_by_connection
|
||||
get_channels
|
||||
),
|
||||
components(schemas(
|
||||
NowPlayingResponse,
|
||||
BlockResponse,
|
||||
SongInfo,
|
||||
ChannelInfo,
|
||||
ChannelStatusResponse,
|
||||
ChannelPlaylistEntry,
|
||||
ChannelPlaylistResponse,
|
||||
ChannelHistoryEntry,
|
||||
ChannelHistoryResponse,
|
||||
CacheStatusInfo
|
||||
ChannelInfo
|
||||
)),
|
||||
tags(
|
||||
(name = "Radio Paradise", description = "Endpoints pour Radio Paradise streaming")
|
||||
(name = "Radio Paradise", description = "Endpoints pour Radio Paradise")
|
||||
)
|
||||
)]
|
||||
pub struct RadioParadiseApiDoc;
|
||||
@@ -795,13 +345,6 @@ pub fn create_api_router(state: RadioParadiseState) -> Router {
|
||||
.route("/block/current", get(get_current_block))
|
||||
.route("/block/{event_id}", get(get_block_by_id))
|
||||
.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)
|
||||
}
|
||||
|
||||
@@ -817,7 +360,7 @@ pub trait RadioParadiseExt {
|
||||
/// - API: `/api/radioparadise/*`
|
||||
/// - `/now-playing`
|
||||
/// - `/block/*`
|
||||
/// - `/channels/{channel_id}/stream/{connection_id}`
|
||||
/// - `/channels`
|
||||
/// - Swagger: `/swagger-ui/radioparadise`
|
||||
async fn init_radioparadise(&mut self) -> anyhow::Result<RadioParadiseState>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user