Protéger les durées des streams contre la diminution et améliorer la récupération des métadonnées

Cette mise à jour protège les durées des streams continus contre la diminution, en s'assurant qu'une fois une chanson diffusée, sa durée ne peut jamais être réduite. Elle améliore également la récupération des métadonnées dans le rendu UPnP en utilisant les données de la queue plutôt que celles de GetPositionInfo, ce qui évite les métadonnées obsolètes. Des ajustements ont été apportés à la gestion des files d'attente interne et OpenHome pour intégrer ces nouvelles règles de protection des durées.
This commit is contained in:
2026-01-31 22:30:30 +01:00
parent 6bdd7ea905
commit acfdf01dab
3 changed files with 306 additions and 79 deletions

View File

@@ -470,7 +470,7 @@ impl PlaybackPosition for UpnpRenderer {
let raw: PositionInfo = avt.get_position_info(0)?;
tracing::trace!(
"GetPositionInfo returned: track_duration={:?}, rel_time={:?}",
"UPnP GetPositionInfo returned: track_duration={:?}, rel_time={:?}",
raw.track_duration,
raw.rel_time
);
@@ -484,13 +484,40 @@ impl PlaybackPosition for UpnpRenderer {
}
});
// duration=00:00:00 means stream (no duration) - don't fallback to cached DIDL
let track_duration = normalized_duration;
// Récupérer les métadonnées depuis la queue (avec protection contre diminution de durée)
// plutôt que depuis GetPositionInfo qui peut retourner des métadonnées obsolètes
let mut track_metadata_xml = None;
let mut track_uri = raw.track_uri.clone();
let mut queue_guard = self.queue.lock().unwrap();
// Récupérer l'item courant de la queue
// Normalement current_index est toujours Some() si la queue n'est pas vide (règle métier)
let queue_item = queue_guard.peek_current().ok().flatten();
if let Some((current_item, _)) = queue_item {
track_uri = Some(current_item.uri.clone());
// Build DIDL metadata XML from cached/protected TrackMetadata
if let Some(ref metadata) = current_item.metadata {
track_metadata_xml = Some(
crate::music_renderer::musicrenderer::build_didl_lite_metadata(
metadata,
&current_item.uri,
&current_item.protocol_info,
),
);
}
}
drop(queue_guard);
tracing::trace!(
"Final PlaybackPositionInfo: track_duration={:?}, rel_time={:?}",
"UPnP playback_position: track_duration={:?}, rel_time={:?}, using_queue_metadata={}",
track_duration,
raw.rel_time
raw.rel_time,
track_metadata_xml.is_some()
);
Ok(PlaybackPositionInfo {
@@ -498,8 +525,8 @@ impl PlaybackPosition for UpnpRenderer {
rel_time: raw.rel_time,
abs_time: raw.abs_time,
track_duration,
track_metadata: raw.track_metadata,
track_uri: raw.track_uri,
track_metadata: track_metadata_xml,
track_uri,
})
}
}

View File

