Files
pmomusic/pmocontrol/src/music_renderer/stream_detection.rs
Eric Coissac 39d7c56a99 Replace unwrap() with expect("mutex poisoned") across all mutex locks
- Replace `.lock().unwrap()` with explicit error messages for poisoned MutexGuard
- Improve robustness against mutex poisoning in renderer, queue and event subsystems
2026-04-12 21:09:38 +02:00

225 lines
7.9 KiB
Rust

//! Stream detection utilities for identifying continuous streams (radio) vs bounded media.
//!
//! This module provides utilities to detect whether a given URL points to a continuous
//! stream (like a radio station) or a bounded media file by analyzing HTTP headers.
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, LazyLock, Mutex};
use std::time::Duration;
use tracing::{debug, trace};
use ureq::Agent;
/// Cache global des résultats de détection de stream par URL
static STREAM_CACHE: LazyLock<Arc<Mutex<HashMap<String, bool>>>> =
LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
/// Set des URLs actuellement en cours de vérification (pour éviter les doublons)
static PENDING_CHECKS: LazyLock<Arc<Mutex<HashSet<String>>>> =
LazyLock::new(|| Arc::new(Mutex::new(HashSet::new())));
/// Default timeout for HTTP HEAD requests when detecting stream type
const DEFAULT_STREAM_DETECTION_TIMEOUT_SECS: u64 = 3;
/// Détecte si une URL correspond à un flux continu (radio sans durée définie).
///
/// Cette fonction effectue une requête HTTP HEAD sur l'URL fournie et analyse les headers
/// de la réponse pour déterminer si c'est un flux continu ou un fichier avec durée définie.
///
/// **Optimisation** : Les résultats sont mis en cache pour éviter de refaire la détection
/// sur la même URL. Si une détection est déjà en cours pour cette URL, la fonction attend
/// ou retourne false pour ne pas bloquer.
///
/// # Critères de détection d'un flux continu :
///
/// - Absence de header `Content-Length` (pas de taille définie)
/// - OU présence de `Transfer-Encoding: chunked` sans `Content-Length`
/// - OU `Content-Type` indiquant un stream (audio/mpeg avec icy-*, application/ogg, etc.)
/// - OU présence de headers ICY (Icecast/Shoutcast) qui indiquent toujours un stream
///
/// # Arguments
///
/// * `url` - L'URL à analyser
///
/// # Returns
///
/// `true` si l'URL correspond à un flux continu, `false` sinon.
/// En cas d'erreur de connexion, retourne `false` par défaut (considéré comme non-stream).
pub fn is_continuous_stream_url(url: &str) -> bool {
// Quick checks on URL pattern before making HTTP request
if is_known_stream_pattern(url) {
debug!("URL {} matches known stream pattern", url);
return true;
}
// Check cache first
{
let cache = STREAM_CACHE.lock().expect("stream cache mutex poisoned");
if let Some(&cached_result) = cache.get(url) {
trace!("Cache hit for {}: is_stream={}", url, cached_result);
return cached_result;
}
}
// Check if already being verified
{
let mut pending = PENDING_CHECKS.lock().expect("pending checks mutex poisoned");
if pending.contains(url) {
debug!(
"Stream detection already in progress for {}, returning false temporarily",
url
);
return false;
}
// Mark as pending
pending.insert(url.to_string());
}
// Spawn background task for detection
let url_owned = url.to_string();
std::thread::spawn(move || {
let result = match check_stream_headers(&url_owned) {
Ok(is_stream) => {
trace!("Stream detection for {}: {}", url_owned, is_stream);
is_stream
}
Err(e) => {
debug!(
"Failed to detect stream type for {}: {}, assuming non-stream",
url_owned, e
);
false
}
};
// Store in cache
{
let mut cache = STREAM_CACHE.lock().expect("stream cache mutex poisoned");
cache.insert(url_owned.clone(), result);
}
// Remove from pending
{
let mut pending = PENDING_CHECKS.lock().expect("pending checks mutex poisoned");
pending.remove(&url_owned);
}
debug!(
"Stream detection completed for {}: is_stream={}",
url_owned, result
);
});
// Return false temporarily while detection is in progress
// The watcher will pick up the correct value on next poll
false
}
/// Vérifie si l'URL correspond à un pattern connu de streaming
fn is_known_stream_pattern(url: &str) -> bool {
let url_lower = url.to_lowercase();
// Common streaming endpoints
url_lower.contains("/stream")
|| url_lower.contains("/live")
|| url_lower.contains("/radio")
|| url_lower.contains(".pls")
|| url_lower.contains(".m3u")
|| url_lower.ends_with(":8000")
|| url_lower.ends_with(":8080")
}
/// Effectue une requête HTTP HEAD et analyse les headers
fn check_stream_headers(url: &str) -> Result<bool, String> {
let agent: Agent = Agent::config_builder()
.timeout_global(Some(Duration::from_secs(
DEFAULT_STREAM_DETECTION_TIMEOUT_SECS,
)))
.build()
.into();
let response = agent
.head(url)
.call()
.map_err(|e| format!("HTTP HEAD request failed: {}", e))?;
// Check for ICY headers (Icecast/Shoutcast) - always indicates streaming
if response.headers().get("icy-name").is_some()
|| response.headers().get("icy-metaint").is_some()
|| response.headers().get("ice-audio-info").is_some()
{
debug!("ICY headers detected for {}, this is a stream", url);
return Ok(true);
}
// Check Content-Length
let has_content_length = response.headers().get("content-length").is_some();
// Check Transfer-Encoding
let is_chunked = response
.headers()
.get("transfer-encoding")
.and_then(|v| v.to_str().ok())
.map(|v| v.to_lowercase().contains("chunked"))
.unwrap_or(false);
// Check Content-Type for streaming indicators
let content_type = response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let content_type_lower = content_type.to_lowercase();
let is_streaming_mime = content_type_lower.contains("audio/mpeg")
|| content_type_lower.contains("audio/aac")
|| content_type_lower.contains("audio/aacp")
|| content_type_lower.contains("application/ogg")
|| content_type_lower.contains("audio/ogg");
// Decision logic:
// - No Content-Length + streaming MIME = stream
// - Chunked encoding without Content-Length = likely stream
// - Has Content-Length = bounded media (not a stream)
let is_stream = !has_content_length && (is_streaming_mime || is_chunked);
trace!(
"Stream detection for {}: content-length={}, chunked={}, streaming_mime={}, is_stream={}",
url,
has_content_length,
is_chunked,
is_streaming_mime,
is_stream
);
Ok(is_stream)
}
/// Canonical check: returns `true` if this item should be treated as a continuous stream.
///
/// Checks `metadata.is_continuous_stream` first (already computed at ingest time),
/// then falls back to the URL-based HTTP detection.
///
/// Use this function everywhere transport-layer code needs to decide whether playback is
/// a continuous stream (radio) vs bounded media (file/album track).
pub fn is_continuous_stream(metadata: Option<&crate::model::TrackMetadata>, uri: &str) -> bool {
metadata.map(|m| m.is_continuous_stream).unwrap_or(false) || is_continuous_stream_url(uri)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_known_stream_patterns() {
assert!(is_known_stream_pattern("http://example.com/stream"));
assert!(is_known_stream_pattern("http://example.com/live"));
assert!(is_known_stream_pattern("http://example.com/radio.mp3"));
assert!(is_known_stream_pattern("http://example.com:8000/"));
assert!(is_known_stream_pattern("http://example.com/playlist.m3u"));
assert!(!is_known_stream_pattern("http://example.com/music.mp3"));
assert!(!is_known_stream_pattern("http://example.com/file.flac"));
}
}