2026-01-24 18:34:19 +01:00
|
|
|
use std::collections::HashMap;
|
2026-04-09 11:19:54 +02:00
|
|
|
use std::sync::{atomic::AtomicBool, Arc, Mutex};
|
2026-01-31 16:52:03 +01:00
|
|
|
use std::time::SystemTime;
|
2025-12-30 16:50:26 +01:00
|
|
|
use std::usize;
|
|
|
|
|
|
2025-12-06 13:37:42 +01:00
|
|
|
use quick_xml::escape::escape;
|
2026-01-31 17:21:18 +01:00
|
|
|
use tracing::{debug, trace, warn};
|
2025-12-06 13:37:42 +01:00
|
|
|
|
2025-12-28 17:45:06 +01:00
|
|
|
use crate::errors::ControlPointError;
|
2025-12-30 16:50:26 +01:00
|
|
|
use crate::upnp_clients::{
|
2026-04-03 21:31:57 +02:00
|
|
|
OhInfoClient, OhPlaylistClient, OhProductClient, OhTrack, OhTrackEntry,
|
|
|
|
|
OPENHOME_PLAYLIST_HEAD_ID,
|
2025-12-30 16:50:26 +01:00
|
|
|
};
|
|
|
|
|
// use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
|
|
|
|
|
use crate::queue::{
|
|
|
|
|
EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueFromRendererInfo, QueueSnapshot,
|
2025-12-06 13:37:42 +01:00
|
|
|
};
|
2025-12-28 17:45:06 +01:00
|
|
|
use crate::{DeviceId, DeviceIdentity, RendererInfo};
|
2025-12-06 13:37:42 +01:00
|
|
|
|
2026-01-31 16:52:03 +01:00
|
|
|
/// Cache for OpenHome track IDs to avoid redundant SOAP calls
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
struct TrackIdsCache {
|
|
|
|
|
/// Cached track IDs
|
|
|
|
|
ids: Option<Vec<u32>>,
|
|
|
|
|
/// Timestamp of last cache update
|
|
|
|
|
last_update: Option<SystemTime>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl TrackIdsCache {
|
|
|
|
|
fn new() -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
ids: None,
|
|
|
|
|
last_update: None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Check if cache is valid (not expired and has data)
|
|
|
|
|
fn is_valid(&self) -> bool {
|
|
|
|
|
if let (Some(_), Some(last_update)) = (&self.ids, self.last_update) {
|
|
|
|
|
if let Ok(elapsed) = SystemTime::now().duration_since(last_update) {
|
|
|
|
|
return elapsed.as_millis() < 1000; // TTL: 1 second
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Get cached IDs if valid
|
|
|
|
|
fn get(&self) -> Option<Vec<u32>> {
|
|
|
|
|
if self.is_valid() {
|
|
|
|
|
self.ids.clone()
|
|
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Update cache with new IDs
|
|
|
|
|
fn set(&mut self, ids: Vec<u32>) {
|
|
|
|
|
self.ids = Some(ids);
|
|
|
|
|
self.last_update = Some(SystemTime::now());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Invalidate cache (called on write operations)
|
|
|
|
|
fn invalidate(&mut self) {
|
|
|
|
|
self.ids = None;
|
|
|
|
|
self.last_update = None;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 10:00:27 +02:00
|
|
|
/// Cache for ReadList results to avoid redundant SOAP calls within a short window.
|
|
|
|
|
/// Key: sorted list of requested IDs. TTL: 500ms.
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
struct ReadListCache {
|
|
|
|
|
ids: Option<Vec<u32>>,
|
|
|
|
|
entries: Option<Vec<OhTrackEntry>>,
|
|
|
|
|
last_update: Option<SystemTime>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl ReadListCache {
|
|
|
|
|
fn new() -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
ids: None,
|
|
|
|
|
entries: None,
|
|
|
|
|
last_update: None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn get(&self, id_list: &[u32]) -> Option<Vec<OhTrackEntry>> {
|
|
|
|
|
if let (Some(cached_ids), Some(entries), Some(last_update)) =
|
|
|
|
|
(&self.ids, &self.entries, self.last_update)
|
|
|
|
|
{
|
|
|
|
|
if let Ok(elapsed) = SystemTime::now().duration_since(last_update) {
|
|
|
|
|
if elapsed.as_millis() < 500 && cached_ids.as_slice() == id_list {
|
|
|
|
|
return Some(entries.clone());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn set(&mut self, ids: Vec<u32>, entries: Vec<OhTrackEntry>) {
|
|
|
|
|
self.ids = Some(ids);
|
|
|
|
|
self.entries = Some(entries);
|
|
|
|
|
self.last_update = Some(SystemTime::now());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn invalidate(&mut self) {
|
|
|
|
|
self.ids = None;
|
|
|
|
|
self.entries = None;
|
|
|
|
|
self.last_update = None;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-31 17:21:18 +01:00
|
|
|
/// Cache for current track ID to avoid redundant Id SOAP calls
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
struct CurrentTrackIdCache {
|
|
|
|
|
/// Cached current track ID (None means no track playing, id=0)
|
|
|
|
|
current_id: Option<Option<u32>>,
|
|
|
|
|
/// Timestamp of last cache update
|
|
|
|
|
last_update: Option<SystemTime>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl CurrentTrackIdCache {
|
|
|
|
|
fn new() -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
current_id: None,
|
|
|
|
|
last_update: None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Check if cache is valid (not expired and has data)
|
|
|
|
|
fn is_valid(&self) -> bool {
|
|
|
|
|
if let (Some(_), Some(last_update)) = (&self.current_id, self.last_update) {
|
|
|
|
|
if let Ok(elapsed) = SystemTime::now().duration_since(last_update) {
|
|
|
|
|
return elapsed.as_millis() < 250; // TTL: 250ms
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Get cached current track ID if valid
|
|
|
|
|
fn get(&self) -> Option<Option<u32>> {
|
|
|
|
|
if self.is_valid() {
|
|
|
|
|
self.current_id
|
|
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Update cache with new current track ID
|
|
|
|
|
fn set(&mut self, id: Option<u32>) {
|
|
|
|
|
self.current_id = Some(id);
|
|
|
|
|
self.last_update = Some(SystemTime::now());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Invalidate cache (called on write operations)
|
|
|
|
|
fn invalidate(&mut self) {
|
|
|
|
|
self.current_id = None;
|
|
|
|
|
self.last_update = None;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-06 13:37:42 +01:00
|
|
|
/// Local mirror of an OpenHome playlist for a single renderer.
|
2026-01-31 17:21:18 +01:00
|
|
|
#[derive(Debug)]
|
2025-12-06 13:37:42 +01:00
|
|
|
pub struct OpenHomeQueue {
|
2025-12-28 17:45:06 +01:00
|
|
|
renderer_id: DeviceId,
|
|
|
|
|
playlist_client: OhPlaylistClient,
|
|
|
|
|
info_client: Option<OhInfoClient>,
|
|
|
|
|
product_client: Option<OhProductClient>,
|
2026-01-24 18:34:19 +01:00
|
|
|
/// Cache des métadonnées par ID OpenHome.
|
|
|
|
|
/// Permet de maintenir des métadonnées à jour même si le service OpenHome
|
|
|
|
|
/// ne permet pas de les modifier directement.
|
2026-01-31 19:56:46 +01:00
|
|
|
metadata_cache: Mutex<HashMap<u32, Option<crate::model::TrackMetadata>>>,
|
2026-04-09 08:39:26 +02:00
|
|
|
/// Cache URI by track ID for fast path matching
|
|
|
|
|
uri_by_id: Mutex<HashMap<u32, String>>,
|
2026-01-31 16:52:03 +01:00
|
|
|
/// Cache for track IDs to avoid redundant IdArray SOAP calls
|
|
|
|
|
track_ids_cache: Arc<Mutex<TrackIdsCache>>,
|
2026-01-31 17:21:18 +01:00
|
|
|
/// Cache for current track ID to avoid redundant Id SOAP calls
|
|
|
|
|
current_track_id_cache: Arc<Mutex<CurrentTrackIdCache>>,
|
2026-03-31 10:00:27 +02:00
|
|
|
/// Cache for ReadList results (TTL 500ms) to avoid redundant SOAP calls
|
|
|
|
|
read_list_cache: Arc<Mutex<ReadListCache>>,
|
2025-12-06 13:37:42 +01:00
|
|
|
}
|
|
|
|
|
|
2026-04-09 08:39:26 +02:00
|
|
|
/// Fast path result for queue sync optimization
|
|
|
|
|
enum FastPathResult {
|
|
|
|
|
/// New items are an append to the current queue
|
|
|
|
|
AppendOnly { new_items: Vec<PlaybackItem> },
|
|
|
|
|
/// Items were deleted from the end
|
|
|
|
|
DeleteFromEnd { delete_ids: Vec<u32> },
|
|
|
|
|
/// No fast path possible - need full LCS sync
|
|
|
|
|
NeedFullSync,
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-06 13:37:42 +01:00
|
|
|
impl OpenHomeQueue {
|
|
|
|
|
pub fn new(
|
2025-12-28 17:45:06 +01:00
|
|
|
renderer_id: DeviceId,
|
2025-12-06 13:37:42 +01:00
|
|
|
playlist: OhPlaylistClient,
|
|
|
|
|
info_client: Option<OhInfoClient>,
|
2025-12-17 21:41:24 +01:00
|
|
|
product_client: Option<OhProductClient>,
|
2025-12-06 13:37:42 +01:00
|
|
|
) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
renderer_id,
|
2025-12-28 17:45:06 +01:00
|
|
|
playlist_client: playlist,
|
2025-12-06 13:37:42 +01:00
|
|
|
info_client,
|
2025-12-17 21:41:24 +01:00
|
|
|
product_client,
|
2026-01-31 19:56:46 +01:00
|
|
|
metadata_cache: Mutex::new(HashMap::new()),
|
2026-04-09 08:39:26 +02:00
|
|
|
uri_by_id: Mutex::new(HashMap::new()),
|
2026-01-31 16:52:03 +01:00
|
|
|
track_ids_cache: Arc::new(Mutex::new(TrackIdsCache::new())),
|
2026-01-31 17:21:18 +01:00
|
|
|
current_track_id_cache: Arc::new(Mutex::new(CurrentTrackIdCache::new())),
|
2026-03-31 10:00:27 +02:00
|
|
|
read_list_cache: Arc::new(Mutex::new(ReadListCache::new())),
|
2025-12-06 13:37:42 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 16:50:26 +01:00
|
|
|
pub fn from_renderer_info(info: &RendererInfo) -> Result<OpenHomeQueue, ControlPointError> {
|
2025-12-28 17:45:06 +01:00
|
|
|
let playlist_client = OhPlaylistClient::from_renderer_info(info)?;
|
|
|
|
|
let info_client = OhInfoClient::from_renderer_info(&info).ok();
|
|
|
|
|
let product_client = OhProductClient::from_renderer_info(&info).ok();
|
|
|
|
|
|
|
|
|
|
Ok(OpenHomeQueue::new(
|
|
|
|
|
info.id(),
|
|
|
|
|
playlist_client,
|
|
|
|
|
info_client,
|
|
|
|
|
product_client,
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn ensure_playlist_source_selected(&self) -> Result<(), ControlPointError> {
|
2025-12-17 21:41:24 +01:00
|
|
|
if let Some(product) = &self.product_client {
|
2025-12-28 17:45:06 +01:00
|
|
|
product.ensure_playlist_source_selected()
|
2025-12-17 21:41:24 +01:00
|
|
|
} else {
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 16:59:23 +02:00
|
|
|
/// Invalide les caches track_ids et read_list (après insert/delete sans impact sur la piste courante).
|
|
|
|
|
fn invalidate_track_caches(&self) {
|
|
|
|
|
self.track_ids_cache.lock().unwrap().invalidate();
|
|
|
|
|
self.read_list_cache.lock().unwrap().invalidate();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Invalide tous les caches (après delete_all, seek, stop — opérations qui changent la piste courante).
|
|
|
|
|
fn invalidate_all_caches(&self) {
|
|
|
|
|
self.track_ids_cache.lock().unwrap().invalidate();
|
|
|
|
|
self.read_list_cache.lock().unwrap().invalidate();
|
|
|
|
|
self.current_track_id_cache.lock().unwrap().invalidate();
|
2026-04-09 08:39:26 +02:00
|
|
|
self.uri_by_id.lock().unwrap().clear();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Tries to detect a simple append-only or delete-from-end pattern without ReadList.
|
|
|
|
|
///
|
|
|
|
|
/// This is a fast path optimization that avoids the expensive ReadList calls when:
|
|
|
|
|
/// 1. New items are an append to existing queue (only inserts needed)
|
|
|
|
|
/// 2. Items were deleted from the end (only deletes needed)
|
|
|
|
|
///
|
|
|
|
|
/// Returns `FastPathResult::NeedFullSync` if the pattern doesn't match or
|
|
|
|
|
/// if cache data is insufficient to verify.
|
|
|
|
|
fn try_fast_path(&self, new_items: &[PlaybackItem]) -> FastPathResult {
|
|
|
|
|
let current_ids = match self.track_ids() {
|
|
|
|
|
Ok(ids) => ids,
|
|
|
|
|
Err(e) => {
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
error = %e,
|
|
|
|
|
"try_fast_path: failed to get current IDs, falling back to full sync"
|
|
|
|
|
);
|
|
|
|
|
return FastPathResult::NeedFullSync;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let current_len = current_ids.len();
|
|
|
|
|
let new_len = new_items.len();
|
|
|
|
|
|
|
|
|
|
if new_len > current_len {
|
|
|
|
|
let prefix_matches = self.check_prefix_matches(¤t_ids, &new_items[..current_len]);
|
|
|
|
|
if prefix_matches {
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
current_len, new_len, "try_fast_path: detected append-only pattern"
|
|
|
|
|
);
|
|
|
|
|
return FastPathResult::AppendOnly {
|
|
|
|
|
new_items: new_items[current_len..].to_vec(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
} else if new_len < current_len {
|
|
|
|
|
let prefix_matches = self.check_prefix_matches(¤t_ids[..new_len], new_items);
|
|
|
|
|
if prefix_matches {
|
|
|
|
|
let delete_ids: Vec<u32> = current_ids[new_len..].to_vec();
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
current_len,
|
|
|
|
|
new_len,
|
|
|
|
|
delete_count = delete_ids.len(),
|
|
|
|
|
"try_fast_path: detected delete-from-end pattern"
|
|
|
|
|
);
|
|
|
|
|
return FastPathResult::DeleteFromEnd { delete_ids };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
current_len, new_len, "try_fast_path: no fast path detected, falling back to full sync"
|
|
|
|
|
);
|
|
|
|
|
FastPathResult::NeedFullSync
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Checks if the prefix of the new items matches the current queue items.
|
|
|
|
|
/// Uses local metadata cache or falls back to URI comparison.
|
|
|
|
|
fn check_prefix_matches(&self, current_ids: &[u32], new_items: &[PlaybackItem]) -> bool {
|
|
|
|
|
if current_ids.len() != new_items.len() {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let metadata_cache = match self.metadata_cache.lock() {
|
|
|
|
|
Ok(cache) => cache,
|
|
|
|
|
Err(_) => return false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let uri_cache = match self.uri_by_id.lock() {
|
|
|
|
|
Ok(cache) => cache,
|
|
|
|
|
Err(_) => return false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
for (idx, new_item) in new_items.iter().enumerate() {
|
|
|
|
|
let current_id = current_ids[idx];
|
|
|
|
|
|
|
|
|
|
// First check if we have cached metadata for this track_id that matches
|
|
|
|
|
if let Some(cached_metadata) = metadata_cache.get(¤t_id) {
|
|
|
|
|
if let Some(cached) = cached_metadata {
|
|
|
|
|
// Compare using title + artist as identity (for streams)
|
|
|
|
|
if let Some(ref new_metadata) = new_item.metadata {
|
|
|
|
|
let same_title = cached.title.as_ref() == new_metadata.title.as_ref();
|
|
|
|
|
let same_artist = cached.artist.as_ref() == new_metadata.artist.as_ref();
|
|
|
|
|
if same_title && same_artist {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// No metadata match - fall back to URI comparison using uri_by_id cache
|
|
|
|
|
if new_item.uri.is_empty() {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Compare against cached URI for this track_id
|
|
|
|
|
if let Some(cached_uri) = uri_cache.get(¤t_id) {
|
|
|
|
|
if cached_uri == &new_item.uri {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// No match found - fall back to full sync
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
true
|
2026-04-06 16:59:23 +02:00
|
|
|
}
|
|
|
|
|
|
2026-01-24 18:34:19 +01:00
|
|
|
/// Met à jour les métadonnées d'un item de la queue à l'index spécifié.
|
|
|
|
|
///
|
|
|
|
|
/// Contrairement au service OpenHome qui ne permet pas de modifier les métadonnées,
|
|
|
|
|
/// cette méthode met à jour le cache local de métadonnées, permettant ainsi au
|
|
|
|
|
/// control point de maintenir des métadonnées à jour même si le média serveur
|
|
|
|
|
/// les modifie.
|
|
|
|
|
///
|
|
|
|
|
/// # Arguments
|
|
|
|
|
/// * `index` - Position de l'item dans la queue (0-based)
|
|
|
|
|
/// * `metadata` - Nouvelles métadonnées à associer à l'item
|
|
|
|
|
///
|
|
|
|
|
/// # Errors
|
|
|
|
|
/// Retourne une erreur si l'index est hors limites.
|
|
|
|
|
pub fn update_item_metadata(
|
|
|
|
|
&mut self,
|
|
|
|
|
index: usize,
|
|
|
|
|
metadata: Option<crate::model::TrackMetadata>,
|
|
|
|
|
) -> Result<(), ControlPointError> {
|
|
|
|
|
let track_id = self.position_to_id(index)?;
|
2026-04-09 08:39:26 +02:00
|
|
|
self.cache_metadata(track_id, metadata, "");
|
2026-01-24 18:34:19 +01:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-31 19:56:46 +01:00
|
|
|
/// Insère ou met à jour les métadonnées dans le cache.
|
2026-01-31 22:30:30 +01:00
|
|
|
/// 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.
|
2026-01-31 19:56:46 +01:00
|
|
|
/// Cette fonction est le SEUL point d'entrée pour modifier le cache.
|
2026-04-09 08:39:26 +02:00
|
|
|
fn cache_metadata(
|
|
|
|
|
&self,
|
|
|
|
|
track_id: u32,
|
|
|
|
|
new_metadata: Option<crate::model::TrackMetadata>,
|
|
|
|
|
uri: &str,
|
|
|
|
|
) {
|
2026-01-31 19:56:46 +01:00
|
|
|
let mut cache = self.metadata_cache.lock().unwrap();
|
2026-04-09 08:39:26 +02:00
|
|
|
let mut uri_cache = self.uri_by_id.lock().unwrap();
|
|
|
|
|
|
|
|
|
|
// Update URI cache
|
|
|
|
|
if !uri.is_empty() {
|
|
|
|
|
uri_cache.insert(track_id, uri.to_string());
|
|
|
|
|
}
|
2026-01-31 19:56:46 +01:00
|
|
|
|
|
|
|
|
// Vérifier s'il y a déjà des métadonnées en cache
|
|
|
|
|
if let Some(cached_meta) = cache.get(&track_id) {
|
2026-01-31 22:30:30 +01:00
|
|
|
// Vérifier si c'est un stream continu
|
|
|
|
|
let is_stream = new_metadata
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map(|m| m.is_continuous_stream)
|
|
|
|
|
.unwrap_or(false);
|
2026-01-31 19:56:46 +01:00
|
|
|
|
2026-01-31 22:30:30 +01:00
|
|
|
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())
|
|
|
|
|
== new_metadata.as_ref().and_then(|m| m.artist.as_ref());
|
2026-01-31 19:56:46 +01:00
|
|
|
|
2026-01-31 22:30:30 +01:00
|
|
|
let same_track = same_title && same_artist;
|
2026-01-31 19:56:46 +01:00
|
|
|
|
2026-01-31 22:30:30 +01:00
|
|
|
if same_track {
|
|
|
|
|
// Même chanson: vérifier que la durée n'a pas diminué
|
|
|
|
|
let should_update = match (
|
|
|
|
|
cached_meta.as_ref().and_then(|m| m.duration.as_ref()),
|
|
|
|
|
new_metadata.as_ref().and_then(|m| m.duration.as_ref()),
|
|
|
|
|
) {
|
|
|
|
|
(Some(cached_dur), Some(new_dur)) => {
|
2026-04-06 16:59:23 +02:00
|
|
|
if super::stream_duration_decreased(cached_dur, new_dur) {
|
|
|
|
|
tracing::trace!(
|
|
|
|
|
"OpenHome cache_metadata: track_id={}, REJECTING update (same track, duration decreased): {} -> {}",
|
|
|
|
|
track_id,
|
|
|
|
|
cached_dur,
|
|
|
|
|
new_dur
|
|
|
|
|
);
|
|
|
|
|
false
|
|
|
|
|
} else {
|
|
|
|
|
if super::stream_duration_increased(cached_dur, new_dur) {
|
|
|
|
|
tracing::debug!(
|
|
|
|
|
"OpenHome cache_metadata: track_id={}, same track, duration increased: {} -> {}",
|
2026-01-31 19:56:46 +01:00
|
|
|
track_id,
|
|
|
|
|
cached_dur,
|
|
|
|
|
new_dur
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
true
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-31 22:30:30 +01:00
|
|
|
_ => true, // Pas de durée ou une seule des deux: accepter
|
|
|
|
|
};
|
2026-01-31 19:56:46 +01:00
|
|
|
|
2026-01-31 22:30:30 +01:00
|
|
|
if should_update {
|
|
|
|
|
cache.insert(track_id, new_metadata);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Chanson différente sur un stream: accepter sans vérification
|
|
|
|
|
tracing::debug!(
|
|
|
|
|
"OpenHome cache_metadata: track_id={}, different stream track (title or artist changed), accepting update",
|
|
|
|
|
track_id
|
|
|
|
|
);
|
2026-01-31 19:56:46 +01:00
|
|
|
cache.insert(track_id, new_metadata);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
2026-01-31 22:30:30 +01:00
|
|
|
// Fichier normal (non-stream): accepter toute mise à jour
|
|
|
|
|
tracing::trace!(
|
|
|
|
|
"OpenHome cache_metadata: track_id={}, non-stream file, accepting update",
|
2026-01-31 19:56:46 +01:00
|
|
|
track_id
|
|
|
|
|
);
|
|
|
|
|
cache.insert(track_id, new_metadata);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Pas dans le cache: insérer directement
|
|
|
|
|
tracing::trace!(
|
2026-01-31 22:30:30 +01:00
|
|
|
"OpenHome cache_metadata: track_id={}, inserting first time, duration={:?}, is_stream={:?}",
|
2026-01-31 19:56:46 +01:00
|
|
|
track_id,
|
2026-01-31 22:30:30 +01:00
|
|
|
new_metadata.as_ref().and_then(|m| m.duration.as_ref()),
|
|
|
|
|
new_metadata.as_ref().map(|m| m.is_continuous_stream)
|
2026-01-31 19:56:46 +01:00
|
|
|
);
|
|
|
|
|
cache.insert(track_id, new_metadata);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-06 13:37:42 +01:00
|
|
|
fn playback_item_from_entry(&self, entry: &OhTrackEntry) -> PlaybackItem {
|
2026-01-31 19:56:46 +01:00
|
|
|
// TOUJOURS préférer les métadonnées du cache si disponibles
|
|
|
|
|
// Le cache contient les métadonnées stables mises lors de l'insertion
|
|
|
|
|
// Les métadonnées de l'entry (venant de ReadList) changent pour les streams
|
|
|
|
|
let metadata = {
|
|
|
|
|
let cache = self.metadata_cache.lock().unwrap();
|
|
|
|
|
if let Some(cached_meta) = cache.get(&entry.id) {
|
|
|
|
|
// Utiliser les métadonnées stables du cache
|
|
|
|
|
tracing::trace!(
|
|
|
|
|
"OpenHome playback_item_from_entry: track_id={}, using CACHE, duration={:?}",
|
|
|
|
|
entry.id,
|
|
|
|
|
cached_meta.as_ref().and_then(|m| m.duration.as_ref())
|
|
|
|
|
);
|
|
|
|
|
cached_meta.clone()
|
|
|
|
|
} else {
|
|
|
|
|
// Pas dans le cache (piste existante avant démarrage de PMOMusic ou ajoutée par autre control point)
|
|
|
|
|
// Utiliser les métadonnées fraîches de l'entry et les mettre en cache pour stabiliser
|
|
|
|
|
let fresh = entry.metadata();
|
|
|
|
|
tracing::debug!(
|
|
|
|
|
"OpenHome playback_item_from_entry: track_id={}, caching metadata from entry (first read), duration={:?}",
|
|
|
|
|
entry.id,
|
|
|
|
|
fresh.as_ref().and_then(|m| m.duration.as_ref())
|
|
|
|
|
);
|
|
|
|
|
drop(cache); // Libérer le lock avant d'appeler cache_metadata
|
2026-04-03 21:31:57 +02:00
|
|
|
// Mettre en cache pour éviter les oscillations sur les flux radio
|
2026-04-09 08:39:26 +02:00
|
|
|
self.cache_metadata(entry.id, fresh.clone(), entry.uri());
|
2026-01-31 19:56:46 +01:00
|
|
|
fresh
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-01-24 18:34:19 +01:00
|
|
|
|
2025-12-30 16:50:26 +01:00
|
|
|
let didl_id = entry
|
|
|
|
|
.didl_id()
|
2025-12-06 13:37:42 +01:00
|
|
|
.unwrap_or_else(|| format!("openhome:{}", entry.id));
|
|
|
|
|
PlaybackItem {
|
2025-12-28 17:45:06 +01:00
|
|
|
media_server_id: DeviceId(format!("openhome:{}", self.renderer_id.0)),
|
|
|
|
|
backend_id: entry.id as usize,
|
2025-12-06 13:37:42 +01:00
|
|
|
didl_id,
|
2025-12-30 16:50:26 +01:00
|
|
|
uri: entry.uri().to_string(),
|
2025-12-17 20:45:51 +01:00
|
|
|
// OpenHome tracks don't provide protocolInfo, use generic default
|
|
|
|
|
protocol_info: "http-get:*:audio/*:*".to_string(),
|
2025-12-06 13:37:42 +01:00
|
|
|
metadata,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 16:50:26 +01:00
|
|
|
fn add_playback_item(
|
|
|
|
|
&mut self,
|
|
|
|
|
item: PlaybackItem,
|
|
|
|
|
after_id: u32,
|
|
|
|
|
) -> Result<u32, ControlPointError> {
|
|
|
|
|
self.ensure_playlist_source_selected()?;
|
|
|
|
|
let metadata_xml = build_metadata_xml(&item);
|
|
|
|
|
let new_id = self
|
|
|
|
|
.playlist_client
|
|
|
|
|
.insert(after_id, &item.uri, &metadata_xml)?;
|
2026-01-24 18:34:19 +01:00
|
|
|
|
|
|
|
|
// Enregistrer les métadonnées dans le cache
|
2026-04-09 08:39:26 +02:00
|
|
|
self.cache_metadata(new_id, item.metadata, &item.uri);
|
2026-01-24 18:34:19 +01:00
|
|
|
|
2025-12-30 16:50:26 +01:00
|
|
|
Ok(new_id)
|
2025-12-06 13:37:42 +01:00
|
|
|
}
|
2025-12-26 18:35:56 +01:00
|
|
|
|
|
|
|
|
/// CASE 1: Replace queue while preserving the currently playing item as first.
|
|
|
|
|
/// The currently playing item is NOT in the new playlist, so we keep it as the first
|
|
|
|
|
/// item and append the entire new playlist after it.
|
|
|
|
|
fn replace_queue_preserve_current(
|
|
|
|
|
&mut self,
|
|
|
|
|
new_items: Vec<PlaybackItem>,
|
2025-12-30 16:50:26 +01:00
|
|
|
playing_id: usize,
|
2026-04-09 11:19:54 +02:00
|
|
|
cancel_token: &Arc<AtomicBool>,
|
|
|
|
|
on_ready: &mut Option<Box<dyn FnOnce() + Send>>,
|
2025-12-30 16:50:26 +01:00
|
|
|
) -> Result<(), ControlPointError> {
|
2026-04-09 11:19:54 +02:00
|
|
|
use std::sync::atomic::Ordering::SeqCst;
|
|
|
|
|
|
2025-12-30 16:50:26 +01:00
|
|
|
let current_track_ids = self.track_ids()?;
|
|
|
|
|
|
|
|
|
|
for &track_id in current_track_ids.iter().rev() {
|
2026-04-09 11:19:54 +02:00
|
|
|
if cancel_token.load(SeqCst) {
|
|
|
|
|
return Err(ControlPointError::SyncCancelled);
|
|
|
|
|
}
|
2025-12-30 16:50:26 +01:00
|
|
|
if track_id as usize != playing_id {
|
2025-12-28 17:45:06 +01:00
|
|
|
self.playlist_client.delete_id_if_exists(track_id)?;
|
2026-01-31 19:56:46 +01:00
|
|
|
self.metadata_cache.lock().unwrap().remove(&track_id);
|
2025-12-26 18:35:56 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 16:50:26 +01:00
|
|
|
let mut previous_id = playing_id as u32;
|
2026-04-09 11:19:54 +02:00
|
|
|
let mut first_insert_done = false;
|
2025-12-26 18:35:56 +01:00
|
|
|
for item in new_items {
|
2026-04-09 11:19:54 +02:00
|
|
|
if cancel_token.load(SeqCst) {
|
|
|
|
|
return Err(ControlPointError::SyncCancelled);
|
|
|
|
|
}
|
2025-12-26 18:35:56 +01:00
|
|
|
let metadata = build_metadata_xml(&item);
|
2025-12-28 17:45:06 +01:00
|
|
|
let new_id = self
|
|
|
|
|
.playlist_client
|
|
|
|
|
.insert(previous_id, &item.uri, &metadata)?;
|
2026-01-24 18:34:19 +01:00
|
|
|
|
2026-04-09 08:39:26 +02:00
|
|
|
self.cache_metadata(new_id, item.metadata, &item.uri);
|
2026-01-24 18:34:19 +01:00
|
|
|
|
2025-12-26 18:35:56 +01:00
|
|
|
previous_id = new_id;
|
2026-04-09 11:19:54 +02:00
|
|
|
|
|
|
|
|
if !first_insert_done && on_ready.is_some() {
|
|
|
|
|
first_insert_done = true;
|
|
|
|
|
if let Some(f) = on_ready.take() {
|
|
|
|
|
f();
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-26 18:35:56 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
"Gentle sync completed: preserved playing track as first item (not in new playlist)"
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-06 16:59:23 +02:00
|
|
|
self.invalidate_track_caches();
|
2026-01-31 16:52:03 +01:00
|
|
|
|
2025-12-26 18:35:56 +01:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 16:50:26 +01:00
|
|
|
/// Helper: Delete items marked for deletion in reverse order with logging.
|
|
|
|
|
fn delete_marked_items(
|
2025-12-26 18:35:56 +01:00
|
|
|
&mut self,
|
2025-12-30 16:50:26 +01:00
|
|
|
old_ids: &[u32],
|
|
|
|
|
keep_flags: &[bool],
|
|
|
|
|
position_label: &str,
|
2026-04-09 11:19:54 +02:00
|
|
|
cancel_token: &Arc<AtomicBool>,
|
2025-12-28 17:45:06 +01:00
|
|
|
) -> Result<(), ControlPointError> {
|
2026-04-09 11:19:54 +02:00
|
|
|
use std::sync::atomic::Ordering::SeqCst;
|
2025-12-30 16:50:26 +01:00
|
|
|
for (idx, &track_id) in old_ids.iter().enumerate().rev() {
|
2026-04-09 11:19:54 +02:00
|
|
|
if cancel_token.load(SeqCst) {
|
|
|
|
|
return Err(ControlPointError::SyncCancelled);
|
|
|
|
|
}
|
2025-12-30 16:50:26 +01:00
|
|
|
if !keep_flags[idx] {
|
2025-12-26 18:35:56 +01:00
|
|
|
debug!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
track_id,
|
2025-12-30 16:50:26 +01:00
|
|
|
position = position_label,
|
2025-12-26 18:35:56 +01:00
|
|
|
"RENDERER OP: DeleteId({})",
|
|
|
|
|
track_id
|
|
|
|
|
);
|
2025-12-28 17:45:06 +01:00
|
|
|
self.playlist_client.delete_id_if_exists(track_id)?;
|
2026-01-31 19:56:46 +01:00
|
|
|
self.metadata_cache.lock().unwrap().remove(&track_id);
|
2025-12-26 18:35:56 +01:00
|
|
|
}
|
|
|
|
|
}
|
2025-12-30 16:50:26 +01:00
|
|
|
Ok(())
|
|
|
|
|
}
|
2025-12-26 18:35:56 +01:00
|
|
|
|
2025-12-30 16:50:26 +01:00
|
|
|
/// Helper: Rebuild a playlist section (before or after pivot) using LCS results.
|
|
|
|
|
fn rebuild_playlist_section(
|
|
|
|
|
&mut self,
|
|
|
|
|
new_items: &[PlaybackItem],
|
|
|
|
|
keep_new_flags: &[bool],
|
|
|
|
|
old_ids: &[u32],
|
|
|
|
|
keep_old_flags: &[bool],
|
|
|
|
|
mut previous_id: u32,
|
|
|
|
|
position_label: &str,
|
2026-04-09 11:19:54 +02:00
|
|
|
cancel_token: &Arc<AtomicBool>,
|
|
|
|
|
on_ready: &mut Option<Box<dyn FnOnce() + Send>>,
|
2025-12-30 16:50:26 +01:00
|
|
|
) -> Result<u32, ControlPointError> {
|
2026-04-09 11:19:54 +02:00
|
|
|
use std::sync::atomic::Ordering::SeqCst;
|
|
|
|
|
|
2025-12-30 16:50:26 +01:00
|
|
|
let remaining_ids: Vec<u32> = old_ids
|
2025-12-26 18:35:56 +01:00
|
|
|
.iter()
|
|
|
|
|
.enumerate()
|
2025-12-30 16:50:26 +01:00
|
|
|
.filter_map(|(idx, &id)| if keep_old_flags[idx] { Some(id) } else { None })
|
2025-12-26 18:35:56 +01:00
|
|
|
.collect();
|
|
|
|
|
|
2025-12-30 16:50:26 +01:00
|
|
|
let mut remaining_idx = 0;
|
2026-04-09 11:19:54 +02:00
|
|
|
let mut first_insert_done = false;
|
2025-12-26 18:35:56 +01:00
|
|
|
|
2025-12-30 16:50:26 +01:00
|
|
|
for (idx, item) in new_items.iter().enumerate() {
|
2026-04-09 11:19:54 +02:00
|
|
|
if cancel_token.load(SeqCst) {
|
|
|
|
|
return Err(ControlPointError::SyncCancelled);
|
|
|
|
|
}
|
2025-12-30 16:50:26 +01:00
|
|
|
if keep_new_flags[idx] {
|
|
|
|
|
let existing_id = remaining_ids[remaining_idx];
|
|
|
|
|
remaining_idx += 1;
|
2025-12-26 18:35:56 +01:00
|
|
|
previous_id = existing_id;
|
2026-01-24 18:34:19 +01:00
|
|
|
|
2026-04-09 08:39:26 +02:00
|
|
|
self.cache_metadata(existing_id, item.metadata.clone(), &item.uri);
|
2026-01-24 18:34:19 +01:00
|
|
|
|
2025-12-26 18:35:56 +01:00
|
|
|
debug!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
track_id = existing_id,
|
2025-12-30 16:50:26 +01:00
|
|
|
position = position_label,
|
2025-12-26 18:35:56 +01:00
|
|
|
"KEPT existing track ID {}",
|
|
|
|
|
existing_id
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
let metadata = build_metadata_xml(item);
|
2025-12-28 17:45:06 +01:00
|
|
|
let new_id = self
|
|
|
|
|
.playlist_client
|
|
|
|
|
.insert(previous_id, &item.uri, &metadata)?;
|
2026-01-24 18:34:19 +01:00
|
|
|
|
2026-04-09 08:39:26 +02:00
|
|
|
self.cache_metadata(new_id, item.metadata.clone(), &item.uri);
|
2026-01-24 18:34:19 +01:00
|
|
|
|
2025-12-26 18:35:56 +01:00
|
|
|
debug!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
after_id = previous_id,
|
|
|
|
|
new_id,
|
2025-12-30 16:50:26 +01:00
|
|
|
position = position_label,
|
2025-12-26 18:35:56 +01:00
|
|
|
"RENDERER OP: Insert(after={}) -> new_id={}",
|
|
|
|
|
previous_id,
|
|
|
|
|
new_id
|
|
|
|
|
);
|
|
|
|
|
previous_id = new_id;
|
2026-04-09 11:19:54 +02:00
|
|
|
|
|
|
|
|
if !first_insert_done && on_ready.is_some() {
|
|
|
|
|
first_insert_done = true;
|
|
|
|
|
if let Some(f) = on_ready.take() {
|
|
|
|
|
f();
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-26 18:35:56 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 16:50:26 +01:00
|
|
|
Ok(previous_id)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// CASE 2: Replace queue with double-LCS (before and after the pivot).
|
|
|
|
|
/// The currently playing item IS in the new playlist, so we use it as a pivot
|
|
|
|
|
/// and apply LCS separately to the portions before and after it.
|
|
|
|
|
fn replace_queue_with_pivot(
|
|
|
|
|
&mut self,
|
|
|
|
|
new_items: Vec<PlaybackItem>,
|
|
|
|
|
pivot_idx_new: usize,
|
|
|
|
|
pivot_id: usize,
|
2026-04-06 16:59:23 +02:00
|
|
|
snapshot: &QueueSnapshot,
|
|
|
|
|
current_track_ids: &[u32],
|
2026-04-09 11:19:54 +02:00
|
|
|
cancel_token: &Arc<AtomicBool>,
|
|
|
|
|
on_ready: &mut Option<Box<dyn FnOnce() + Send>>,
|
2025-12-30 16:50:26 +01:00
|
|
|
) -> Result<(), ControlPointError> {
|
|
|
|
|
// Find the pivot index in our current state
|
|
|
|
|
let pivot_idx = current_track_ids
|
|
|
|
|
.iter()
|
|
|
|
|
.position(|&id| id as usize == pivot_id)
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
ControlPointError::OpenHomeError(format!(
|
|
|
|
|
"Pivot track ID {} not found in playlist",
|
|
|
|
|
pivot_id
|
|
|
|
|
))
|
|
|
|
|
})?;
|
|
|
|
|
|
|
|
|
|
// Split current data at the pivot
|
|
|
|
|
let old_before: Vec<PlaybackItem> = snapshot.items[..pivot_idx].to_vec();
|
|
|
|
|
let old_after: Vec<PlaybackItem> = snapshot.items[pivot_idx + 1..].to_vec();
|
|
|
|
|
let old_ids_before: Vec<u32> = current_track_ids[..pivot_idx].to_vec();
|
|
|
|
|
let old_ids_after: Vec<u32> = current_track_ids[pivot_idx + 1..].to_vec();
|
|
|
|
|
|
|
|
|
|
let new_before = &new_items[..pivot_idx_new];
|
|
|
|
|
let new_after = &new_items[pivot_idx_new + 1..];
|
|
|
|
|
|
|
|
|
|
// LCS on the AFTER part (using fresh data from OpenHome)
|
2026-04-06 16:59:23 +02:00
|
|
|
let (keep_old_after, keep_new_after) = lcs_flags_optimized(&old_after, new_after);
|
2025-12-30 16:50:26 +01:00
|
|
|
|
|
|
|
|
// LCS on the BEFORE part (using fresh data from OpenHome)
|
2026-04-06 16:59:23 +02:00
|
|
|
let (keep_old_before, keep_new_before) = lcs_flags_optimized(&old_before, new_before);
|
2025-12-30 16:50:26 +01:00
|
|
|
|
|
|
|
|
// Delete items marked for deletion in AFTER part (reverse order)
|
2026-04-09 11:19:54 +02:00
|
|
|
self.delete_marked_items(&old_ids_after, &keep_old_after, "AFTER pivot", cancel_token)?;
|
2025-12-30 16:50:26 +01:00
|
|
|
|
|
|
|
|
// Delete items marked for deletion in BEFORE part (reverse order)
|
2026-04-09 11:19:54 +02:00
|
|
|
self.delete_marked_items(
|
|
|
|
|
&old_ids_before,
|
|
|
|
|
&keep_old_before,
|
|
|
|
|
"BEFORE pivot",
|
|
|
|
|
cancel_token,
|
|
|
|
|
)?;
|
2025-12-30 16:50:26 +01:00
|
|
|
|
|
|
|
|
// Rebuild the playlist: [BEFORE, PIVOT, AFTER]
|
2026-01-03 08:19:23 +01:00
|
|
|
// Rebuild BEFORE part (we don't need the returned previous_id)
|
|
|
|
|
self.rebuild_playlist_section(
|
2025-12-30 16:50:26 +01:00
|
|
|
new_before,
|
|
|
|
|
&keep_new_before,
|
|
|
|
|
&old_ids_before,
|
|
|
|
|
&keep_old_before,
|
|
|
|
|
OPENHOME_PLAYLIST_HEAD_ID,
|
|
|
|
|
"BEFORE pivot",
|
2026-04-09 11:19:54 +02:00
|
|
|
cancel_token,
|
|
|
|
|
on_ready,
|
2025-12-30 16:50:26 +01:00
|
|
|
)?;
|
|
|
|
|
|
2026-01-03 08:19:23 +01:00
|
|
|
// PIVOT keeps its ID and position - it's the anchor point
|
2025-12-30 16:50:26 +01:00
|
|
|
let previous_id = pivot_id as u32;
|
2026-01-24 18:34:19 +01:00
|
|
|
|
|
|
|
|
// Mettre à jour les métadonnées du pivot
|
2026-04-09 08:39:26 +02:00
|
|
|
self.cache_metadata(
|
|
|
|
|
previous_id,
|
|
|
|
|
new_items[pivot_idx_new].metadata.clone(),
|
|
|
|
|
&new_items[pivot_idx_new].uri,
|
|
|
|
|
);
|
2026-01-24 18:34:19 +01:00
|
|
|
|
2025-12-26 18:35:56 +01:00
|
|
|
debug!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
pivot_id,
|
|
|
|
|
pivot_idx_new,
|
|
|
|
|
"PIVOT preserved with ID {} at index {}",
|
|
|
|
|
pivot_id,
|
|
|
|
|
pivot_idx_new
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Rebuild AFTER part
|
2025-12-30 16:50:26 +01:00
|
|
|
self.rebuild_playlist_section(
|
|
|
|
|
new_after,
|
|
|
|
|
&keep_new_after,
|
|
|
|
|
&old_ids_after,
|
|
|
|
|
&keep_old_after,
|
|
|
|
|
previous_id,
|
|
|
|
|
"AFTER pivot",
|
2026-04-09 11:19:54 +02:00
|
|
|
cancel_token,
|
|
|
|
|
on_ready,
|
2025-12-30 16:50:26 +01:00
|
|
|
)?;
|
2025-12-26 18:35:56 +01:00
|
|
|
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
pivot_idx = pivot_idx_new,
|
|
|
|
|
pivot_id,
|
|
|
|
|
"Gentle sync completed: double-LCS with pivot (playing track preserved)"
|
|
|
|
|
);
|
|
|
|
|
|
2026-01-31 16:52:03 +01:00
|
|
|
// Invalidate cache after playlist modifications
|
2026-04-06 16:59:23 +02:00
|
|
|
self.invalidate_track_caches();
|
2026-01-31 16:52:03 +01:00
|
|
|
|
2025-12-26 18:35:56 +01:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Standard LCS-based replacement (used when no currently playing item).
|
|
|
|
|
fn replace_queue_standard_lcs(
|
|
|
|
|
&mut self,
|
|
|
|
|
items: Vec<PlaybackItem>,
|
2026-04-06 16:59:23 +02:00
|
|
|
snapshot: &QueueSnapshot,
|
|
|
|
|
current_track_ids: &[u32],
|
2026-04-09 11:19:54 +02:00
|
|
|
cancel_token: &Arc<AtomicBool>,
|
|
|
|
|
on_ready: &mut Option<Box<dyn FnOnce() + Send>>,
|
2025-12-28 17:45:06 +01:00
|
|
|
) -> Result<(), ControlPointError> {
|
2026-04-09 11:19:54 +02:00
|
|
|
use std::sync::atomic::Ordering::SeqCst;
|
|
|
|
|
|
2026-04-03 20:45:35 +02:00
|
|
|
debug!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
current_count = snapshot.items.len(),
|
|
|
|
|
desired_count = items.len(),
|
|
|
|
|
current_uris = ?snapshot.items.iter().map(|i| i.uri.as_str()).collect::<Vec<_>>(),
|
|
|
|
|
current_didl_ids = ?snapshot.items.iter().map(|i| i.didl_id.as_str()).collect::<Vec<_>>(),
|
|
|
|
|
desired_uris = ?items.iter().map(|i| i.uri.as_str()).collect::<Vec<_>>(),
|
|
|
|
|
desired_didl_ids = ?items.iter().map(|i| i.didl_id.as_str()).collect::<Vec<_>>(),
|
|
|
|
|
"LCS input: current vs desired items"
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-06 16:59:23 +02:00
|
|
|
let (keep_current, keep_desired) = lcs_flags_optimized(&snapshot.items, &items);
|
2025-12-26 18:35:56 +01:00
|
|
|
|
|
|
|
|
let items_to_keep = keep_current.iter().filter(|&&k| k).count();
|
2026-04-03 21:31:57 +02:00
|
|
|
let items_to_delete = keep_current.iter().filter(|&k| !k).count();
|
|
|
|
|
let items_to_add = keep_desired.iter().filter(|&k| !k).count();
|
2025-12-26 18:35:56 +01:00
|
|
|
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
keep = items_to_keep,
|
|
|
|
|
delete = items_to_delete,
|
|
|
|
|
add = items_to_add,
|
|
|
|
|
"LCS computed: minimizing OpenHome playlist operations"
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-03 21:31:57 +02:00
|
|
|
let current_track_id = self.playlist_client.id().ok().filter(|&id| id != 0);
|
|
|
|
|
|
|
|
|
|
let current_track_in_new_playlist = current_track_id.and_then(|current_id| {
|
|
|
|
|
items
|
|
|
|
|
.iter()
|
|
|
|
|
.position(|item| item.backend_id as u32 == current_id)
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-26 18:35:56 +01:00
|
|
|
if items_to_keep == 0 && items_to_delete > 0 {
|
2026-04-03 21:31:57 +02:00
|
|
|
if current_track_in_new_playlist.is_some() {
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
current_track_in_playlist = true,
|
|
|
|
|
"Preserving currently playing track - using insert/delete instead of delete_all"
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
"Using delete_all() for complete replacement (safe - no current track or not in new playlist)"
|
|
|
|
|
);
|
|
|
|
|
self.playlist_client.delete_all()?;
|
|
|
|
|
self.metadata_cache.lock().unwrap().clear();
|
|
|
|
|
}
|
2025-12-26 18:35:56 +01:00
|
|
|
} else {
|
2025-12-30 16:50:26 +01:00
|
|
|
for idx in (0..current_track_ids.len()).rev() {
|
2026-04-09 11:19:54 +02:00
|
|
|
if cancel_token.load(SeqCst) {
|
|
|
|
|
return Err(ControlPointError::SyncCancelled);
|
|
|
|
|
}
|
2025-12-26 18:35:56 +01:00
|
|
|
if !keep_current[idx] {
|
2025-12-30 16:50:26 +01:00
|
|
|
let track_id = current_track_ids[idx];
|
2025-12-28 17:45:06 +01:00
|
|
|
self.playlist_client.delete_id_if_exists(track_id)?;
|
2026-01-31 19:56:46 +01:00
|
|
|
self.metadata_cache.lock().unwrap().remove(&track_id);
|
2025-12-26 18:35:56 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 16:50:26 +01:00
|
|
|
let remaining_ids: Vec<u32> = current_track_ids
|
|
|
|
|
.iter()
|
|
|
|
|
.enumerate()
|
|
|
|
|
.filter_map(|(idx, &id)| {
|
|
|
|
|
if keep_current.get(idx).copied().unwrap_or(false) {
|
|
|
|
|
Some(id)
|
|
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
2025-12-26 18:35:56 +01:00
|
|
|
let mut remaining_idx = 0usize;
|
|
|
|
|
let mut previous_id = OPENHOME_PLAYLIST_HEAD_ID;
|
2026-04-09 11:19:54 +02:00
|
|
|
let mut first_insert_done = false;
|
2025-12-26 18:35:56 +01:00
|
|
|
|
|
|
|
|
for (idx, item) in items.into_iter().enumerate() {
|
2026-04-09 11:19:54 +02:00
|
|
|
if cancel_token.load(SeqCst) {
|
|
|
|
|
return Err(ControlPointError::SyncCancelled);
|
|
|
|
|
}
|
2025-12-26 18:35:56 +01:00
|
|
|
if keep_desired[idx] {
|
|
|
|
|
if remaining_idx >= remaining_ids.len() {
|
2025-12-28 17:45:06 +01:00
|
|
|
return Err(ControlPointError::OpenHomeError(format!(
|
2025-12-26 18:35:56 +01:00
|
|
|
"OpenHome playlist refresh bookkeeping mismatch (kept entries underflow)"
|
2025-12-28 17:45:06 +01:00
|
|
|
)));
|
2025-12-26 18:35:56 +01:00
|
|
|
}
|
|
|
|
|
let existing_id = remaining_ids[remaining_idx];
|
|
|
|
|
remaining_idx += 1;
|
|
|
|
|
previous_id = existing_id;
|
2026-01-24 18:34:19 +01:00
|
|
|
|
2026-04-09 08:39:26 +02:00
|
|
|
self.cache_metadata(existing_id, item.metadata, &item.uri);
|
2025-12-26 18:35:56 +01:00
|
|
|
} else {
|
|
|
|
|
let metadata = build_metadata_xml(&item);
|
2025-12-28 17:45:06 +01:00
|
|
|
let new_id = self
|
|
|
|
|
.playlist_client
|
|
|
|
|
.insert(previous_id, &item.uri, &metadata)?;
|
2026-01-24 18:34:19 +01:00
|
|
|
|
2026-04-09 08:39:26 +02:00
|
|
|
self.cache_metadata(new_id, item.metadata, &item.uri);
|
2026-01-24 18:34:19 +01:00
|
|
|
|
2025-12-26 18:35:56 +01:00
|
|
|
previous_id = new_id;
|
2026-04-09 11:19:54 +02:00
|
|
|
|
|
|
|
|
if !first_insert_done && on_ready.is_some() {
|
|
|
|
|
first_insert_done = true;
|
|
|
|
|
if let Some(f) = on_ready.take() {
|
|
|
|
|
f();
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-26 18:35:56 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if remaining_idx != remaining_ids.len() {
|
2025-12-28 17:45:06 +01:00
|
|
|
return Err(ControlPointError::OpenHomeError(format!(
|
2025-12-26 18:35:56 +01:00
|
|
|
"OpenHome playlist refresh bookkeeping mismatch (kept entries overflow)"
|
2025-12-28 17:45:06 +01:00
|
|
|
)));
|
2025-12-26 18:35:56 +01:00
|
|
|
}
|
|
|
|
|
|
2026-04-06 16:59:23 +02:00
|
|
|
self.invalidate_track_caches();
|
2026-01-31 16:52:03 +01:00
|
|
|
|
2025-12-26 18:35:56 +01:00
|
|
|
Ok(())
|
|
|
|
|
}
|
2025-12-06 13:37:42 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_metadata_xml(item: &PlaybackItem) -> String {
|
|
|
|
|
let title = item
|
|
|
|
|
.metadata
|
|
|
|
|
.as_ref()
|
|
|
|
|
.and_then(|m| m.title.as_deref())
|
|
|
|
|
.unwrap_or("Unknown");
|
|
|
|
|
let escaped_title = escape(title);
|
|
|
|
|
let escaped_uri = escape(item.uri.as_str());
|
|
|
|
|
let escaped_id = escape(item.didl_id.as_str());
|
|
|
|
|
|
|
|
|
|
let mut xml = String::from(
|
|
|
|
|
r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">"#,
|
|
|
|
|
);
|
|
|
|
|
xml.push_str(&format!(
|
|
|
|
|
r#"<item id="{}" parentID="-1" restricted="1">"#,
|
|
|
|
|
escaped_id
|
|
|
|
|
));
|
|
|
|
|
xml.push_str(&format!("<dc:title>{}</dc:title>", escaped_title));
|
|
|
|
|
|
|
|
|
|
if let Some(meta) = &item.metadata {
|
|
|
|
|
if let Some(artist) = meta.artist.as_deref() {
|
|
|
|
|
let escaped = escape(artist);
|
|
|
|
|
xml.push_str(&format!("<upnp:artist>{}</upnp:artist>", escaped));
|
|
|
|
|
xml.push_str(&format!("<dc:creator>{}</dc:creator>", escaped));
|
|
|
|
|
}
|
|
|
|
|
if let Some(album) = meta.album.as_deref() {
|
|
|
|
|
let escaped = escape(album);
|
|
|
|
|
xml.push_str(&format!("<upnp:album>{}</upnp:album>", escaped));
|
|
|
|
|
}
|
|
|
|
|
if let Some(genre) = meta.genre.as_deref() {
|
|
|
|
|
let escaped = escape(genre);
|
|
|
|
|
xml.push_str(&format!("<upnp:genre>{}</upnp:genre>", escaped));
|
|
|
|
|
}
|
|
|
|
|
if let Some(uri) = meta.album_art_uri.as_deref() {
|
|
|
|
|
let escaped = escape(uri);
|
2025-12-15 11:18:58 +01:00
|
|
|
xml.push_str(&format!("<upnp:albumArtURI>{}</upnp:albumArtURI>", escaped));
|
2025-12-06 13:37:42 +01:00
|
|
|
}
|
|
|
|
|
if let Some(date) = meta.date.as_deref() {
|
|
|
|
|
let escaped = escape(date);
|
|
|
|
|
xml.push_str(&format!("<dc:date>{}</dc:date>", escaped));
|
|
|
|
|
}
|
|
|
|
|
if let Some(track_no) = meta.track_number.as_deref() {
|
|
|
|
|
let escaped = escape(track_no);
|
|
|
|
|
xml.push_str(&format!(
|
|
|
|
|
"<upnp:originalTrackNumber>{}</upnp:originalTrackNumber>",
|
|
|
|
|
escaped
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-17 20:45:51 +01:00
|
|
|
let escaped_protocol_info = escape(item.protocol_info.as_str());
|
2026-01-23 22:02:03 +01:00
|
|
|
|
|
|
|
|
// Build <res> element with optional duration attribute
|
|
|
|
|
xml.push_str(&format!(r#"<res protocolInfo="{}""#, escaped_protocol_info));
|
|
|
|
|
if let Some(meta) = &item.metadata {
|
|
|
|
|
if let Some(duration) = meta.duration.as_deref() {
|
|
|
|
|
let escaped_duration = escape(duration);
|
|
|
|
|
xml.push_str(&format!(r#" duration="{}""#, escaped_duration));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
xml.push_str(&format!(r#">{}</res>"#, escaped_uri));
|
2025-12-06 13:37:42 +01:00
|
|
|
xml.push_str(r#"<upnp:class>object.item.audioItem.musicTrack</upnp:class></item></DIDL-Lite>"#);
|
|
|
|
|
xml
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-16 22:45:38 +01:00
|
|
|
/// Compare two PlaybackItems for equality.
|
|
|
|
|
/// Items are considered equal if they have the same URI OR the same didl_id.
|
|
|
|
|
/// This allows matching items even when the MediaServer returns different URIs
|
|
|
|
|
/// for the same logical track (e.g., with session tokens or different encodings).
|
|
|
|
|
fn items_match(a: &PlaybackItem, b: &PlaybackItem) -> bool {
|
|
|
|
|
a.uri == b.uri || a.didl_id == b.didl_id
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-17 21:41:24 +01:00
|
|
|
fn lcs_flags(current: &[PlaybackItem], desired: &[PlaybackItem]) -> (Vec<bool>, Vec<bool>) {
|
|
|
|
|
let m = current.len();
|
|
|
|
|
let n = desired.len();
|
|
|
|
|
let mut dp = vec![vec![0u32; n + 1]; m + 1];
|
|
|
|
|
|
|
|
|
|
for i in 0..m {
|
|
|
|
|
for j in 0..n {
|
2026-01-16 22:45:38 +01:00
|
|
|
if items_match(¤t[i], &desired[j]) {
|
2025-12-17 21:41:24 +01:00
|
|
|
dp[i + 1][j + 1] = dp[i][j] + 1;
|
|
|
|
|
} else {
|
|
|
|
|
dp[i + 1][j + 1] = dp[i + 1][j].max(dp[i][j + 1]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut keep_current = vec![false; m];
|
|
|
|
|
let mut keep_desired = vec![false; n];
|
|
|
|
|
let (mut i, mut j) = (m, n);
|
|
|
|
|
|
|
|
|
|
while i > 0 && j > 0 {
|
2026-01-16 22:45:38 +01:00
|
|
|
if items_match(¤t[i - 1], &desired[j - 1]) {
|
2025-12-17 21:41:24 +01:00
|
|
|
keep_current[i - 1] = true;
|
|
|
|
|
keep_desired[j - 1] = true;
|
|
|
|
|
i -= 1;
|
|
|
|
|
j -= 1;
|
|
|
|
|
} else if dp[i - 1][j] >= dp[i][j - 1] {
|
|
|
|
|
i -= 1;
|
|
|
|
|
} else {
|
|
|
|
|
j -= 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
(keep_current, keep_desired)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 16:59:23 +02:00
|
|
|
fn lcs_flags_optimized(
|
|
|
|
|
current: &[PlaybackItem],
|
|
|
|
|
desired: &[PlaybackItem],
|
|
|
|
|
) -> (Vec<bool>, Vec<bool>) {
|
|
|
|
|
if current.is_empty() {
|
|
|
|
|
return (vec![], vec![true; desired.len()]);
|
|
|
|
|
}
|
|
|
|
|
if desired.is_empty() {
|
|
|
|
|
return (vec![true; current.len()], vec![]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let leading = current
|
|
|
|
|
.iter()
|
|
|
|
|
.zip(desired.iter())
|
|
|
|
|
.take_while(|(c, d)| items_match(c, d))
|
|
|
|
|
.count();
|
|
|
|
|
|
|
|
|
|
let c_tail = ¤t[leading..];
|
|
|
|
|
let d_tail = &desired[leading..];
|
|
|
|
|
let trailing = c_tail
|
|
|
|
|
.iter()
|
|
|
|
|
.rev()
|
|
|
|
|
.zip(d_tail.iter().rev())
|
|
|
|
|
.take_while(|(c, d)| items_match(c, d))
|
|
|
|
|
.count();
|
|
|
|
|
|
|
|
|
|
let c_mid = &c_tail[..c_tail.len().saturating_sub(trailing)];
|
|
|
|
|
let d_mid = &d_tail[..d_tail.len().saturating_sub(trailing)];
|
|
|
|
|
|
|
|
|
|
if c_mid.is_empty() && d_mid.is_empty() {
|
|
|
|
|
return (vec![true; current.len()], vec![true; desired.len()]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let (keep_c_mid, keep_d_mid) = lcs_flags(c_mid, d_mid);
|
|
|
|
|
|
|
|
|
|
let mut keep_current = vec![true; leading];
|
|
|
|
|
keep_current.extend(keep_c_mid);
|
|
|
|
|
keep_current.extend(vec![true; trailing]);
|
|
|
|
|
|
|
|
|
|
let mut keep_desired = vec![true; leading];
|
|
|
|
|
keep_desired.extend(keep_d_mid);
|
|
|
|
|
keep_desired.extend(vec![true; trailing]);
|
|
|
|
|
|
|
|
|
|
(keep_current, keep_desired)
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-06 13:37:42 +01:00
|
|
|
impl QueueBackend for OpenHomeQueue {
|
2025-12-30 16:50:26 +01:00
|
|
|
fn len(&self) -> Result<usize, ControlPointError> {
|
|
|
|
|
Ok(self.track_ids()?.len())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Return the list of OpenHome track IDs in order.
|
|
|
|
|
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
|
|
|
|
|
self.ensure_playlist_source_selected()?;
|
2026-01-31 16:52:03 +01:00
|
|
|
|
|
|
|
|
// Lock the cache for the entire operation to prevent race conditions
|
|
|
|
|
let mut cache = self.track_ids_cache.lock().unwrap();
|
|
|
|
|
|
|
|
|
|
// Check if cache is valid
|
|
|
|
|
if let Some(cached_ids) = cache.get() {
|
|
|
|
|
return Ok(cached_ids);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Cache miss or expired - fetch from service (keep lock held to prevent concurrent calls)
|
|
|
|
|
let ids = self.playlist_client.id_array()?;
|
|
|
|
|
|
2026-03-31 15:43:12 +02:00
|
|
|
tracing::trace!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
ids_count = ids.len(),
|
|
|
|
|
ids = ?ids,
|
|
|
|
|
"track_ids: cache miss, fetched from Pizzicato"
|
|
|
|
|
);
|
|
|
|
|
|
2026-01-31 16:52:03 +01:00
|
|
|
// Update cache before releasing lock
|
|
|
|
|
cache.set(ids.clone());
|
|
|
|
|
|
|
|
|
|
Ok(ids)
|
2025-12-30 16:50:26 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
|
|
|
|
|
self.track_ids()?
|
|
|
|
|
.iter()
|
|
|
|
|
.position(|&tid| tid == id)
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
ControlPointError::QueueError(format!("Item {} id is not present in the queue", id))
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn position_to_id(&self, index: usize) -> Result<u32, ControlPointError> {
|
|
|
|
|
let idxs = self.track_ids()?;
|
|
|
|
|
|
|
|
|
|
if index < idxs.len() {
|
|
|
|
|
Ok(idxs[index])
|
|
|
|
|
} else {
|
|
|
|
|
Err(ControlPointError::QueueError(format!(
|
|
|
|
|
"Index out of bound {} >= {}",
|
|
|
|
|
index,
|
|
|
|
|
idxs.len()
|
|
|
|
|
)))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
|
2026-01-31 17:21:18 +01:00
|
|
|
// Hold lock during entire operation to prevent race conditions
|
|
|
|
|
let mut cache = self.current_track_id_cache.lock().unwrap();
|
|
|
|
|
|
|
|
|
|
// Return cached value if valid
|
|
|
|
|
if let Some(cached_id) = cache.get() {
|
|
|
|
|
return Ok(cached_id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Cache miss - fetch from backend
|
2026-01-03 08:19:23 +01:00
|
|
|
let id = self.playlist_client.id()?;
|
|
|
|
|
// OpenHome returns 0 when no track is selected/playing
|
2026-01-31 17:21:18 +01:00
|
|
|
let result = if id == 0 { None } else { Some(id) };
|
|
|
|
|
|
|
|
|
|
// Update cache
|
|
|
|
|
cache.set(result);
|
|
|
|
|
|
|
|
|
|
Ok(result)
|
2025-12-30 16:50:26 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
|
|
|
|
|
if let Some(id) = self.current_track()? {
|
|
|
|
|
return Ok(Some(self.id_to_position(id)?));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(None)
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-28 17:45:06 +01:00
|
|
|
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
|
|
|
|
self.ensure_playlist_source_selected()?;
|
2026-01-31 17:21:18 +01:00
|
|
|
|
|
|
|
|
// Use cached track_ids() instead of calling read_all_tracks() which bypasses cache
|
|
|
|
|
let ids = self.track_ids()?;
|
|
|
|
|
|
|
|
|
|
if ids.is_empty() {
|
|
|
|
|
return Ok(QueueSnapshot {
|
|
|
|
|
items: Vec::new(),
|
|
|
|
|
current_index: None,
|
|
|
|
|
playlist_id: None,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 10:00:27 +02:00
|
|
|
// Read metadata for all tracks (batched), with 500ms cache to avoid
|
|
|
|
|
// redundant SOAP calls during sync_queue (which calls queue_snapshot twice).
|
2026-04-06 16:59:23 +02:00
|
|
|
const MAX_BATCH: usize = 256;
|
2026-01-31 17:21:18 +01:00
|
|
|
let mut entries = Vec::with_capacity(ids.len());
|
|
|
|
|
for chunk in ids.chunks(MAX_BATCH) {
|
2026-03-31 10:00:27 +02:00
|
|
|
if let Some(cached) = self.read_list_cache.lock().unwrap().get(chunk) {
|
|
|
|
|
trace!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
"ReadList cache hit for {} IDs",
|
|
|
|
|
chunk.len()
|
|
|
|
|
);
|
|
|
|
|
entries.extend(cached);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-01-31 17:21:18 +01:00
|
|
|
match self.playlist_client.read_list(chunk) {
|
2026-03-31 10:00:27 +02:00
|
|
|
Ok(batch) => {
|
|
|
|
|
self.read_list_cache
|
|
|
|
|
.lock()
|
|
|
|
|
.unwrap()
|
|
|
|
|
.set(chunk.to_vec(), batch.clone());
|
|
|
|
|
entries.extend(batch);
|
|
|
|
|
}
|
2026-01-31 17:21:18 +01:00
|
|
|
Err(err) => {
|
|
|
|
|
// If batch fails, try one by one
|
|
|
|
|
if chunk.len() > 1 {
|
|
|
|
|
for id in chunk {
|
|
|
|
|
match self.playlist_client.read_list(&[*id]) {
|
|
|
|
|
Ok(mut single) => entries.append(&mut single),
|
|
|
|
|
Err(inner_err) => return Err(inner_err),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
return Err(err);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-28 17:45:06 +01:00
|
|
|
let mut items = Vec::with_capacity(entries.len());
|
|
|
|
|
|
|
|
|
|
for entry in &entries {
|
|
|
|
|
items.push(self.playback_item_from_entry(entry));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get the currently playing track ID from the renderer (may be None if no track is playing)
|
|
|
|
|
let current_id = self.playlist_client.id().ok();
|
|
|
|
|
|
|
|
|
|
// Find the index of the current track in the playlist
|
2025-12-30 16:50:26 +01:00
|
|
|
let current_index = current_id.and_then(|id| {
|
|
|
|
|
items
|
|
|
|
|
.iter()
|
|
|
|
|
.position(|entry_id| entry_id.backend_id == id as usize)
|
|
|
|
|
});
|
2025-12-28 17:45:06 +01:00
|
|
|
|
2025-12-06 13:37:42 +01:00
|
|
|
Ok(QueueSnapshot {
|
2025-12-30 16:50:26 +01:00
|
|
|
items: items,
|
|
|
|
|
current_index: current_index,
|
2026-01-09 21:35:00 +01:00
|
|
|
playlist_id: None,
|
2025-12-06 13:37:42 +01:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-28 17:45:06 +01:00
|
|
|
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
|
2025-12-30 16:50:26 +01:00
|
|
|
if let Some(index) = index {
|
|
|
|
|
let track_id = self.position_to_id(index)?;
|
2025-12-17 21:41:24 +01:00
|
|
|
self.ensure_playlist_source_selected()?;
|
2025-12-30 16:50:26 +01:00
|
|
|
self.playlist_client.seek_id(track_id)?;
|
|
|
|
|
} else {
|
|
|
|
|
self.ensure_playlist_source_selected()?;
|
2026-03-31 15:43:12 +02:00
|
|
|
tracing::trace!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
"STOP command via set_index(None) on OpenHome playlist"
|
|
|
|
|
);
|
2025-12-30 16:50:26 +01:00
|
|
|
self.playlist_client.stop()?;
|
2025-12-06 13:37:42 +01:00
|
|
|
}
|
2026-01-31 17:21:18 +01:00
|
|
|
// Invalidate caches (seek_id/stop modifies playlist state and current track)
|
2026-04-06 16:59:23 +02:00
|
|
|
self.invalidate_all_caches();
|
2025-12-06 13:37:42 +01:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn replace_queue(
|
|
|
|
|
&mut self,
|
|
|
|
|
items: Vec<PlaybackItem>,
|
|
|
|
|
current_index: Option<usize>,
|
2025-12-28 17:45:06 +01:00
|
|
|
) -> Result<(), ControlPointError> {
|
2025-12-30 16:50:26 +01:00
|
|
|
if let Some(ci) = current_index {
|
|
|
|
|
if ci >= items.len() {
|
|
|
|
|
return Err(ControlPointError::QueueError(format!(
|
|
|
|
|
"Invalid Current index parameter {} >= {}",
|
|
|
|
|
ci,
|
|
|
|
|
items.len()
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
self.ensure_playlist_source_selected()?;
|
|
|
|
|
self.playlist_client.delete_all()?;
|
2026-01-31 19:56:46 +01:00
|
|
|
self.metadata_cache.lock().unwrap().clear();
|
2025-12-30 16:50:26 +01:00
|
|
|
|
2026-01-31 17:21:18 +01:00
|
|
|
// Invalidate caches after delete_all (clears queue and current track)
|
2026-04-06 16:59:23 +02:00
|
|
|
self.invalidate_all_caches();
|
2026-01-31 16:52:03 +01:00
|
|
|
|
2025-12-30 16:50:26 +01:00
|
|
|
if items.is_empty() {
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut previous_id = OPENHOME_PLAYLIST_HEAD_ID;
|
|
|
|
|
|
|
|
|
|
for item in items {
|
|
|
|
|
let metadata = build_metadata_xml(&item);
|
|
|
|
|
let new_id = self
|
|
|
|
|
.playlist_client
|
|
|
|
|
.insert(previous_id, &item.uri, &metadata)?;
|
2026-01-24 18:34:19 +01:00
|
|
|
|
|
|
|
|
// Enregistrer les métadonnées dans le cache
|
2026-04-09 08:39:26 +02:00
|
|
|
self.cache_metadata(new_id, item.metadata, &item.uri);
|
2026-01-24 18:34:19 +01:00
|
|
|
|
2025-12-30 16:50:26 +01:00
|
|
|
previous_id = new_id;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-31 16:52:03 +01:00
|
|
|
// Invalidate cache after insertions
|
2026-04-06 16:59:23 +02:00
|
|
|
self.invalidate_track_caches();
|
2026-01-31 16:52:03 +01:00
|
|
|
|
2025-12-30 16:50:26 +01:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-09 11:19:54 +02:00
|
|
|
fn sync_queue(
|
|
|
|
|
&mut self,
|
|
|
|
|
items: Vec<PlaybackItem>,
|
|
|
|
|
cancel_token: &Arc<AtomicBool>,
|
|
|
|
|
mut on_ready: Option<Box<dyn FnOnce() + Send>>,
|
|
|
|
|
) -> Result<(), ControlPointError> {
|
|
|
|
|
use std::sync::atomic::Ordering::SeqCst;
|
|
|
|
|
|
2025-12-17 21:41:24 +01:00
|
|
|
self.ensure_playlist_source_selected()?;
|
2026-04-03 21:31:57 +02:00
|
|
|
|
2026-04-09 11:19:54 +02:00
|
|
|
if cancel_token.load(SeqCst) {
|
|
|
|
|
return Err(ControlPointError::SyncCancelled);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 21:31:57 +02:00
|
|
|
let pre_current_track = self.playlist_client.id().ok();
|
|
|
|
|
tracing::warn!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
pre_current_track_id = pre_current_track,
|
|
|
|
|
pre_items_count = items.len(),
|
|
|
|
|
"sync_queue: START - current track before modification"
|
|
|
|
|
);
|
|
|
|
|
|
2025-12-17 21:41:24 +01:00
|
|
|
if items.is_empty() {
|
2026-04-03 21:31:57 +02:00
|
|
|
tracing::warn!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
"sync_queue: Empty playlist - clearing queue with delete_all"
|
|
|
|
|
);
|
2025-12-28 17:45:06 +01:00
|
|
|
self.playlist_client.delete_all()?;
|
2026-01-31 19:56:46 +01:00
|
|
|
self.metadata_cache.lock().unwrap().clear();
|
2026-04-06 16:59:23 +02:00
|
|
|
self.invalidate_all_caches();
|
2026-04-03 21:31:57 +02:00
|
|
|
|
|
|
|
|
let post_current_track = self.playlist_client.id().ok();
|
|
|
|
|
tracing::warn!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
post_current_track_id = post_current_track,
|
|
|
|
|
"sync_queue: END - current track after delete_all (should be 0)"
|
|
|
|
|
);
|
2025-12-17 21:41:24 +01:00
|
|
|
return Ok(());
|
|
|
|
|
}
|
2025-12-06 13:37:42 +01:00
|
|
|
|
2026-04-09 08:39:26 +02:00
|
|
|
match self.try_fast_path(&items) {
|
|
|
|
|
FastPathResult::AppendOnly { new_items } => {
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
new_items_count = new_items.len(),
|
|
|
|
|
"sync_queue: fast path - append-only detected"
|
|
|
|
|
);
|
|
|
|
|
let current_ids = self.track_ids()?;
|
2026-04-09 11:19:54 +02:00
|
|
|
let mut after_id = current_ids
|
|
|
|
|
.last()
|
|
|
|
|
.copied()
|
|
|
|
|
.unwrap_or(OPENHOME_PLAYLIST_HEAD_ID);
|
2026-04-09 08:39:26 +02:00
|
|
|
let mut uri_cache = self.uri_by_id.lock().unwrap();
|
|
|
|
|
for item in &new_items {
|
2026-04-09 11:19:54 +02:00
|
|
|
if cancel_token.load(SeqCst) {
|
|
|
|
|
return Err(ControlPointError::SyncCancelled);
|
|
|
|
|
}
|
2026-04-09 08:39:26 +02:00
|
|
|
let metadata = build_metadata_xml(item);
|
|
|
|
|
let uri = item.uri.as_str();
|
|
|
|
|
let metadata_xml = metadata.as_str();
|
|
|
|
|
after_id = self.playlist_client.insert(after_id, uri, metadata_xml)?;
|
|
|
|
|
if !item.uri.is_empty() {
|
|
|
|
|
uri_cache.insert(after_id, item.uri.clone());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
drop(uri_cache);
|
|
|
|
|
self.invalidate_track_caches();
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
FastPathResult::DeleteFromEnd { delete_ids } => {
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
delete_count = delete_ids.len(),
|
|
|
|
|
"sync_queue: fast path - delete-from-end detected"
|
|
|
|
|
);
|
|
|
|
|
for id in delete_ids.iter().rev() {
|
2026-04-09 11:19:54 +02:00
|
|
|
if cancel_token.load(SeqCst) {
|
|
|
|
|
return Err(ControlPointError::SyncCancelled);
|
|
|
|
|
}
|
2026-04-09 08:39:26 +02:00
|
|
|
self.playlist_client.delete_id(*id)?;
|
|
|
|
|
}
|
|
|
|
|
self.invalidate_all_caches();
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
FastPathResult::NeedFullSync => {
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
"sync_queue: fast path not applicable, proceeding with full sync"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-30 16:50:26 +01:00
|
|
|
let snapshot = self.queue_snapshot()?;
|
2026-04-03 21:31:57 +02:00
|
|
|
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
snapshot_items_len = snapshot.items.len(),
|
|
|
|
|
snapshot_current_index = snapshot.current_index,
|
|
|
|
|
new_items_count = items.len(),
|
|
|
|
|
"sync_queue: snapshot vs new items comparison"
|
|
|
|
|
);
|
|
|
|
|
|
2025-12-30 16:50:26 +01:00
|
|
|
let playing_info = snapshot.current_index.and_then(|idx| {
|
2026-01-17 13:13:35 +01:00
|
|
|
if idx < snapshot.items.len() {
|
|
|
|
|
Some((
|
|
|
|
|
idx,
|
|
|
|
|
snapshot.items[idx].backend_id,
|
|
|
|
|
snapshot.items[idx].uri.clone(),
|
|
|
|
|
snapshot.items[idx].didl_id.clone(),
|
|
|
|
|
))
|
|
|
|
|
} else {
|
|
|
|
|
warn!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
current_index = idx,
|
|
|
|
|
items_len = snapshot.items.len(),
|
|
|
|
|
"OpenHome renderer in inconsistent state: current_index out of bounds, treating as no current track"
|
|
|
|
|
);
|
|
|
|
|
None
|
|
|
|
|
}
|
2025-12-27 16:15:43 +01:00
|
|
|
});
|
2025-12-17 21:41:24 +01:00
|
|
|
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
2025-12-30 16:50:26 +01:00
|
|
|
actual_items = snapshot.items.len(),
|
2025-12-26 18:35:56 +01:00
|
|
|
playing_info_detected = playing_info.is_some(),
|
2025-12-30 16:50:26 +01:00
|
|
|
"OpenHome playlist state"
|
2025-12-17 21:41:24 +01:00
|
|
|
);
|
|
|
|
|
|
2026-04-09 11:19:54 +02:00
|
|
|
let has_pivot = playing_info.is_some();
|
|
|
|
|
if has_pivot {
|
|
|
|
|
if let Some(f) = on_ready.take() {
|
|
|
|
|
f();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-16 22:45:38 +01:00
|
|
|
if let Some((playing_idx, playing_id, playing_uri, playing_didl_id)) = playing_info {
|
|
|
|
|
let new_playing_idx = items
|
|
|
|
|
.iter()
|
|
|
|
|
.position(|item| item.uri == playing_uri)
|
|
|
|
|
.or_else(|| {
|
|
|
|
|
items
|
|
|
|
|
.iter()
|
|
|
|
|
.position(|item| item.didl_id == playing_didl_id)
|
|
|
|
|
});
|
2025-12-26 18:35:56 +01:00
|
|
|
|
2026-03-31 10:00:27 +02:00
|
|
|
tracing::trace!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
playing_uri = playing_uri.as_str(),
|
|
|
|
|
playing_didl_id = ?playing_didl_id,
|
|
|
|
|
pivot_found = new_playing_idx.is_some(),
|
|
|
|
|
desired_uris = ?items.iter().map(|i| i.uri.as_str()).collect::<Vec<_>>(),
|
|
|
|
|
"sync_queue: pivot search result"
|
|
|
|
|
);
|
|
|
|
|
|
2025-12-26 18:35:56 +01:00
|
|
|
if let Some(pivot_idx) = new_playing_idx {
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
playing_idx,
|
|
|
|
|
pivot_idx,
|
|
|
|
|
"Gentle sync: currently playing item found in new playlist at index {}",
|
|
|
|
|
pivot_idx
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-06 16:59:23 +02:00
|
|
|
let current_ids_for_pivot: Vec<u32> =
|
|
|
|
|
snapshot.items.iter().map(|i| i.backend_id as u32).collect();
|
|
|
|
|
self.replace_queue_with_pivot(
|
|
|
|
|
items,
|
|
|
|
|
pivot_idx,
|
|
|
|
|
playing_id,
|
|
|
|
|
&snapshot,
|
|
|
|
|
¤t_ids_for_pivot,
|
2026-04-09 11:19:54 +02:00
|
|
|
cancel_token,
|
|
|
|
|
&mut on_ready,
|
2026-04-06 16:59:23 +02:00
|
|
|
)?;
|
2025-12-26 18:35:56 +01:00
|
|
|
} else {
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
playing_idx,
|
|
|
|
|
"Gentle sync: currently playing item not in new playlist, preserving as first item"
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-09 11:19:54 +02:00
|
|
|
self.replace_queue_preserve_current(
|
|
|
|
|
items,
|
|
|
|
|
playing_id,
|
|
|
|
|
cancel_token,
|
|
|
|
|
&mut on_ready,
|
|
|
|
|
)?;
|
2025-12-26 18:35:56 +01:00
|
|
|
}
|
|
|
|
|
} else {
|
2026-04-03 21:31:57 +02:00
|
|
|
if snapshot.items.is_empty() && !items.is_empty() {
|
|
|
|
|
tracing::warn!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
snapshot_items = snapshot.items.len(),
|
|
|
|
|
new_items = items.len(),
|
|
|
|
|
"OpenHome playlist appears empty - possible stale cache or device issue, NOT clearing queue"
|
|
|
|
|
);
|
|
|
|
|
return self.enqueue_items(items, crate::queue::EnqueueMode::AppendToEnd);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-22 10:26:34 +01:00
|
|
|
debug!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
2025-12-26 18:35:56 +01:00
|
|
|
"No currently playing item, using standard LCS sync"
|
2025-12-22 10:26:34 +01:00
|
|
|
);
|
2026-04-06 16:59:23 +02:00
|
|
|
let current_ids_for_lcs: Vec<u32> =
|
|
|
|
|
snapshot.items.iter().map(|i| i.backend_id as u32).collect();
|
2026-04-09 11:19:54 +02:00
|
|
|
self.replace_queue_standard_lcs(
|
|
|
|
|
items,
|
|
|
|
|
&snapshot,
|
|
|
|
|
¤t_ids_for_lcs,
|
|
|
|
|
cancel_token,
|
|
|
|
|
&mut on_ready,
|
|
|
|
|
)?;
|
2025-12-06 13:37:42 +01:00
|
|
|
}
|
|
|
|
|
|
2026-04-03 21:31:57 +02:00
|
|
|
let post_current_track = self.playlist_client.id().ok();
|
|
|
|
|
let post_ids = self.track_ids();
|
|
|
|
|
tracing::warn!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
post_current_track_id = post_current_track,
|
|
|
|
|
post_items_count = post_ids.as_ref().map(|v| v.len()).unwrap_or(0),
|
|
|
|
|
"sync_queue: END - current track after modifications"
|
|
|
|
|
);
|
|
|
|
|
|
2025-12-06 13:37:42 +01:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-28 17:45:06 +01:00
|
|
|
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
2025-12-30 16:50:26 +01:00
|
|
|
let snapshot = self.queue_snapshot()?;
|
|
|
|
|
|
|
|
|
|
if index < snapshot.items.len() {
|
|
|
|
|
return Ok(Some(snapshot.items[index].clone()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Err(ControlPointError::QueueError(format!(
|
|
|
|
|
"get_item index out of bound {} >= {}",
|
|
|
|
|
index,
|
|
|
|
|
snapshot.items.len()
|
|
|
|
|
)))
|
2025-12-06 13:37:42 +01:00
|
|
|
}
|
|
|
|
|
|
2025-12-28 17:45:06 +01:00
|
|
|
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
|
2025-12-30 16:50:26 +01:00
|
|
|
let metadata = build_metadata_xml(&item);
|
|
|
|
|
|
|
|
|
|
let ids = self.track_ids()?;
|
|
|
|
|
|
|
|
|
|
if index >= ids.len() {
|
|
|
|
|
return Err(ControlPointError::QueueError(format!(
|
|
|
|
|
"get_item index out of bound {} >= {}",
|
|
|
|
|
index,
|
|
|
|
|
ids.len()
|
|
|
|
|
)));
|
2025-12-06 13:37:42 +01:00
|
|
|
}
|
|
|
|
|
|
2025-12-17 21:41:24 +01:00
|
|
|
self.ensure_playlist_source_selected()?;
|
2025-12-30 16:50:26 +01:00
|
|
|
|
|
|
|
|
let track_id = ids[index];
|
2025-12-06 13:37:42 +01:00
|
|
|
let before_id = if index == 0 {
|
2025-12-17 21:41:24 +01:00
|
|
|
OPENHOME_PLAYLIST_HEAD_ID
|
2025-12-06 13:37:42 +01:00
|
|
|
} else {
|
2025-12-30 16:50:26 +01:00
|
|
|
ids[index - 1]
|
2025-12-06 13:37:42 +01:00
|
|
|
};
|
|
|
|
|
|
2025-12-30 16:50:26 +01:00
|
|
|
let ci = self.current_index()?;
|
2025-12-26 15:43:40 +01:00
|
|
|
// Use delete_id_if_exists() to handle cases where another control point
|
|
|
|
|
// may have already modified the playlist
|
2025-12-30 16:50:26 +01:00
|
|
|
self.playlist_client.delete_id(track_id)?;
|
2025-12-28 17:45:06 +01:00
|
|
|
let new_id = self
|
|
|
|
|
.playlist_client
|
|
|
|
|
.insert(before_id, &item.uri, &metadata)?;
|
2025-12-06 13:37:42 +01:00
|
|
|
|
2026-01-24 18:34:19 +01:00
|
|
|
// Mettre à jour le cache avec les nouvelles métadonnées
|
2026-01-31 19:56:46 +01:00
|
|
|
self.metadata_cache.lock().unwrap().remove(&track_id);
|
2026-04-09 08:39:26 +02:00
|
|
|
self.uri_by_id.lock().unwrap().remove(&track_id);
|
|
|
|
|
self.cache_metadata(new_id, item.metadata, &item.uri);
|
2026-01-24 18:34:19 +01:00
|
|
|
|
2025-12-30 16:50:26 +01:00
|
|
|
if ci == Some(index) {
|
|
|
|
|
self.playlist_client.seek_id(new_id)?;
|
2025-12-06 13:37:42 +01:00
|
|
|
}
|
|
|
|
|
|
2026-01-31 16:52:03 +01:00
|
|
|
// Invalidate cache after playlist modifications
|
2026-04-06 16:59:23 +02:00
|
|
|
self.invalidate_track_caches();
|
2026-01-31 16:52:03 +01:00
|
|
|
|
2025-12-06 13:37:42 +01:00
|
|
|
Ok(())
|
|
|
|
|
}
|
2025-12-27 22:15:10 +01:00
|
|
|
|
|
|
|
|
/// Override enqueue_items to add items directly to the OpenHome playlist.
|
2025-12-30 16:50:26 +01:00
|
|
|
fn enqueue_items(
|
|
|
|
|
&mut self,
|
|
|
|
|
items: Vec<PlaybackItem>,
|
|
|
|
|
mode: EnqueueMode,
|
|
|
|
|
) -> Result<(), ControlPointError> {
|
2025-12-27 22:15:10 +01:00
|
|
|
if items.is_empty() {
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
match mode {
|
|
|
|
|
EnqueueMode::AppendToEnd => {
|
2025-12-30 16:50:26 +01:00
|
|
|
let ids = self.track_ids()?;
|
|
|
|
|
let mut after_id = if ids.len() > 0 {
|
|
|
|
|
ids[ids.len() - 1]
|
|
|
|
|
} else {
|
|
|
|
|
OPENHOME_PLAYLIST_HEAD_ID
|
|
|
|
|
};
|
2025-12-27 22:15:10 +01:00
|
|
|
// Append to the end of the OpenHome playlist
|
|
|
|
|
for item in items {
|
2025-12-30 16:50:26 +01:00
|
|
|
after_id = self.add_playback_item(item, after_id)?;
|
2025-12-27 22:15:10 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
EnqueueMode::InsertAfterCurrent => {
|
2025-12-30 16:50:26 +01:00
|
|
|
if let Some(mut after_id) = self.current_track()? {
|
|
|
|
|
for item in items {
|
|
|
|
|
after_id = self.add_playback_item(item, after_id)?;
|
|
|
|
|
}
|
2025-12-27 22:15:10 +01:00
|
|
|
} else {
|
2025-12-30 16:50:26 +01:00
|
|
|
self.enqueue_items(items, EnqueueMode::AppendToEnd)?;
|
2025-12-27 22:15:10 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
EnqueueMode::ReplaceAll => {
|
|
|
|
|
// Replace the entire playlist
|
|
|
|
|
self.replace_queue(items, None)?;
|
2026-01-31 16:52:03 +01:00
|
|
|
return Ok(());
|
2025-12-27 22:15:10 +01:00
|
|
|
}
|
|
|
|
|
}
|
2026-01-31 16:52:03 +01:00
|
|
|
|
|
|
|
|
// Invalidate cache after playlist modifications (except ReplaceAll which already does it)
|
2026-04-06 16:59:23 +02:00
|
|
|
self.invalidate_track_caches();
|
2026-01-31 16:52:03 +01:00
|
|
|
|
2025-12-30 16:50:26 +01:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Optimized helpers to avoid unnecessary network calls
|
|
|
|
|
|
|
|
|
|
/// Optimized clear_queue: use delete_all() directly instead of replace_queue.
|
|
|
|
|
fn clear_queue(&mut self) -> Result<(), ControlPointError> {
|
|
|
|
|
self.ensure_playlist_source_selected()?;
|
2026-01-31 16:52:03 +01:00
|
|
|
self.playlist_client.delete_all()?;
|
2026-01-31 17:21:18 +01:00
|
|
|
// Invalidate caches after clearing playlist (clears queue and current track)
|
2026-04-06 16:59:23 +02:00
|
|
|
self.invalidate_all_caches();
|
2026-01-31 16:52:03 +01:00
|
|
|
Ok(())
|
2025-12-30 16:50:26 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Optimized is_empty: only fetch track IDs, not the full playlist.
|
|
|
|
|
fn is_empty(&self) -> Result<bool, ControlPointError> {
|
|
|
|
|
Ok(self.track_ids()?.is_empty())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Optimized upcoming_len: calculate from len() and current_index() without fetching items.
|
|
|
|
|
fn upcoming_len(&self) -> Result<usize, ControlPointError> {
|
|
|
|
|
let len = self.len()?;
|
|
|
|
|
match self.current_index()? {
|
|
|
|
|
None => Ok(len),
|
|
|
|
|
Some(idx) => Ok(len.saturating_sub(idx + 1)),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Optimized peek_current: use primitives instead of full snapshot.
|
2026-01-09 14:30:53 +01:00
|
|
|
fn peek_current(&mut self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
2025-12-30 16:50:26 +01:00
|
|
|
let len = self.len()?;
|
|
|
|
|
if len == 0 {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let current_idx = self.current_index()?;
|
|
|
|
|
let resolved_index = match current_idx {
|
|
|
|
|
Some(idx) if idx < len => Some(idx),
|
|
|
|
|
_ => None,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let item_index = resolved_index.unwrap_or(0);
|
|
|
|
|
let item = match self.get_item(item_index)? {
|
|
|
|
|
Some(item) => item,
|
|
|
|
|
None => return Ok(None),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let remaining = match resolved_index {
|
|
|
|
|
Some(idx) => len.saturating_sub(idx + 1),
|
|
|
|
|
None => len,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Ok(Some((item, remaining)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Optimized dequeue_next: use primitives instead of full snapshot.
|
|
|
|
|
fn dequeue_next(&mut self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
|
|
|
|
let len = self.len()?;
|
|
|
|
|
if len == 0 {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let current_idx = self.current_index()?;
|
|
|
|
|
let next_index = match current_idx {
|
|
|
|
|
None => 0,
|
|
|
|
|
Some(idx) => {
|
|
|
|
|
let candidate = idx + 1;
|
|
|
|
|
if candidate >= len {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
}
|
|
|
|
|
candidate
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let Some(item) = self.get_item(next_index)? else {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let remaining = len.saturating_sub(next_index + 1);
|
|
|
|
|
self.set_index(Some(next_index))?;
|
|
|
|
|
Ok(Some((item, remaining)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Optimized append_or_init_index: use enqueue_items which is already optimized.
|
|
|
|
|
fn append_or_init_index(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
|
|
|
|
let was_empty = self.is_empty()?;
|
|
|
|
|
|
|
|
|
|
// Use the already optimized enqueue_items(AppendToEnd)
|
|
|
|
|
self.enqueue_items(items, EnqueueMode::AppendToEnd)?;
|
|
|
|
|
|
|
|
|
|
// If the queue was empty before, set index to 0
|
|
|
|
|
if was_empty && !self.is_empty()? {
|
|
|
|
|
self.set_index(Some(0))?;
|
|
|
|
|
}
|
2025-12-27 22:15:10 +01:00
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
2025-12-06 13:37:42 +01:00
|
|
|
}
|
2025-12-30 16:50:26 +01:00
|
|
|
|
|
|
|
|
impl QueueFromRendererInfo for OpenHomeQueue {
|
|
|
|
|
fn from_renderer_info(renderer: &RendererInfo) -> Result<Self, ControlPointError> {
|
|
|
|
|
OpenHomeQueue::from_renderer_info(renderer)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn to_backend(self) -> MusicQueue {
|
2026-04-09 11:19:54 +02:00
|
|
|
MusicQueue::from_openhome(self)
|
2025-12-30 16:50:26 +01:00
|
|
|
}
|
|
|
|
|
}
|