@@ -54,6 +54,182 @@ impl InternalQueue {
pub fn items(&self) -> &[PlaybackItem] {
&self.items
}
/// Assure l'invariant : si length > 0 et current_index est None, alors current_index = Some(0)
fn ensure_current_index_invariant(&mut self) {
if !self.items.is_empty() && self.current_index.is_none() {
self.current_index = Some(0);
}
}
/// Vérifie si une durée a diminué (format HH:MM:SS).
/// Retourne true si new_duration < old_duration.
fn duration_decreased(old_duration: &str, new_duration: &str) -> bool {
let parse_duration = |dur: &str| -> Option<u32> {
let parts: Vec<&str> = dur.split(':').collect();
if parts.len() == 3 {
let h: u32 = parts[0].parse().ok()?;
let m: u32 = parts[1].parse().ok()?;
let s: u32 = parts[2].parse().ok()?;
Some(h * 3600 + m * 60 + s)
} else {
None
}
};
if let (Some(old_secs), Some(new_secs)) =
(parse_duration(old_duration), parse_duration(new_duration))
{
new_secs < old_secs
} else {
false // Impossible de parser: considérer que ça n'a pas diminué
}
}
/// Protège les durées des streams contre la diminution.
/// Pour chaque item de `new_items`, si c'est un stream avec la même URI qu'un item existant,
/// et que c'est la même chanson (même titre/artiste), ne met à jour la durée que si elle augmente.
fn protect_stream_durations(&self, mut new_items: Vec<PlaybackItem>) -> Vec<PlaybackItem> {
use std::collections::HashMap;
// Construire une HashMap URI -> (durée, titre, artiste) pour les streams de l'ancienne queue
let old_stream_metadata: HashMap<String, (String, Option<String>, Option<String>)> = self
.items
.iter()
.filter_map(|item| {
if let Some(ref meta) = item.metadata {
if meta.is_continuous_stream {
if let Some(ref duration) = meta.duration {
return Some((
item.uri.clone(),
(duration.clone(), meta.title.clone(), meta.artist.clone()),
));
}
}
}
None
})
.collect();
// Mettre à jour les durées des nouveaux items streams en protégeant contre la diminution
for item in &mut new_items {
if let (Some(new_meta), Some((old_duration, old_title, old_artist))) =
(&mut item.metadata, old_stream_metadata.get(&item.uri))
{
if new_meta.is_continuous_stream {
// Vérifier si c'est la même chanson
let same_track = new_meta.title == *old_title && new_meta.artist == *old_artist;
if same_track {
// Même chanson: protéger contre la diminution de durée
if let Some(ref new_duration) = new_meta.duration {
if Self::duration_decreased(old_duration, new_duration) {
// Garder l'ancienne durée
tracing::trace!(
"InternalQueue protect_stream_durations: uri={}, keeping old duration (decreased): {} vs {}",
item.uri,
old_duration,
new_duration
);
new_meta.duration = Some(old_duration.clone());
}
}
}
}
}
}
new_items
}
/// Fusionne les métadonnées en protégeant les streams contre la diminution de durée.
/// Pour les streams continus, si c'est la même chanson (même titre ET même artiste ET même URI),
/// la durée ne peut jamais diminuer.
fn merge_metadata_protecting_streams(
old_metadata: &Option<crate::model::TrackMetadata>,
new_metadata: &Option<crate::model::TrackMetadata>,
uri: &str,
) -> Option<crate::model::TrackMetadata> {
match (old_metadata, new_metadata) {
(Some(old_meta), Some(new_meta)) => {
// Vérifier si c'est un stream continu
if new_meta.is_continuous_stream {
// Vérifier si c'est la même chanson (titre ET artiste identiques)
let same_title = old_meta.title == new_meta.title;
let same_artist = old_meta.artist == new_meta.artist;
let same_track = same_title && same_artist;
if same_track {
// Même chanson sur un stream: vérifier que la durée n'a pas diminué
let should_update = match (&old_meta.duration, &new_meta.duration) {
(Some(old_dur), Some(new_dur)) => {
// Parser les durées (format HH:MM:SS)
let parse_duration = |dur: &str| -> Option<u32> {
let parts: Vec<&str> = dur.split(':').collect();
if parts.len() == 3 {
let h: u32 = parts[0].parse().ok()?;
let m: u32 = parts[1].parse().ok()?;
let s: u32 = parts[2].parse().ok()?;
Some(h * 3600 + m * 60 + s)
} else {
None
}
};
if let (Some(old_secs), Some(new_secs)) =
(parse_duration(old_dur), parse_duration(new_dur))
{
if new_secs < old_secs {
// Durée a diminué: garder l'ancienne
tracing::trace!(
"InternalQueue merge_metadata: uri={}, REJECTING update (same stream track, duration decreased): {} -> {}",
uri,
old_dur,
new_dur
);
false
} else {
// Durée a augmenté ou est égale: accepter
if new_secs > old_secs {
tracing::debug!(
"InternalQueue merge_metadata: uri={}, same stream track, duration increased: {} -> {}",
uri,
old_dur,
new_dur
);
}
true
}
} else {
// Impossible de parser: accepter par défaut
true
}
}
_ => true, // Pas de durée ou une seule des deux: accepter
};
if should_update {
Some(new_meta.clone())
} else {
// Garder l'ancienne durée
Some(old_meta.clone())
}
} else {
// Chanson différente sur un stream: accepter les nouvelles métadonnées
tracing::debug!(
"InternalQueue merge_metadata: uri={}, different stream track (title or artist changed), accepting update",
uri
);
Some(new_meta.clone())
}
} else {
// Fichier normal (non-stream): accepter les nouvelles métadonnées
Some(new_meta.clone())
}
}
(_, new_meta) => new_meta.clone(), // Pas d'anciennes métadonnées: utiliser les nouvelles
}
}
}
impl QueueBackend for InternalQueue {
@@ -128,6 +304,7 @@ impl QueueBackend for InternalQueue {
) -> Result<(), ControlPointError> {
self.items = items;
self.current_index = current_index.filter(|&i| i < self.items.len());
self.ensure_current_index_invariant();
Ok(())
}
@@ -138,6 +315,9 @@ impl QueueBackend for InternalQueue {
return self.replace_queue(Vec::new(), None);
}
// Protéger les durées des streams contre la diminution
let updated_items = self.protect_stream_durations(items);
// Récupérer l'item actuel
let current = self.current_index.and_then(|idx| {
self.items
@@ -147,11 +327,11 @@ impl QueueBackend for InternalQueue {
if let Some((_current_idx, current_uri, current_didl_id)) = current {
// Chercher l'item actuel dans la nouvelle liste (par URI d'abord, puis par didl_id)
let new_idx = items
let new_idx = updated_items
.iter()
.position(|item| item.uri == current_uri)
.or_else(|| {
items
updated_items
.iter()
.position(|item| item.didl_id == current_didl_id)
});
@@ -164,7 +344,7 @@ impl QueueBackend for InternalQueue {
new_idx,
"sync_queue: current item found in new playlist"
);
self.replace_queue(items, Some(new_idx))
self.replace_queue(updated_items, Some(new_idx))
} else {
// Item pas trouvé - cela ne devrait pas arriver si la playlist n'a pas changé
// Loguer pour diagnostic
@@ -172,18 +352,18 @@ impl QueueBackend for InternalQueue {
renderer = self.renderer_id.0.as_str(),
current_uri = current_uri.as_str(),
current_didl_id = current_didl_id.as_str(),
new_items_count = items.len(),
new_items_count = updated_items.len(),
"sync_queue: current item NOT found in new playlist, preserving as first item"
);
let current_item = self.items[self.current_index.unwrap()].clone();
let mut new_items = Vec::with_capacity(items.len() + 1);
let mut new_items = Vec::with_capacity(updated_items.len() + 1);
new_items.push(current_item);
new_items.extend(items);
new_items.extend(updated_items);
self.replace_queue(new_items, Some(0))
}
} else {
// Pas d'item actuel
self.replace_queue(items, None)
self.replace_queue(updated_items, None)
}
}
@@ -194,9 +374,12 @@ impl QueueBackend for InternalQueue {
) -> Result<(), ControlPointError> {
use crate::queue::EnqueueMode;
// Protéger les durées des streams contre la diminution
let protected_items = self.protect_stream_durations(items);
match mode {
EnqueueMode::AppendToEnd => {
self.items.extend(items);
self.items.extend(protected_items);
}
EnqueueMode::InsertAfterCurrent => {
let insert_pos = self
@@ -204,15 +387,17 @@ impl QueueBackend for InternalQueue {
.map(|i| (i + 1).min(self.items.len()))
.unwrap_or(0);
for (offset, item) in items.into_iter().enumerate() {
for (offset, item) in protected_items.into_iter().enumerate() {
self.items.insert(insert_pos + offset, item);
}
}
EnqueueMode::ReplaceAll => {
self.items = items;
self.items = protected_items;
self.current_index = None;
}
}
self.ensure_current_index_invariant();
Ok(())
}
@@ -310,13 +495,10 @@ impl QueueBackend for InternalQueue {
}
fn append_or_init_index(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
let was_empty = self.items.is_empty();
self.items.extend(items);
if was_empty && !self.items.is_empty() {
self.current_index = Some(0);
}
// Protéger les durées des streams contre la diminution
let protected_items = self.protect_stream_durations(items);
self.items.extend(protected_items);
self.ensure_current_index_invariant();
Ok(())
}

View File

@@ -195,15 +195,24 @@ impl OpenHomeQueue {
}
/// Insère ou met à jour les métadonnées dans le cache.
/// RÈGLE: Pour une même chanson (même titre ET même artiste), la durée ne peut jamais diminuer.
/// Si le titre ou l'artiste change, c'est une nouvelle chanson donc toute durée est acceptée.
/// RÈGLE: Pour les streams continus, pour une même chanson (même titre ET même artiste),
/// la durée ne peut jamais diminuer. Si le titre ou l'artiste change, c'est une nouvelle
/// chanson donc toute durée est acceptée.
/// Pour les fichiers normaux (non-streams), les métadonnées sont acceptées telles quelles.
/// Cette fonction est le SEUL point d'entrée pour modifier le cache.
fn cache_metadata(&self, track_id: u32, new_metadata: Option<crate::model::TrackMetadata>) {
let mut cache = self.metadata_cache.lock().unwrap();
// Vérifier s'il y a déjà des métadonnées en cache
if let Some(cached_meta) = cache.get(&track_id) {
// Vérifier si c'est la même chanson (titre ET artiste identiques)
// Vérifier si c'est un stream continu
let is_stream = new_metadata
.as_ref()
.map(|m| m.is_continuous_stream)
.unwrap_or(false);
if is_stream {
// Pour les streams: vérifier si c'est la même chanson (titre ET artiste identiques)
let same_title = cached_meta.as_ref().and_then(|m| m.title.as_ref())
== new_metadata.as_ref().and_then(|m| m.title.as_ref());
let same_artist = cached_meta.as_ref().and_then(|m| m.artist.as_ref())
@@ -267,9 +276,17 @@ impl OpenHomeQueue {
cache.insert(track_id, new_metadata);
}
} else {
// Chanson différente: accepter sans vérification
// Chanson différente sur un stream: accepter sans vérification
tracing::debug!(
"OpenHome cache_metadata: track_id={}, different track (title or artist changed), accepting update",
"OpenHome cache_metadata: track_id={}, different stream track (title or artist changed), accepting update",
track_id
);
cache.insert(track_id, new_metadata);
}
} else {
// Fichier normal (non-stream): accepter toute mise à jour
tracing::trace!(
"OpenHome cache_metadata: track_id={}, non-stream file, accepting update",
track_id
);
cache.insert(track_id, new_metadata);
@@ -277,9 +294,10 @@ impl OpenHomeQueue {
} else {
// Pas dans le cache: insérer directement
tracing::trace!(
"OpenHome cache_metadata: track_id={}, inserting first time, duration={:?}",
"OpenHome cache_metadata: track_id={}, inserting first time, duration={:?}, is_stream={:?}",
track_id,
new_metadata.as_ref().and_then(|m| m.duration.as_ref())
new_metadata.as_ref().and_then(|m| m.duration.as_ref()),
new_metadata.as_ref().map(|m| m.is_continuous_stream)
);
cache.insert(track_id, new_metadata);
}