Files
pmomusic/pmoradiofrance/src/api_rest.rs
Eric Coissac a0c1ab4228 Bump version to 0.3.12 and enhance Radio France source integration
Bump version from 0.3.11 to 0.3.12

- Update Cargo.toml and version.txt to 0.3.12
- Refactor Radio France source registration to share Arc with API
- Enhance API routes to start/stop metadata refresh on stream access
- Add cleanup logic for stream disconnection
- Improve logging for metadata refresh operations
- Update Radio France source methods to be public for external access
2026-01-23 23:13:53 +01:00

196 lines
6.4 KiB
Rust

//! Endpoints API REST pour Radio France
//!
//! Ce module définit les handlers HTTP pour accéder aux stations Radio France,
//! leurs métadonnées live et les flux de streaming.
use crate::models::LiveResponse;
use crate::playlist::StationGroups;
use crate::pmoserver_ext::RadioFranceState;
use axum::{
body::Body,
extract::{Path, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
routing::get,
Json, Router,
};
use futures::StreamExt;
use serde_json;
use std::sync::Arc;
// ============ Gestion des erreurs ============
struct AppError(String);
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match self.0.as_str() {
"not_found" => (StatusCode::NOT_FOUND, self.0),
"internal_error" => (StatusCode::INTERNAL_SERVER_ERROR, self.0),
"bad_gateway" => (StatusCode::BAD_GATEWAY, self.0),
_ => (StatusCode::INTERNAL_SERVER_ERROR, self.0),
};
let body = Json(serde_json::json!({
"error": message
}));
(status, body).into_response()
}
}
impl From<String> for AppError {
fn from(err: String) -> Self {
Self(err)
}
}
/// Crée le router pour l'API Radio France
pub fn create_router(state: RadioFranceState) -> Router {
Router::new()
.route("/stations", get(get_stations))
.route("/{slug}/metadata", get(get_metadata))
.route("/{slug}/stream", get(proxy_stream))
.route("/default-logo", get(get_default_logo))
.with_state(state)
}
// ============================================================================
// Route Handlers
// ============================================================================
/// GET /api/radiofrance/stations
/// Returns the grouped list of stations
#[axum::debug_handler]
async fn get_stations(
State(state): State<RadioFranceState>,
) -> Result<Json<StationGroups>, AppError> {
let stations = state
.client
.get_stations()
.await
.map_err(|e| AppError(e.to_string()))?;
let groups = StationGroups::from_stations(stations);
Ok(Json(groups))
}
/// GET /api/radiofrance/{slug}/metadata
/// Returns live metadata for a station (with caching)
async fn get_metadata(
State(state): State<RadioFranceState>,
Path(slug): Path<String>,
) -> Result<Json<LiveResponse>, AppError> {
let metadata = state
.client
.get_live_metadata(&slug)
.await
.map_err(|e| AppError(e.to_string()))?;
Ok(Json(metadata))
}
/// GET /api/radiofrance/{slug}/stream
/// Proxies the AAC stream from Radio France (passthrough, no transcoding)
async fn proxy_stream(
State(state): State<RadioFranceState>,
Path(slug): Path<String>,
) -> Result<Response, AppError> {
// Start metadata refresh when stream is accessed
#[cfg(feature = "logging")]
tracing::info!("Stream proxy accessed for station: {}", slug);
if let Some(ref source) = state.source {
// Spawn refresh task (non-blocking)
let source_clone = Arc::clone(source);
let slug_clone = slug.clone();
tokio::spawn(async move {
if let Err(e) = source_clone.start_metadata_refresh(&slug_clone).await {
#[cfg(feature = "logging")]
tracing::error!("Failed to start metadata refresh for {}: {}", slug_clone, e);
}
});
} else {
#[cfg(feature = "logging")]
tracing::warn!("No source available to start metadata refresh");
}
// Get the stream URL
let stream_url = state
.client
.get_stream_url(&slug)
.await
.map_err(|e| AppError(format!("Stream not found: {}", e)))?;
// Connect to the Radio France stream
let response = reqwest::get(&stream_url)
.await
.map_err(|e| AppError(format!("Failed to connect: {}", e)))?;
if !response.status().is_success() {
return Err(AppError(format!("Upstream returned {}", response.status())));
}
// Build response headers
let mut headers = HeaderMap::new();
headers.insert("content-type", "audio/aac".parse().unwrap());
headers.insert("cache-control", "no-cache".parse().unwrap());
// Create streaming body with cleanup on disconnect
let stream = response
.bytes_stream()
.map(|chunk| chunk.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e)));
// Wrap the stream to detect when client disconnects
let source_for_cleanup = state.source.clone();
let slug_for_cleanup = slug.clone();
let monitored_stream =
futures::stream::unfold((stream, false), move |(mut stream, mut done)| {
let source = source_for_cleanup.clone();
let slug = slug_for_cleanup.clone();
async move {
if done {
return None;
}
match stream.next().await {
Some(Ok(chunk)) => Some((Ok(chunk), (stream, false))),
Some(Err(e)) => {
// Error occurred, stop refresh
#[cfg(feature = "logging")]
tracing::info!("Stream error for {}, stopping refresh", slug);
if let Some(src) = source {
src.stop_metadata_refresh(&slug).await;
}
Some((Err(e), (stream, true)))
}
None => {
// Stream ended normally, stop refresh
#[cfg(feature = "logging")]
tracing::info!("Stream ended for {}, stopping refresh", slug);
if let Some(src) = source {
src.stop_metadata_refresh(&slug).await;
}
None
}
}
}
});
let body = Body::from_stream(monitored_stream);
Ok((headers, body).into_response())
}
/// GET /api/radiofrance/default-logo
/// Returns the default Radio France logo (embedded in binary)
async fn get_default_logo() -> impl IntoResponse {
use crate::source::RADIOFRANCE_DEFAULT_IMAGE;
let mut headers = HeaderMap::new();
headers.insert("Content-Type", "image/webp".parse().unwrap());
headers.insert("Cache-Control", "public, max-age=86400".parse().unwrap());
(headers, RADIOFRANCE_DEFAULT_IMAGE).into_response()
}