2025-12-17 21:41:24 +01:00
|
|
|
use anyhow::{anyhow, Result};
|
2025-12-06 13:37:42 +01:00
|
|
|
use pmodidl::DIDLLite;
|
|
|
|
|
use quick_xml::escape::escape;
|
2025-12-17 21:41:24 +01:00
|
|
|
use tracing::debug;
|
2025-12-06 13:37:42 +01:00
|
|
|
|
|
|
|
|
use crate::media_server::ServerId;
|
|
|
|
|
use crate::model::RendererId;
|
|
|
|
|
use crate::openhome_client::{
|
2025-12-17 21:41:24 +01:00
|
|
|
parse_track_metadata_from_didl, OhInfoClient, OhPlaylistClient, OhProductClient, OhTrackEntry,
|
|
|
|
|
OPENHOME_PLAYLIST_HEAD_ID,
|
2025-12-06 13:37:42 +01:00
|
|
|
};
|
|
|
|
|
use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
|
|
|
|
|
use crate::queue_backend::{PlaybackItem, QueueBackend, QueueSnapshot};
|
|
|
|
|
|
|
|
|
|
/// Local mirror of an OpenHome playlist for a single renderer.
|
|
|
|
|
#[derive(Clone, Debug)]
|
|
|
|
|
pub struct OpenHomeQueue {
|
|
|
|
|
pub renderer_id: RendererId,
|
|
|
|
|
pub playlist: OhPlaylistClient,
|
|
|
|
|
pub info_client: Option<OhInfoClient>,
|
2025-12-17 21:41:24 +01:00
|
|
|
pub product_client: Option<OhProductClient>,
|
2025-12-06 13:37:42 +01:00
|
|
|
pub items: Vec<PlaybackItem>,
|
|
|
|
|
pub current_index: Option<usize>,
|
|
|
|
|
track_ids: Vec<u32>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl OpenHomeQueue {
|
|
|
|
|
pub fn new(
|
|
|
|
|
renderer_id: RendererId,
|
|
|
|
|
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,
|
|
|
|
|
playlist,
|
|
|
|
|
info_client,
|
2025-12-17 21:41:24 +01:00
|
|
|
product_client,
|
2025-12-06 13:37:42 +01:00
|
|
|
items: Vec::new(),
|
|
|
|
|
current_index: None,
|
|
|
|
|
track_ids: Vec::new(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Reload the full OpenHome playlist snapshot into local playback items.
|
|
|
|
|
///
|
|
|
|
|
/// This mirrors the logic previously implemented by
|
|
|
|
|
/// `OpenHomeRenderer::snapshot_openhome_playlist` but converts entries
|
|
|
|
|
/// directly into `PlaybackItem`s.
|
|
|
|
|
pub fn refresh_from_openhome(&mut self) -> Result<()> {
|
2025-12-17 21:41:24 +01:00
|
|
|
self.ensure_playlist_source_selected()?;
|
2025-12-06 13:37:42 +01:00
|
|
|
let entries = self.playlist.read_all_tracks()?;
|
|
|
|
|
let mut items = Vec::with_capacity(entries.len());
|
|
|
|
|
let mut track_ids = Vec::with_capacity(entries.len());
|
|
|
|
|
|
|
|
|
|
for entry in &entries {
|
|
|
|
|
items.push(self.playback_item_from_entry(entry));
|
|
|
|
|
track_ids.push(entry.id);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-26 18:35:56 +01:00
|
|
|
// Try multiple methods to determine the currently playing track, from most to least reliable:
|
|
|
|
|
// 1. Info.Id() - Direct ID query (fastest, but fails if track no longer in playlist)
|
|
|
|
|
// 2. Info.Track() - Returns URI, which we can search for (works even if track removed)
|
|
|
|
|
// 3. None - No current track can be determined
|
2025-12-27 10:20:16 +01:00
|
|
|
let current_id = self.playlist.id()?;
|
|
|
|
|
// {
|
|
|
|
|
// // Try Info.Id() first
|
|
|
|
|
// if let Ok(id) = client.id() {
|
|
|
|
|
// debug!(
|
|
|
|
|
// renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
// track_id = id,
|
|
|
|
|
// "Detected current track via Info.Id()"
|
|
|
|
|
// );
|
|
|
|
|
// return Some(id);
|
|
|
|
|
// }
|
|
|
|
|
|
|
|
|
|
// // If Id() fails, try Track() to get the URI and search for it
|
|
|
|
|
// if let Ok(track_info) = client.track() {
|
|
|
|
|
// debug!(
|
|
|
|
|
// renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
// track_uri = track_info.uri.as_str(),
|
|
|
|
|
// "Info.Id() failed, searching for current track by URI from Info.Track()"
|
|
|
|
|
// );
|
|
|
|
|
// return entries
|
|
|
|
|
// .iter()
|
|
|
|
|
// .find(|entry| entry.uri == track_info.uri)
|
|
|
|
|
// .map(|entry| {
|
|
|
|
|
// debug!(
|
|
|
|
|
// renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
// found_id = entry.id,
|
|
|
|
|
// found_uri = entry.uri.as_str(),
|
|
|
|
|
// "Found current track ID by matching URI"
|
|
|
|
|
// );
|
|
|
|
|
// entry.id
|
|
|
|
|
// });
|
|
|
|
|
// }
|
|
|
|
|
|
|
|
|
|
// debug!(
|
|
|
|
|
// renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
// "Both Info.Id() and Info.Track() failed, cannot determine current track"
|
|
|
|
|
// );
|
|
|
|
|
// None
|
|
|
|
|
// });
|
|
|
|
|
|
|
|
|
|
// let current_index = current_id
|
|
|
|
|
// .and_then(|id| track_ids.iter().position(|entry_id| *entry_id == id));
|
2025-12-06 13:37:42 +01:00
|
|
|
|
|
|
|
|
self.items = items;
|
|
|
|
|
self.track_ids = track_ids;
|
2025-12-27 10:20:16 +01:00
|
|
|
self.current_index = Some(current_id as usize);
|
2025-12-06 13:37:42 +01:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn openhome_playlist_snapshot(&self) -> Result<OpenHomePlaylistSnapshot> {
|
|
|
|
|
let tracks = self
|
|
|
|
|
.items
|
|
|
|
|
.iter()
|
|
|
|
|
.zip(self.track_ids.iter())
|
|
|
|
|
.map(|(item, track_id)| OpenHomePlaylistTrack {
|
|
|
|
|
id: *track_id,
|
|
|
|
|
uri: item.uri.clone(),
|
|
|
|
|
title: item.metadata.as_ref().and_then(|m| m.title.clone()),
|
|
|
|
|
artist: item.metadata.as_ref().and_then(|m| m.artist.clone()),
|
|
|
|
|
album: item.metadata.as_ref().and_then(|m| m.album.clone()),
|
2025-12-15 11:18:58 +01:00
|
|
|
album_art_uri: item.metadata.as_ref().and_then(|m| m.album_art_uri.clone()),
|
2025-12-06 13:37:42 +01:00
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
Ok(OpenHomePlaylistSnapshot {
|
|
|
|
|
renderer_id: self.renderer_id.0.clone(),
|
|
|
|
|
current_id: self
|
|
|
|
|
.current_index
|
|
|
|
|
.and_then(|idx| self.track_ids.get(idx).copied()),
|
2025-12-17 21:41:24 +01:00
|
|
|
current_index: self.current_index,
|
2025-12-06 13:37:42 +01:00
|
|
|
tracks,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn len(&self) -> usize {
|
|
|
|
|
self.items.len()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Return the list of OpenHome track IDs in order.
|
|
|
|
|
pub fn openhome_track_ids(&self) -> Vec<u32> {
|
|
|
|
|
self.track_ids.clone()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn select_track_id(&mut self, id: u32) -> Result<()> {
|
|
|
|
|
let index = match self.track_ids.iter().position(|&tid| tid == id) {
|
|
|
|
|
Some(pos) => pos,
|
|
|
|
|
None => {
|
|
|
|
|
self.refresh_from_openhome()?;
|
|
|
|
|
self.track_ids
|
|
|
|
|
.iter()
|
|
|
|
|
.position(|&tid| tid == id)
|
|
|
|
|
.ok_or_else(|| anyhow!("Unknown OpenHome track id {}", id))?
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-17 21:41:24 +01:00
|
|
|
self.ensure_playlist_source_selected()?;
|
2025-12-06 13:37:42 +01:00
|
|
|
self.playlist.play_id(id)?;
|
|
|
|
|
self.current_index = Some(index);
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn clear(&mut self) -> Result<()> {
|
2025-12-17 21:41:24 +01:00
|
|
|
self.ensure_playlist_source_selected()?;
|
|
|
|
|
self.playlist.delete_all()?;
|
|
|
|
|
self.items.clear();
|
|
|
|
|
self.track_ids.clear();
|
|
|
|
|
self.current_index = None;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Replace the remote OpenHome playlist entirely with `items`.
|
|
|
|
|
///
|
|
|
|
|
/// This is used when attaching a media server playlist: we want to drop any
|
|
|
|
|
/// stale entries (even if they were inserted by another control point) and
|
|
|
|
|
/// rebuild the renderer playlist from scratch.
|
|
|
|
|
pub fn replace_entire_playlist(
|
|
|
|
|
&mut self,
|
|
|
|
|
items: Vec<PlaybackItem>,
|
|
|
|
|
current_index: Option<usize>,
|
|
|
|
|
) -> Result<()> {
|
|
|
|
|
self.ensure_playlist_source_selected()?;
|
2025-12-06 13:37:42 +01:00
|
|
|
self.playlist.delete_all()?;
|
|
|
|
|
self.items.clear();
|
|
|
|
|
self.track_ids.clear();
|
|
|
|
|
self.current_index = None;
|
2025-12-17 21:41:24 +01:00
|
|
|
|
|
|
|
|
if items.is_empty() {
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut rebuilt_items = Vec::with_capacity(items.len());
|
|
|
|
|
let mut rebuilt_ids = Vec::with_capacity(items.len());
|
|
|
|
|
let mut previous_id = OPENHOME_PLAYLIST_HEAD_ID;
|
|
|
|
|
|
|
|
|
|
for item in items {
|
|
|
|
|
let metadata = build_metadata_xml(&item);
|
|
|
|
|
let new_id = self.playlist.insert(previous_id, &item.uri, &metadata)?;
|
|
|
|
|
previous_id = new_id;
|
|
|
|
|
rebuilt_ids.push(new_id);
|
|
|
|
|
rebuilt_items.push(self.item_with_openhome_id(item, new_id));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let normalized = current_index
|
|
|
|
|
.filter(|&idx| idx < rebuilt_ids.len())
|
|
|
|
|
.or_else(|| Some(0));
|
|
|
|
|
|
|
|
|
|
self.items = rebuilt_items;
|
|
|
|
|
self.track_ids = rebuilt_ids;
|
|
|
|
|
self.current_index = normalized;
|
2025-12-06 13:37:42 +01:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn add_playback_item(
|
|
|
|
|
&mut self,
|
|
|
|
|
item: PlaybackItem,
|
|
|
|
|
after_id: Option<u32>,
|
|
|
|
|
play: bool,
|
|
|
|
|
) -> Result<u32> {
|
2025-12-17 21:41:24 +01:00
|
|
|
self.ensure_playlist_source_selected()?;
|
2025-12-06 13:37:42 +01:00
|
|
|
let metadata_xml = build_metadata_xml(&item);
|
|
|
|
|
let insert_after = match after_id {
|
|
|
|
|
Some(id) => id,
|
|
|
|
|
None => self.track_ids.last().copied().unwrap_or(0),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let new_id = self
|
|
|
|
|
.playlist
|
|
|
|
|
.insert(insert_after, &item.uri, &metadata_xml)?;
|
|
|
|
|
|
|
|
|
|
if play {
|
|
|
|
|
self.playlist.play_id(new_id)?;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut insert_index = after_id
|
|
|
|
|
.and_then(|id| {
|
|
|
|
|
if id == 0 {
|
|
|
|
|
Some(0)
|
|
|
|
|
} else {
|
2025-12-15 11:18:58 +01:00
|
|
|
self.track_ids
|
|
|
|
|
.iter()
|
|
|
|
|
.position(|tid| *tid == id)
|
|
|
|
|
.map(|pos| pos + 1)
|
2025-12-06 13:37:42 +01:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.unwrap_or_else(|| self.track_ids.len());
|
|
|
|
|
|
|
|
|
|
if insert_index > self.track_ids.len() {
|
|
|
|
|
insert_index = self.track_ids.len();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
self.track_ids.insert(insert_index, new_id);
|
2025-12-17 21:41:24 +01:00
|
|
|
let stored_item = self.item_with_openhome_id(item, new_id);
|
|
|
|
|
self.items.insert(insert_index, stored_item);
|
2025-12-06 13:37:42 +01:00
|
|
|
|
|
|
|
|
self.current_index = if play {
|
|
|
|
|
Some(insert_index)
|
|
|
|
|
} else {
|
2025-12-15 11:18:58 +01:00
|
|
|
self.current_index
|
|
|
|
|
.map(|idx| if insert_index <= idx { idx + 1 } else { idx })
|
2025-12-06 13:37:42 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
Ok(new_id)
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-17 21:41:24 +01:00
|
|
|
fn ensure_playlist_source_selected(&self) -> Result<()> {
|
|
|
|
|
if let Some(product) = &self.product_client {
|
|
|
|
|
product.ensure_playlist_source_selected().map_err(|err| {
|
|
|
|
|
anyhow!(
|
|
|
|
|
"Failed to select OpenHome Playlist source for {}: {}",
|
|
|
|
|
self.renderer_id.0,
|
|
|
|
|
err
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
} else {
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-06 13:37:42 +01:00
|
|
|
fn playback_item_from_entry(&self, entry: &OhTrackEntry) -> PlaybackItem {
|
|
|
|
|
let metadata = parse_track_metadata_from_didl(&entry.metadata_xml);
|
|
|
|
|
let didl_id = didl_id_from_metadata(&entry.metadata_xml)
|
|
|
|
|
.unwrap_or_else(|| format!("openhome:{}", entry.id));
|
|
|
|
|
PlaybackItem {
|
|
|
|
|
media_server_id: ServerId(format!("openhome:{}", self.renderer_id.0)),
|
|
|
|
|
didl_id,
|
|
|
|
|
uri: entry.uri.clone(),
|
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-17 21:41:24 +01:00
|
|
|
fn item_with_openhome_id(&self, mut item: PlaybackItem, track_id: u32) -> PlaybackItem {
|
|
|
|
|
item.didl_id = format!("openhome:{}", track_id);
|
|
|
|
|
item.media_server_id = ServerId(format!("openhome:{}", self.renderer_id.0));
|
|
|
|
|
item
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-06 13:37:42 +01:00
|
|
|
fn ensure_track_id(&mut self, index: usize) -> Result<u32> {
|
|
|
|
|
if index >= self.items.len() {
|
|
|
|
|
return Err(anyhow!("Index out of bounds in OpenHomeQueue: {}", index));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let Some(id) = self.track_ids.get(index).copied() {
|
|
|
|
|
return Ok(id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
self.refresh_from_openhome()?;
|
|
|
|
|
self.track_ids
|
|
|
|
|
.get(index)
|
|
|
|
|
.copied()
|
|
|
|
|
.ok_or_else(|| anyhow!("Failed to resolve OpenHome track id at index {}", index))
|
|
|
|
|
}
|
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>,
|
|
|
|
|
playing_idx: usize,
|
|
|
|
|
playing_id: u32,
|
|
|
|
|
) -> Result<()> {
|
|
|
|
|
// Re-read the current playlist state to get fresh IDs
|
|
|
|
|
// This minimizes race conditions where IDs become invalid between our last refresh
|
|
|
|
|
// and now (due to UPnP events from the server)
|
|
|
|
|
let current_entries = self.playlist.read_all_tracks()?;
|
|
|
|
|
let current_ids: Vec<u32> = current_entries.iter().map(|e| e.id).collect();
|
|
|
|
|
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
fresh_id_count = current_ids.len(),
|
|
|
|
|
cached_id_count = self.track_ids.len(),
|
|
|
|
|
"Re-read playlist before deletions to avoid stale ID errors"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Find the playing track in the fresh list
|
|
|
|
|
let fresh_playing_idx = current_ids.iter().position(|&id| id == playing_id);
|
|
|
|
|
|
|
|
|
|
if fresh_playing_idx.is_none() {
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
playing_id,
|
|
|
|
|
"Playing track not found in fresh playlist - renderer state may have changed, aborting modification"
|
|
|
|
|
);
|
|
|
|
|
// The playing track is gone - don't try to manipulate the playlist
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Delete everything except the currently playing item (using fresh IDs)
|
|
|
|
|
for &track_id in current_ids.iter().rev() {
|
|
|
|
|
if track_id != playing_id {
|
|
|
|
|
self.playlist.delete_id_if_exists(track_id)?;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Rebuild: [currently_playing, new_items...]
|
|
|
|
|
let mut rebuilt_items = Vec::with_capacity(1 + new_items.len());
|
|
|
|
|
let mut rebuilt_ids = Vec::with_capacity(1 + new_items.len());
|
|
|
|
|
|
|
|
|
|
rebuilt_items.push(self.items[playing_idx].clone());
|
|
|
|
|
rebuilt_ids.push(playing_id);
|
|
|
|
|
|
|
|
|
|
let mut previous_id = playing_id;
|
|
|
|
|
for item in new_items {
|
|
|
|
|
let metadata = build_metadata_xml(&item);
|
|
|
|
|
let new_id = self.playlist.insert(previous_id, &item.uri, &metadata)?;
|
|
|
|
|
previous_id = new_id;
|
|
|
|
|
rebuilt_ids.push(new_id);
|
|
|
|
|
rebuilt_items.push(self.item_with_openhome_id(item, new_id));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
self.items = rebuilt_items;
|
|
|
|
|
self.track_ids = rebuilt_ids;
|
|
|
|
|
self.current_index = Some(0); // Currently playing is now at index 0
|
|
|
|
|
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
"Gentle sync completed: preserved playing track as first item (not in new playlist)"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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: u32,
|
|
|
|
|
) -> Result<()> {
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
pivot_id,
|
|
|
|
|
pivot_idx_new,
|
|
|
|
|
new_playlist_len = new_items.len(),
|
|
|
|
|
"Starting replace_queue_with_pivot - will re-read from OpenHome"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Re-read the current playlist state from OpenHome (the ONLY source of truth)
|
|
|
|
|
// This is CRITICAL to avoid deleting IDs that no longer exist, which can
|
|
|
|
|
// put the renderer (upmpdcli) into a degraded state where Info.TransportState()
|
|
|
|
|
// starts returning HTTP 500 errors.
|
|
|
|
|
let current_entries = self.playlist.read_all_tracks()?;
|
|
|
|
|
|
|
|
|
|
// Convert entries to PlaybackItems - this is the REAL current state
|
|
|
|
|
let mut fresh_items = Vec::with_capacity(current_entries.len());
|
|
|
|
|
let mut fresh_ids = Vec::with_capacity(current_entries.len());
|
|
|
|
|
for entry in ¤t_entries {
|
|
|
|
|
fresh_items.push(self.playback_item_from_entry(entry));
|
|
|
|
|
fresh_ids.push(entry.id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
fresh_count = fresh_items.len(),
|
|
|
|
|
"Re-read playlist from OpenHome (source of truth)"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Find the pivot in the fresh list
|
|
|
|
|
let fresh_pivot_idx = fresh_ids.iter().position(|&id| id == pivot_id);
|
|
|
|
|
|
|
|
|
|
if fresh_pivot_idx.is_none() {
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
pivot_id,
|
|
|
|
|
"Pivot track not found in fresh playlist - renderer state changed, aborting"
|
|
|
|
|
);
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let fresh_pivot_idx = fresh_pivot_idx.unwrap();
|
|
|
|
|
|
|
|
|
|
// Split fresh data at the pivot - use ONLY fresh data, ignore cache
|
|
|
|
|
let old_before: Vec<PlaybackItem> = fresh_items[..fresh_pivot_idx].to_vec();
|
|
|
|
|
let old_after: Vec<PlaybackItem> = fresh_items[fresh_pivot_idx + 1..].to_vec();
|
|
|
|
|
let old_ids_before: Vec<u32> = fresh_ids[..fresh_pivot_idx].to_vec();
|
|
|
|
|
let old_ids_after: Vec<u32> = fresh_ids[fresh_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)
|
|
|
|
|
let (keep_old_after, keep_new_after) = lcs_flags(&old_after, new_after);
|
|
|
|
|
|
|
|
|
|
// LCS on the BEFORE part (using fresh data from OpenHome)
|
|
|
|
|
let (keep_old_before, keep_new_before) = lcs_flags(&old_before, new_before);
|
|
|
|
|
|
|
|
|
|
// Delete items marked for deletion in AFTER part (reverse order)
|
|
|
|
|
for (idx, &track_id) in old_ids_after.iter().enumerate().rev() {
|
|
|
|
|
if !keep_old_after[idx] {
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
track_id,
|
|
|
|
|
position = "AFTER pivot",
|
|
|
|
|
"RENDERER OP: DeleteId({})",
|
|
|
|
|
track_id
|
|
|
|
|
);
|
|
|
|
|
self.playlist.delete_id_if_exists(track_id)?;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Delete items marked for deletion in BEFORE part (reverse order)
|
|
|
|
|
for (idx, &track_id) in old_ids_before.iter().enumerate().rev() {
|
|
|
|
|
if !keep_old_before[idx] {
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
track_id,
|
|
|
|
|
position = "BEFORE pivot",
|
|
|
|
|
"RENDERER OP: DeleteId({})",
|
|
|
|
|
track_id
|
|
|
|
|
);
|
|
|
|
|
self.playlist.delete_id_if_exists(track_id)?;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Rebuild the playlist: [BEFORE, PIVOT, AFTER]
|
|
|
|
|
let mut rebuilt_items = Vec::with_capacity(new_items.len());
|
|
|
|
|
let mut rebuilt_ids = Vec::with_capacity(new_items.len());
|
|
|
|
|
|
|
|
|
|
// Collect IDs of kept items in BEFORE part (in order)
|
|
|
|
|
let remaining_before: Vec<u32> = old_ids_before
|
|
|
|
|
.iter()
|
|
|
|
|
.enumerate()
|
|
|
|
|
.filter_map(|(idx, &id)| if keep_old_before[idx] { Some(id) } else { None })
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
let mut remaining_before_idx = 0;
|
|
|
|
|
let mut previous_id = OPENHOME_PLAYLIST_HEAD_ID;
|
|
|
|
|
|
|
|
|
|
// Rebuild BEFORE part
|
|
|
|
|
for (idx, item) in new_before.iter().enumerate() {
|
|
|
|
|
if keep_new_before[idx] {
|
|
|
|
|
let existing_id = remaining_before[remaining_before_idx];
|
|
|
|
|
remaining_before_idx += 1;
|
|
|
|
|
previous_id = existing_id;
|
|
|
|
|
rebuilt_ids.push(existing_id);
|
|
|
|
|
rebuilt_items.push(self.item_with_openhome_id(item.clone(), existing_id));
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
track_id = existing_id,
|
|
|
|
|
position = "BEFORE pivot",
|
|
|
|
|
"KEPT existing track ID {}",
|
|
|
|
|
existing_id
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
let metadata = build_metadata_xml(item);
|
|
|
|
|
let new_id = self.playlist.insert(previous_id, &item.uri, &metadata)?;
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
after_id = previous_id,
|
|
|
|
|
new_id,
|
|
|
|
|
position = "BEFORE pivot",
|
|
|
|
|
"RENDERER OP: Insert(after={}) -> new_id={}",
|
|
|
|
|
previous_id,
|
|
|
|
|
new_id
|
|
|
|
|
);
|
|
|
|
|
previous_id = new_id;
|
|
|
|
|
rebuilt_ids.push(new_id);
|
|
|
|
|
rebuilt_items.push(self.item_with_openhome_id(item.clone(), new_id));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add PIVOT (keeps its ID!)
|
|
|
|
|
rebuilt_ids.push(pivot_id);
|
|
|
|
|
rebuilt_items.push(self.item_with_openhome_id(new_items[pivot_idx_new].clone(), pivot_id));
|
|
|
|
|
previous_id = pivot_id;
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
pivot_id,
|
|
|
|
|
pivot_idx_new,
|
|
|
|
|
"PIVOT preserved with ID {} at index {}",
|
|
|
|
|
pivot_id,
|
|
|
|
|
pivot_idx_new
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Collect IDs of kept items in AFTER part (in order)
|
|
|
|
|
let remaining_after: Vec<u32> = old_ids_after
|
|
|
|
|
.iter()
|
|
|
|
|
.enumerate()
|
|
|
|
|
.filter_map(|(idx, &id)| if keep_old_after[idx] { Some(id) } else { None })
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
let mut remaining_after_idx = 0;
|
|
|
|
|
|
|
|
|
|
// Rebuild AFTER part
|
|
|
|
|
for (idx, item) in new_after.iter().enumerate() {
|
|
|
|
|
if keep_new_after[idx] {
|
|
|
|
|
let existing_id = remaining_after[remaining_after_idx];
|
|
|
|
|
remaining_after_idx += 1;
|
|
|
|
|
previous_id = existing_id;
|
|
|
|
|
rebuilt_ids.push(existing_id);
|
|
|
|
|
rebuilt_items.push(self.item_with_openhome_id(item.clone(), existing_id));
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
track_id = existing_id,
|
|
|
|
|
position = "AFTER pivot",
|
|
|
|
|
"KEPT existing track ID {}",
|
|
|
|
|
existing_id
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
let metadata = build_metadata_xml(item);
|
|
|
|
|
let new_id = self.playlist.insert(previous_id, &item.uri, &metadata)?;
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
after_id = previous_id,
|
|
|
|
|
new_id,
|
|
|
|
|
position = "AFTER pivot",
|
|
|
|
|
"RENDERER OP: Insert(after={}) -> new_id={}",
|
|
|
|
|
previous_id,
|
|
|
|
|
new_id
|
|
|
|
|
);
|
|
|
|
|
previous_id = new_id;
|
|
|
|
|
rebuilt_ids.push(new_id);
|
|
|
|
|
rebuilt_items.push(self.item_with_openhome_id(item.clone(), new_id));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
self.items = rebuilt_items;
|
|
|
|
|
self.track_ids = rebuilt_ids;
|
|
|
|
|
self.current_index = Some(pivot_idx_new); // Pivot is at its new position
|
|
|
|
|
|
|
|
|
|
// VERIFICATION: Check that pivot ID is preserved
|
|
|
|
|
let final_pivot_id = self.track_ids.get(pivot_idx_new).copied();
|
|
|
|
|
if final_pivot_id != Some(pivot_id) {
|
|
|
|
|
return Err(anyhow!(
|
|
|
|
|
"CRITICAL BUG: Pivot ID changed from {} to {:?} during replace_queue_with_pivot!",
|
|
|
|
|
pivot_id,
|
|
|
|
|
final_pivot_id
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
pivot_idx = pivot_idx_new,
|
|
|
|
|
pivot_id,
|
|
|
|
|
final_playlist_len = self.track_ids.len(),
|
|
|
|
|
pivot_verified = true,
|
|
|
|
|
"Gentle sync completed: double-LCS with pivot (playing track preserved)"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Standard LCS-based replacement (used when no currently playing item).
|
|
|
|
|
fn replace_queue_standard_lcs(
|
|
|
|
|
&mut self,
|
|
|
|
|
items: Vec<PlaybackItem>,
|
|
|
|
|
current_index: Option<usize>,
|
|
|
|
|
) -> Result<()> {
|
|
|
|
|
let (keep_current, keep_desired) = lcs_flags(&self.items, &items);
|
|
|
|
|
|
|
|
|
|
let items_to_keep = keep_current.iter().filter(|&&k| k).count();
|
|
|
|
|
let items_to_delete = keep_current.iter().filter(|&&k| !k).count();
|
|
|
|
|
let items_to_add = keep_desired.iter().filter(|&&k| !k).count();
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// If we're replacing everything (keep=0), use delete_all() instead of
|
|
|
|
|
// individual delete_id() calls. This is much more robust for live playlists
|
|
|
|
|
// where track IDs can become invalid between refresh and deletion.
|
|
|
|
|
if items_to_keep == 0 && items_to_delete > 0 {
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
"Using delete_all() for complete replacement (more robust for live playlists)"
|
|
|
|
|
);
|
|
|
|
|
self.playlist.delete_all()?;
|
|
|
|
|
self.track_ids.clear();
|
|
|
|
|
self.items.clear();
|
|
|
|
|
} else {
|
|
|
|
|
// Selective deletion when keeping some items
|
|
|
|
|
for idx in (0..self.track_ids.len()).rev() {
|
|
|
|
|
if !keep_current[idx] {
|
|
|
|
|
let track_id = self.track_ids[idx];
|
|
|
|
|
// Use delete_id_if_exists() to handle cases where another control point
|
|
|
|
|
// may have already modified the playlist
|
|
|
|
|
self.playlist.delete_id_if_exists(track_id)?;
|
|
|
|
|
self.track_ids.remove(idx);
|
|
|
|
|
self.items.remove(idx);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let remaining_ids = self.track_ids.clone();
|
|
|
|
|
let mut remaining_idx = 0usize;
|
|
|
|
|
let mut previous_id = OPENHOME_PLAYLIST_HEAD_ID;
|
|
|
|
|
let mut rebuilt_items = Vec::with_capacity(items.len());
|
|
|
|
|
let mut rebuilt_ids = Vec::with_capacity(items.len());
|
|
|
|
|
|
|
|
|
|
for (idx, item) in items.into_iter().enumerate() {
|
|
|
|
|
if keep_desired[idx] {
|
|
|
|
|
if remaining_idx >= remaining_ids.len() {
|
|
|
|
|
return Err(anyhow!(
|
|
|
|
|
"OpenHome playlist refresh bookkeeping mismatch (kept entries underflow)"
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let existing_id = remaining_ids[remaining_idx];
|
|
|
|
|
remaining_idx += 1;
|
|
|
|
|
previous_id = existing_id;
|
|
|
|
|
rebuilt_ids.push(existing_id);
|
|
|
|
|
rebuilt_items.push(self.item_with_openhome_id(item, existing_id));
|
|
|
|
|
} else {
|
|
|
|
|
let metadata = build_metadata_xml(&item);
|
|
|
|
|
let new_id = self.playlist.insert(previous_id, &item.uri, &metadata)?;
|
|
|
|
|
previous_id = new_id;
|
|
|
|
|
rebuilt_ids.push(new_id);
|
|
|
|
|
rebuilt_items.push(self.item_with_openhome_id(item, new_id));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if remaining_idx != remaining_ids.len() {
|
|
|
|
|
return Err(anyhow!(
|
|
|
|
|
"OpenHome playlist refresh bookkeeping mismatch (kept entries overflow)"
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let previous_index = self
|
|
|
|
|
.current_index
|
|
|
|
|
.and_then(|idx| if idx < rebuilt_ids.len() { Some(idx) } else { None });
|
|
|
|
|
let normalized = current_index
|
|
|
|
|
.filter(|&i| i < rebuilt_ids.len())
|
|
|
|
|
.or(previous_index)
|
|
|
|
|
.or_else(|| if rebuilt_ids.is_empty() { None } else { Some(0) });
|
|
|
|
|
self.items = rebuilt_items;
|
|
|
|
|
self.track_ids = rebuilt_ids;
|
|
|
|
|
self.current_index = normalized;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
2025-12-06 13:37:42 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn didl_id_from_metadata(xml: &str) -> Option<String> {
|
|
|
|
|
if xml.trim().is_empty() {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let parsed = pmodidl::parse_metadata::<DIDLLite>(xml).ok()?;
|
2025-12-15 11:18:58 +01:00
|
|
|
parsed.data.items.first().map(|item| item.id.clone())
|
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());
|
2025-12-06 13:37:42 +01:00
|
|
|
xml.push_str(&format!(
|
2025-12-17 20:45:51 +01:00
|
|
|
r#"<res protocolInfo="{}">{}</res>"#,
|
|
|
|
|
escaped_protocol_info, 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
|
|
|
|
|
}
|
|
|
|
|
|
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 {
|
|
|
|
|
if current[i].uri == desired[j].uri {
|
|
|
|
|
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 {
|
|
|
|
|
if current[i - 1].uri == desired[j - 1].uri {
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-06 13:37:42 +01:00
|
|
|
impl QueueBackend for OpenHomeQueue {
|
|
|
|
|
fn queue_snapshot(&self) -> Result<QueueSnapshot> {
|
|
|
|
|
Ok(QueueSnapshot {
|
|
|
|
|
items: self.items.clone(),
|
|
|
|
|
current_index: self.current_index,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn set_index(&mut self, index: Option<usize>) -> Result<()> {
|
|
|
|
|
let normalized = index.filter(|&i| i < self.items.len());
|
|
|
|
|
if let Some(idx) = normalized {
|
|
|
|
|
let track_id = self.ensure_track_id(idx)?;
|
2025-12-17 21:41:24 +01:00
|
|
|
self.ensure_playlist_source_selected()?;
|
2025-12-06 13:37:42 +01:00
|
|
|
self.playlist.play_id(track_id)?;
|
|
|
|
|
}
|
|
|
|
|
self.current_index = normalized;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn replace_queue(
|
|
|
|
|
&mut self,
|
|
|
|
|
items: Vec<PlaybackItem>,
|
|
|
|
|
current_index: Option<usize>,
|
|
|
|
|
) -> Result<()> {
|
2025-12-17 21:41:24 +01:00
|
|
|
self.ensure_playlist_source_selected()?;
|
|
|
|
|
if items.is_empty() {
|
|
|
|
|
self.playlist.delete_all()?;
|
|
|
|
|
self.items.clear();
|
|
|
|
|
self.track_ids.clear();
|
|
|
|
|
self.current_index = None;
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
2025-12-06 13:37:42 +01:00
|
|
|
|
2025-12-17 21:41:24 +01:00
|
|
|
// Synchronize local state with the actual OpenHome playlist before computing
|
|
|
|
|
// differences. Without this, any drift between our cache and the renderer
|
|
|
|
|
// (e.g., manual edits from another control point) would keep the stale items.
|
|
|
|
|
self.refresh_from_openhome()?;
|
|
|
|
|
|
2025-12-26 18:35:56 +01:00
|
|
|
// Try to get the currently playing track ID from the renderer.
|
|
|
|
|
// Note: Some OpenHome renderers (like upmpdcli) don't reliably support Info.Id(),
|
|
|
|
|
// so we fall back to using our internal current_index pointer.
|
|
|
|
|
let currently_playing_id_from_renderer = self
|
2025-12-27 10:20:16 +01:00
|
|
|
.playlist.id().ok();
|
2025-12-17 21:41:24 +01:00
|
|
|
|
2025-12-26 18:35:56 +01:00
|
|
|
// Find the currently playing item in our local state.
|
|
|
|
|
// Priority: 1) Renderer-reported ID, 2) Our internal current_index
|
|
|
|
|
let playing_info = if let Some(id) = currently_playing_id_from_renderer {
|
|
|
|
|
// CASE: Renderer explicitly reported the playing track ID
|
|
|
|
|
self.track_ids
|
|
|
|
|
.iter()
|
|
|
|
|
.position(|&tid| tid == id)
|
|
|
|
|
.map(|idx| (idx, id, self.items[idx].uri.clone()))
|
|
|
|
|
} else if let Some(idx) = self.current_index {
|
|
|
|
|
// CASE: Use our internal pointer (fallback for renderers without Info.Id() support)
|
|
|
|
|
if idx < self.track_ids.len() && idx < self.items.len() {
|
|
|
|
|
let id = self.track_ids[idx];
|
|
|
|
|
let uri = self.items[idx].uri.clone();
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
current_index = idx,
|
|
|
|
|
track_id = id,
|
|
|
|
|
"Using internal current_index as fallback (renderer didn't report playing ID)"
|
|
|
|
|
);
|
|
|
|
|
Some((idx, id, uri))
|
|
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
};
|
2025-12-17 21:41:24 +01:00
|
|
|
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
2025-12-26 18:35:56 +01:00
|
|
|
actual_items = self.items.len(),
|
|
|
|
|
currently_playing_id_from_renderer = ?currently_playing_id_from_renderer,
|
|
|
|
|
playing_info_detected = playing_info.is_some(),
|
|
|
|
|
"OpenHome playlist state refreshed before replace_queue"
|
2025-12-17 21:41:24 +01:00
|
|
|
);
|
|
|
|
|
|
2025-12-26 18:35:56 +01:00
|
|
|
if let Some((playing_idx, playing_id, playing_uri)) = playing_info {
|
|
|
|
|
// Find if the currently playing item is in the new playlist (by URI)
|
|
|
|
|
let new_playing_idx = items.iter().position(|item| item.uri == playing_uri);
|
|
|
|
|
|
|
|
|
|
if let Some(pivot_idx) = new_playing_idx {
|
|
|
|
|
// CASE 2: Currently playing item IS in the new playlist
|
|
|
|
|
// Use gentle double-LCS strategy: preserve the pivot and sync before/after separately
|
|
|
|
|
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
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
self.replace_queue_with_pivot(items, pivot_idx, playing_id)?;
|
|
|
|
|
} else {
|
|
|
|
|
// CASE 1: Currently playing item NOT in the new playlist
|
|
|
|
|
// Keep it as first item and append the new playlist after it
|
|
|
|
|
debug!(
|
|
|
|
|
renderer = self.renderer_id.0.as_str(),
|
|
|
|
|
playing_idx,
|
|
|
|
|
"Gentle sync: currently playing item not in new playlist, preserving as first item"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
self.replace_queue_preserve_current(items, playing_idx, playing_id)?;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// No currently playing item or can't determine it - use standard LCS
|
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
|
|
|
);
|
2025-12-26 18:35:56 +01:00
|
|
|
self.replace_queue_standard_lcs(items, current_index)?;
|
2025-12-06 13:37:42 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>> {
|
|
|
|
|
Ok(self.items.get(index).cloned())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<()> {
|
|
|
|
|
if index >= self.items.len() {
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-17 21:41:24 +01:00
|
|
|
self.ensure_playlist_source_selected()?;
|
2025-12-06 13:37:42 +01:00
|
|
|
let track_id = self.ensure_track_id(index)?;
|
|
|
|
|
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 {
|
|
|
|
|
self.ensure_track_id(index - 1)?
|
|
|
|
|
};
|
|
|
|
|
|
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
|
|
|
|
|
self.playlist.delete_id_if_exists(track_id)?;
|
2025-12-06 13:37:42 +01:00
|
|
|
let metadata = build_metadata_xml(&item);
|
|
|
|
|
let new_id = self.playlist.insert(before_id, &item.uri, &metadata)?;
|
|
|
|
|
|
|
|
|
|
if self.current_index == Some(index) {
|
|
|
|
|
self.playlist.play_id(new_id)?;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-17 21:41:24 +01:00
|
|
|
self.items[index] = self.item_with_openhome_id(item, new_id);
|
2025-12-06 13:37:42 +01:00
|
|
|
self.track_ids[index] = new_id;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
}
|