Implémentation de la détection de flux continu et gestion de la barre de progression
Cette mise à jour implémente la détection des flux continus (radio, streaming) et améliore la gestion de la barre de progression pour ces flux. Les changements incluent : - Ajout d'une méthode predicat `is_playing_a_stream()` au niveau de `MusicRenderer` - Développement d'une fonction utilitaire `is_continuous_stream_url()` pour analyser les headers HTTP - Mise à jour des backends (UPnP, OpenHome, LinkPlay, ArylicTcp, Chromecast) pour détecter les flux continus - Ajout d'un flag `continuous_stream` dans chaque backend - Export du module de détection de flux - Mise à jour de l'API REST et des événements SSE pour transmettre l'état du flux - Correction de la gestion de la position de lecture pour les flux continus Les flux continus sont maintenant correctement détectés via une analyse HTTP des headers, permettant une gestion appropriée de la barre de progression dans l'interface web.
This commit is contained in:
100
Blackboard/Report/gestion_progressbar_on_stream.md
Normal file
100
Blackboard/Report/gestion_progressbar_on_stream.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# Rapport : Gestion de la barre de progression sur flux continu
|
||||
|
||||
## Résumé
|
||||
|
||||
Implémentation de l'étape 1 de la tâche : création d'une méthode prédicat `is_playing_a_stream()` au niveau de `MusicRenderer` qui détecte si la lecture en cours est un flux continu (radio) en interrogeant directement les serveurs HTTP.
|
||||
|
||||
## Fichiers créés
|
||||
|
||||
- `pmocontrol/src/music_renderer/stream_detection.rs` - Module utilitaire pour détecter les flux continus via analyse HTTP
|
||||
|
||||
## Fichiers modifiés
|
||||
|
||||
### Nouveaux champs `continuous_stream` ajoutés aux backends
|
||||
|
||||
1. `pmocontrol/src/music_renderer/upnp_renderer.rs`
|
||||
- Ajout du champ `continuous_stream: Arc<Mutex<bool>>`
|
||||
- Méthode `is_continuous_stream() -> bool`
|
||||
- Détection dans `play_uri()` et `play_from_queue()` via `is_continuous_stream_url()`
|
||||
|
||||
2. `pmocontrol/src/music_renderer/openhome_renderer.rs`
|
||||
- Ajout des champs `continuous_stream: Arc<Mutex<bool>>` et `current_track_uri: Arc<Mutex<Option<String>>>`
|
||||
- Méthode `is_continuous_stream() -> bool`
|
||||
- Détection dans `playback_position()` uniquement lors du changement d'URL
|
||||
|
||||
3. `pmocontrol/src/music_renderer/linkplay_renderer.rs`
|
||||
- Ajout du champ `continuous_stream: Arc<Mutex<bool>>`
|
||||
- Méthode `is_continuous_stream() -> bool`
|
||||
- Détection dans `play_uri()` via `is_continuous_stream_url()`
|
||||
|
||||
4. `pmocontrol/src/music_renderer/arylic_tcp.rs`
|
||||
- Ajout du champ `continuous_stream: Arc<Mutex<bool>>`
|
||||
- Méthode `is_continuous_stream() -> bool`
|
||||
|
||||
5. `pmocontrol/src/music_renderer/chromecast_renderer.rs`
|
||||
- Ajout du champ `continuous_stream: Arc<Mutex<bool>>`
|
||||
- Méthode `is_continuous_stream() -> bool`
|
||||
- Détection dans `play_uri()` via `is_continuous_stream_url()`
|
||||
|
||||
### Méthode publique au niveau MusicRenderer
|
||||
|
||||
6. `pmocontrol/src/music_renderer/musicrenderer.rs`
|
||||
- Ajout de la méthode publique `is_playing_a_stream() -> bool` qui :
|
||||
- Vérifie que le renderer est en état `Playing`
|
||||
- Interroge le backend pour le statut `continuous_stream`
|
||||
- Retourne `false` si non en lecture ou si lecture d'un fichier avec durée
|
||||
|
||||
### Export du module
|
||||
|
||||
7. `pmocontrol/src/music_renderer/mod.rs`
|
||||
- Déclaration du module `stream_detection`
|
||||
- Export public de `is_continuous_stream_url`
|
||||
|
||||
## Approche technique
|
||||
|
||||
### Fonction utilitaire centralisée
|
||||
|
||||
La fonction `is_continuous_stream_url(url: &str) -> bool` analyse l'URL en deux étapes :
|
||||
|
||||
1. **Pattern matching rapide** : Détection de patterns connus (`/stream`, `/live`, `/radio`, ports 8000/8080, etc.)
|
||||
|
||||
2. **Analyse HTTP HEAD** : Si pas de pattern connu, requête HTTP HEAD pour analyser les headers :
|
||||
- Headers ICY (Icecast/Shoutcast) → toujours un stream
|
||||
- Absence de `Content-Length` + MIME type audio → stream
|
||||
- `Transfer-Encoding: chunked` sans `Content-Length` → probablement un stream
|
||||
- Présence de `Content-Length` → fichier délimité (non-stream)
|
||||
|
||||
### Stratégie par backend
|
||||
|
||||
#### Backends sans playlist interne (UPnP, Chromecast, LinkPlay)
|
||||
- Détection au moment du `play_uri()` car on connaît l'URL qu'on va jouer
|
||||
- Appel de `is_continuous_stream_url(uri)` pour analyser le serveur HTTP
|
||||
- Mise à jour du flag `continuous_stream`
|
||||
|
||||
#### Backend OpenHome (playlist interne)
|
||||
- Détection dans `playback_position()` car on ne contrôle pas directement ce qui est joué
|
||||
- **Uniquement lors du changement d'URL** (détecté via `current_track_uri`)
|
||||
- Cache l'URL courante pour éviter les vérifications répétées
|
||||
- Appel de `is_continuous_stream_url()` seulement quand l'URL change
|
||||
|
||||
#### Backend ArylicTcp
|
||||
- Pas de support `play_uri()`, donc flag initialisé mais non utilisé pour l'instant
|
||||
- Prêt pour extension future si nécessaire
|
||||
|
||||
### Points clés de l'implémentation
|
||||
|
||||
1. **Détection basée sur le serveur HTTP réel** et non sur les métadonnées DIDL du MediaServer (qui peuvent être segmentées artificiellement)
|
||||
|
||||
2. **Optimisation OpenHome** : Vérification uniquement au changement d'URL pour éviter les requêtes HTTP répétées
|
||||
|
||||
3. **Timeout de 3 secondes** pour les requêtes HTTP HEAD pour ne pas bloquer
|
||||
|
||||
4. **Gestion d'erreur gracieuse** : En cas d'échec de connexion, considère comme non-stream (comportement par défaut sûr)
|
||||
|
||||
## Résultat
|
||||
|
||||
La méthode `MusicRenderer::is_playing_a_stream()` retourne maintenant `true` si et seulement si :
|
||||
- Le renderer est en état `Playing` ET
|
||||
- L'URL en cours de lecture est un flux continu (détecté via analyse HTTP)
|
||||
|
||||
Cette implémentation pose les bases pour l'étape 2 qui consistera à utiliser ce prédicat pour gérer correctement la barre de progression sur les flux continus segmentés par métadonnées.
|
||||
@@ -1,15 +0,0 @@
|
||||
**Tu réaliseras ce travail en appliquant scrupuleusement les règles définies dans [@Rules.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/Rules.md)**
|
||||
|
||||
Ce travail se réalisera dans la crate [@pmoradiofrance](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmoradiofrance).
|
||||
|
||||
|
||||
- L'ensemble des éditions devraient se réaliser dans le fichier [@playlist.rs](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmoradiofrance/src/playlist.rs).
|
||||
- Il est fort probable que tu n'aies besoin de lire aucun autre fichier.
|
||||
|
||||
Le problème, les groupes de radio sont composés d'une radio principale, parfois, et de web radio parfois.
|
||||
|
||||
Quand on a une radio principale unique, comme France Culture, tout se passe bien.
|
||||
|
||||
Quand on a un groupe de radio avec une radio principale et des web radios, la radio principale devrait se trouver à l'index 0 du container et les radios secondaires aux index suivants. Mais la radio principale est absente. ÷Sans doute qu'elle a été écrasée par des webradio.
|
||||
|
||||
Et quant au seul cas de groupe de radio qui n'ont pas de radio principale, ICI, Les radios locales de Radio France, Rien ne marche du tout, on n'arrive même pas à accéder au container. C'est peut-être un reliquat du temps où tu traitais ICI à part.
|
||||
19
Blackboard/Todo/gestion_progressbar_on_stream.md
Normal file
19
Blackboard/Todo/gestion_progressbar_on_stream.md
Normal file
@@ -0,0 +1,19 @@
|
||||
** Ce travail devra être réalisé en suivant scrupuleusement les consignes listées dans le fichier [@Rules.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/Rules.md) **
|
||||
|
||||
- **La crâte concernée est [@pmocontrol](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmocontrol)** SEULEMENT celle-ci pourra être modifiée.
|
||||
|
||||
**Objectif de la tâche**: fournir une gestion correcte de la barre de progression de lecture sur des flux continu type radio artificiellement segmentée par l'intermédiaire des métadonnées
|
||||
|
||||
Les radios web émettent un flux continu de données. Certaines d'entre elles émettent en parallèle des métadonnées permettant d'un point de vue logique de segmenter ce flux continu en chunks auxquels correspondent des métadonnées différentes. Il s'agit avec ce patch de faire en sorte que la Progress Bar reflète l'état d'avancement à l'intérieur de chacun de ces segments virtuels.
|
||||
|
||||
**Méthode proposée**: Patcher la gestion des événements SSE vers l'application web de manière à envoyer des données de position de lecture en accord avec ces métadonnées dans le cas d'emissions en flux continu.
|
||||
|
||||
**Étape 1**: Implementer au niveau de la classe `MusicRenderer` une méthode predicat `is_playing_a_stream` qui retourne `true` si la lecture est en cours et que la musique est une radio en flux continu, et `false` sinon. Pour construire cela Une méthode adaptée à chacun des `MusicRendererBackend` est nécessaire.
|
||||
|
||||
sur les lecteurs UPNP ou Chromecast ou assimilés. En fait tous les lecteurs qui ne gèrent pas en interne une playlist de lecture, cette implémentation est triviale. Lorsque l'on joue un morceau, on sait quel morceau est en train de jouer, il suffit donc de vérifier que le flux est délimité dans le temps ou pas. Cela peut se faire au moment où l'on envoie l'ordre de lecture d'une URL. On vérifie cette URL et l'on place un flag continous_stream dans le backend correspondant à vrai.
|
||||
|
||||
Pour les rendereurs OpenHome Il me semble que le plus simple est de modifier la méthode `playback_position` de [@openhome.rs](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmocontrol/src/music_renderer/openhome.rs) check si l'URL en train d'être jouée a changé. Si c'est le cas, on vérifie si la nouvelle URL est une radio en flux continu et on met à jour le flag continous_stream.
|
||||
|
||||
**Étape 2**: Afin de tester cette fonctionnalité correctement et pour rendre l'interface w&eb plus informative. Il faudrait pousser via l'interface SSE une information indiquant le changement d'état : Flux continu vs morceau. dans la même idée on peut rajouter à l'API REST du controlpoint Un point indiquant si l'on est en train de jouer un morceau ou un flux.
|
||||
|
||||
On pourra alors modifier l'appli web [@webapp](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmoapp/webapp) en vue Pour qu'elle affiche à côté de l'emplacement où est marquée `Attachée à une playlist` Un petit flag du même type graphique indiquant que l'on est sur une web radio.
|
||||
@@ -7,7 +7,7 @@
|
||||
:title="buttonTitle"
|
||||
ref="buttonRef"
|
||||
>
|
||||
<Clock :size="24" />
|
||||
<Clock :size="36" />
|
||||
<span
|
||||
v-if="timerState?.active && remainingMinutes !== null"
|
||||
class="timer-badge"
|
||||
|
||||
@@ -435,6 +435,10 @@ pub enum RendererEvent {
|
||||
id: DeviceId,
|
||||
binding: Option<PlaylistBinding>,
|
||||
},
|
||||
StreamStateChanged {
|
||||
id: DeviceId,
|
||||
is_stream: bool,
|
||||
},
|
||||
TimerStarted {
|
||||
id: DeviceId,
|
||||
duration_seconds: u32,
|
||||
|
||||
@@ -45,6 +45,8 @@ pub struct ArylicTcpRenderer {
|
||||
port: u16,
|
||||
timeout: Duration,
|
||||
queue: Arc<Mutex<MusicQueue>>,
|
||||
/// Flag indicating if currently playing a continuous stream (radio without duration)
|
||||
continuous_stream: Arc<Mutex<bool>>,
|
||||
}
|
||||
|
||||
impl ArylicTcpRenderer {
|
||||
@@ -123,6 +125,7 @@ impl RendererFromMediaRendererInfo for ArylicTcpRenderer {
|
||||
port: ARYLIC_TCP_PORT,
|
||||
timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
|
||||
queue,
|
||||
continuous_stream: Arc::new(Mutex::new(false)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -131,6 +134,13 @@ impl RendererFromMediaRendererInfo for ArylicTcpRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
impl ArylicTcpRenderer {
|
||||
/// Returns true if currently playing a continuous stream (radio without duration)
|
||||
pub fn is_continuous_stream(&self) -> bool {
|
||||
*self.continuous_stream.lock().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl TransportControl for ArylicTcpRenderer {
|
||||
fn play_uri(&self, _uri: &str, _meta: &str) -> Result<(), ControlPointError> {
|
||||
Err(ControlPointError::upnp_operation_not_supported(
|
||||
|
||||
@@ -59,6 +59,8 @@ pub struct ChromecastRenderer {
|
||||
/// Wrapped in Arc<Mutex> to allow cloning and proper thread lifecycle management.
|
||||
thread_handle: Arc<Mutex<Option<JoinHandle<()>>>>,
|
||||
queue: Arc<Mutex<MusicQueue>>,
|
||||
/// Flag indicating if currently playing a continuous stream (radio without duration)
|
||||
continuous_stream: Arc<Mutex<bool>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ChromecastRenderer {
|
||||
@@ -156,6 +158,7 @@ impl RendererFromMediaRendererInfo for ChromecastRenderer {
|
||||
stop_signal,
|
||||
thread_handle,
|
||||
queue,
|
||||
continuous_stream: Arc::new(Mutex::new(false)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -164,10 +167,26 @@ impl RendererFromMediaRendererInfo for ChromecastRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
impl ChromecastRenderer {
|
||||
/// Returns true if currently playing a continuous stream (radio without duration)
|
||||
pub fn is_continuous_stream(&self) -> bool {
|
||||
*self.continuous_stream.lock().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl TransportControl for ChromecastRenderer {
|
||||
fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
|
||||
debug!("ChromecastRenderer: play_uri({})", uri);
|
||||
|
||||
// Détecte si l'URL est un flux continu
|
||||
let is_stream = crate::music_renderer::is_continuous_stream_url(uri);
|
||||
*self.continuous_stream.lock().unwrap() = is_stream;
|
||||
tracing::debug!(
|
||||
"ChromecastRenderer play_uri: URI={}, continuous_stream={}",
|
||||
uri,
|
||||
is_stream
|
||||
);
|
||||
|
||||
// Signal any existing play thread to stop
|
||||
if let Ok(mut stop) = self.stop_signal.lock() {
|
||||
*stop = true;
|
||||
|
||||
@@ -28,6 +28,8 @@ pub struct LinkPlayRenderer {
|
||||
host: String,
|
||||
timeout: Duration,
|
||||
queue: Arc<Mutex<MusicQueue>>,
|
||||
/// Flag indicating if currently playing a continuous stream (radio without duration)
|
||||
continuous_stream: Arc<Mutex<bool>>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for LinkPlayRenderer {
|
||||
@@ -77,6 +79,7 @@ impl RendererFromMediaRendererInfo for LinkPlayRenderer {
|
||||
host,
|
||||
timeout: Duration::from_secs(DEFAULT_HTTP_TIMEOUT_SECS),
|
||||
queue,
|
||||
continuous_stream: Arc::new(Mutex::new(false)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -85,8 +88,24 @@ impl RendererFromMediaRendererInfo for LinkPlayRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
impl LinkPlayRenderer {
|
||||
/// Returns true if currently playing a continuous stream (radio without duration)
|
||||
pub fn is_continuous_stream(&self) -> bool {
|
||||
*self.continuous_stream.lock().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl TransportControl for LinkPlayRenderer {
|
||||
fn play_uri(&self, uri: &str, _meta: &str) -> Result<(), ControlPointError> {
|
||||
// Détecte si l'URL est un flux continu
|
||||
let is_stream = crate::music_renderer::is_continuous_stream_url(uri);
|
||||
*self.continuous_stream.lock().unwrap() = is_stream;
|
||||
tracing::debug!(
|
||||
"LinkPlayRenderer play_uri: URI={}, continuous_stream={}",
|
||||
uri,
|
||||
is_stream
|
||||
);
|
||||
|
||||
let encoded = percent_encode(uri);
|
||||
self.send_player_command(&format!("play:{}", encoded))
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ mod chromecast_renderer;
|
||||
|
||||
mod musicrenderer;
|
||||
mod sleep_timer;
|
||||
mod stream_detection;
|
||||
pub mod time_utils;
|
||||
pub mod watcher;
|
||||
|
||||
@@ -21,6 +22,7 @@ pub use crate::music_renderer::capabilities::{
|
||||
};
|
||||
pub use crate::music_renderer::musicrenderer::{MusicRenderer, PlaylistBinding};
|
||||
pub use crate::music_renderer::sleep_timer::SleepTimer;
|
||||
pub use crate::music_renderer::stream_detection::is_continuous_stream_url;
|
||||
use crate::{
|
||||
RendererInfo, errors::ControlPointError, music_renderer::musicrenderer::MusicRendererBackend,
|
||||
};
|
||||
|
||||
@@ -82,7 +82,7 @@ pub enum MusicRendererBackend {
|
||||
}
|
||||
|
||||
/// Internal state for tracking playback and control flow.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[derive(Debug, Clone)]
|
||||
struct MusicRendererState {
|
||||
/// Last known track metadata (cached to avoid repeated queries).
|
||||
last_metadata: Option<TrackMetadata>,
|
||||
@@ -96,6 +96,22 @@ struct MusicRendererState {
|
||||
/// This prevents auto-advance on transient STOPPED states during track initialization.
|
||||
/// Auto-advance is only allowed when this flag is true.
|
||||
has_played_since_track_start: bool,
|
||||
/// Timestamp when the current track started playing.
|
||||
/// Used to calculate elapsed time when renderer returns unreliable position info.
|
||||
track_start_time: Option<SystemTime>,
|
||||
}
|
||||
|
||||
impl Default for MusicRendererState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
last_metadata: None,
|
||||
playback_source: PlaybackSource::default(),
|
||||
user_stop_requested: false,
|
||||
sleep_timer: SleepTimer::default(),
|
||||
has_played_since_track_start: false,
|
||||
track_start_time: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -323,7 +339,72 @@ impl MusicRenderer {
|
||||
.expect("WatchedState mutex poisoned");
|
||||
let prev_position = watched.position.clone();
|
||||
|
||||
// Poll position every tick
|
||||
// First poll to check for metadata changes BEFORE getting patched position
|
||||
let raw_position = self
|
||||
.lock_backend_for("poll_position")
|
||||
.playback_position()
|
||||
.ok();
|
||||
|
||||
// Detect and handle metadata/track changes FIRST
|
||||
if let Some(ref raw_pos) = raw_position {
|
||||
if let Some(metadata) = extract_track_metadata(raw_pos) {
|
||||
let metadata_changed = watched
|
||||
.metadata
|
||||
.as_ref()
|
||||
.map(|prev| prev != &metadata)
|
||||
.unwrap_or(true);
|
||||
|
||||
if metadata_changed {
|
||||
// Check if this is a track change (title/artist/album) to reset track_start_time
|
||||
// Also initialize track_start_time on first metadata detection
|
||||
let is_first_metadata = watched.metadata.is_none();
|
||||
let track_changed = watched
|
||||
.metadata
|
||||
.as_ref()
|
||||
.map(|prev| {
|
||||
let title_changed = prev.title != metadata.title;
|
||||
let artist_changed = prev.artist != metadata.artist;
|
||||
let album_changed = prev.album != metadata.album;
|
||||
|
||||
if !is_first_metadata {
|
||||
tracing::info!(
|
||||
"MusicRenderer [{}]: Comparing - title_changed={} ({:?} vs {:?}), artist_changed={} ({:?} vs {:?})",
|
||||
self.info.friendly_name(),
|
||||
title_changed, prev.title, metadata.title,
|
||||
artist_changed, prev.artist, metadata.artist
|
||||
);
|
||||
}
|
||||
|
||||
title_changed || artist_changed || album_changed
|
||||
})
|
||||
.unwrap_or(false); // Only true if there was previous metadata AND it differs
|
||||
|
||||
if is_first_metadata || track_changed {
|
||||
tracing::info!(
|
||||
"MusicRenderer [{}]: {} ({:?} -> {:?}), resetting track_start_time",
|
||||
self.info.friendly_name(),
|
||||
if is_first_metadata {
|
||||
"First metadata"
|
||||
} else {
|
||||
"Track changed"
|
||||
},
|
||||
watched.metadata.as_ref().and_then(|m| m.title.as_ref()),
|
||||
metadata.title
|
||||
);
|
||||
// This resets track_start_time BEFORE we calculate position
|
||||
self.set_last_metadata(Some(metadata.clone()));
|
||||
}
|
||||
|
||||
self.emit_event(RendererEvent::MetadataChanged {
|
||||
id: self.id(),
|
||||
metadata: metadata.clone(),
|
||||
});
|
||||
watched.metadata = Some(metadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now get the fully patched position (with correct track_start_time and rel_time)
|
||||
if let Ok(position) = self.playback_position() {
|
||||
let changed = watched
|
||||
.position
|
||||
@@ -338,29 +419,6 @@ impl MusicRenderer {
|
||||
});
|
||||
}
|
||||
|
||||
// Extract and emit metadata changes
|
||||
if let Some(metadata) = extract_track_metadata(&position) {
|
||||
let metadata_changed = watched
|
||||
.metadata
|
||||
.as_ref()
|
||||
.map(|prev| prev != &metadata)
|
||||
.unwrap_or(true);
|
||||
|
||||
if metadata_changed {
|
||||
debug!(
|
||||
renderer = self.info.friendly_name(),
|
||||
title = metadata.title.as_deref(),
|
||||
artist = metadata.artist.as_deref(),
|
||||
"Emitting metadata changed event"
|
||||
);
|
||||
self.emit_event(RendererEvent::MetadataChanged {
|
||||
id: self.id(),
|
||||
metadata: metadata.clone(),
|
||||
});
|
||||
watched.metadata = Some(metadata);
|
||||
}
|
||||
}
|
||||
|
||||
watched.position = Some(position);
|
||||
}
|
||||
|
||||
@@ -419,6 +477,16 @@ impl MusicRenderer {
|
||||
watched.mute = Some(mute);
|
||||
}
|
||||
}
|
||||
|
||||
// Check stream state (every other tick to avoid excessive polling)
|
||||
let is_stream = self.is_playing_a_stream();
|
||||
if watched.is_stream != Some(is_stream) {
|
||||
self.emit_event(RendererEvent::StreamStateChanged {
|
||||
id: self.id(),
|
||||
is_stream,
|
||||
});
|
||||
watched.is_stream = Some(is_stream);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -573,6 +641,43 @@ impl MusicRenderer {
|
||||
self.info.capabilities().supports_set_next()
|
||||
}
|
||||
|
||||
/// Returns true if currently playing a continuous stream (radio without duration).
|
||||
///
|
||||
/// This method queries the backend to determine if the current playback is a continuous
|
||||
/// stream. The detection is based on HTTP headers analysis performed when the URL was
|
||||
/// set via play_uri or when the track changed (for OpenHome).
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `true` if:
|
||||
/// - The renderer is currently playing AND
|
||||
/// - The current track is a continuous stream (radio, live broadcast, etc.)
|
||||
///
|
||||
/// `false` otherwise (not playing, or playing a bounded media file)
|
||||
pub fn is_playing_a_stream(&self) -> bool {
|
||||
let backend = self.lock_backend_for("is_playing_a_stream");
|
||||
|
||||
// Check if currently playing
|
||||
let is_playing = match backend.playback_state() {
|
||||
Ok(PlaybackState::Playing) => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if !is_playing {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Query backend for stream status
|
||||
match &*backend {
|
||||
MusicRendererBackend::Upnp(upnp) => upnp.is_continuous_stream(),
|
||||
MusicRendererBackend::OpenHome(oh) => oh.is_continuous_stream(),
|
||||
MusicRendererBackend::LinkPlay(lp) => lp.is_continuous_stream(),
|
||||
MusicRendererBackend::ArylicTcp(ary) => ary.is_continuous_stream(),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.is_continuous_stream(),
|
||||
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.is_continuous_stream(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Prepare the renderer for attaching a new playlist by clearing the queue and stopping playback.
|
||||
pub fn clear_for_playlist_attach(&self) -> Result<(), ControlPointError> {
|
||||
let mut backend = self.lock_backend_for("clear_for_playlist_attach");
|
||||
@@ -755,19 +860,19 @@ impl MusicRenderer {
|
||||
}
|
||||
|
||||
/// Get playback position
|
||||
///
|
||||
/// This method patches the position info from the backend:
|
||||
/// - If track_duration is None, try to extract it from DIDL metadata
|
||||
/// - Calculates rel_time from track_start_time (backend values are unreliable)
|
||||
///
|
||||
/// Note: track_start_time is updated by poll_and_emit_changes when track changes
|
||||
pub fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
|
||||
let mut position_info = self
|
||||
.lock_backend_for("playback_position")
|
||||
.playback_position()?;
|
||||
|
||||
// Si track_duration est absent ou invalide, essayer de le parser depuis le DIDL metadata
|
||||
let needs_duration_fix = position_info
|
||||
.track_duration
|
||||
.as_ref()
|
||||
.map(|d| d == "00:00:00" || d == "0:00:00")
|
||||
.unwrap_or(true); // None = true
|
||||
|
||||
if needs_duration_fix {
|
||||
// Si track_duration est absent, essayer de le parser depuis le DIDL metadata
|
||||
if position_info.track_duration.is_none() {
|
||||
if let Some(ref metadata_xml) = position_info.track_metadata {
|
||||
if let Some(duration) = parse_didl_duration(metadata_xml) {
|
||||
tracing::debug!(
|
||||
@@ -779,6 +884,29 @@ impl MusicRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate rel_time from track_start_time if available (backend values are unreliable)
|
||||
if let Some(start_time) = self.track_start_time() {
|
||||
if let Ok(elapsed) = start_time.elapsed() {
|
||||
let secs = elapsed.as_secs() as u32;
|
||||
let hours = secs / 3600;
|
||||
let minutes = (secs % 3600) / 60;
|
||||
let seconds = secs % 60;
|
||||
let new_rel_time = format!("{:02}:{:02}:{:02}", hours, minutes, seconds);
|
||||
tracing::info!(
|
||||
"MusicRenderer: Patching rel_time: backend={:?} -> calculated={} (elapsed={}s)",
|
||||
position_info.rel_time,
|
||||
new_rel_time,
|
||||
secs
|
||||
);
|
||||
position_info.rel_time = Some(new_rel_time);
|
||||
}
|
||||
} else {
|
||||
tracing::info!(
|
||||
"MusicRenderer: No track_start_time, keeping backend rel_time={:?}",
|
||||
position_info.rel_time
|
||||
);
|
||||
}
|
||||
|
||||
Ok(position_info)
|
||||
}
|
||||
|
||||
@@ -1076,8 +1204,19 @@ impl MusicRenderer {
|
||||
}
|
||||
|
||||
/// Sets the last known track metadata.
|
||||
/// Updates track_start_time only if the metadata actually changes.
|
||||
pub fn set_last_metadata(&self, metadata: Option<TrackMetadata>) {
|
||||
self.state.lock().unwrap().last_metadata = metadata;
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let metadata_changed = state.last_metadata != metadata;
|
||||
state.last_metadata = metadata;
|
||||
if metadata_changed {
|
||||
state.track_start_time = Some(SystemTime::now());
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the timestamp when the current track started playing.
|
||||
pub fn track_start_time(&self) -> Option<SystemTime> {
|
||||
self.state.lock().unwrap().track_start_time
|
||||
}
|
||||
|
||||
/// Gets the current playback source.
|
||||
|
||||
@@ -32,6 +32,10 @@ pub struct OpenHomeRenderer {
|
||||
#[allow(dead_code)]
|
||||
radio_client: Option<OhRadioClient>,
|
||||
queue: Arc<Mutex<MusicQueue>>,
|
||||
/// Flag indicating if currently playing a continuous stream (radio without duration)
|
||||
continuous_stream: Arc<Mutex<bool>>,
|
||||
/// Cached current track URI to detect track changes
|
||||
current_track_uri: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
impl OpenHomeRenderer {
|
||||
@@ -52,9 +56,16 @@ impl OpenHomeRenderer {
|
||||
product_client,
|
||||
radio_client,
|
||||
queue,
|
||||
continuous_stream: Arc::new(Mutex::new(false)),
|
||||
current_track_uri: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if currently playing a continuous stream (radio without duration)
|
||||
pub fn is_continuous_stream(&self) -> bool {
|
||||
*self.continuous_stream.lock().unwrap()
|
||||
}
|
||||
|
||||
pub fn has_playlist(&self) -> bool {
|
||||
self.playlist.is_some()
|
||||
}
|
||||
@@ -316,8 +327,27 @@ impl PlaybackPosition for OpenHomeRenderer {
|
||||
if let Some(info_client) = &self.info_client {
|
||||
match info_client.track() {
|
||||
Ok(track) => {
|
||||
track_uri = Some(track.uri);
|
||||
track_uri = Some(track.uri.clone());
|
||||
track_metadata_xml = track.metadata_xml;
|
||||
|
||||
// Check if the URI has changed to detect track changes
|
||||
let mut cached_uri = self.current_track_uri.lock().unwrap();
|
||||
let uri_changed = cached_uri.as_ref() != Some(&track.uri);
|
||||
|
||||
if uri_changed {
|
||||
tracing::debug!(
|
||||
"OpenHome track URI changed: {:?} -> {:?}",
|
||||
cached_uri,
|
||||
track.uri
|
||||
);
|
||||
|
||||
// Détecte si la nouvelle URL est un flux continu
|
||||
let is_stream = crate::music_renderer::is_continuous_stream_url(&track.uri);
|
||||
*self.continuous_stream.lock().unwrap() = is_stream;
|
||||
tracing::debug!("OpenHome URI changed, continuous_stream={}", is_stream);
|
||||
|
||||
*cached_uri = Some(track.uri);
|
||||
}
|
||||
}
|
||||
Err(err) => debug!(
|
||||
// renderer = self.info.id.0.as_str(),
|
||||
@@ -327,12 +357,9 @@ impl PlaybackPosition for OpenHomeRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
// Get duration from Time service, but fall back to DIDL metadata if duration is 0
|
||||
// Get duration from Time service - duration_secs=0 means stream (no duration)
|
||||
let track_duration = if time_info.duration_secs == 0 {
|
||||
// Try to extract duration from DIDL metadata
|
||||
track_metadata_xml
|
||||
.as_ref()
|
||||
.and_then(|xml| parse_didl_duration_openhome(xml))
|
||||
None
|
||||
} else {
|
||||
Some(format_hhmmss_u32(time_info.duration_secs))
|
||||
};
|
||||
@@ -366,10 +393,6 @@ fn parse_didl_duration_openhome(didl: &str) -> Option<String> {
|
||||
let duration_offset = duration_start + "duration=\"".len();
|
||||
if let Some(duration_end) = tag_attrs[duration_offset..].find('"') {
|
||||
let duration = &tag_attrs[duration_offset..duration_offset + duration_end];
|
||||
tracing::info!(
|
||||
"OpenHome: Extracted duration from DIDL metadata: {}",
|
||||
duration
|
||||
);
|
||||
return Some(duration.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
148
pmocontrol/src/music_renderer/stream_detection.rs
Normal file
148
pmocontrol/src/music_renderer/stream_detection.rs
Normal file
@@ -0,0 +1,148 @@
|
||||
//! 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::time::Duration;
|
||||
use tracing::{debug, trace};
|
||||
use ureq::Agent;
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// # 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;
|
||||
}
|
||||
|
||||
// Make HTTP HEAD request to analyze headers
|
||||
match check_stream_headers(url) {
|
||||
Ok(is_stream) => {
|
||||
trace!("Stream detection for {}: {}", url, is_stream);
|
||||
is_stream
|
||||
}
|
||||
Err(e) => {
|
||||
debug!(
|
||||
"Failed to detect stream type for {}: {}, assuming non-stream",
|
||||
url, e
|
||||
);
|
||||
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)
|
||||
}
|
||||
|
||||
#[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"));
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,8 @@ pub struct UpnpRenderer {
|
||||
queue: Arc<Mutex<MusicQueue>>,
|
||||
/// Durée extraite du DIDL-Lite (fallback si l'ampli ne la retourne pas)
|
||||
cached_duration: Arc<Mutex<Option<String>>>,
|
||||
/// Flag indicating if currently playing a continuous stream (radio without duration)
|
||||
continuous_stream: Arc<Mutex<bool>>,
|
||||
}
|
||||
|
||||
impl UpnpRenderer {
|
||||
@@ -106,8 +108,14 @@ impl UpnpRenderer {
|
||||
has_avtransport_set_next,
|
||||
queue,
|
||||
cached_duration: Arc::new(Mutex::new(None)),
|
||||
continuous_stream: Arc::new(Mutex::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if currently playing a continuous stream (radio without duration)
|
||||
pub fn is_continuous_stream(&self) -> bool {
|
||||
*self.continuous_stream.lock().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl RendererFromMediaRendererInfo for UpnpRenderer {
|
||||
@@ -157,6 +165,7 @@ impl RendererFromMediaRendererInfo for UpnpRenderer {
|
||||
has_avtransport_set_next: info.capabilities().has_avtransport_set_next(),
|
||||
queue,
|
||||
cached_duration: Arc::new(Mutex::new(None)),
|
||||
continuous_stream: Arc::new(Mutex::new(false)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -206,15 +215,19 @@ impl QueueTransportControl for UpnpRenderer {
|
||||
)
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
"play_from_queue DIDL metadata (first 800 chars):\n{}",
|
||||
&metadata[..metadata.len().min(800)]
|
||||
// Détecte si l'URL est un flux continu en interrogeant le serveur HTTP
|
||||
let is_stream = crate::music_renderer::is_continuous_stream_url(&item.uri);
|
||||
*self.continuous_stream.lock().unwrap() = is_stream;
|
||||
tracing::debug!(
|
||||
"UpnpRenderer play_from_queue: URI={}, continuous_stream={}",
|
||||
item.uri,
|
||||
is_stream
|
||||
);
|
||||
|
||||
// Parse et cache la durée du DIDL
|
||||
// Parse et cache la durée du DIDL (fallback pour certains amplis)
|
||||
let duration = parse_didl_duration(&metadata);
|
||||
if let Some(ref dur) = duration {
|
||||
tracing::info!("Caching duration from queue DIDL: {}", dur);
|
||||
tracing::debug!("Caching duration from queue DIDL: {}", dur);
|
||||
*self.cached_duration.lock().unwrap() = Some(dur.clone());
|
||||
} else {
|
||||
tracing::debug!("No duration to cache from queue DIDL");
|
||||
@@ -346,7 +359,6 @@ fn parse_didl_duration(didl: &str) -> Option<String> {
|
||||
let duration_offset = duration_start + "duration=\"".len();
|
||||
if let Some(duration_end) = tag_attrs[duration_offset..].find('"') {
|
||||
let duration = &tag_attrs[duration_offset..duration_offset + duration_end];
|
||||
tracing::info!("Extracted duration from DIDL: {}", duration);
|
||||
return Some(duration.to_string());
|
||||
}
|
||||
}
|
||||
@@ -369,15 +381,22 @@ impl TransportControl for UpnpRenderer {
|
||||
);
|
||||
}
|
||||
|
||||
// Parse le DIDL pour extraire la durée
|
||||
// Détecte si l'URL est un flux continu en interrogeant le serveur HTTP
|
||||
let is_stream = crate::music_renderer::is_continuous_stream_url(uri);
|
||||
*self.continuous_stream.lock().unwrap() = is_stream;
|
||||
tracing::debug!(
|
||||
"UpnpRenderer play_uri: URI={}, continuous_stream={}",
|
||||
uri,
|
||||
is_stream
|
||||
);
|
||||
|
||||
// Parse le DIDL pour extraire la durée (fallback pour certains amplis)
|
||||
let duration = parse_didl_duration(meta);
|
||||
if let Some(ref dur) = duration {
|
||||
tracing::info!("Caching duration from DIDL: {}", dur);
|
||||
tracing::debug!("Caching duration from DIDL: {}", dur);
|
||||
*self.cached_duration.lock().unwrap() = Some(dur.clone());
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"No duration to cache from DIDL (this may be expected for streams without duration)"
|
||||
);
|
||||
tracing::debug!("No duration to cache from DIDL");
|
||||
*self.cached_duration.lock().unwrap() = None;
|
||||
}
|
||||
|
||||
@@ -465,23 +484,8 @@ impl PlaybackPosition for UpnpRenderer {
|
||||
}
|
||||
});
|
||||
|
||||
// Si l'ampli ne retourne pas de durée, utilise la durée cachée du DIDL
|
||||
let track_duration = if normalized_duration.is_none() {
|
||||
let cached = self.cached_duration.lock().unwrap();
|
||||
if let Some(ref duration) = *cached {
|
||||
tracing::debug!("Using cached duration from DIDL as fallback: {}", duration);
|
||||
Some(duration.clone())
|
||||
} else {
|
||||
tracing::warn!("No track_duration from renderer and no cached duration available!");
|
||||
None
|
||||
}
|
||||
} else {
|
||||
tracing::debug!(
|
||||
"Using track_duration from renderer: {:?}",
|
||||
normalized_duration
|
||||
);
|
||||
normalized_duration
|
||||
};
|
||||
// duration=00:00:00 means stream (no duration) - don't fallback to cached DIDL
|
||||
let track_duration = normalized_duration;
|
||||
|
||||
tracing::trace!(
|
||||
"Final PlaybackPositionInfo: track_duration={:?}, rel_time={:?}",
|
||||
|
||||
@@ -89,6 +89,8 @@ pub struct WatchedState {
|
||||
pub mute: Option<bool>,
|
||||
/// Last known track metadata
|
||||
pub metadata: Option<TrackMetadata>,
|
||||
/// Last known stream state (continuous stream vs bounded media)
|
||||
pub is_stream: Option<bool>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -302,6 +302,16 @@ pub struct SleepTimerState {
|
||||
pub remaining_seconds: Option<u32>,
|
||||
}
|
||||
|
||||
/// État du flux (stream vs morceau délimité)
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct StreamState {
|
||||
/// true si lecture en cours d'un flux continu (radio), false sinon
|
||||
pub is_stream: bool,
|
||||
/// true si actuellement en lecture, false sinon
|
||||
pub is_playing: bool,
|
||||
}
|
||||
|
||||
/// Réponse générique de succès
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
|
||||
@@ -14,8 +14,8 @@ use crate::openapi::{
|
||||
AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry, ErrorResponse,
|
||||
FullRendererSnapshot, MediaServerSummary, PlayContentRequest, QueueSnapshot,
|
||||
RendererCapabilitiesSummary, RendererProtocolSummary, RendererState, RendererSummary,
|
||||
SeekQueueRequest, SeekRequest, SleepTimerRequest, SleepTimerState, SuccessResponse,
|
||||
TransferQueueRequest, VolumeSetRequest,
|
||||
SeekQueueRequest, SeekRequest, SleepTimerRequest, SleepTimerState, StreamState,
|
||||
SuccessResponse, TransferQueueRequest, VolumeSetRequest,
|
||||
};
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use crate::queue::PlaybackItem;
|
||||
@@ -1333,6 +1333,54 @@ async fn get_sleep_timer_state(
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HANDLERS - STREAM STATE
|
||||
// ============================================================================
|
||||
|
||||
/// GET /control/renderers/{renderer_id}/stream-state - Récupère l'état stream du renderer
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/renderers/{renderer_id}/stream-state",
|
||||
params(
|
||||
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "État du flux", body = StreamState),
|
||||
(status = 404, description = "Renderer non trouvé", body = ErrorResponse)
|
||||
),
|
||||
tag = "control"
|
||||
)]
|
||||
async fn get_stream_state(
|
||||
State(state): State<ControlPointState>,
|
||||
Path(renderer_id): Path<String>,
|
||||
) -> Result<Json<StreamState>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let rid = DeviceId(renderer_id.clone());
|
||||
|
||||
let renderer = state
|
||||
.control_point
|
||||
.music_renderer_by_id(&rid)
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Renderer {} not found", renderer_id),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let is_stream = renderer.is_playing_a_stream();
|
||||
let is_playing = renderer
|
||||
.playback_state()
|
||||
.map(|s| matches!(s, crate::model::PlaybackState::Playing))
|
||||
.unwrap_or(false);
|
||||
|
||||
Ok(Json(StreamState {
|
||||
is_stream,
|
||||
is_playing,
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HANDLERS - QUEUE SHUFFLE
|
||||
// ============================================================================
|
||||
@@ -2290,6 +2338,11 @@ pub fn create_api_router(state: ControlPointState, control_point: Arc<ControlPoi
|
||||
"/renderers/{renderer_id}/timer/cancel",
|
||||
post(cancel_sleep_timer),
|
||||
)
|
||||
// Stream state
|
||||
.route(
|
||||
"/renderers/{renderer_id}/stream-state",
|
||||
get(get_stream_state),
|
||||
)
|
||||
// Playlist binding
|
||||
.route(
|
||||
"/renderers/{renderer_id}/binding/attach",
|
||||
|
||||
@@ -85,6 +85,11 @@ pub enum RendererEventPayload {
|
||||
container_id: Option<String>,
|
||||
timestamp: chrono::DateTime<chrono::Utc>,
|
||||
},
|
||||
StreamStateChanged {
|
||||
renderer_id: String,
|
||||
is_stream: bool,
|
||||
timestamp: chrono::DateTime<chrono::Utc>,
|
||||
},
|
||||
TimerStarted {
|
||||
renderer_id: String,
|
||||
duration_seconds: u32,
|
||||
@@ -160,6 +165,158 @@ pub enum UnifiedEventPayload {
|
||||
MediaServer(MediaServerEventPayload),
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/// Convertit un RendererEvent en RendererEventPayload pour SSE
|
||||
///
|
||||
/// Cette fonction centralise la conversion pour éviter la duplication de code
|
||||
/// entre les différents streams SSE (renderers-only et all-events).
|
||||
#[cfg(feature = "pmoserver")]
|
||||
fn renderer_event_to_payload(
|
||||
event: RendererEvent,
|
||||
timestamp: chrono::DateTime<chrono::Utc>,
|
||||
) -> RendererEventPayload {
|
||||
match event {
|
||||
RendererEvent::StateChanged { id, state } => RendererEventPayload::StateChanged {
|
||||
renderer_id: id.0,
|
||||
state: state.as_str().to_string(),
|
||||
timestamp,
|
||||
},
|
||||
RendererEvent::PositionChanged { id, position } => RendererEventPayload::PositionChanged {
|
||||
renderer_id: id.0,
|
||||
track: position.track,
|
||||
rel_time: position.rel_time,
|
||||
track_duration: position.track_duration,
|
||||
timestamp,
|
||||
},
|
||||
RendererEvent::VolumeChanged { id, volume } => RendererEventPayload::VolumeChanged {
|
||||
renderer_id: id.0,
|
||||
volume,
|
||||
timestamp,
|
||||
},
|
||||
RendererEvent::MuteChanged { id, mute } => RendererEventPayload::MuteChanged {
|
||||
renderer_id: id.0,
|
||||
mute,
|
||||
timestamp,
|
||||
},
|
||||
RendererEvent::MetadataChanged { id, metadata } => RendererEventPayload::MetadataChanged {
|
||||
renderer_id: id.0,
|
||||
title: metadata.title,
|
||||
artist: metadata.artist,
|
||||
album: metadata.album,
|
||||
album_art_uri: metadata.album_art_uri,
|
||||
timestamp,
|
||||
},
|
||||
RendererEvent::QueueUpdated { id, queue_length } => RendererEventPayload::QueueUpdated {
|
||||
renderer_id: id.0,
|
||||
queue_length,
|
||||
timestamp,
|
||||
},
|
||||
RendererEvent::BindingChanged { id, binding } => RendererEventPayload::BindingChanged {
|
||||
renderer_id: id.0,
|
||||
server_id: binding.as_ref().map(|b| b.server_id.0.clone()),
|
||||
container_id: binding.as_ref().map(|b| b.container_id.clone()),
|
||||
timestamp,
|
||||
},
|
||||
RendererEvent::StreamStateChanged { id, is_stream } => {
|
||||
RendererEventPayload::StreamStateChanged {
|
||||
renderer_id: id.0,
|
||||
is_stream,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
RendererEvent::TimerStarted {
|
||||
id,
|
||||
duration_seconds,
|
||||
remaining_seconds,
|
||||
} => RendererEventPayload::TimerStarted {
|
||||
renderer_id: id.0,
|
||||
duration_seconds,
|
||||
remaining_seconds,
|
||||
timestamp,
|
||||
},
|
||||
RendererEvent::TimerUpdated {
|
||||
id,
|
||||
duration_seconds,
|
||||
remaining_seconds,
|
||||
} => RendererEventPayload::TimerUpdated {
|
||||
renderer_id: id.0,
|
||||
duration_seconds,
|
||||
remaining_seconds,
|
||||
timestamp,
|
||||
},
|
||||
RendererEvent::TimerTick {
|
||||
id,
|
||||
remaining_seconds,
|
||||
} => RendererEventPayload::TimerTick {
|
||||
renderer_id: id.0,
|
||||
remaining_seconds,
|
||||
timestamp,
|
||||
},
|
||||
RendererEvent::TimerExpired { id } => RendererEventPayload::TimerExpired {
|
||||
renderer_id: id.0,
|
||||
timestamp,
|
||||
},
|
||||
RendererEvent::TimerCancelled { id } => RendererEventPayload::TimerCancelled {
|
||||
renderer_id: id.0,
|
||||
timestamp,
|
||||
},
|
||||
RendererEvent::Online { id, info } => RendererEventPayload::Online {
|
||||
renderer_id: id.0,
|
||||
friendly_name: info.friendly_name,
|
||||
model_name: info.model_name,
|
||||
manufacturer: info.manufacturer,
|
||||
timestamp,
|
||||
},
|
||||
RendererEvent::Offline { id } => RendererEventPayload::Offline {
|
||||
renderer_id: id.0,
|
||||
timestamp,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertit un MediaServerEvent en MediaServerEventPayload pour SSE
|
||||
///
|
||||
/// Cette fonction centralise la conversion pour éviter la duplication de code
|
||||
/// entre les différents streams SSE (servers-only et all-events).
|
||||
#[cfg(feature = "pmoserver")]
|
||||
fn media_server_event_to_payload(
|
||||
event: MediaServerEvent,
|
||||
timestamp: chrono::DateTime<chrono::Utc>,
|
||||
) -> MediaServerEventPayload {
|
||||
match event {
|
||||
MediaServerEvent::GlobalUpdated {
|
||||
server_id,
|
||||
system_update_id,
|
||||
} => MediaServerEventPayload::GlobalUpdated {
|
||||
server_id: server_id.0,
|
||||
system_update_id,
|
||||
timestamp,
|
||||
},
|
||||
MediaServerEvent::ContainersUpdated {
|
||||
server_id,
|
||||
container_ids,
|
||||
} => MediaServerEventPayload::ContainersUpdated {
|
||||
server_id: server_id.0,
|
||||
container_ids,
|
||||
timestamp,
|
||||
},
|
||||
MediaServerEvent::Online { server_id, info } => MediaServerEventPayload::Online {
|
||||
server_id: server_id.0,
|
||||
friendly_name: info.friendly_name,
|
||||
model_name: info.model_name,
|
||||
manufacturer: info.manufacturer,
|
||||
timestamp,
|
||||
},
|
||||
MediaServerEvent::Offline { server_id } => MediaServerEventPayload::Offline {
|
||||
server_id: server_id.0,
|
||||
timestamp,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HANDLERS SSE
|
||||
// ============================================================================
|
||||
@@ -241,114 +398,7 @@ pub async fn renderer_events_sse(
|
||||
// Regular events from the control point
|
||||
Some(event) = rx_tokio.recv() => {
|
||||
let timestamp = chrono::Utc::now();
|
||||
|
||||
let payload = match event {
|
||||
RendererEvent::StateChanged { id, state } => {
|
||||
RendererEventPayload::StateChanged {
|
||||
renderer_id: id.0,
|
||||
state: state.as_str().to_string(),
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
RendererEvent::PositionChanged { id, position } => {
|
||||
RendererEventPayload::PositionChanged {
|
||||
renderer_id: id.0,
|
||||
track: position.track,
|
||||
rel_time: position.rel_time,
|
||||
track_duration: position.track_duration,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
RendererEvent::VolumeChanged { id, volume } => {
|
||||
RendererEventPayload::VolumeChanged {
|
||||
renderer_id: id.0,
|
||||
volume,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
RendererEvent::MuteChanged { id, mute } => {
|
||||
RendererEventPayload::MuteChanged {
|
||||
renderer_id: id.0,
|
||||
mute,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
RendererEvent::MetadataChanged { id, metadata } => {
|
||||
RendererEventPayload::MetadataChanged {
|
||||
renderer_id: id.0,
|
||||
title: metadata.title,
|
||||
artist: metadata.artist,
|
||||
album: metadata.album,
|
||||
album_art_uri: metadata.album_art_uri,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
RendererEvent::QueueUpdated { id, queue_length } => {
|
||||
RendererEventPayload::QueueUpdated {
|
||||
renderer_id: id.0,
|
||||
queue_length,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
RendererEvent::BindingChanged { id, binding } => {
|
||||
RendererEventPayload::BindingChanged {
|
||||
renderer_id: id.0,
|
||||
server_id: binding.as_ref().map(|b| b.server_id.0.clone()),
|
||||
container_id: binding.as_ref().map(|b| b.container_id.clone()),
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
RendererEvent::TimerStarted { id, duration_seconds, remaining_seconds } => {
|
||||
RendererEventPayload::TimerStarted {
|
||||
renderer_id: id.0,
|
||||
duration_seconds,
|
||||
remaining_seconds,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
RendererEvent::TimerUpdated { id, duration_seconds, remaining_seconds } => {
|
||||
RendererEventPayload::TimerUpdated {
|
||||
renderer_id: id.0,
|
||||
duration_seconds,
|
||||
remaining_seconds,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
RendererEvent::TimerTick { id, remaining_seconds } => {
|
||||
RendererEventPayload::TimerTick {
|
||||
renderer_id: id.0,
|
||||
remaining_seconds,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
RendererEvent::TimerExpired { id } => {
|
||||
RendererEventPayload::TimerExpired {
|
||||
renderer_id: id.0,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
RendererEvent::TimerCancelled { id } => {
|
||||
RendererEventPayload::TimerCancelled {
|
||||
renderer_id: id.0,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
RendererEvent::Online { id, info } => {
|
||||
RendererEventPayload::Online {
|
||||
renderer_id: id.0,
|
||||
friendly_name: info.friendly_name.clone(),
|
||||
model_name: info.model_name.clone(),
|
||||
manufacturer: info.manufacturer.clone(),
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
RendererEvent::Offline { id } => {
|
||||
RendererEventPayload::Offline {
|
||||
renderer_id: id.0,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
};
|
||||
let payload = renderer_event_to_payload(event, timestamp);
|
||||
|
||||
if let Ok(json) = serde_json::to_string(&payload) {
|
||||
yield Ok::<_, axum::Error>(Event::default().event("renderer").data(json));
|
||||
@@ -468,38 +518,7 @@ pub async fn media_server_events_sse(
|
||||
// Regular events from the control point
|
||||
Some(event) = rx_tokio.recv() => {
|
||||
let timestamp = chrono::Utc::now();
|
||||
|
||||
let payload = match event {
|
||||
MediaServerEvent::GlobalUpdated { server_id, system_update_id } => {
|
||||
MediaServerEventPayload::GlobalUpdated {
|
||||
server_id: server_id.0,
|
||||
system_update_id,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
MediaServerEvent::ContainersUpdated { server_id, container_ids } => {
|
||||
MediaServerEventPayload::ContainersUpdated {
|
||||
server_id: server_id.0,
|
||||
container_ids,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
MediaServerEvent::Online { server_id, info } => {
|
||||
MediaServerEventPayload::Online {
|
||||
server_id: server_id.0,
|
||||
friendly_name: info.friendly_name.clone(),
|
||||
model_name: info.model_name.clone(),
|
||||
manufacturer: info.manufacturer.clone(),
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
MediaServerEvent::Offline { server_id } => {
|
||||
MediaServerEventPayload::Offline {
|
||||
server_id: server_id.0,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
};
|
||||
let payload = media_server_event_to_payload(event, timestamp);
|
||||
|
||||
if let Ok(json) = serde_json::to_string(&payload) {
|
||||
yield Ok::<_, axum::Error>(Event::default().event("media_server").data(json));
|
||||
@@ -650,114 +669,7 @@ pub async fn all_events_sse(State(control_point): State<Arc<ControlPoint>>) -> i
|
||||
tokio::select! {
|
||||
Some(event) = renderer_rx_tokio.recv() => {
|
||||
let timestamp = chrono::Utc::now();
|
||||
|
||||
let renderer_payload = match event {
|
||||
RendererEvent::StateChanged { id, state } => {
|
||||
RendererEventPayload::StateChanged {
|
||||
renderer_id: id.0,
|
||||
state: state.as_str().to_string(),
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
RendererEvent::PositionChanged { id, position } => {
|
||||
RendererEventPayload::PositionChanged {
|
||||
renderer_id: id.0,
|
||||
track: position.track,
|
||||
rel_time: position.rel_time,
|
||||
track_duration: position.track_duration,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
RendererEvent::VolumeChanged { id, volume } => {
|
||||
RendererEventPayload::VolumeChanged {
|
||||
renderer_id: id.0,
|
||||
volume,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
RendererEvent::MuteChanged { id, mute } => {
|
||||
RendererEventPayload::MuteChanged {
|
||||
renderer_id: id.0,
|
||||
mute,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
RendererEvent::MetadataChanged { id, metadata } => {
|
||||
RendererEventPayload::MetadataChanged {
|
||||
renderer_id: id.0,
|
||||
title: metadata.title,
|
||||
artist: metadata.artist,
|
||||
album: metadata.album,
|
||||
album_art_uri: metadata.album_art_uri,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
RendererEvent::QueueUpdated { id, queue_length } => {
|
||||
RendererEventPayload::QueueUpdated {
|
||||
renderer_id: id.0,
|
||||
queue_length,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
RendererEvent::BindingChanged { id, binding } => {
|
||||
RendererEventPayload::BindingChanged {
|
||||
renderer_id: id.0,
|
||||
server_id: binding.as_ref().map(|b| b.server_id.0.clone()),
|
||||
container_id: binding.as_ref().map(|b| b.container_id.clone()),
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
RendererEvent::TimerStarted { id, duration_seconds, remaining_seconds } => {
|
||||
RendererEventPayload::TimerStarted {
|
||||
renderer_id: id.0,
|
||||
duration_seconds,
|
||||
remaining_seconds,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
RendererEvent::TimerUpdated { id, duration_seconds, remaining_seconds } => {
|
||||
RendererEventPayload::TimerUpdated {
|
||||
renderer_id: id.0,
|
||||
duration_seconds,
|
||||
remaining_seconds,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
RendererEvent::TimerTick { id, remaining_seconds } => {
|
||||
RendererEventPayload::TimerTick {
|
||||
renderer_id: id.0,
|
||||
remaining_seconds,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
RendererEvent::TimerExpired { id } => {
|
||||
RendererEventPayload::TimerExpired {
|
||||
renderer_id: id.0,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
RendererEvent::TimerCancelled { id } => {
|
||||
RendererEventPayload::TimerCancelled {
|
||||
renderer_id: id.0,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
RendererEvent::Online { id, info } => {
|
||||
RendererEventPayload::Online {
|
||||
renderer_id: id.0,
|
||||
friendly_name: info.friendly_name.clone(),
|
||||
model_name: info.model_name.clone(),
|
||||
manufacturer: info.manufacturer.clone(),
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
RendererEvent::Offline { id } => {
|
||||
RendererEventPayload::Offline {
|
||||
renderer_id: id.0,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
};
|
||||
let renderer_payload = renderer_event_to_payload(event, timestamp);
|
||||
|
||||
let payload = UnifiedEventPayload::Renderer(renderer_payload);
|
||||
|
||||
@@ -767,39 +679,7 @@ pub async fn all_events_sse(State(control_point): State<Arc<ControlPoint>>) -> i
|
||||
}
|
||||
Some(event) = server_rx_tokio.recv() => {
|
||||
let timestamp = chrono::Utc::now();
|
||||
|
||||
let server_payload = match event {
|
||||
MediaServerEvent::GlobalUpdated { server_id, system_update_id } => {
|
||||
MediaServerEventPayload::GlobalUpdated {
|
||||
server_id: server_id.0,
|
||||
system_update_id,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
MediaServerEvent::ContainersUpdated { server_id, container_ids } => {
|
||||
MediaServerEventPayload::ContainersUpdated {
|
||||
server_id: server_id.0,
|
||||
container_ids,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
MediaServerEvent::Online { server_id, info } => {
|
||||
MediaServerEventPayload::Online {
|
||||
server_id: server_id.0,
|
||||
friendly_name: info.friendly_name.clone(),
|
||||
model_name: info.model_name.clone(),
|
||||
manufacturer: info.manufacturer.clone(),
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
MediaServerEvent::Offline { server_id } => {
|
||||
MediaServerEventPayload::Offline {
|
||||
server_id: server_id.0,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let server_payload = media_server_event_to_payload(event, timestamp);
|
||||
let payload = UnifiedEventPayload::MediaServer(server_payload);
|
||||
|
||||
if let Ok(json) = serde_json::to_string(&payload) {
|
||||
|
||||
@@ -599,7 +599,6 @@ impl OhTimeClient {
|
||||
.map_err(|_| {
|
||||
ControlPointError::UpnpBadReturnValue("Second".to_string(), "".to_string())
|
||||
})?;
|
||||
|
||||
Ok(OhTimePosition {
|
||||
track_count,
|
||||
duration_secs,
|
||||
|
||||
Reference in New Issue
Block a user