From 3c5a91e0ff51524a6ee24dcb3934a89b094cee0c Mon Sep 17 00:00:00 2001 From: Eric Coissac Date: Sat, 31 Jan 2026 19:56:46 +0100 Subject: [PATCH] Fix playback state handling and metadata caching This commit addresses several issues related to playback state reporting and metadata handling: 1. In the backend (pmocontrol), the music renderer now correctly uses raw playback states instead of computed logical states for event emission, and fixes stream time calculation to only occur when the device is actually playing. 2. The frontend (CurrentTrack.vue) now logs additional debug information for duration and transport state to help trace playback issues. 3. The SSE event handling in useRenderers.ts has been enhanced with more detailed logging and uses deep copying to prevent shared state issues between renderers. 4. The OpenHome queue implementation now uses a Mutex for metadata caching and implements logic to prevent duration from decreasing for the same track, which helps stabilize metadata for streaming content. These changes improve the reliability and accuracy of playback state and metadata display across all components. --- .../components/pmocontrol/CurrentTrack.vue | 9 +- pmoapp/webapp/src/composables/useRenderers.ts | 36 +- pmoapp/webapp/src/composables/useTabs.ts | 263 ++-- .../src/music_renderer/musicrenderer.rs | 54 +- pmocontrol/src/music_renderer/watcher.rs | 54 - pmocontrol/src/queue/openhome.rs | 167 ++- pmocontrol/src/queue/openhome.rs.bak | 1193 +++++++++++++++++ 7 files changed, 1546 insertions(+), 230 deletions(-) create mode 100644 pmocontrol/src/queue/openhome.rs.bak diff --git a/pmoapp/webapp/src/components/pmocontrol/CurrentTrack.vue b/pmoapp/webapp/src/components/pmocontrol/CurrentTrack.vue index fd0248bb..1e94c98b 100644 --- a/pmoapp/webapp/src/components/pmocontrol/CurrentTrack.vue +++ b/pmoapp/webapp/src/components/pmocontrol/CurrentTrack.vue @@ -100,7 +100,14 @@ function formatTime(ms: number | null | undefined): string { } const currentTime = computed(() => formatTime(state.value?.position_ms)); -const totalTime = computed(() => formatTime(state.value?.duration_ms)); +const totalTime = computed(() => { + const duration = state.value?.duration_ms; + const transport = state.value?.transport_state; + console.log( + `[CurrentTrack] rendererId=${props.rendererId}, duration_ms=${duration}, transport=${transport}, title=${state.value?.current_track?.title}`, + ); + return formatTime(duration); +}); const hasCover = computed( () => !!metadata.value?.album_art_uri && !imageError.value, diff --git a/pmoapp/webapp/src/composables/useRenderers.ts b/pmoapp/webapp/src/composables/useRenderers.ts index 703abbd0..831c2693 100644 --- a/pmoapp/webapp/src/composables/useRenderers.ts +++ b/pmoapp/webapp/src/composables/useRenderers.ts @@ -4,7 +4,7 @@ * - Les snapshots complets proviennent de /renderers/{id}/full * - Les événements SSE ne servent qu'à déclencher un refetch. */ -import { ref, reactive, computed, type Ref } from "vue"; +import { ref, reactive, computed, toRaw, type Ref } from "vue"; import { api } from "../services/pmocontrol/api"; import { sse } from "../services/pmocontrol/sse"; import type { @@ -109,6 +109,9 @@ function ensureSSEConnected() { snapshotState.lastEventAt.set(rendererId, timestamp); const snapshot = snapshotState.snapshots.get(rendererId); + console.log( + `[SSE Event] type=${event.type}, rendererId=${rendererId}, snapshot exists=${!!snapshot}, transport=${snapshot?.state?.transport_state}`, + ); // Si pas de snapshot, on doit fetch if (!snapshot) { @@ -123,6 +126,11 @@ function ensureSSEConnected() { break; case "position_changed": + // Debug: log pour tracer les oscillations + console.log( + `[position_changed] renderer=${rendererId}, duration=${event.track_duration}`, + ); + // Mettre à jour position et durée de manière atomique pour garantir la cohérence // Le backend envoie TOUJOURS les deux valeurs (même si null) @@ -157,9 +165,22 @@ function ensureSSEConnected() { snapshot.state.duration_ms = null; } - // Important: Trigger reactivity en réassignant l'objet complet - // Cela garantit que position_ms et duration_ms sont mis à jour atomiquement - snapshotState.snapshots.set(rendererId, { ...snapshot }); + // Important: Trigger reactivity en réassignant l'objet complet avec deep copy + // Le shallow copy ne suffit pas car snapshot.state est partagé entre renderers + // Il faut copier state aussi pour éviter que les modifications d'un renderer + // n'affectent les autres renderers + // IMPORTANT: Utiliser toRaw() pour obtenir l'objet brut non-réactif avant de copier + // sinon Vue copie les getters réactifs qui continuent à pointer vers l'objet d'origine + const rawState = toRaw(snapshot.state); + const newState = { ...rawState }; + const newSnapshot = { + ...snapshot, + state: newState, + }; + console.log( + `[position_changed] Setting new snapshot for ${rendererId}, state ref=${Object.prototype.toString.call(newState)}, transport=${newState.transport_state}`, + ); + snapshotState.snapshots.set(rendererId, newSnapshot); break; case "volume_changed": @@ -183,8 +204,11 @@ function ensureSSEConnected() { snapshot.state.current_track.artist = event.artist; snapshot.state.current_track.album = event.album; snapshot.state.current_track.album_art_uri = event.album_art_uri; - // Important: Trigger reactivity en réassignant l'objet complet - snapshotState.snapshots.set(rendererId, { ...snapshot }); + // Important: Trigger reactivity en réassignant l'objet complet avec deep copy + snapshotState.snapshots.set(rendererId, { + ...snapshot, + state: { ...snapshot.state }, + }); break; case "queue_updated": diff --git a/pmoapp/webapp/src/composables/useTabs.ts b/pmoapp/webapp/src/composables/useTabs.ts index dcaf1527..d541dc9b 100644 --- a/pmoapp/webapp/src/composables/useTabs.ts +++ b/pmoapp/webapp/src/composables/useTabs.ts @@ -3,42 +3,45 @@ * Onglets renderer auto-générés depuis la liste des renderers online. * Onglets server ouverts manuellement via le drawer (fermables). */ -import { reactive, computed, watch, onMounted, type Component } from 'vue' -import { useMediaQuery } from '@vueuse/core' -import { Radio, Server } from 'lucide-vue-next' -import type { RendererSummary, MediaServerSummary } from '../services/pmocontrol/types' +import { reactive, computed, watch, onMounted, type Component } from "vue"; +import { useMediaQuery } from "@vueuse/core"; +import { Radio, Server } from "lucide-vue-next"; +import type { + RendererSummary, + MediaServerSummary, +} from "../services/pmocontrol/types"; export interface Tab { - id: string // "renderer-{id}", "server-{id}" - type: 'renderer' | 'server' - title: string // Nom affiché (tronqué sur mobile) - fullTitle: string // Nom complet (pour tooltip) - icon: Component + id: string; // "renderer-{id}", "server-{id}" + type: "renderer" | "server"; + title: string; // Nom affiché (tronqué sur mobile) + fullTitle: string; // Nom complet (pour tooltip) + icon: Component; metadata?: { - rendererId?: string - serverId?: string - } - closeable: boolean // renderer: false (auto-géré), server: true (manuel) + rendererId?: string; + serverId?: string; + }; + closeable: boolean; // renderer: false (auto-géré), server: true (manuel) } interface TabsState { - tabs: Tab[] - activeTabId: string - tabHistory: string[] // Pour back/forward navigation + tabs: Tab[]; + activeTabId: string; + tabHistory: string[]; // Pour back/forward navigation } -const MAX_TABS = 12 // Augmenté car onglets auto-générés -const STORAGE_KEY = 'pmo-tabs-state' +const MAX_TABS = 12; // Augmenté car onglets auto-générés +const STORAGE_KEY = "pmo-tabs-state"; // État global partagé entre toutes les instances du composable const state = reactive({ tabs: [], - activeTabId: '', + activeTabId: "", tabHistory: [], -}) +}); // Flag pour éviter les boucles de sauvegarde -let isRestoringFromStorage = false +let isRestoringFromStorage = false; /** * Retourne le titre complet sans troncature @@ -46,7 +49,7 @@ let isRestoringFromStorage = false */ function truncateTitle(title: string): string { // Retourner le titre complet, le CSS gère l'ellipsis de façon stable - return title + return title; } /** @@ -54,13 +57,13 @@ function truncateTitle(title: string): string { * Note: On ne sauvegarde que les onglets server (les renderer tabs sont auto-générés) */ function saveToLocalStorage() { - if (isRestoringFromStorage) return + if (isRestoringFromStorage) return; try { const stateToSave = { // Sauvegarder uniquement les onglets server (fermables manuellement) tabs: state.tabs - .filter((tab) => tab.type === 'server') + .filter((tab) => tab.type === "server") .map((tab) => ({ ...tab, // On ne peut pas sauvegarder les composants Vue, on sauve juste le type @@ -68,10 +71,10 @@ function saveToLocalStorage() { })), activeTabId: state.activeTabId, tabHistory: state.tabHistory, - } - localStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave)) + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave)); } catch (error) { - console.error('[useTabs] Erreur sauvegarde localStorage:', error) + console.error("[useTabs] Erreur sauvegarde localStorage:", error); } } @@ -81,37 +84,37 @@ function saveToLocalStorage() { */ function restoreFromLocalStorage() { try { - const saved = localStorage.getItem(STORAGE_KEY) - if (!saved) return + const saved = localStorage.getItem(STORAGE_KEY); + if (!saved) return; - isRestoringFromStorage = true - const savedState = JSON.parse(saved) + isRestoringFromStorage = true; + const savedState = JSON.parse(saved); // Reconstituer uniquement les tabs server avec les bonnes icônes const serverTabs = (savedState.tabs || []) - .filter((tab: Tab) => tab.type === 'server') + .filter((tab: Tab) => tab.type === "server") .map((tab: Tab) => ({ ...tab, icon: Server, closeable: true, fullTitle: tab.fullTitle || tab.title, // Fallback si fullTitle n'existe pas - })) + })); // Ajouter les tabs server restaurés (les renderer tabs seront ajoutés par syncWithRenderers) - state.tabs.push(...serverTabs) + state.tabs.push(...serverTabs); - state.activeTabId = savedState.activeTabId || '' - state.tabHistory = savedState.tabHistory || [] + state.activeTabId = savedState.activeTabId || ""; + state.tabHistory = savedState.tabHistory || []; // Vérifier que l'onglet actif existe toujours (sera validé après syncWithRenderers) if (!state.tabs.find((t) => t.id === state.activeTabId)) { - state.activeTabId = '' + state.activeTabId = ""; } - isRestoringFromStorage = false + isRestoringFromStorage = false; } catch (error) { - console.error('[useTabs] Erreur restauration localStorage:', error) - isRestoringFromStorage = false + console.error("[useTabs] Erreur restauration localStorage:", error); + isRestoringFromStorage = false; } } @@ -119,31 +122,33 @@ function restoreFromLocalStorage() { * Trouve un onglet par son ID */ function findTab(tabId: string): Tab | undefined { - return state.tabs.find((t) => t.id === tabId) + return state.tabs.find((t) => t.id === tabId); } /** * Ouvre un nouvel onglet ou active un onglet existant */ -function openTab(newTab: Omit & { id?: string; fullTitle?: string }): string { +function openTab( + newTab: Omit & { id?: string; fullTitle?: string }, +): string { // Générer un ID si non fourni const tabId = newTab.id || - (newTab.type === 'renderer' + (newTab.type === "renderer" ? `renderer-${newTab.metadata?.rendererId}` - : `server-${newTab.metadata?.serverId}`) + : `server-${newTab.metadata?.serverId}`); // Si l'onglet existe déjà, on le sélectionne - const existingTab = findTab(tabId) + const existingTab = findTab(tabId); if (existingTab) { - switchTab(tabId) - return tabId + switchTab(tabId); + return tabId; } // Vérifier la limite max if (state.tabs.length >= MAX_TABS) { - console.warn(`[useTabs] Limite max de ${MAX_TABS} onglets atteinte`) - return state.activeTabId + console.warn(`[useTabs] Limite max de ${MAX_TABS} onglets atteinte`); + return state.activeTabId; } // Créer le nouvel onglet @@ -155,12 +160,12 @@ function openTab(newTab: Omit & { id?: string; fullTitl icon: newTab.icon, metadata: newTab.metadata, closeable: newTab.closeable !== false, // true par défaut sauf si explicitement false - } + }; - state.tabs.push(tab) - switchTab(tabId) + state.tabs.push(tab); + switchTab(tabId); - return tabId + return tabId; } /** @@ -168,24 +173,26 @@ function openTab(newTab: Omit & { id?: string; fullTitl * Les onglets renderer sont auto-gérés et ne peuvent pas être fermés manuellement */ function closeTab(tabId: string) { - const tab = findTab(tabId) - if (!tab) return + const tab = findTab(tabId); + if (!tab) return; // Ne fermer que les onglets server (closeable = true) // Les renderer tabs sont auto-gérés par syncWithRenderers - if (!tab.closeable || tab.type === 'renderer') { - console.warn('[useTabs] Impossible de fermer un onglet renderer (auto-géré)') - return + if (!tab.closeable || tab.type === "renderer") { + console.warn( + "[useTabs] Impossible de fermer un onglet renderer (auto-géré)", + ); + return; } - const tabIndex = state.tabs.findIndex((t) => t.id === tabId) - if (tabIndex === -1) return + const tabIndex = state.tabs.findIndex((t) => t.id === tabId); + if (tabIndex === -1) return; // Supprimer l'onglet - state.tabs.splice(tabIndex, 1) + state.tabs.splice(tabIndex, 1); // Supprimer de l'historique - state.tabHistory = state.tabHistory.filter((id) => id !== tabId) + state.tabHistory = state.tabHistory.filter((id) => id !== tabId); // Si c'était l'onglet actif, basculer vers le précédent dans l'historique if (state.activeTabId === tabId) { @@ -193,17 +200,17 @@ function closeTab(tabId: string) { const previousTab = state.tabHistory .slice() .reverse() - .find((id) => state.tabs.some((t) => t.id === id)) + .find((id) => state.tabs.some((t) => t.id === id)); if (previousTab) { - switchTab(previousTab) + switchTab(previousTab); } else { // Fallback sur le premier onglet disponible (ou vide) - const firstTab = state.tabs[0] + const firstTab = state.tabs[0]; if (firstTab) { - switchTab(firstTab.id) + switchTab(firstTab.id); } else { - state.activeTabId = '' + state.activeTabId = ""; } } } @@ -213,21 +220,21 @@ function closeTab(tabId: string) { * Change l'onglet actif */ function switchTab(tabId: string) { - const tab = findTab(tabId) + const tab = findTab(tabId); if (!tab) { - console.warn(`[useTabs] Onglet ${tabId} introuvable`) - return + console.warn(`[useTabs] Onglet ${tabId} introuvable`); + return; } - state.activeTabId = tabId + state.activeTabId = tabId; // Ajouter à l'historique (en évitant les doublons consécutifs) if (state.tabHistory[state.tabHistory.length - 1] !== tabId) { - state.tabHistory.push(tabId) + state.tabHistory.push(tabId); // Limiter la taille de l'historique if (state.tabHistory.length > 20) { - state.tabHistory.shift() + state.tabHistory.shift(); } } } @@ -236,20 +243,21 @@ function switchTab(tabId: string) { * Onglet suivant (pour swipe gesture) */ function nextTab() { - const currentIndex = state.tabs.findIndex((t) => t.id === state.activeTabId) - const nextIndex = (currentIndex + 1) % state.tabs.length - const nextTabObj = state.tabs[nextIndex] - if (nextTabObj) switchTab(nextTabObj.id) + const currentIndex = state.tabs.findIndex((t) => t.id === state.activeTabId); + const nextIndex = (currentIndex + 1) % state.tabs.length; + const nextTabObj = state.tabs[nextIndex]; + if (nextTabObj) switchTab(nextTabObj.id); } /** * Onglet précédent (pour swipe gesture) */ function previousTab() { - const currentIndex = state.tabs.findIndex((t) => t.id === state.activeTabId) - const previousIndex = currentIndex === 0 ? state.tabs.length - 1 : currentIndex - 1 - const prevTabObj = state.tabs[previousIndex] - if (prevTabObj) switchTab(prevTabObj.id) + const currentIndex = state.tabs.findIndex((t) => t.id === state.activeTabId); + const previousIndex = + currentIndex === 0 ? state.tabs.length - 1 : currentIndex - 1; + const prevTabObj = state.tabs[previousIndex]; + if (prevTabObj) switchTab(prevTabObj.id); } /** @@ -257,56 +265,63 @@ function previousTab() { * Les renderer tabs sont automatiquement créés/supprimés selon l'état online */ function syncWithRenderers(renderers: RendererSummary[]) { - const onlineRenderers = renderers.filter((r) => r.online) + const onlineRenderers = renderers.filter((r) => r.online); // Extraire les tabs renderer actuels - const currentRendererTabs = state.tabs.filter((t) => t.type === 'renderer') + const currentRendererTabs = state.tabs.filter((t) => t.type === "renderer"); // IDs des renderers online - const onlineRendererIds = new Set(onlineRenderers.map((r) => `renderer-${r.id}`)) + const onlineRendererIds = new Set( + onlineRenderers.map((r) => `renderer-${r.id}`), + ); // Supprimer les tabs des renderers qui ne sont plus online - const renderersToRemove = currentRendererTabs.filter((t) => !onlineRendererIds.has(t.id)) + const renderersToRemove = currentRendererTabs.filter( + (t) => !onlineRendererIds.has(t.id), + ); renderersToRemove.forEach((tab) => { - const index = state.tabs.findIndex((t) => t.id === tab.id) - if (index !== -1) state.tabs.splice(index, 1) - state.tabHistory = state.tabHistory.filter((id) => id !== tab.id) - }) + const index = state.tabs.findIndex((t) => t.id === tab.id); + if (index !== -1) state.tabs.splice(index, 1); + state.tabHistory = state.tabHistory.filter((id) => id !== tab.id); + }); // Ajouter les nouveaux renderers online - const currentRendererIds = new Set(currentRendererTabs.map((t) => t.id)) + const currentRendererIds = new Set(currentRendererTabs.map((t) => t.id)); onlineRenderers.forEach((renderer) => { - const tabId = `renderer-${renderer.id}` + const tabId = `renderer-${renderer.id}`; if (!currentRendererIds.has(tabId)) { const newTab: Tab = { id: tabId, - type: 'renderer', + type: "renderer", title: truncateTitle(renderer.friendly_name), fullTitle: renderer.friendly_name, icon: Radio, metadata: { rendererId: renderer.id }, closeable: false, // renderer tabs ne sont pas fermables manuellement - } - state.tabs.unshift(newTab) // Ajouter au début + }; + state.tabs.unshift(newTab); // Ajouter au début } - }) + }); // Vérifier que l'onglet actif existe toujours - if (state.activeTabId && !state.tabs.find((t) => t.id === state.activeTabId)) { + if ( + state.activeTabId && + !state.tabs.find((t) => t.id === state.activeTabId) + ) { // Basculer vers le premier onglet disponible - const firstTab = state.tabs[0] + const firstTab = state.tabs[0]; if (firstTab) { - state.activeTabId = firstTab.id + state.activeTabId = firstTab.id; } else { - state.activeTabId = '' + state.activeTabId = ""; } } // Si aucun onglet actif et qu'il y a des onglets, sélectionner le premier if (!state.activeTabId && state.tabs.length > 0) { - const firstTab = state.tabs[0] + const firstTab = state.tabs[0]; if (firstTab) { - state.activeTabId = firstTab.id + state.activeTabId = firstTab.id; } } } @@ -315,15 +330,15 @@ function syncWithRenderers(renderers: RendererSummary[]) { * Ouvre un onglet server (manuel) */ function openServer(server: MediaServerSummary | undefined) { - if (!server) return '' + if (!server) return ""; return openTab({ - type: 'server', + type: "server", title: server.friendly_name, icon: Server, metadata: { serverId: server.id }, closeable: true, - }) + }); } /** @@ -334,29 +349,45 @@ export function useTabs() { watch( () => [state.tabs, state.activeTabId, state.tabHistory], () => { - saveToLocalStorage() + saveToLocalStorage(); }, { deep: true }, - ) + ); // Restaurer au montage (uniquement les server tabs) onMounted(() => { // Restaurer seulement si pas déjà fait - if (state.tabs.filter((t) => t.type === 'server').length === 0) { - restoreFromLocalStorage() + if (state.tabs.filter((t) => t.type === "server").length === 0) { + restoreFromLocalStorage(); } - }) + }); // Détection de la largeur d'écran pour le mode compact // Mode compact sur les écrans < 900px (tablettes et mobiles) - const isNarrowScreen = useMediaQuery('(max-width: 900px)') + const isNarrowScreen = useMediaQuery("(max-width: 900px)"); + + // Watch activeTabId changes for debugging + watch( + () => state.activeTabId, + (newId, oldId) => { + if (newId !== oldId) { + const newTab = findTab(newId); + const oldTab = findTab(oldId); + console.log( + `[useTabs] activeTabId changed from ${oldId} (${oldTab?.fullTitle}) to ${newId} (${newTab?.fullTitle})`, + ); + } + }, + ); // Computed properties - const activeTab = computed(() => findTab(state.activeTabId) || state.tabs[0] || null) - const hasMultipleTabs = computed(() => state.tabs.length > 1) - const canAddTab = computed(() => state.tabs.length < MAX_TABS) - const isEmpty = computed(() => state.tabs.length === 0) - const compactMode = computed(() => isNarrowScreen.value) + const activeTab = computed( + () => findTab(state.activeTabId) || state.tabs[0] || null, + ); + const hasMultipleTabs = computed(() => state.tabs.length > 1); + const canAddTab = computed(() => state.tabs.length < MAX_TABS); + const isEmpty = computed(() => state.tabs.length === 0); + const compactMode = computed(() => isNarrowScreen.value); return { // State @@ -378,5 +409,5 @@ export function useTabs() { previousTab, openServer, findTab, - } + }; } diff --git a/pmocontrol/src/music_renderer/musicrenderer.rs b/pmocontrol/src/music_renderer/musicrenderer.rs index 34db25ca..b9df8cca 100644 --- a/pmocontrol/src/music_renderer/musicrenderer.rs +++ b/pmocontrol/src/music_renderer/musicrenderer.rs @@ -30,8 +30,8 @@ use crate::music_renderer::openhome_renderer::OpenHomeRenderer; use crate::music_renderer::sleep_timer::SleepTimer; use crate::music_renderer::upnp_renderer::UpnpRenderer; use crate::music_renderer::watcher::{ - WatchStrategy, WatchedState, compute_logical_playback_state, extract_track_metadata, - playback_position_equal, playback_state_equal, + WatchStrategy, WatchedState, extract_track_metadata, playback_position_equal, + playback_state_equal, }; use crate::online::DeviceConnectionState; use crate::queue::{ @@ -541,22 +541,18 @@ impl MusicRenderer { } // Poll state every tick + // Note: Device-specific bugs (like Arylic/LinkPlay reporting STOPPED while playing) + // should be corrected in the backend's playback_state() method, not here. if let Some(raw_state) = raw_state { - let logical_state = compute_logical_playback_state( - &raw_state, - prev_position.as_ref(), - watched.position.as_ref(), - ); - let changed = watched .state .as_ref() - .map(|prev| !playback_state_equal(prev, &logical_state)) + .map(|prev| !playback_state_equal(prev, &raw_state)) .unwrap_or(true); // Emit event only for non-transient states to reduce noise - if changed && !matches!(logical_state, PlaybackState::Transitioning) { - let state_clone = logical_state.clone(); + if changed && !matches!(raw_state, PlaybackState::Transitioning) { + let state_clone = raw_state.clone(); drop(watched); self.emit_event(RendererEvent::StateChanged { id: self.id(), @@ -572,7 +568,7 @@ impl MusicRenderer { .expect("WatchedState mutex poisoned"); } - watched.state = Some(logical_state); + watched.state = Some(raw_state); } // Handle volume/mute updates (polled every other tick) @@ -1014,22 +1010,28 @@ impl MusicRenderer { // Pour les flux continus uniquement : calculer rel_time depuis track_start_time // Pour les fichiers normaux : garder les valeurs du backend + // IMPORTANT: Ne calculer le temps que si le lecteur est en PLAYING let is_stream = self.is_playing_a_stream(); if is_stream { - if let Some(start_time) = self.track_start_time() { - if let Ok(elapsed) = start_time.elapsed() { - let secs = elapsed.as_secs() as u32; - let hours = secs / 3600; - let minutes = (secs % 3600) / 60; - let seconds = secs % 60; - let new_rel_time = format!("{:02}:{:02}:{:02}", hours, minutes, seconds); - tracing::debug!( - "MusicRenderer [{}]: Stream - calculating rel_time from track_start_time: {} (elapsed={}s)", - self.info.friendly_name(), - new_rel_time, - secs - ); - position_info.rel_time = Some(new_rel_time); + // Vérifier que le lecteur est en lecture avant de calculer le temps écoulé + let is_playing = matches!(self.playback_state().ok(), Some(PlaybackState::Playing)); + + if is_playing { + if let Some(start_time) = self.track_start_time() { + if let Ok(elapsed) = start_time.elapsed() { + let secs = elapsed.as_secs() as u32; + let hours = secs / 3600; + let minutes = (secs % 3600) / 60; + let seconds = secs % 60; + let new_rel_time = format!("{:02}:{:02}:{:02}", hours, minutes, seconds); + tracing::debug!( + "MusicRenderer [{}]: Stream - calculating rel_time from track_start_time: {} (elapsed={}s)", + self.info.friendly_name(), + new_rel_time, + secs + ); + position_info.rel_time = Some(new_rel_time); + } } } } diff --git a/pmocontrol/src/music_renderer/watcher.rs b/pmocontrol/src/music_renderer/watcher.rs index 83a63bc1..30b2a523 100644 --- a/pmocontrol/src/music_renderer/watcher.rs +++ b/pmocontrol/src/music_renderer/watcher.rs @@ -117,60 +117,6 @@ pub fn playback_position_equal(a: &PlaybackPositionInfo, b: &PlaybackPositionInf && a.track_uri == b.track_uri } -/// Compute a logical playback state by combining the raw AVTransport state -/// with previous and current position information. -/// -/// This is designed to compensate for buggy LinkPlay/Arylic devices that -/// report: -/// - STOPPED while the time actually advances, -/// - NO_MEDIA_PRESENT while track duration is known. -pub fn compute_logical_playback_state( - raw: &PlaybackState, - prev_position: Option<&PlaybackPositionInfo>, - current_position: Option<&PlaybackPositionInfo>, -) -> PlaybackState { - // Rule 1: Arylic / LinkPlay sometimes report STOPPED while the stream is - // actually playing. If we detect that the relative time advances between - // two polls, we treat this as Playing. - if let PlaybackState::Stopped = raw { - if let (Some(prev), Some(curr)) = (prev_position, current_position) { - if let (Some(prev_rel), Some(curr_rel)) = ( - parse_optional_hms_to_secs(&prev.rel_time), - parse_optional_hms_to_secs(&curr.rel_time), - ) { - if curr_rel > prev_rel { - let delta = curr_rel - prev_rel; - // Our poll loop runs every 500ms; accept small jitter in the delta. - if delta <= 5 { - return PlaybackState::Playing; - } - } - } - } - } - - // Rule 2: Some devices report NO_MEDIA_PRESENT while exposing a non-zero - // track duration. In practice this behaves like a stopped transport with - // a loaded track. - if let PlaybackState::NoMedia = raw { - let duration_secs = current_position - .and_then(|p| parse_optional_hms_to_secs(&p.track_duration)) - .or_else(|| prev_position.and_then(|p| parse_optional_hms_to_secs(&p.track_duration))); - - if matches!(duration_secs, Some(d) if d > 0) { - return PlaybackState::Stopped; - } - } - - // Fallback: keep the raw (already normalized) state. - raw.clone() -} - -/// Parse an optional HH:MM:SS time string to seconds. -fn parse_optional_hms_to_secs(value: &Option) -> Option { - value.as_ref().and_then(|s| parse_hms_to_secs(s)) -} - /// Parse "HH:MM:SS" style time strings to seconds. /// /// Returns None for empty or sentinel values such as "NOT_IMPLEMENTED" or "-:--:--". diff --git a/pmocontrol/src/queue/openhome.rs b/pmocontrol/src/queue/openhome.rs index f8f8c9dc..fde596b1 100644 --- a/pmocontrol/src/queue/openhome.rs +++ b/pmocontrol/src/queue/openhome.rs @@ -125,7 +125,7 @@ pub struct OpenHomeQueue { /// 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. - metadata_cache: HashMap>, + metadata_cache: Mutex>>, /// Cache for track IDs to avoid redundant IdArray SOAP calls track_ids_cache: Arc>, /// Cache for current track ID to avoid redundant Id SOAP calls @@ -144,7 +144,7 @@ impl OpenHomeQueue { playlist_client: playlist, info_client, product_client, - metadata_cache: HashMap::new(), + metadata_cache: Mutex::new(HashMap::new()), track_ids_cache: Arc::new(Mutex::new(TrackIdsCache::new())), current_track_id_cache: Arc::new(Mutex::new(CurrentTrackIdCache::new())), } @@ -190,17 +190,130 @@ impl OpenHomeQueue { metadata: Option, ) -> Result<(), ControlPointError> { let track_id = self.position_to_id(index)?; - self.metadata_cache.insert(track_id, metadata); + self.cache_metadata(track_id, metadata); Ok(()) } + /// Insère ou met à jour les métadonnées dans le cache. + /// RÈGLE: Pour une même chanson (même titre ET même artiste), la durée ne peut jamais diminuer. + /// Si le titre ou l'artiste change, c'est une nouvelle chanson donc toute durée est acceptée. + /// Cette fonction est le SEUL point d'entrée pour modifier le cache. + fn cache_metadata(&self, track_id: u32, new_metadata: Option) { + let mut cache = self.metadata_cache.lock().unwrap(); + + // Vérifier s'il y a déjà des métadonnées en cache + if let Some(cached_meta) = cache.get(&track_id) { + // Vérifier si c'est la même chanson (titre ET artiste identiques) + 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()); + + let same_track = same_title && same_artist; + + 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)) => { + // Parser les durées (format HH:MM:SS) + let parse_duration = |dur: &str| -> Option { + let parts: Vec<&str> = dur.split(':').collect(); + if parts.len() == 3 { + let h: u32 = parts[0].parse().ok()?; + let m: u32 = parts[1].parse().ok()?; + let s: u32 = parts[2].parse().ok()?; + Some(h * 3600 + m * 60 + s) + } else { + None + } + }; + + if let (Some(cached_secs), Some(new_secs)) = + (parse_duration(cached_dur), parse_duration(new_dur)) + { + if new_secs < cached_secs { + // Durée a diminué pour la même chanson: refuser + tracing::trace!( + "OpenHome cache_metadata: track_id={}, REJECTING update (same track, duration decreased): {} -> {}", + track_id, + cached_dur, + new_dur + ); + false + } else { + // Durée a augmenté ou est égale: accepter + if new_secs > cached_secs { + tracing::debug!( + "OpenHome cache_metadata: track_id={}, same track, duration increased: {} -> {}", + track_id, + cached_dur, + new_dur + ); + } + true + } + } else { + // Impossible de parser: accepter par défaut + true + } + } + _ => true, // Pas de durée ou une seule des deux: accepter + }; + + if should_update { + cache.insert(track_id, new_metadata); + } + } else { + // Chanson différente: accepter sans vérification + tracing::debug!( + "OpenHome cache_metadata: track_id={}, different track (title or artist changed), accepting update", + track_id + ); + cache.insert(track_id, new_metadata); + } + } else { + // Pas dans le cache: insérer directement + tracing::trace!( + "OpenHome cache_metadata: track_id={}, inserting first time, duration={:?}", + track_id, + new_metadata.as_ref().and_then(|m| m.duration.as_ref()) + ); + cache.insert(track_id, new_metadata); + } + } + fn playback_item_from_entry(&self, entry: &OhTrackEntry) -> PlaybackItem { - // Utiliser les métadonnées du cache si disponibles, sinon celles de l'entrée - let metadata = self - .metadata_cache - .get(&entry.id) - .cloned() - .unwrap_or_else(|| entry.metadata()); + // 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 + // Mettre en cache pour éviter les oscillations sur les flux radio + self.cache_metadata(entry.id, fresh.clone()); + fresh + } + }; let didl_id = entry .didl_id() @@ -228,7 +341,7 @@ impl OpenHomeQueue { .insert(after_id, &item.uri, &metadata_xml)?; // Enregistrer les métadonnées dans le cache - self.metadata_cache.insert(new_id, item.metadata); + self.cache_metadata(new_id, item.metadata); Ok(new_id) } @@ -250,7 +363,7 @@ impl OpenHomeQueue { for &track_id in current_track_ids.iter().rev() { if track_id as usize != playing_id { self.playlist_client.delete_id_if_exists(track_id)?; - self.metadata_cache.remove(&track_id); + self.metadata_cache.lock().unwrap().remove(&track_id); } } @@ -263,7 +376,7 @@ impl OpenHomeQueue { .insert(previous_id, &item.uri, &metadata)?; // Enregistrer les métadonnées dans le cache - self.metadata_cache.insert(new_id, item.metadata); + self.cache_metadata(new_id, item.metadata); previous_id = new_id; } @@ -296,7 +409,7 @@ impl OpenHomeQueue { track_id ); self.playlist_client.delete_id_if_exists(track_id)?; - self.metadata_cache.remove(&track_id); + self.metadata_cache.lock().unwrap().remove(&track_id); } } Ok(()) @@ -329,8 +442,7 @@ impl OpenHomeQueue { previous_id = existing_id; // Mettre à jour les métadonnées de l'item existant conservé - self.metadata_cache - .insert(existing_id, item.metadata.clone()); + self.cache_metadata(existing_id, item.metadata.clone()); debug!( renderer = self.renderer_id.0.as_str(), @@ -346,7 +458,7 @@ impl OpenHomeQueue { .insert(previous_id, &item.uri, &metadata)?; // Enregistrer les métadonnées du nouvel item - self.metadata_cache.insert(new_id, item.metadata.clone()); + self.cache_metadata(new_id, item.metadata.clone()); debug!( renderer = self.renderer_id.0.as_str(), @@ -424,8 +536,7 @@ impl OpenHomeQueue { let previous_id = pivot_id as u32; // Mettre à jour les métadonnées du pivot - self.metadata_cache - .insert(previous_id, new_items[pivot_idx_new].metadata.clone()); + self.cache_metadata(previous_id, new_items[pivot_idx_new].metadata.clone()); debug!( renderer = self.renderer_id.0.as_str(), @@ -492,7 +603,7 @@ impl OpenHomeQueue { "Using delete_all() for complete replacement (more robust for live playlists)" ); self.playlist_client.delete_all()?; - self.metadata_cache.clear(); + self.metadata_cache.lock().unwrap().clear(); } else { // Selective deletion when keeping some items for idx in (0..current_track_ids.len()).rev() { @@ -501,7 +612,7 @@ impl OpenHomeQueue { // Use delete_id_if_exists() to handle cases where another control point // may have already modified the playlist self.playlist_client.delete_id_if_exists(track_id)?; - self.metadata_cache.remove(&track_id); + self.metadata_cache.lock().unwrap().remove(&track_id); } } } @@ -534,7 +645,8 @@ impl OpenHomeQueue { previous_id = existing_id; // Mettre à jour les métadonnées de l'item existant conservé - self.metadata_cache.insert(existing_id, item.metadata); + // La fonction cache_metadata gère la protection contre la diminution de durée + self.cache_metadata(existing_id, item.metadata); } else { let metadata = build_metadata_xml(&item); let new_id = self @@ -542,7 +654,7 @@ impl OpenHomeQueue { .insert(previous_id, &item.uri, &metadata)?; // Enregistrer les métadonnées du nouvel item - self.metadata_cache.insert(new_id, item.metadata); + self.cache_metadata(new_id, item.metadata); previous_id = new_id; } @@ -761,6 +873,7 @@ impl QueueBackend for OpenHomeQueue { } // Read metadata for all tracks (batched) + // playback_item_from_entry() will prioritize cached metadata over entry metadata const MAX_BATCH: usize = 64; let mut entries = Vec::with_capacity(ids.len()); for chunk in ids.chunks(MAX_BATCH) { @@ -837,7 +950,7 @@ impl QueueBackend for OpenHomeQueue { self.ensure_playlist_source_selected()?; self.playlist_client.delete_all()?; - self.metadata_cache.clear(); + self.metadata_cache.lock().unwrap().clear(); // Invalidate caches after delete_all (clears queue and current track) self.track_ids_cache.lock().unwrap().invalidate(); @@ -856,7 +969,7 @@ impl QueueBackend for OpenHomeQueue { .insert(previous_id, &item.uri, &metadata)?; // Enregistrer les métadonnées dans le cache - self.metadata_cache.insert(new_id, item.metadata); + self.cache_metadata(new_id, item.metadata); previous_id = new_id; } @@ -871,7 +984,7 @@ impl QueueBackend for OpenHomeQueue { self.ensure_playlist_source_selected()?; if items.is_empty() { self.playlist_client.delete_all()?; - self.metadata_cache.clear(); + self.metadata_cache.lock().unwrap().clear(); // Invalidate caches after delete_all (clears queue and current track) self.track_ids_cache.lock().unwrap().invalidate(); self.current_track_id_cache.lock().unwrap().invalidate(); @@ -1002,8 +1115,8 @@ impl QueueBackend for OpenHomeQueue { .insert(before_id, &item.uri, &metadata)?; // Mettre à jour le cache avec les nouvelles métadonnées - self.metadata_cache.remove(&track_id); - self.metadata_cache.insert(new_id, item.metadata); + self.metadata_cache.lock().unwrap().remove(&track_id); + self.cache_metadata(new_id, item.metadata); if ci == Some(index) { self.playlist_client.seek_id(new_id)?; diff --git a/pmocontrol/src/queue/openhome.rs.bak b/pmocontrol/src/queue/openhome.rs.bak new file mode 100644 index 00000000..2ed9f309 --- /dev/null +++ b/pmocontrol/src/queue/openhome.rs.bak @@ -0,0 +1,1193 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::SystemTime; +use std::usize; + +use quick_xml::escape::escape; +use tracing::{debug, trace, warn}; + +use crate::errors::ControlPointError; +use crate::upnp_clients::{ + OPENHOME_PLAYLIST_HEAD_ID, OhInfoClient, OhPlaylistClient, OhProductClient, OhTrack, + OhTrackEntry, +}; +// use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack}; +use crate::queue::{ + EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueFromRendererInfo, QueueSnapshot, +}; +use crate::{DeviceId, DeviceIdentity, RendererInfo}; + +/// Cache for OpenHome track IDs to avoid redundant SOAP calls +#[derive(Debug)] +struct TrackIdsCache { + /// Cached track IDs + ids: Option>, + /// Timestamp of last cache update + last_update: Option, +} + +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> { + if self.is_valid() { + self.ids.clone() + } else { + None + } + } + + /// Update cache with new IDs + fn set(&mut self, ids: Vec) { + 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; + } +} + +/// 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>, + /// Timestamp of last cache update + last_update: Option, +} + +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> { + if self.is_valid() { + self.current_id + } else { + None + } + } + + /// Update cache with new current track ID + fn set(&mut self, id: Option) { + 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; + } +} + +/// Local mirror of an OpenHome playlist for a single renderer. +#[derive(Debug)] +pub struct OpenHomeQueue { + renderer_id: DeviceId, + playlist_client: OhPlaylistClient, + info_client: Option, + product_client: Option, + /// 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. + metadata_cache: Mutex>>, + /// Cache for track IDs to avoid redundant IdArray SOAP calls + track_ids_cache: Arc>, + /// Cache for current track ID to avoid redundant Id SOAP calls + current_track_id_cache: Arc>, +} + +impl OpenHomeQueue { + pub fn new( + renderer_id: DeviceId, + playlist: OhPlaylistClient, + info_client: Option, + product_client: Option, + ) -> Self { + Self { + renderer_id, + playlist_client: playlist, + info_client, + product_client, + metadata_cache: Mutex::new(HashMap::new()), + track_ids_cache: Arc::new(Mutex::new(TrackIdsCache::new())), + current_track_id_cache: Arc::new(Mutex::new(CurrentTrackIdCache::new())), + } + } + + pub fn from_renderer_info(info: &RendererInfo) -> Result { + 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> { + if let Some(product) = &self.product_client { + product.ensure_playlist_source_selected() + } else { + Ok(()) + } + } + + /// 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, + ) -> Result<(), ControlPointError> { + let track_id = self.position_to_id(index)?; + self.metadata_cache + .lock() + .unwrap() + .insert(track_id, metadata); + Ok(()) + } + + fn playback_item_from_entry(&mut self, entry: &OhTrackEntry) -> PlaybackItem { + // 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 mut 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()) + ); + // Mettre en cache pour éviter les oscillations sur les flux radio + cache.insert(entry.id, fresh.clone()); + fresh + } + }; + + let didl_id = entry + .didl_id() + .unwrap_or_else(|| format!("openhome:{}", entry.id)); + PlaybackItem { + media_server_id: DeviceId(format!("openhome:{}", self.renderer_id.0)), + backend_id: entry.id as usize, + didl_id, + uri: entry.uri().to_string(), + // OpenHome tracks don't provide protocolInfo, use generic default + protocol_info: "http-get:*:audio/*:*".to_string(), + metadata, + } + } + + fn add_playback_item( + &mut self, + item: PlaybackItem, + after_id: u32, + ) -> Result { + 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)?; + + // Enregistrer les métadonnées dans le cache + self.metadata_cache.insert(new_id, item.metadata); + + Ok(new_id) + } + + /// 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, + playing_id: usize, + ) -> Result<(), ControlPointError> { + // Get current track IDs from OpenHome + let current_track_ids = self.track_ids()?; + + // Delete everything except the currently playing item + // Using delete_id_if_exists() to handle cases where another control point + // may have already modified the playlist + for &track_id in current_track_ids.iter().rev() { + if track_id as usize != playing_id { + self.playlist_client.delete_id_if_exists(track_id)?; + self.metadata_cache.remove(&track_id); + } + } + + // Insert new items after the currently playing track + let mut previous_id = playing_id as u32; + for item in new_items { + let metadata = build_metadata_xml(&item); + let new_id = self + .playlist_client + .insert(previous_id, &item.uri, &metadata)?; + + // Enregistrer les métadonnées dans le cache + self.metadata_cache.insert(new_id, item.metadata); + + previous_id = new_id; + } + + debug!( + renderer = self.renderer_id.0.as_str(), + "Gentle sync completed: preserved playing track as first item (not in new playlist)" + ); + + // Invalidate cache after playlist modifications + self.track_ids_cache.lock().unwrap().invalidate(); + + Ok(()) + } + + /// Helper: Delete items marked for deletion in reverse order with logging. + fn delete_marked_items( + &mut self, + old_ids: &[u32], + keep_flags: &[bool], + position_label: &str, + ) -> Result<(), ControlPointError> { + for (idx, &track_id) in old_ids.iter().enumerate().rev() { + if !keep_flags[idx] { + debug!( + renderer = self.renderer_id.0.as_str(), + track_id, + position = position_label, + "RENDERER OP: DeleteId({})", + track_id + ); + self.playlist_client.delete_id_if_exists(track_id)?; + self.metadata_cache.remove(&track_id); + } + } + Ok(()) + } + + /// 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, + ) -> Result { + // Collect IDs of kept items (in order) + let remaining_ids: Vec = old_ids + .iter() + .enumerate() + .filter_map(|(idx, &id)| if keep_old_flags[idx] { Some(id) } else { None }) + .collect(); + + let mut remaining_idx = 0; + + // Rebuild section + for (idx, item) in new_items.iter().enumerate() { + if keep_new_flags[idx] { + let existing_id = remaining_ids[remaining_idx]; + remaining_idx += 1; + previous_id = existing_id; + + // Mettre à jour les métadonnées de l'item existant conservé + self.metadata_cache + .insert(existing_id, item.metadata.clone()); + + debug!( + renderer = self.renderer_id.0.as_str(), + track_id = existing_id, + position = position_label, + "KEPT existing track ID {}", + existing_id + ); + } else { + let metadata = build_metadata_xml(item); + let new_id = self + .playlist_client + .insert(previous_id, &item.uri, &metadata)?; + + // Enregistrer les métadonnées du nouvel item + self.metadata_cache.insert(new_id, item.metadata.clone()); + + debug!( + renderer = self.renderer_id.0.as_str(), + after_id = previous_id, + new_id, + position = position_label, + "RENDERER OP: Insert(after={}) -> new_id={}", + previous_id, + new_id + ); + previous_id = new_id; + } + } + + 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, + pivot_idx_new: usize, + pivot_id: usize, + ) -> Result<(), ControlPointError> { + // Get current state from OpenHome + let snapshot = self.queue_snapshot()?; + let current_track_ids = self.track_ids()?; + + // 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 = snapshot.items[..pivot_idx].to_vec(); + let old_after: Vec = snapshot.items[pivot_idx + 1..].to_vec(); + let old_ids_before: Vec = current_track_ids[..pivot_idx].to_vec(); + let old_ids_after: Vec = 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) + 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) + self.delete_marked_items(&old_ids_after, &keep_old_after, "AFTER pivot")?; + + // Delete items marked for deletion in BEFORE part (reverse order) + self.delete_marked_items(&old_ids_before, &keep_old_before, "BEFORE pivot")?; + + // Rebuild the playlist: [BEFORE, PIVOT, AFTER] + // Rebuild BEFORE part (we don't need the returned previous_id) + self.rebuild_playlist_section( + new_before, + &keep_new_before, + &old_ids_before, + &keep_old_before, + OPENHOME_PLAYLIST_HEAD_ID, + "BEFORE pivot", + )?; + + // PIVOT keeps its ID and position - it's the anchor point + let previous_id = pivot_id as u32; + + // Mettre à jour les métadonnées du pivot + self.metadata_cache + .insert(previous_id, new_items[pivot_idx_new].metadata.clone()); + + 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 + self.rebuild_playlist_section( + new_after, + &keep_new_after, + &old_ids_after, + &keep_old_after, + previous_id, + "AFTER pivot", + )?; + + 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)" + ); + + // Invalidate cache after playlist modifications + self.track_ids_cache.lock().unwrap().invalidate(); + + Ok(()) + } + + /// Standard LCS-based replacement (used when no currently playing item). + fn replace_queue_standard_lcs( + &mut self, + items: Vec, + _current_index: Option, + ) -> Result<(), ControlPointError> { + // Get current state from OpenHome + let snapshot = self.queue_snapshot()?; + let current_track_ids = self.track_ids()?; + + let (keep_current, keep_desired) = lcs_flags(&snapshot.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_client.delete_all()?; + self.metadata_cache.clear(); + } else { + // Selective deletion when keeping some items + for idx in (0..current_track_ids.len()).rev() { + if !keep_current[idx] { + let track_id = current_track_ids[idx]; + // Use delete_id_if_exists() to handle cases where another control point + // may have already modified the playlist + self.playlist_client.delete_id_if_exists(track_id)?; + self.metadata_cache.remove(&track_id); + } + } + } + + // Rebuild by inserting new items + let remaining_ids: Vec = current_track_ids + .iter() + .enumerate() + .filter_map(|(idx, &id)| { + if keep_current.get(idx).copied().unwrap_or(false) { + Some(id) + } else { + None + } + }) + .collect(); + + let mut remaining_idx = 0usize; + let mut previous_id = OPENHOME_PLAYLIST_HEAD_ID; + + for (idx, item) in items.into_iter().enumerate() { + if keep_desired[idx] { + if remaining_idx >= remaining_ids.len() { + return Err(ControlPointError::OpenHomeError(format!( + "OpenHome playlist refresh bookkeeping mismatch (kept entries underflow)" + ))); + } + let existing_id = remaining_ids[remaining_idx]; + remaining_idx += 1; + previous_id = existing_id; + + // Mettre à jour les métadonnées de l'item existant conservé + self.metadata_cache.insert(existing_id, item.metadata); + } else { + let metadata = build_metadata_xml(&item); + let new_id = self + .playlist_client + .insert(previous_id, &item.uri, &metadata)?; + + // Enregistrer les métadonnées du nouvel item + self.metadata_cache.insert(new_id, item.metadata); + + previous_id = new_id; + } + } + + if remaining_idx != remaining_ids.len() { + return Err(ControlPointError::OpenHomeError(format!( + "OpenHome playlist refresh bookkeeping mismatch (kept entries overflow)" + ))); + } + + // Invalidate cache after playlist modifications + self.track_ids_cache.lock().unwrap().invalidate(); + + Ok(()) + } +} + +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#""#, + ); + xml.push_str(&format!( + r#""#, + escaped_id + )); + xml.push_str(&format!("{}", escaped_title)); + + if let Some(meta) = &item.metadata { + if let Some(artist) = meta.artist.as_deref() { + let escaped = escape(artist); + xml.push_str(&format!("{}", escaped)); + xml.push_str(&format!("{}", escaped)); + } + if let Some(album) = meta.album.as_deref() { + let escaped = escape(album); + xml.push_str(&format!("{}", escaped)); + } + if let Some(genre) = meta.genre.as_deref() { + let escaped = escape(genre); + xml.push_str(&format!("{}", escaped)); + } + if let Some(uri) = meta.album_art_uri.as_deref() { + let escaped = escape(uri); + xml.push_str(&format!("{}", escaped)); + } + if let Some(date) = meta.date.as_deref() { + let escaped = escape(date); + xml.push_str(&format!("{}", escaped)); + } + if let Some(track_no) = meta.track_number.as_deref() { + let escaped = escape(track_no); + xml.push_str(&format!( + "{}", + escaped + )); + } + } + + let escaped_protocol_info = escape(item.protocol_info.as_str()); + + // Build element with optional duration attribute + xml.push_str(&format!(r#"{}"#, escaped_uri)); + xml.push_str(r#"object.item.audioItem.musicTrack"#); + xml +} + +/// 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 +} + +fn lcs_flags(current: &[PlaybackItem], desired: &[PlaybackItem]) -> (Vec, Vec) { + 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 items_match(¤t[i], &desired[j]) { + 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 items_match(¤t[i - 1], &desired[j - 1]) { + 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) +} + +impl QueueBackend for OpenHomeQueue { + fn len(&self) -> Result { + Ok(self.track_ids()?.len()) + } + + /// Return the list of OpenHome track IDs in order. + fn track_ids(&self) -> Result, ControlPointError> { + self.ensure_playlist_source_selected()?; + + // 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()?; + + // Update cache before releasing lock + cache.set(ids.clone()); + + Ok(ids) + } + + fn id_to_position(&self, id: u32) -> Result { + 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 { + 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, ControlPointError> { + // 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 + let id = self.playlist_client.id()?; + // OpenHome returns 0 when no track is selected/playing + let result = if id == 0 { None } else { Some(id) }; + + // Update cache + cache.set(result); + + Ok(result) + } + + fn current_index(&self) -> Result, ControlPointError> { + if let Some(id) = self.current_track()? { + return Ok(Some(self.id_to_position(id)?)); + } + + Ok(None) + } + + fn queue_snapshot(&self) -> Result { + self.ensure_playlist_source_selected()?; + + // 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, + }); + } + + // Read metadata for all tracks (batched) + // playback_item_from_entry() will prioritize cached metadata over entry metadata + const MAX_BATCH: usize = 64; + let mut entries = Vec::with_capacity(ids.len()); + for chunk in ids.chunks(MAX_BATCH) { + match self.playlist_client.read_list(chunk) { + Ok(mut batch) => entries.append(&mut batch), + 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); + } + } + } + } + + 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 + let current_index = current_id.and_then(|id| { + items + .iter() + .position(|entry_id| entry_id.backend_id == id as usize) + }); + + Ok(QueueSnapshot { + items: items, + current_index: current_index, + playlist_id: None, + }) + } + + fn set_index(&mut self, index: Option) -> Result<(), ControlPointError> { + if let Some(index) = index { + let track_id = self.position_to_id(index)?; + self.ensure_playlist_source_selected()?; + self.playlist_client.seek_id(track_id)?; + } else { + self.ensure_playlist_source_selected()?; + self.playlist_client.stop()?; + } + // Invalidate caches (seek_id/stop modifies playlist state and current track) + self.track_ids_cache.lock().unwrap().invalidate(); + self.current_track_id_cache.lock().unwrap().invalidate(); + Ok(()) + } + + fn replace_queue( + &mut self, + items: Vec, + current_index: Option, + ) -> Result<(), ControlPointError> { + 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()?; + self.metadata_cache.clear(); + + // Invalidate caches after delete_all (clears queue and current track) + self.track_ids_cache.lock().unwrap().invalidate(); + self.current_track_id_cache.lock().unwrap().invalidate(); + + 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)?; + + // Enregistrer les métadonnées dans le cache + self.metadata_cache.insert(new_id, item.metadata); + + previous_id = new_id; + } + + // Invalidate cache after insertions + self.track_ids_cache.lock().unwrap().invalidate(); + + Ok(()) + } + + fn sync_queue(&mut self, items: Vec) -> Result<(), ControlPointError> { + self.ensure_playlist_source_selected()?; + if items.is_empty() { + self.playlist_client.delete_all()?; + self.metadata_cache.clear(); + // Invalidate caches after delete_all (clears queue and current track) + self.track_ids_cache.lock().unwrap().invalidate(); + self.current_track_id_cache.lock().unwrap().invalidate(); + return Ok(()); + } + + // 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. + let snapshot = self.queue_snapshot()?; + // Note: current_index may point to an index that doesn't exist in items + // if the OpenHome renderer is in an inconsistent state (e.g., IdArray returns + // IDs but ReadList returns empty TrackList). We must bounds-check here. + let playing_info = snapshot.current_index.and_then(|idx| { + 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 + } + }); + + debug!( + renderer = self.renderer_id.0.as_str(), + actual_items = snapshot.items.len(), + playing_info_detected = playing_info.is_some(), + "OpenHome playlist state" + ); + + if let Some((playing_idx, playing_id, playing_uri, playing_didl_id)) = playing_info { + // Find if the currently playing item is in the new playlist (by URI first, then by didl_id) + let new_playing_idx = items + .iter() + .position(|item| item.uri == playing_uri) + .or_else(|| { + items + .iter() + .position(|item| item.didl_id == playing_didl_id) + }); + + 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_id)?; + } + } else { + // No currently playing item or can't determine it - use standard LCS + debug!( + renderer = self.renderer_id.0.as_str(), + "No currently playing item, using standard LCS sync" + ); + self.replace_queue_standard_lcs(items, Some(0))?; + } + + Ok(()) + } + + fn get_item(&self, index: usize) -> Result, ControlPointError> { + 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() + ))) + } + + fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> { + 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() + ))); + } + + self.ensure_playlist_source_selected()?; + + let track_id = ids[index]; + let before_id = if index == 0 { + OPENHOME_PLAYLIST_HEAD_ID + } else { + ids[index - 1] + }; + + let ci = self.current_index()?; + // Use delete_id_if_exists() to handle cases where another control point + // may have already modified the playlist + self.playlist_client.delete_id(track_id)?; + let new_id = self + .playlist_client + .insert(before_id, &item.uri, &metadata)?; + + // Mettre à jour le cache avec les nouvelles métadonnées + self.metadata_cache.remove(&track_id); + self.metadata_cache.insert(new_id, item.metadata); + + if ci == Some(index) { + self.playlist_client.seek_id(new_id)?; + } + + // Invalidate cache after playlist modifications + self.track_ids_cache.lock().unwrap().invalidate(); + + Ok(()) + } + + /// Override enqueue_items to add items directly to the OpenHome playlist. + fn enqueue_items( + &mut self, + items: Vec, + mode: EnqueueMode, + ) -> Result<(), ControlPointError> { + if items.is_empty() { + return Ok(()); + } + + match mode { + EnqueueMode::AppendToEnd => { + let ids = self.track_ids()?; + let mut after_id = if ids.len() > 0 { + ids[ids.len() - 1] + } else { + OPENHOME_PLAYLIST_HEAD_ID + }; + // Append to the end of the OpenHome playlist + for item in items { + after_id = self.add_playback_item(item, after_id)?; + } + } + EnqueueMode::InsertAfterCurrent => { + if let Some(mut after_id) = self.current_track()? { + for item in items { + after_id = self.add_playback_item(item, after_id)?; + } + } else { + self.enqueue_items(items, EnqueueMode::AppendToEnd)?; + } + } + EnqueueMode::ReplaceAll => { + // Replace the entire playlist + self.replace_queue(items, None)?; + return Ok(()); + } + } + + // Invalidate cache after playlist modifications (except ReplaceAll which already does it) + self.track_ids_cache.lock().unwrap().invalidate(); + + 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()?; + self.playlist_client.delete_all()?; + // Invalidate caches after clearing playlist (clears queue and current track) + self.track_ids_cache.lock().unwrap().invalidate(); + self.current_track_id_cache.lock().unwrap().invalidate(); + Ok(()) + } + + /// Optimized is_empty: only fetch track IDs, not the full playlist. + fn is_empty(&self) -> Result { + Ok(self.track_ids()?.is_empty()) + } + + /// Optimized upcoming_len: calculate from len() and current_index() without fetching items. + fn upcoming_len(&self) -> Result { + 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. + fn peek_current(&mut self) -> Result, ControlPointError> { + 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, 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) -> 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))?; + } + + Ok(()) + } +} + +impl QueueFromRendererInfo for OpenHomeQueue { + fn from_renderer_info(renderer: &RendererInfo) -> Result { + OpenHomeQueue::from_renderer_info(renderer) + } + + fn to_backend(self) -> MusicQueue { + MusicQueue::OpenHome(self) + } +}