Files
pmomusic/pmoradiofrance/src/metadata_cache.rs
Eric Coissac 718c0d2aed 🔧 refactor(base_url): centraliser gestion des URLs absolues via middleware
- Ajout d'un BaseUrl layer dans pmoserver pour gérer les URLs absolues (LAN/WAN)
- Renommage de `covers_absolute_url_for` → ` covers_relative_route`, stocker les routes relatives
- Mise à jour des appels UPnP vers `covers_absolute_url_for_upnp` (fallback PMO_SERVER_URL)
- Correction des tâches de fond pour stocker les routes, pas l'URL complète
- Suppression du feature gate `simd` inutilisé dans pmoaudio/src/lib.rs
2026-04-04 10:14:51 +02:00

811 lines
29 KiB
Rust

//! Cache de métadonnées pour Radio France avec TTL et système d'événements
//!
//! Ce module fournit un cache centralisé pour les métadonnées des stations Radio France:
//! - Cache in-memory avec TTL basé sur `end_time` de l'API
//! - Gestion automatique du cache de covers via pmocovers
//! - Système d'événements pour la synchronisation GENA
//! - Cache persistant des stations via pmoconfig
//!
//! # Architecture
//!
//! - `CachedMetadata` : Métadonnées simplifiées pour construire un DIDL
//! - `MetadataCache` : Gère le cache in-memory + cache persistant + événements
use crate::client::RadioFranceClient;
use crate::config_ext::{RadioFranceConfigExt, StationInfo};
use crate::error::Result;
use crate::models::{ImageSize, LiveResponse, Station};
use pmoconfig::Config;
#[cfg(feature = "playlist")]
use pmodidl::{Container, Item, Resource};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::sync::RwLock;
#[cfg(feature = "cache")]
use pmocovers::Cache as CoverCache;
// ============================================================================
// CachedMetadata
// ============================================================================
/// Métadonnées simplifiées pour construire un DIDL de playlist à un item
///
/// Contient UNIQUEMENT les données nécessaires pour remplir le DIDL.
/// Construit depuis `LiveResponse` avec les règles de mapping RF → UPnP.
#[derive(Debug, Clone)]
pub struct CachedMetadata {
/// Slug de la station
pub slug: String,
// Champs pour le DIDL (playlist + item)
pub title: String,
pub creator: Option<String>,
pub artist: Option<String>,
pub album: Option<String>,
pub genre: Option<String>,
pub class: String,
pub album_art: Option<String>, // URL publique de la cover
pub album_art_pk: Option<String>, // PK dans pmocovers
// Resource (stream)
pub stream_url: String,
pub protocol_info: String,
pub sample_frequency: Option<String>,
pub nr_audio_channels: Option<String>,
pub duration: Option<String>, // Calculé depuis end_time - start_time
// Bornes temporelles de l'émission/morceau courant
pub start_time: Option<u64>, // Unix timestamp
pub end_time: Option<u64>, // Unix timestamp (sert aussi de TTL)
/// Timestamp de la dernière récupération (pour TTL minimum)
pub fetched_at: u64,
}
impl CachedMetadata {
/// Parse depuis LiveResponse avec gestion automatique du cache de covers
///
/// IMPORTANT: Préserve les règles de mapping RF → UPnP existantes:
/// - Radio musicale avec song → métadonnées du morceau
/// - Radio parlée → agrégation émission/producteur
/// - Évite duplications du nom de station
/// - Calcule duration depuis end_time
#[cfg(feature = "cache")]
pub async fn from_live_response(
station: &Station,
live: &LiveResponse,
cover_cache: &Arc<CoverCache>,
server_base_url: &str,
) -> Result<Self> {
// 1. Extraire les champs metadata avec les règles RF → UPnP
let (title, creator, artist, album, genre, class) =
Self::extract_metadata_fields(station, live);
// 2. Gestion de la cover avec cache
let (album_art, album_art_pk) = Self::cache_cover(live, cover_cache, server_base_url).await;
// 3. Construction de la ressource (stream)
let (stream_url, protocol_info, sample_frequency, nr_audio_channels, duration) =
Self::build_stream_resource(live, &station.slug, server_base_url);
// 4. Bornes temporelles + TTL
let start_time = live.now.start_time;
let end_time = live.now.end_time;
let fetched_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
Ok(Self {
slug: station.slug.clone(),
title,
creator,
artist,
album,
genre,
class,
album_art,
album_art_pk,
stream_url,
protocol_info,
sample_frequency,
nr_audio_channels,
duration,
start_time,
end_time,
fetched_at,
})
}
/// Extrait les champs de métadonnées selon le type de radio
///
/// RÈGLES DE MAPPING (préservées du code existant):
/// - Radio musicale avec song → titre/artiste/album du morceau
/// - Radio parlée → agrégation émission/producteur
fn extract_metadata_fields(
station: &Station,
metadata: &LiveResponse,
) -> (
String,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
String,
) {
let now = &metadata.now;
// Détecter si c'est une radio musicale avec un morceau en cours
if let Some(ref song) = now.song {
// Radio musicale avec morceau
let title = now.first_line.title_or_default().to_string();
let song_artist = if song.interpreters.is_empty() {
None
} else {
Some(song.artists_display())
};
// Artist affiché = "Station - Artiste du morceau" pour identifier la radio
// Éviter la duplication si l'artiste est égal au nom de la station
let artist = if let Some(ref art) = song_artist {
if art != &station.name {
Some(format!("{} - {}", station.name, art))
} else {
Some(station.name.clone())
}
} else {
Some(station.name.clone())
};
let album = song.release.title.clone();
let creator = song_artist; // Creator reste l'artiste du morceau
let genre = Some("Music".to_string());
let class = "object.item.audioItem.musicTrack".to_string();
(title, creator, artist, album, genre, class)
} else {
// Radio parlée ou segment talk sur radio musicale
let first = now.first_line.title_or_default();
let second = now.second_line.title_or_default();
// Construire le titre en évitant les duplications
let title = if !first.is_empty() && !second.is_empty() {
// Si first contient déjà second, utiliser seulement first
if first.contains(second) {
first.to_string()
} else {
format!("{}{}", first, second)
}
} else if !first.is_empty() {
first.to_string()
} else {
station.name.clone()
};
// Artist/Creator = "{Station} - {Subtitle}"
// Éviter la duplication si subtitle == nom de la station
let artist = if !second.is_empty() && second != station.name {
Some(format!("{} - {}", station.name, second))
} else {
Some(station.name.clone())
};
let creator = artist.clone();
// Album = nom de l'émission principale
let album = if !first.is_empty() {
Some(first.to_string())
} else {
Some(station.name.clone())
};
let genre = Some("Talk Radio".to_string());
let class = "object.item.audioItem.audioBroadcast".to_string();
(title, creator, artist, album, genre, class)
}
}
/// Cache la cover et retourne (url_publique, pk)
#[cfg(feature = "cache")]
async fn cache_cover(
metadata: &LiveResponse,
cache: &Arc<CoverCache>,
server_base_url: &str,
) -> (Option<String>, Option<String>) {
// Extraire l'URL de la cover — deux stratégies selon la source :
// 1. visual_background.src peut être une URL S3 directe ou une URL Pikapi
// 2. visuals.card/player contiennent des URLs Pikapi avec UUID extractable
let cover_url = metadata
.now
.visual_background
.as_ref()
.map(|v| {
// Si c'est déjà une URL directe (S3, CDN), l'utiliser telle quelle
// Sinon essayer d'extraire l'UUID Pikapi pour construire une URL haute-résolution
if v.src.starts_with("http") && !v.src.contains("/pikapi/") {
v.src.clone()
} else if let Some(uuid) = v.extract_uuid() {
ImageSize::Large.build_url(&uuid)
} else {
v.src.clone()
}
})
.or_else(|| {
metadata.now.visuals.as_ref().and_then(|visuals| {
visuals
.card
.as_ref()
.and_then(|c| c.extract_uuid())
.or_else(|| visuals.player.as_ref().and_then(|p| p.extract_uuid()))
.map(|uuid| ImageSize::Large.build_url(&uuid))
})
});
let cover_url = match cover_url {
Some(u) => u,
None => {
let logo_url = format!(
"{}/api/radiofrance/default-logo",
server_base_url.trim_end_matches('/')
);
return (Some(logo_url), None);
}
};
// Tenter de catcher la cover
match cache.add_from_url(&cover_url, Some("radiofrance")).await {
Ok(pk) => {
// Stocker la route relative (le handler REST appliquera base_url.url_for())
let album_art_route = pmocache::covers_route_for(&pk, None);
#[cfg(feature = "logging")]
tracing::debug!(
"Cached cover - url: {}, PK: {}, route: {}",
cover_url,
pk,
album_art_route
);
(Some(album_art_route), Some(pk))
}
Err(e) => {
#[cfg(feature = "logging")]
tracing::warn!("Failed to cache Radio France cover {}: {}", cover_url, e);
// Fallback sur le logo par défaut - route relative
let logo_route = "/api/radiofrance/default-logo".to_string();
(Some(logo_route), None)
}
}
}
/// Construit la ressource stream avec URL du proxy
fn build_stream_resource(
metadata: &LiveResponse,
station_slug: &str,
server_base_url: &str,
) -> (
String,
String,
Option<String>,
Option<String>,
Option<String>,
) {
// Durée totale = end_time - start_time (valeur fixe, indépendante de now)
let duration = match (metadata.now.start_time, metadata.now.end_time) {
(Some(start), Some(end)) if end > start => {
let duration_secs = end - start;
let hours = duration_secs / 3600;
let minutes = (duration_secs % 3600) / 60;
let seconds = duration_secs % 60;
Some(format!("{}:{:02}:{:02}", hours, minutes, seconds))
}
_ => None,
};
// URL du proxy PMOMusic
let url = format!(
"{}/api/radiofrance/{}/stream",
server_base_url.trim_end_matches('/'),
station_slug
);
// Déterminer le protocol_info et caractéristiques audio
let best_stream = metadata.now.media.best_hifi_stream();
let (protocol_info, sample_frequency, nr_audio_channels) = match best_stream {
Some(stream) => {
let protocol_info = match stream.format {
crate::models::StreamFormat::Aac => "http-get:*:audio/aac:*".to_string(),
crate::models::StreamFormat::Hls => {
"http-get:*:application/vnd.apple.mpegurl:*".to_string()
}
crate::models::StreamFormat::Mp3 => "http-get:*:audio/mpeg:*".to_string(),
};
let sample_freq = match stream.format {
crate::models::StreamFormat::Aac => Some("48000".to_string()),
_ => None,
};
let channels = match stream.format {
crate::models::StreamFormat::Aac | crate::models::StreamFormat::Mp3 => {
Some("2".to_string())
}
_ => None,
};
(protocol_info, sample_freq, channels)
}
None => {
// Fallback
("http-get:*:audio/aac:*".to_string(), None, None)
}
};
(
url,
protocol_info,
sample_frequency,
nr_audio_channels,
duration,
)
}
/// Construit un Container DIDL de playlist à un item
///
/// La playlist et l'item ont EXACTEMENT les mêmes métadonnées
#[cfg(feature = "playlist")]
pub fn to_didl(&self, playlist_id: &str, parent_id: &str) -> Container {
// Durée totale = end_time - start_time (valeur fixe)
let duration = match (self.start_time, self.end_time) {
(Some(start), Some(end)) if end > start => {
let duration_secs = end - start;
let hours = duration_secs / 3600;
let minutes = (duration_secs % 3600) / 60;
let seconds = duration_secs % 60;
let dur = format!("{}:{:02}:{:02}", hours, minutes, seconds);
#[cfg(feature = "logging")]
tracing::debug!(
"Duration for {}: {} (start: {}, end: {}, total: {}s)",
self.slug, dur, start, end, duration_secs
);
Some(dur)
}
_ => {
#[cfg(feature = "logging")]
tracing::warn!(
"No duration for {}: start_time={:?}, end_time={:?}",
self.slug, self.start_time, self.end_time
);
None
}
};
let item = Item {
id: format!("{}:stream", playlist_id),
parent_id: playlist_id.to_string(),
restricted: Some("1".to_string()),
title: self.title.clone(),
creator: self.creator.clone(),
class: self.class.clone(),
artist: self.artist.clone(),
album: self.album.clone(),
genre: self.genre.clone(),
album_art: self.album_art.clone(),
album_art_pk: self.album_art_pk.clone(),
// dc:date: début réel de diffusion du segment en RFC 3339 UTC.
// Les timestamps Radio France sont des Unix timestamps UTC.
// Pmocontrol utilise cette valeur pour calibrer track_start_time.
date: self.start_time.and_then(|start| {
chrono::DateTime::from_timestamp(start as i64, 0)
.map(|dt| dt.to_rfc3339())
}),
original_track_number: None,
resources: vec![Resource {
protocol_info: self.protocol_info.clone(),
bits_per_sample: None,
sample_frequency: self.sample_frequency.clone(),
nr_audio_channels: self.nr_audio_channels.clone(),
duration,
url: self.stream_url.clone(),
}],
descriptions: vec![],
};
// Container de playlist avec LES MÊMES métadonnées
Container {
id: playlist_id.to_string(),
parent_id: parent_id.to_string(),
restricted: Some("1".to_string()),
child_count: Some("1".to_string()),
searchable: Some("0".to_string()),
title: self.title.clone(),
class: "object.container.playlistContainer".to_string(),
artist: self.artist.clone(),
album_art: self.album_art.clone(),
containers: vec![],
items: vec![item],
}
}
/// TTL minimum quand l'API ne fournit pas de end_time (3 minutes)
pub const FALLBACK_TTL_SECS: u64 = 3 * 60;
/// Vérifie si le TTL est dépassé
///
/// - Si `end_time` est fourni par l'API : expire exactement quand l'émission se termine
/// - Sinon : expire après `FALLBACK_TTL_SECS` depuis le dernier fetch
pub fn is_expired(&self) -> bool {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
if let Some(end_time) = self.end_time {
now >= end_time
} else {
now >= self.fetched_at + Self::FALLBACK_TTL_SECS
}
}
}
// ============================================================================
// MetadataCache
// ============================================================================
/// Type de callback pour les notifications de mise à jour
pub type MetadataUpdateCallback = Arc<dyn Fn(&str) + Send + Sync>;
/// Cache de métadonnées avec TTL et système d'événements
///
/// Gère:
/// - Cache in-memory des métadonnées (HashMap avec TTL)
/// - Cache persistant des stations (via pmoconfig)
/// - Système d'événements (subscribe/notify)
pub struct MetadataCache {
/// Cache in-memory slug -> CachedMetadata
cache: Arc<RwLock<HashMap<String, CachedMetadata>>>,
/// Client HTTP Radio France
client: RadioFranceClient,
/// Cache de covers (pmocovers)
#[cfg(feature = "cache")]
cover_cache: Arc<CoverCache>,
/// URL de base du serveur
server_base_url: String,
/// Configuration (pour cache persistant des stations)
config: Arc<Config>,
/// Abonnés aux événements
subscribers: Arc<RwLock<Vec<MetadataUpdateCallback>>>,
}
impl MetadataCache {
/// Constructeur avec tous les paramètres obligatoires
///
/// Charge automatiquement le mapping station depuis pmoconfig (si valide).
/// Si le cache est absent ou expiré, utilise les valeurs hardcodées du client.
#[cfg(feature = "cache")]
pub fn new(
client: RadioFranceClient,
cover_cache: Arc<CoverCache>,
server_base_url: String,
config: Arc<Config>,
) -> Self {
// Charger le mapping depuis pmoconfig et l'injecter dans le client
if let Ok(Some(mapping)) = config.get_radiofrance_station_mapping() {
#[cfg(feature = "logging")]
tracing::info!(
"Loaded station mapping from config ({} entries)",
mapping.len()
);
client.set_station_mapping(mapping);
} else {
// Pas de cache persistant : persister le mapping par défaut
let mapping = client.get_station_mapping();
let _ = config.set_radiofrance_station_mapping(&mapping);
#[cfg(feature = "logging")]
tracing::info!(
"Initialized station mapping from defaults ({} entries)",
mapping.len()
);
}
Self {
cache: Arc::new(RwLock::new(HashMap::new())),
client,
cover_cache,
server_base_url,
config,
subscribers: Arc::new(RwLock::new(Vec::new())),
}
}
/// Récupère les métadonnées (rafraîchit si TTL expiré)
///
/// # Logique
///
/// 1. Vérifie le cache in-memory
/// 2. Si valide, retourne directement
/// 3. Sinon, appelle API Radio France
/// 4. Met à jour le cache
/// 5. Notifie les abonnés
/// 6. Retourne les métadonnées
///
/// # Graceful degradation
///
/// Si l'API Radio France est down, retourne les données expirées du cache
#[cfg(feature = "cache")]
pub async fn get(&self, slug: &str) -> Result<CachedMetadata> {
// 1. Vérifie le cache
{
let cache = self.cache.read().await;
if let Some(metadata) = cache.get(slug) {
if !metadata.is_expired() {
return Ok(metadata.clone());
}
}
}
// 2. TTL expiré ou absent: appelle API Radio France
let live_response = match self.client.live_metadata(slug).await {
Ok(resp) => resp,
Err(e) => {
// Si station inconnue → tenter une re-découverte du mapping
let is_unknown = e.to_string().contains("Unknown station slug");
if is_unknown {
#[cfg(feature = "logging")]
tracing::warn!("Unknown station '{}', attempting rediscovery", slug);
match self.client.rediscover_station(slug).await {
Ok(_) => {
// Persister le mapping mis à jour
self.persist_station_mapping();
// Réessayer la requête
match self.client.live_metadata(slug).await {
Ok(resp) => resp,
Err(e2) => return Err(e2),
}
}
Err(discover_err) => {
#[cfg(feature = "logging")]
tracing::error!("Rediscovery failed for '{}': {}", slug, discover_err);
return Err(e);
}
}
} else {
// Graceful degradation: retourner les données expirées si API down
let cache = self.cache.read().await;
if let Some(metadata) = cache.get(slug) {
#[cfg(feature = "logging")]
tracing::warn!(
"API Radio France down for {}, using expired cache: {}",
slug,
e
);
return Ok(metadata.clone());
}
return Err(e);
}
}
};
// 3. Récupérer le nom de la station depuis la liste des stations
let station_name = {
let stations = self.get_stations().await.unwrap_or_default();
stations
.iter()
.find(|s| s.slug == slug)
.map(|s| s.name.clone())
.unwrap_or_else(|| slug.to_string())
};
// 4. Parse LiveResponse -> CachedMetadata
let metadata = CachedMetadata::from_live_response(
&Station {
slug: slug.to_string(),
name: station_name,
},
&live_response,
&self.cover_cache,
&self.server_base_url,
)
.await?;
// 4. Met à jour le cache — notifier seulement si le titre a changé (vrai changement de piste)
let should_notify = {
let mut cache = self.cache.write().await;
let changed = cache
.get(slug)
.map(|old| old.title != metadata.title)
.unwrap_or(false); // premier fetch → pas de notification
cache.insert(slug.to_string(), metadata.clone());
changed
};
// 5. Notifie les abonnés uniquement si la piste a changé
if should_notify {
self.notify_async(slug).await;
}
// 6. Retourne les métadonnées
Ok(metadata)
}
/// Récupère la liste des stations (cache persistant via pmoconfig, TTL 7 jours)
///
/// # Logique
///
/// 1. Essaie de lire depuis pmoconfig (cache TTL 7 jours)
/// 2. Si cache valide, retourne sans appel réseau
/// 3. Sinon, découvre via scraping et met à jour pmoconfig
pub async fn get_stations(&self) -> Result<Vec<Station>> {
// 1. Essayer le cache persistant
if let Ok(Some(stations)) = self.config.get_radiofrance_stations_cached() {
#[cfg(feature = "logging")]
tracing::debug!("Using cached station list ({} stations)", stations.len());
return Ok(stations);
}
// 2. Cache miss : découverte via scraping
#[cfg(feature = "logging")]
tracing::info!("Station cache miss, discovering via web scraping...");
let stations = self.client.discover_all_stations().await?;
// 3. Persister dans pmoconfig
if let Err(e) = self.config.set_radiofrance_cached_stations(&stations) {
#[cfg(feature = "logging")]
tracing::warn!("Failed to cache station list: {}", e);
}
Ok(stations)
}
/// Récupère les métadonnées live brutes de l'API (sans cache)
///
/// Cette méthode est utilisée par l'API REST pour retourner
/// la réponse complète de l'API Radio France
pub async fn get_live_metadata(&self, slug: &str) -> Result<LiveResponse> {
self.client.live_metadata(slug).await
}
/// Récupère l'URL du stream HiFi pour une station
pub async fn get_stream_url(&self, slug: &str) -> Result<String> {
self.client.get_hifi_stream_url(slug).await
}
/// Force une re-découverte du mapping pour un slug donné
///
/// Utilisé quand un stream retourne une erreur HTTP (404, connexion refusée, etc.)
/// Persiste le mapping mis à jour dans pmoconfig.
pub async fn handle_stream_failure(&self, slug: &str) {
#[cfg(feature = "logging")]
tracing::warn!("Stream failure for '{}', triggering rediscovery", slug);
match self.client.rediscover_station(slug).await {
Ok((id, url)) => {
#[cfg(feature = "logging")]
tracing::info!("Re-discovered station '{}': id={}, url={}", slug, id, url);
self.persist_station_mapping();
// Invalider le cache mémoire pour forcer un refresh des métadonnées
let mut cache = self.cache.write().await;
cache.remove(slug);
}
Err(e) => {
#[cfg(feature = "logging")]
tracing::error!("Rediscovery failed for '{}': {}", slug, e);
}
}
}
/// Persiste le mapping courant dans pmoconfig
fn persist_station_mapping(&self) {
let mapping = self.client.get_station_mapping();
if let Err(e) = self.config.set_radiofrance_station_mapping(&mapping) {
#[cfg(feature = "logging")]
tracing::warn!("Failed to persist station mapping: {}", e);
}
}
/// Met à jour manuellement le mapping d'une station et le persiste
pub fn update_station_mapping(&self, slug: &str, station_id: u32, stream_url: String) {
self.client
.update_station_entry(slug, station_id, stream_url.clone());
// Persister dans pmoconfig
let info = StationInfo {
station_id,
stream_url,
};
if let Err(e) = self.config.upsert_radiofrance_station_info(slug, info) {
#[cfg(feature = "logging")]
tracing::warn!("Failed to upsert station info for '{}': {}", slug, e);
}
}
/// S'abonner aux changements de métadonnées
///
/// Le callback sera appelé avec le slug chaque fois que
/// les métadonnées de ce slug sont rafraîchies
pub fn subscribe(&self, callback: MetadataUpdateCallback) {
// Spawn une tâche pour éviter le blocking dans le runtime
let subscribers = self.subscribers.clone();
tokio::spawn(async move {
let mut subs = subscribers.write().await;
subs.push(callback);
});
}
/// Notifier tous les abonnés qu'un slug a été mis à jour (version async)
async fn notify_async(&self, slug: &str) {
// Clone pour éviter de bloquer longtemps
let callbacks: Vec<_> = {
let subscribers = self.subscribers.read().await;
subscribers.clone()
};
for callback in callbacks.iter() {
callback(slug);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cached_metadata_is_expired() {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let base = CachedMetadata {
slug: "test".to_string(),
title: "Test".to_string(),
creator: None,
artist: None,
album: None,
genre: None,
class: "test".to_string(),
album_art: None,
album_art_pk: None,
stream_url: "".to_string(),
protocol_info: "".to_string(),
sample_frequency: None,
nr_audio_channels: None,
duration: None,
start_time: None,
end_time: Some(0), // Dans le passé
fetched_at: now,
};
// end_time dans le passé → expiré
assert!(base.is_expired());
// end_time dans le futur → non expiré
let not_expired = CachedMetadata {
end_time: Some(now + 3600),
..base.clone()
};
assert!(!not_expired.is_expired());
// Pas de end_time, fetched_at récent → non expiré (fallback TTL)
let no_end_fresh = CachedMetadata {
end_time: None,
fetched_at: now,
..base.clone()
};
assert!(!no_end_fresh.is_expired());
// Pas de end_time, fetched_at ancien → expiré
let no_end_old = CachedMetadata {
end_time: None,
fetched_at: now - CachedMetadata::FALLBACK_TTL_SECS - 1,
..base
};
assert!(no_end_old.is_expired());
}
}