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) + } +}