Add async_trait annotation to RadioParadiseExt trait and its implementation to suppress the "async fn in public traits" warning. This is the recommended approach for traits with async methods as it ensures proper Future bounds (Send) are generated.
386 lines
11 KiB
Rust
386 lines
11 KiB
Rust
//! Extension pmoserver pour Radio Paradise
|
|
//!
|
|
//! Ce module fournit un trait d'extension pour ajouter facilement l'API Radio Paradise
|
|
//! à un serveur pmoserver.
|
|
|
|
use crate::channels::{max_channel_id, ChannelDescriptor, ALL_CHANNELS};
|
|
use crate::{Block, NowPlaying, RadioParadiseClient};
|
|
use async_trait::async_trait;
|
|
use axum::{
|
|
extract::{Path, Query, State},
|
|
http::StatusCode,
|
|
routing::get,
|
|
Json, Router,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::sync::Arc;
|
|
use tokio::sync::RwLock;
|
|
use utoipa::{OpenApi, ToSchema};
|
|
|
|
/// État partagé pour l'API Radio Paradise
|
|
#[derive(Clone)]
|
|
pub struct RadioParadiseState {
|
|
client: Arc<RwLock<RadioParadiseClient>>,
|
|
}
|
|
|
|
#[derive(Debug, Default, Deserialize)]
|
|
#[serde(default)]
|
|
struct ParadiseQuery {
|
|
channel: Option<u8>,
|
|
}
|
|
|
|
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))?;
|
|
|
|
Ok(Self {
|
|
client: Arc::new(RwLock::new(client)),
|
|
})
|
|
}
|
|
|
|
async fn client_for_params(
|
|
&self,
|
|
params: &ParadiseQuery,
|
|
) -> Result<RadioParadiseClient, StatusCode> {
|
|
let base_client = {
|
|
let client_guard = self.client.read().await;
|
|
client_guard.clone()
|
|
};
|
|
|
|
let mut client = base_client;
|
|
|
|
if let Some(channel) = params.channel {
|
|
if channel > max_channel_id() {
|
|
tracing::warn!("Invalid Radio Paradise channel requested: {}", channel);
|
|
return Err(StatusCode::BAD_REQUEST);
|
|
}
|
|
client = client.clone_with_channel(channel);
|
|
}
|
|
|
|
Ok(client)
|
|
}
|
|
}
|
|
|
|
/// Information sur un canal Radio Paradise
|
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
|
pub struct ChannelInfo {
|
|
/// ID du canal (0-3)
|
|
pub id: u8,
|
|
/// Nom du canal
|
|
pub name: String,
|
|
/// Description
|
|
pub description: String,
|
|
}
|
|
|
|
impl From<&ChannelDescriptor> for ChannelInfo {
|
|
fn from(descriptor: &ChannelDescriptor) -> Self {
|
|
Self {
|
|
id: descriptor.id,
|
|
name: descriptor.display_name.to_string(),
|
|
description: descriptor.description.to_string(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Réponse avec informations étendues sur le morceau en cours
|
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
|
pub struct NowPlayingResponse {
|
|
/// Event ID du block actuel
|
|
pub event: u64,
|
|
/// Event ID du prochain block
|
|
pub end_event: u64,
|
|
/// URL de streaming du block
|
|
pub stream_url: String,
|
|
/// Durée totale du block en ms
|
|
pub block_length_ms: u64,
|
|
/// Index du morceau actuel
|
|
pub current_song_index: Option<usize>,
|
|
/// Morceau actuel
|
|
pub current_song: Option<SongInfo>,
|
|
/// Tous les morceaux du block
|
|
pub songs: Vec<SongInfo>,
|
|
}
|
|
|
|
/// Information sur un morceau
|
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
|
pub struct SongInfo {
|
|
/// Index dans le block
|
|
pub index: usize,
|
|
/// Artiste
|
|
pub artist: String,
|
|
/// Titre
|
|
pub title: String,
|
|
/// Album
|
|
pub album: String,
|
|
/// Année
|
|
pub year: Option<u32>,
|
|
/// Temps écoulé depuis le début du block (ms)
|
|
pub elapsed_ms: u64,
|
|
/// Durée du morceau (ms)
|
|
pub duration_ms: u64,
|
|
/// URL de la pochette
|
|
pub cover_url: Option<String>,
|
|
/// Note (0-10)
|
|
pub rating: Option<f32>,
|
|
}
|
|
|
|
/// Réponse pour un block
|
|
#[derive(Debug, Clone, Serialize, ToSchema)]
|
|
pub struct BlockResponse {
|
|
/// Event ID du block
|
|
pub event: u64,
|
|
/// Event ID du prochain block
|
|
pub end_event: u64,
|
|
/// URL de streaming
|
|
pub url: String,
|
|
/// Durée totale (ms)
|
|
pub length_ms: u64,
|
|
/// Morceaux du block
|
|
pub songs: Vec<SongInfo>,
|
|
}
|
|
|
|
impl From<Block> for BlockResponse {
|
|
fn from(block: Block) -> Self {
|
|
let songs = block
|
|
.songs_ordered()
|
|
.into_iter()
|
|
.map(|(index, song)| SongInfo {
|
|
index,
|
|
artist: song.artist.clone(),
|
|
title: song.title.clone(),
|
|
album: song.album.clone().unwrap_or_default(),
|
|
year: song.year,
|
|
elapsed_ms: song.elapsed,
|
|
duration_ms: song.duration,
|
|
cover_url: song.cover.as_ref().and_then(|c| block.cover_url(c)),
|
|
rating: song.rating,
|
|
})
|
|
.collect();
|
|
|
|
Self {
|
|
event: block.event,
|
|
end_event: block.end_event,
|
|
url: block.url,
|
|
length_ms: block.length,
|
|
songs,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<NowPlaying> for NowPlayingResponse {
|
|
fn from(np: NowPlaying) -> Self {
|
|
let songs: Vec<SongInfo> = np
|
|
.block
|
|
.songs_ordered()
|
|
.into_iter()
|
|
.map(|(index, song)| SongInfo {
|
|
index,
|
|
artist: song.artist.clone(),
|
|
title: song.title.clone(),
|
|
album: song.album.clone().unwrap_or_default(),
|
|
year: song.year,
|
|
elapsed_ms: song.elapsed,
|
|
duration_ms: song.duration,
|
|
cover_url: song.cover.as_ref().and_then(|c| np.block.cover_url(c)),
|
|
rating: song.rating,
|
|
})
|
|
.collect();
|
|
|
|
let current_song = np.current_song.as_ref().and_then(|song| {
|
|
let index = np.current_song_index?;
|
|
Some(SongInfo {
|
|
index,
|
|
artist: song.artist.clone(),
|
|
title: song.title.clone(),
|
|
album: song.album.clone().unwrap_or_default(),
|
|
year: song.year,
|
|
elapsed_ms: song.elapsed,
|
|
duration_ms: song.duration,
|
|
cover_url: song.cover.as_ref().and_then(|c| np.block.cover_url(c)),
|
|
rating: song.rating,
|
|
})
|
|
});
|
|
|
|
Self {
|
|
event: np.block.event,
|
|
end_event: np.block.end_event,
|
|
stream_url: np.block.url,
|
|
block_length_ms: np.block.length,
|
|
current_song_index: np.current_song_index,
|
|
current_song,
|
|
songs,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// GET /now-playing - Récupère le morceau en cours
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/now-playing",
|
|
params(
|
|
("channel" = Option<u8>, Query, description = "Channel ID (0-3)")
|
|
),
|
|
responses(
|
|
(status = 200, description = "Morceau en cours", body = NowPlayingResponse),
|
|
(status = 500, description = "Erreur serveur")
|
|
),
|
|
tag = "Radio Paradise"
|
|
)]
|
|
async fn get_now_playing(
|
|
State(state): State<RadioParadiseState>,
|
|
Query(params): Query<ParadiseQuery>,
|
|
) -> Result<Json<NowPlayingResponse>, StatusCode> {
|
|
let client = state.client_for_params(¶ms).await?;
|
|
let now_playing = client.now_playing().await.map_err(|e| {
|
|
tracing::error!("Failed to fetch now playing from Radio Paradise: {}", e);
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
Ok(Json(now_playing.into()))
|
|
}
|
|
|
|
/// GET /block/current - Récupère le block actuel
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/block/current",
|
|
params(
|
|
("channel" = Option<u8>, Query, description = "Channel ID (0-3)")
|
|
),
|
|
responses(
|
|
(status = 200, description = "Block actuel", body = BlockResponse),
|
|
(status = 500, description = "Erreur serveur")
|
|
),
|
|
tag = "Radio Paradise"
|
|
)]
|
|
async fn get_current_block(
|
|
State(state): State<RadioParadiseState>,
|
|
Query(params): Query<ParadiseQuery>,
|
|
) -> Result<Json<BlockResponse>, StatusCode> {
|
|
let client = state.client_for_params(¶ms).await?;
|
|
let block = client.get_block(None).await.map_err(|e| {
|
|
tracing::error!("Failed to fetch current block from Radio Paradise: {}", e);
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
Ok(Json(block.into()))
|
|
}
|
|
|
|
/// GET /block/{event_id} - Récupère un block spécifique
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/block/{event_id}",
|
|
params(
|
|
("event_id" = u64, Path, description = "Event ID du block"),
|
|
("channel" = Option<u8>, Query, description = "Channel ID (0-3)")
|
|
),
|
|
responses(
|
|
(status = 200, description = "Block demandé", body = BlockResponse),
|
|
(status = 500, description = "Erreur serveur")
|
|
),
|
|
tag = "Radio Paradise"
|
|
)]
|
|
async fn get_block_by_id(
|
|
State(state): State<RadioParadiseState>,
|
|
Path(event_id): Path<u64>,
|
|
Query(params): Query<ParadiseQuery>,
|
|
) -> Result<Json<BlockResponse>, StatusCode> {
|
|
let client = state.client_for_params(¶ms).await?;
|
|
let block = client.get_block(Some(event_id)).await.map_err(|e| {
|
|
tracing::error!(
|
|
"Failed to fetch block {} from Radio Paradise: {}",
|
|
event_id,
|
|
e
|
|
);
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
Ok(Json(block.into()))
|
|
}
|
|
|
|
/// GET /channels - Liste les canaux disponibles
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/channels",
|
|
responses(
|
|
(status = 200, description = "Liste des canaux", body = Vec<ChannelInfo>)
|
|
),
|
|
tag = "Radio Paradise"
|
|
)]
|
|
async fn get_channels() -> Json<Vec<ChannelInfo>> {
|
|
let channels: Vec<ChannelInfo> = ALL_CHANNELS.iter().map(Into::into).collect();
|
|
Json(channels)
|
|
}
|
|
|
|
/// 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 de Radio Paradise"
|
|
),
|
|
paths(
|
|
get_now_playing,
|
|
get_current_block,
|
|
get_block_by_id,
|
|
get_channels
|
|
),
|
|
components(schemas(
|
|
NowPlayingResponse,
|
|
BlockResponse,
|
|
SongInfo,
|
|
ChannelInfo
|
|
)),
|
|
tags(
|
|
(name = "Radio Paradise", description = "Endpoints pour Radio Paradise")
|
|
)
|
|
)]
|
|
pub struct RadioParadiseApiDoc;
|
|
|
|
/// Crée le router pour l'API Radio Paradise
|
|
pub fn create_api_router(state: RadioParadiseState) -> Router {
|
|
Router::new()
|
|
.route("/now-playing", get(get_now_playing))
|
|
.route("/block/current", get(get_current_block))
|
|
.route("/block/{event_id}", get(get_block_by_id))
|
|
.route("/channels", get(get_channels))
|
|
.with_state(state)
|
|
}
|
|
|
|
/// Trait d'extension pour pmoserver::Server
|
|
///
|
|
/// Permet d'initialiser Radio Paradise avec routes HTTP complètes
|
|
#[cfg(feature = "pmoserver")]
|
|
#[async_trait]
|
|
pub trait RadioParadiseExt {
|
|
/// Initialise l'API Radio Paradise
|
|
///
|
|
/// # Routes créées
|
|
///
|
|
/// - API: `/api/radioparadise/*`
|
|
/// - `/now-playing`
|
|
/// - `/block/*`
|
|
/// - `/channels`
|
|
/// - Swagger: `/swagger-ui/radioparadise`
|
|
async fn init_radioparadise(&mut self) -> anyhow::Result<RadioParadiseState>;
|
|
}
|
|
|
|
#[cfg(feature = "pmoserver")]
|
|
#[async_trait]
|
|
impl RadioParadiseExt for pmoserver::Server {
|
|
async fn init_radioparadise(&mut self) -> anyhow::Result<RadioParadiseState> {
|
|
let state = RadioParadiseState::new().await?;
|
|
|
|
// Créer le router API
|
|
let api_router = create_api_router(state.clone());
|
|
|
|
// L'enregistrer avec OpenAPI
|
|
self.add_openapi(api_router, RadioParadiseApiDoc::openapi(), "radioparadise")
|
|
.await;
|
|
|
|
Ok(state)
|
|
}
|
|
}
|