- Replace `.lock().unwrap()` with explicit error messages for poisoned MutexGuard - Improve robustness against mutex poisoning in renderer, queue and event subsystems
225 lines
7.9 KiB
Rust
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"));
|
|
}
|
|
}
|