diff --git a/pmoapp/src/pmoserver_impl.rs b/pmoapp/src/pmoserver_impl.rs index d66f4d31..bbbbdd41 100644 --- a/pmoapp/src/pmoserver_impl.rs +++ b/pmoapp/src/pmoserver_impl.rs @@ -36,18 +36,44 @@ impl WebAppExt for Server { where W: RustEmbed + Clone + Send + Sync + 'static, { - let path = path.to_string(); - - self.add_spa::(&path).await; + let mount_path = normalize_mount_path(path); + mount_spa_with_trailing_slash_redirect::(self, &mount_path).await; } async fn add_webapp_with_redirect(&mut self, path: &str) where W: RustEmbed + Clone + Send + Sync + 'static, { - let path = path.to_string(); + let mount_path = normalize_mount_path(path); - self.add_spa::(&path).await; - self.add_redirect("/", &path).await; + mount_spa_with_trailing_slash_redirect::(self, &mount_path).await; + self.add_redirect("/", &mount_path).await; + } +} + +/// S'assure que les chemins SPA sont cohérents : `"/app"` devient `"/app"`, +/// tandis que `"/"` reste tel quel. Les espaces ou slashs multiples sont +/// nettoyés pour éviter des routes dupliquées. +fn normalize_mount_path(path: &str) -> String { + let trimmed = path.trim(); + + if trimmed.is_empty() || trimmed == "/" { + "/".to_string() + } else { + format!("/{}", trimmed.trim_matches('/')) + } +} + +/// Monte la SPA et ajoute automatiquement une redirection `"/app/" -> "/app"` +/// afin que les URLs avec slash final servent également l'application. +async fn mount_spa_with_trailing_slash_redirect(server: &mut Server, path: &str) +where + W: RustEmbed + Clone + Send + Sync + 'static, +{ + server.add_spa::(path).await; + + if path != "/" { + let trailing = format!("{}/", path.trim_end_matches('/')); + server.add_redirect(&trailing, path).await; } } diff --git a/pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue b/pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue index af487cd2..10af54c8 100644 --- a/pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue +++ b/pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue @@ -26,7 +26,6 @@ const { addToQueue, attachAndPlayPlaylist, attachPlaylist, - fetchQueue } = useRenderers() const uiStore = useUIStore() @@ -127,9 +126,7 @@ async function handlePlayContainer(containerId: string, rendererId: string) { async function handleQueueContainer(containerId: string, rendererId: string) { try { - // Attacher la playlist (sans démarrer la lecture) await attachPlaylist(rendererId, props.serverId, containerId) - await fetchQueue(rendererId, true) uiStore.notifySuccess('Playlist attachée à la queue !') } catch (err) { const message = err instanceof Error ? err.message : 'Erreur inconnue' diff --git a/pmoapp/webapp/src/composables/useRenderers.ts b/pmoapp/webapp/src/composables/useRenderers.ts index 9cc00e42..ca6e715c 100644 --- a/pmoapp/webapp/src/composables/useRenderers.ts +++ b/pmoapp/webapp/src/composables/useRenderers.ts @@ -1,292 +1,256 @@ /** - * Composable pour gérer les renderers - * Architecture simple : l'API est la source de vérité, SSE déclenche des re-fetch + * Composable pour gérer les renderers. + * Le ControlPoint est la seule source de vérité : + * - Les snapshots complets proviennent de /renderers/{id}/full + * - Les événements SSE ne servent qu'à déclencher un refetch. */ -import { ref, computed, type Ref } from 'vue' +import { ref, reactive, computed, type Ref } from 'vue' import { api } from '../services/pmocontrol/api' import { sse } from '../services/pmocontrol/sse' import type { RendererSummary, RendererState, QueueSnapshot, - AttachedPlaylistInfo + AttachedPlaylistInfo, + FullRendererSnapshot, } from '../services/pmocontrol/types' -// Cache global partagé entre toutes les instances du composable -const renderersCache = ref>(new Map()) -const statesCache = ref>(new Map()) -const queuesCache = ref>(new Map()) -const bindingsCache = ref>(new Map()) - -// Timestamps pour éviter les re-fetch trop fréquents -const lastFetch = { - renderers: 0, - states: new Map(), - queues: new Map(), - bindings: new Map() +interface RendererSnapshotState { + snapshots: Map + lastSnapshotAt: Map + lastEventAt: Map + loadingIds: Set + selectedRendererId: string | null } -const CACHE_DURATION_MS = 2000 // 2 secondes +const renderersCache = ref>(new Map()) +const RENDERERS_CACHE_MS = 2000 +const lastRenderersFetch = ref(0) + +const snapshotState = reactive({ + snapshots: reactive(new Map()), + lastSnapshotAt: reactive(new Map()), + lastEventAt: reactive(new Map()), + loadingIds: reactive(new Set()), + selectedRendererId: null, +}) + +const loading = ref(false) +const error = ref(null) -// Connecter SSE une seule fois au module let sseConnected = false function ensureSSEConnected() { if (sseConnected) return sse.onRendererEvent((event) => { const rendererId = event.renderer_id - - switch (event.type) { - case 'state_changed': - case 'position_changed': - case 'volume_changed': - case 'mute_changed': - case 'metadata_changed': - // Invalider le cache de l'état et re-fetch - lastFetch.states.delete(rendererId) - api.getRendererState(rendererId).then(state => { - statesCache.value.set(rendererId, state) - }) - break - - case 'queue_updated': - // Invalider le cache de la queue et re-fetch - lastFetch.queues.delete(rendererId) - api.getQueue(rendererId).then(queue => { - queuesCache.value.set(rendererId, queue) - }) - // Mettre à jour queue_len dans l'état - api.getRendererState(rendererId).then(state => { - statesCache.value.set(rendererId, state) - }) - break - - case 'binding_changed': - // Re-fetch binding et queue - lastFetch.bindings.delete(rendererId) - lastFetch.queues.delete(rendererId) - api.getBinding(rendererId).then(binding => { - bindingsCache.value.set(rendererId, binding) - }) - api.getQueue(rendererId).then(queue => { - queuesCache.value.set(rendererId, queue) - }) - break + const timestamp = Date.parse(event.timestamp ?? '') || Date.now() + snapshotState.lastEventAt.set(rendererId, timestamp) + const lastSnapshot = snapshotState.lastSnapshotAt.get(rendererId) ?? 0 + if (!snapshotState.snapshots.has(rendererId) || timestamp > lastSnapshot) { + void fetchRendererSnapshot(rendererId, { force: true }) } }) sseConnected = true } -/** - * Composable principal pour gérer les renderers - */ +const allRenderers = computed(() => Array.from(renderersCache.value.values())) +const onlineRenderers = computed(() => allRenderers.value.filter((r) => r.online)) +const allSnapshots = computed(() => Array.from(snapshotState.snapshots.values())) +const playingRenderers = computed(() => + allSnapshots.value + .filter((snapshot) => snapshot.state.transport_state === 'PLAYING') + .map((snapshot) => snapshot.state), +) + +function getRendererById(id: string) { + return renderersCache.value.get(id) +} + +function getSnapshotById(id: string) { + return snapshotState.snapshots.get(id) ?? null +} + +function getStateById(id: string): RendererState | null { + return snapshotState.snapshots.get(id)?.state ?? null +} + +function getQueueById(id: string): QueueSnapshot | null { + return snapshotState.snapshots.get(id)?.queue ?? null +} + +function getBindingById(id: string): AttachedPlaylistInfo | null { + return snapshotState.snapshots.get(id)?.binding ?? null +} + +function isSnapshotLoading(id: string) { + return snapshotState.loadingIds.has(id) +} + +function selectRenderer(id: string | null) { + snapshotState.selectedRendererId = id +} + +async function fetchRenderers(force = false) { + ensureSSEConnected() + + const now = Date.now() + if (!force && now - lastRenderersFetch.value < RENDERERS_CACHE_MS) { + return + } + + try { + loading.value = true + error.value = null + const data = await api.getRenderers() + renderersCache.value = new Map(data.map((renderer) => [renderer.id, renderer])) + lastRenderersFetch.value = now + } catch (err) { + error.value = err instanceof Error ? err.message : 'Erreur fetch renderers' + console.error('[useRenderers] Erreur fetch:', err) + } finally { + loading.value = false + } +} + +async function fetchRendererSnapshot(rendererId: string, opts?: { force?: boolean }) { + ensureSSEConnected() + const force = opts?.force ?? false + const hasSnapshot = snapshotState.snapshots.has(rendererId) + + if (!force && hasSnapshot) { + const lastSnapshot = snapshotState.lastSnapshotAt.get(rendererId) ?? 0 + const lastEvent = snapshotState.lastEventAt.get(rendererId) ?? 0 + if (lastEvent <= lastSnapshot) { + return + } + } + + if (snapshotState.loadingIds.has(rendererId)) { + return + } + + snapshotState.loadingIds.add(rendererId) + try { + const snapshot = await api.getRendererFullSnapshot(rendererId) + snapshotState.snapshots.set(rendererId, snapshot) + snapshotState.lastSnapshotAt.set(rendererId, Date.now()) + } catch (err) { + console.error(`[useRenderers] Erreur snapshot ${rendererId}:`, err) + } finally { + snapshotState.loadingIds.delete(rendererId) + } +} + +// Transport controls +async function play(id: string) { + await api.play(id) +} + +async function resumeOrPlayFromQueue(id: string) { + const snapshot = snapshotState.snapshots.get(id) + if (!snapshot) { + throw new Error(`Renderer ${id} non trouvé`) + } + + const state = snapshot.state + if (state.transport_state === 'PAUSED') { + return play(id) + } + + if ( + ['STOPPED', 'NO_MEDIA'].includes(state.transport_state) && + snapshot.queue.items.length > 0 + ) { + return api.resume(id) + } + + throw new Error('La file d\'attente est vide. Ajoutez des morceaux avant de démarrer la lecture.') +} + +async function pause(id: string) { + await api.pause(id) +} + +async function stop(id: string) { + await api.stop(id) +} + +async function next(id: string) { + await api.next(id) +} + +// Volume controls +async function setVolume(id: string, volume: number) { + await api.setVolume(id, volume) +} + +async function volumeUp(id: string) { + await api.volumeUp(id) +} + +async function volumeDown(id: string) { + await api.volumeDown(id) +} + +async function toggleMute(id: string) { + await api.toggleMute(id) +} + +// Playlist binding +async function attachPlaylist( + rendererId: string, + serverId: string, + containerId: string, + options?: { autoPlay?: boolean }, +) { + await api.attachPlaylist(rendererId, serverId, containerId, options?.autoPlay ?? false) +} + +async function detachPlaylist(rendererId: string) { + await api.detachPlaylist(rendererId) +} + +async function attachAndPlayPlaylist( + rendererId: string, + serverId: string, + containerId: string, +) { + await attachPlaylist(rendererId, serverId, containerId, { autoPlay: true }) +} + +// Queue content +async function playContent(rendererId: string, serverId: string, objectId: string) { + await api.playContent(rendererId, serverId, objectId) +} + +async function addToQueue(rendererId: string, serverId: string, objectId: string) { + await api.addToQueue(rendererId, serverId, objectId) +} + export function useRenderers() { ensureSSEConnected() - const loading = ref(false) - const error = ref(null) - - // Getters computed - const allRenderers = computed(() => Array.from(renderersCache.value.values())) - const onlineRenderers = computed(() => allRenderers.value.filter(r => r.online)) - - const allStates = computed(() => Array.from(statesCache.value.values())) - const playingRenderers = computed(() => - allStates.value.filter(s => s.transport_state === 'PLAYING') - ) - - // Fetch renderers list - async function fetchRenderers(force = false) { - const now = Date.now() - if (!force && now - lastFetch.renderers < CACHE_DURATION_MS) { - return // Cache encore valide - } - - try { - loading.value = true - error.value = null - const data = await api.getRenderers() - - renderersCache.value.clear() - data.forEach(r => renderersCache.value.set(r.id, r)) - lastFetch.renderers = now - } catch (e) { - error.value = e instanceof Error ? e.message : 'Erreur fetch renderers' - console.error('[useRenderers] Erreur fetch:', e) - } finally { - loading.value = false - } - } - - // Fetch renderer state - async function fetchRendererState(id: string, force = false) { - const now = Date.now() - const last = lastFetch.states.get(id) || 0 - if (!force && now - last < CACHE_DURATION_MS) { - return // Cache encore valide - } - - try { - const state = await api.getRendererState(id) - statesCache.value.set(id, state) - lastFetch.states.set(id, now) - } catch (e) { - console.error(`[useRenderers] Erreur fetch state ${id}:`, e) - } - } - - // Fetch queue - async function fetchQueue(id: string, force = false) { - const now = Date.now() - const last = lastFetch.queues.get(id) || 0 - if (!force && now - last < CACHE_DURATION_MS) { - return // Cache encore valide - } - - try { - const queue = await api.getQueue(id) - queuesCache.value.set(id, queue) - lastFetch.queues.set(id, now) - } catch (e) { - console.error(`[useRenderers] Erreur fetch queue ${id}:`, e) - } - } - - // Fetch binding - async function fetchBinding(id: string, force = false) { - const now = Date.now() - const last = lastFetch.bindings.get(id) || 0 - if (!force && now - last < CACHE_DURATION_MS) { - return // Cache encore valide - } - - try { - const binding = await api.getBinding(id) - bindingsCache.value.set(id, binding) - lastFetch.bindings.set(id, now) - } catch (e) { - console.error(`[useRenderers] Erreur fetch binding ${id}:`, e) - } - } - - // Transport controls (pas de cache, juste des commandes) - async function play(id: string) { - await api.play(id) - // SSE mettra à jour l'état automatiquement - } - - async function resumeOrPlayFromQueue(id: string) { - const state = statesCache.value.get(id) - if (!state) { - throw new Error(`Renderer ${id} non trouvé`) - } - - if (state.transport_state === 'PAUSED') { - return play(id) - } - - if ((state.transport_state === 'STOPPED' || state.transport_state === 'NO_MEDIA') && - state.queue_len && state.queue_len > 0) { - return api.resume(id) - } - - throw new Error('La file d\'attente est vide. Ajoutez des morceaux avant de démarrer la lecture.') - } - - async function pause(id: string) { - await api.pause(id) - } - - async function stop(id: string) { - await api.stop(id) - } - - async function next(id: string) { - await api.next(id) - } - - // Volume controls - async function setVolume(id: string, volume: number) { - await api.setVolume(id, volume) - } - - async function volumeUp(id: string) { - await api.volumeUp(id) - } - - async function volumeDown(id: string) { - await api.volumeDown(id) - } - - async function toggleMute(id: string) { - await api.toggleMute(id) - } - - // Playlist binding - async function attachPlaylist(rendererId: string, serverId: string, containerId: string) { - await api.attachPlaylist(rendererId, serverId, containerId) - // Re-fetch binding et queue - await fetchBinding(rendererId, true) - await fetchQueue(rendererId, true) - } - - async function detachPlaylist(rendererId: string) { - await api.detachPlaylist(rendererId) - bindingsCache.value.set(rendererId, null) - } - - async function attachAndPlayPlaylist(rendererId: string, serverId: string, containerId: string) { - await api.attachPlaylist(rendererId, serverId, containerId) - await fetchBinding(rendererId, true) - await fetchQueue(rendererId, true) - } - - // Queue content - async function playContent(rendererId: string, serverId: string, objectId: string) { - await api.playContent(rendererId, serverId, objectId) - // SSE mettra à jour la queue - } - - async function addToQueue(rendererId: string, serverId: string, objectId: string) { - await api.addToQueue(rendererId, serverId, objectId) - // SSE mettra à jour la queue - } - - // Getters pour un renderer spécifique - function getRendererById(id: string) { - return renderersCache.value.get(id) - } - - function getStateById(id: string) { - return statesCache.value.get(id) - } - - function getQueueById(id: string) { - return queuesCache.value.get(id) - } - - function getBindingById(id: string) { - return bindingsCache.value.get(id) - } - return { - // État loading, error, - // Getters + // Collections allRenderers, onlineRenderers, playingRenderers, + // Accessors getRendererById, + getSnapshotById, getStateById, getQueueById, getBindingById, - // Actions fetch + isSnapshotLoading, + selectRenderer, + snapshotState, + // Fetchers fetchRenderers, - fetchRendererState, - fetchQueue, - fetchBinding, + fetchRendererSnapshot, // Transport controls play, resumeOrPlayFromQueue, @@ -304,37 +268,32 @@ export function useRenderers() { attachAndPlayPlaylist, // Queue content playContent, - addToQueue + addToQueue, } } -/** - * Composable pour un renderer spécifique (avec auto-refresh) - */ export function useRenderer(rendererId: Ref) { ensureSSEConnected() const renderer = computed(() => renderersCache.value.get(rendererId.value)) - const state = computed(() => statesCache.value.get(rendererId.value)) - const queue = computed(() => queuesCache.value.get(rendererId.value)) - const binding = computed(() => bindingsCache.value.get(rendererId.value)) + const snapshot = computed(() => snapshotState.snapshots.get(rendererId.value) ?? null) + const state = computed(() => snapshot.value?.state ?? null) + const queue = computed(() => snapshot.value?.queue ?? null) + const binding = computed(() => snapshot.value?.binding ?? null) - // Auto-refresh au montage - const { fetchRendererState, fetchQueue, fetchBinding } = useRenderers() - - async function refresh() { + async function refresh(force = true) { await Promise.all([ - fetchRendererState(rendererId.value, true), - fetchQueue(rendererId.value, true), - fetchBinding(rendererId.value, true) + fetchRenderers(force), + fetchRendererSnapshot(rendererId.value, { force: true }), ]) } return { renderer, + snapshot, state, queue, binding, - refresh + refresh, } } diff --git a/pmoapp/webapp/src/services/pmocontrol/api.ts b/pmoapp/webapp/src/services/pmocontrol/api.ts index 8d1a06cc..7468126a 100644 --- a/pmoapp/webapp/src/services/pmocontrol/api.ts +++ b/pmoapp/webapp/src/services/pmocontrol/api.ts @@ -4,6 +4,7 @@ import type { RendererSummary, RendererState, + FullRendererSnapshot, QueueSnapshot, AttachedPlaylistInfo, MediaServerSummary, @@ -68,6 +69,14 @@ class PMOControlAPI { return this.request(`/renderers/${encodeURIComponent(id)}`) } + /** + * Récupère le snapshot complet d'un renderer + * GET /api/control/renderers/{id}/full + */ + async getRendererFullSnapshot(id: string): Promise { + return this.request(`/renderers/${encodeURIComponent(id)}/full`) + } + /** * Récupère la queue d'un renderer (avec current_index) * GET /api/control/renderers/{id}/queue @@ -195,9 +204,14 @@ class PMOControlAPI { async attachPlaylist( rendererId: string, serverId: string, - containerId: string + containerId: string, + autoPlay = false ): Promise { - const payload: AttachPlaylistRequest = { server_id: serverId, container_id: containerId } + const payload: AttachPlaylistRequest = { + server_id: serverId, + container_id: containerId, + auto_play: autoPlay, + } return this.request(`/renderers/${encodeURIComponent(rendererId)}/binding/attach`, { method: 'POST', body: JSON.stringify(payload), diff --git a/pmoapp/webapp/src/services/pmocontrol/types.ts b/pmoapp/webapp/src/services/pmocontrol/types.ts index 1642ebd9..39fbcd02 100644 --- a/pmoapp/webapp/src/services/pmocontrol/types.ts +++ b/pmoapp/webapp/src/services/pmocontrol/types.ts @@ -77,6 +77,12 @@ export interface QueueSnapshot { current_index: number | null // Index de la piste en cours (null si rien en lecture) } +export interface FullRendererSnapshot { + state: RendererState + queue: QueueSnapshot + binding: AttachedPlaylistInfo | null +} + // ============================================================================ // OPENHOME PLAYLIST // ============================================================================ @@ -141,6 +147,7 @@ export interface VolumeSetRequest { export interface AttachPlaylistRequest { server_id: string container_id: string + auto_play?: boolean } export interface PlayContentRequest { diff --git a/pmoapp/webapp/src/views/DashboardView.vue b/pmoapp/webapp/src/views/DashboardView.vue index 005c9a3c..52a77a40 100644 --- a/pmoapp/webapp/src/views/DashboardView.vue +++ b/pmoapp/webapp/src/views/DashboardView.vue @@ -11,7 +11,7 @@ const { onlineRenderers, getStateById, fetchRenderers, - fetchRendererState + fetchRendererSnapshot } = useRenderers() const { @@ -25,9 +25,8 @@ onMounted(async () => { await fetchRenderers() await fetchServers() - // Charger les états de tous les renderers pour afficher les covers for (const renderer of renderers.value) { - fetchRendererState(renderer.id) + fetchRendererSnapshot(renderer.id, { force: true }) } }) diff --git a/pmoapp/webapp/src/views/RendererView.vue b/pmoapp/webapp/src/views/RendererView.vue index 12976fb9..03048da5 100644 --- a/pmoapp/webapp/src/views/RendererView.vue +++ b/pmoapp/webapp/src/views/RendererView.vue @@ -24,7 +24,7 @@ const uiStore = useUIStore() const rendererId = computed(() => route.params.id as string) const { renderer, state, refresh } = useRenderer(toRef(() => rendererId.value)) -const { fetchRenderers } = useRenderers() +const { fetchRenderers, selectRenderer: selectRendererSnapshot } = useRenderers() const openHomeSupported = computed(() => { const current = renderer.value if (!current) return false @@ -44,8 +44,8 @@ const canAddOhTrack = computed(() => newOhUri.value.trim().length > 0) // Charger les données au montage si nécessaire onMounted(async () => { - // Indiquer à uiStore quel renderer est sélectionné uiStore.selectRenderer(rendererId.value) + selectRendererSnapshot(rendererId.value) // Charger toutes les données du renderer if (!renderer.value) { @@ -76,6 +76,7 @@ watch(rendererId, () => { // Nettoyer la sélection au démontage onUnmounted(() => { uiStore.selectRenderer(null) + selectRendererSnapshot(null) }) function goBack() { diff --git a/pmocontrol/examples/full_control_point_demo.rs b/pmocontrol/examples/full_control_point_demo.rs index 5af38635..ed4182cb 100644 --- a/pmocontrol/examples/full_control_point_demo.rs +++ b/pmocontrol/examples/full_control_point_demo.rs @@ -746,11 +746,9 @@ impl App { self.pending_binding_container.clone(), self.renderer_id(), ) { - self.control_point.attach_queue_to_playlist( - &renderer_id, - server.id.clone(), - container.clone(), - ); + self.control_point + .attach_queue_to_playlist(&renderer_id, server.id.clone(), container.clone()) + .context("Failed to attach queue to playlist")?; self.ui_state .set_status(format!("File liée à '{}'", container)); } diff --git a/pmocontrol/examples/live_pmomusic_demo.rs b/pmocontrol/examples/live_pmomusic_demo.rs index be7f02de..7829d908 100644 --- a/pmocontrol/examples/live_pmomusic_demo.rs +++ b/pmocontrol/examples/live_pmomusic_demo.rs @@ -134,11 +134,13 @@ fn main() -> Result<()> { .context("Failed to enqueue playback items")?; // Attach queue to live playlist container - control_point.attach_queue_to_playlist( - &renderer_id, - server_info.id.clone(), - live_playlist_container.id.clone(), - ); + control_point + .attach_queue_to_playlist( + &renderer_id, + server_info.id.clone(), + live_playlist_container.id.clone(), + ) + .context("Failed to attach queue to live playlist container")?; println!( "✓ Queue attached to Live Playlist container '{}' (id: {}) on server '{}'", live_playlist_container.title, live_playlist_container.id, server_info.friendly_name diff --git a/pmocontrol/examples/queue_pmomusic_demo.rs b/pmocontrol/examples/queue_pmomusic_demo.rs index 5a6f9196..a61740f5 100644 --- a/pmocontrol/examples/queue_pmomusic_demo.rs +++ b/pmocontrol/examples/queue_pmomusic_demo.rs @@ -127,11 +127,9 @@ fn main() -> Result<()> { // Attach queue to playlist container if we found one if let Some(container_id) = bound_container_id { - control_point.attach_queue_to_playlist( - &renderer_id, - server_info.id.clone(), - container_id.clone(), - ); + control_point + .attach_queue_to_playlist(&renderer_id, server_info.id.clone(), container_id.clone()) + .context("Failed to attach queue to playlist container")?; println!( "✓ Queue attached to playlist container {} on server {}", container_id, server_info.friendly_name diff --git a/pmocontrol/src/control_point.rs b/pmocontrol/src/control_point.rs index 3f1e1303..98408b30 100644 --- a/pmocontrol/src/control_point.rs +++ b/pmocontrol/src/control_point.rs @@ -27,6 +27,11 @@ use crate::media_server::{ use crate::media_server_events::spawn_media_server_event_runtime; use crate::model::TrackMetadata; use crate::model::{MediaServerEvent, RendererEvent, RendererId, RendererInfo}; +#[cfg(feature = "pmoserver")] +use crate::openapi::{ + CurrentTrackMetadata, FullRendererSnapshot, QueueItem, QueueSnapshotView, RendererBindingView, + RendererStateView, +}; use crate::openhome_client::parse_track_metadata_from_didl; use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack}; use crate::openhome_renderer::{format_seconds, map_openhome_state}; @@ -68,6 +73,10 @@ pub enum OpenHomeAccessError { /// - lance un SsdpClient dans un thread, /// - passe les SsdpEvent au DiscoveryManager, /// - applique les DeviceUpdate dans le DeviceRegistry. +/// +/// Le runtime est **l'unique source de vérité** pour l'état des renderers : +/// les clients doivent toujours consommer des snapshots consolidés côté serveur +/// et n'utiliser les événements SSE que comme signaux de rafraîchissement. pub struct ControlPoint { registry: Arc>, event_bus: RendererEventBus, @@ -575,7 +584,7 @@ impl ControlPoint { } // User-driven mutation: detach any playlist binding - self.detach_binding_on_user_mutation(renderer_id, "clear_queue"); + self.detach_playlist_binding(renderer_id, "clear_queue"); if self.runtime.uses_openhome_playlist(renderer_id) { let renderer = self.openhome_renderer(renderer_id)?; @@ -628,7 +637,7 @@ impl ControlPoint { } // User-driven mutation: detach any playlist binding - self.detach_binding_on_user_mutation(renderer_id, "enqueue_items"); + self.detach_playlist_binding(renderer_id, "enqueue_items"); if self.runtime.uses_openhome_playlist(renderer_id) { self.enqueue_items_openhome(renderer_id, items)?; @@ -698,10 +707,7 @@ impl ControlPoint { } /// Retourne les métadonnées courantes depuis le snapshot en mémoire - pub fn get_current_track_metadata( - &self, - renderer_id: &RendererId, - ) -> Option { + pub fn get_current_track_metadata(&self, renderer_id: &RendererId) -> Option { self.runtime.current_track_metadata(renderer_id) } @@ -726,6 +732,122 @@ impl ControlPoint { renderer.openhome_playlist_len() } + /// Build a fully consistent snapshot for UI consumers (state + queue + binding). + #[cfg(feature = "pmoserver")] + pub fn renderer_full_snapshot( + &self, + renderer_id: &RendererId, + ) -> anyhow::Result { + let renderer = self + .music_renderer_by_id(renderer_id) + .ok_or_else(|| anyhow!("Renderer {} not found", renderer_id.0))?; + let info = renderer.info(); + + let (runtime_snapshot, queue_items, current_index) = + self.runtime.renderer_snapshot_bundle(renderer_id); + let playback_source = self.runtime.playback_source(renderer_id); + let queue_len = queue_items.len(); + + let mut queue_current_index = current_index; + if queue_current_index.is_none() { + if let Some(position) = runtime_snapshot.position.as_ref() { + if let Some(uri) = position.track_uri.as_ref() { + if let Some(idx) = queue_items.iter().position(|item| item.uri == *uri) { + queue_current_index = Some(idx); + } + } else if let Some(track_no) = position.track { + let zero_based = track_no.saturating_sub(1) as usize; + if zero_based < queue_items.len() { + queue_current_index = Some(zero_based); + } + } + } + } + + if queue_current_index.is_none() + && matches!(playback_source, PlaybackSource::FromQueue) + && runtime_snapshot + .state + .as_ref() + .map(|state| matches!(state, PlaybackState::Playing | PlaybackState::Paused)) + .unwrap_or(false) + && !queue_items.is_empty() + { + queue_current_index = Some(0); + } + + let queue_view_items: Vec = queue_items + .iter() + .enumerate() + .map(|(index, item)| QueueItem { + index, + uri: item.uri.clone(), + title: item.title.clone(), + artist: item.artist.clone(), + album: item.album.clone(), + album_art_uri: item.album_art_uri.clone(), + server_id: item.server_id.as_ref().map(|s| s.0.clone()), + object_id: item.object_id.clone(), + }) + .collect(); + + let queue_view = QueueSnapshotView { + renderer_id: renderer_id.0.clone(), + items: queue_view_items, + current_index: queue_current_index, + }; + + let binding = self.current_queue_playlist_binding(renderer_id).map( + |(server_id, container_id, has_seen_update)| RendererBindingView { + server_id: server_id.0, + container_id, + has_seen_update, + }, + ); + + let (position_ms, duration_ms) = + convert_runtime_position(runtime_snapshot.position.as_ref()); + let queue_current_metadata = queue_current_index + .and_then(|idx| queue_items.get(idx)) + .map(current_track_from_playback_item); + + let current_track = runtime_snapshot + .last_metadata + .as_ref() + .map(|meta| CurrentTrackMetadata { + title: meta.title.clone(), + artist: meta.artist.clone(), + album: meta.album.clone(), + album_art_uri: meta.album_art_uri.clone(), + }) + .or(queue_current_metadata); + + let state_view = RendererStateView { + id: renderer_id.0.clone(), + friendly_name: info.friendly_name.clone(), + transport_state: runtime_snapshot + .state + .as_ref() + .map(playback_state_label) + .unwrap_or_else(|| "UNKNOWN".to_string()), + position_ms, + duration_ms, + volume: runtime_snapshot + .last_volume + .and_then(|value| u8::try_from(value).ok()), + mute: runtime_snapshot.last_mute, + queue_len, + attached_playlist: binding.clone(), + current_track, + }; + + Ok(FullRendererSnapshot { + state: state_view, + queue: queue_view, + binding, + }) + } + pub fn clear_openhome_playlist(&self, renderer_id: &RendererId) -> anyhow::Result<()> { let renderer = self.openhome_renderer(renderer_id)?; renderer.openhome_playlist_clear()?; @@ -1048,37 +1170,35 @@ impl ControlPoint { renderer_id: &RendererId, server_id: ServerId, container_id: String, - ) { - self.attach_queue_to_playlist_internal(renderer_id, server_id, container_id, false); + ) -> anyhow::Result<()> { + self.attach_queue_to_playlist_with_options(renderer_id, server_id, container_id, false) } - /// Attach a renderer's queue to a playlist container without doing the initial refresh. - /// - /// This is useful when the queue has already been manually populated and we just want - /// to track future changes to the playlist. - pub fn attach_queue_to_playlist_without_refresh( + /// Attach a renderer queue to a playlist with explicit `auto_play` behaviour. + pub fn attach_queue_to_playlist_with_options( &self, renderer_id: &RendererId, server_id: ServerId, container_id: String, - ) { - self.attach_queue_to_playlist_internal(renderer_id, server_id, container_id, true); + auto_play: bool, + ) -> anyhow::Result<()> { + self.attach_queue_to_playlist_internal(renderer_id, &server_id, &container_id, auto_play) } - /// Internal implementation with optional skip of initial refresh + /// Internal implementation shared by every attach wrapper. fn attach_queue_to_playlist_internal( &self, renderer_id: &RendererId, - server_id: ServerId, - container_id: String, - skip_initial_refresh: bool, - ) { + server_id: &ServerId, + container_id: &str, + auto_play: bool, + ) -> anyhow::Result<()> { let binding = PlaylistBinding { server_id: server_id.clone(), - container_id: container_id.clone(), + container_id: container_id.to_string(), has_seen_update: false, - pending_refresh: !skip_initial_refresh, - auto_play_on_refresh: !skip_initial_refresh, + pending_refresh: true, + auto_play_on_refresh: auto_play, }; { @@ -1087,37 +1207,32 @@ impl ControlPoint { info!( renderer = renderer_id.0.as_str(), server = server_id.0.as_str(), - container = container_id.as_str(), - skip_refresh = skip_initial_refresh, + container = container_id, + auto_play, "Queue attached to playlist container" ); - } // Drop bindings lock here before calling refresh_attached_queue_for + } - // Emit binding changed event to notify frontend self.emit_renderer_event(RendererEvent::BindingChanged { id: renderer_id.clone(), binding: Some(binding), }); - if !skip_initial_refresh { - let mut auto_start_cb = |rid: &RendererId| self.start_queue_playback_if_idle(rid); - if let Err(err) = refresh_attached_queue_for( - &self.registry, - &self.runtime, - &self.playlist_bindings, - renderer_id, - &self.event_bus, - Some(&mut auto_start_cb), - ) { - warn!( - renderer = renderer_id.0.as_str(), - server = server_id.0.as_str(), - container = container_id.as_str(), - error = %err, - "Initial playlist refresh after attachment failed" - ); - } - } + let mut auto_start_cb = |rid: &RendererId| self.start_queue_playback_if_idle(rid); + let callback: Option<&mut dyn FnMut(&RendererId) -> anyhow::Result<()>> = if auto_play { + Some(&mut auto_start_cb) + } else { + None + }; + + refresh_attached_queue_for( + &self.registry, + &self.runtime, + &self.playlist_bindings, + renderer_id, + &self.event_bus, + callback, + ) } /// Detach a renderer's queue from its associated playlist container. @@ -1125,29 +1240,7 @@ impl ControlPoint { /// After calling this, the queue will no longer be automatically refreshed /// from the server. If no binding existed, this is a no-op. pub fn detach_queue_playlist(&self, renderer_id: &RendererId) { - let removed = { - let mut bindings = self.playlist_bindings.lock().unwrap(); - bindings.remove(renderer_id) - }; - - if let Some(binding) = removed { - info!( - renderer = renderer_id.0.as_str(), - server = binding.server_id.0.as_str(), - container = binding.container_id.as_str(), - "Queue detached from playlist container" - ); - // Emit binding changed event to notify frontend - self.emit_renderer_event(RendererEvent::BindingChanged { - id: renderer_id.clone(), - binding: None, - }); - } else { - debug!( - renderer = renderer_id.0.as_str(), - "detach_queue_playlist: no binding to remove" - ); - } + self.detach_playlist_binding(renderer_id, "api_detach"); } /// Query the current playlist binding for a renderer's queue, if any. @@ -1168,19 +1261,33 @@ impl ControlPoint { }) } - /// Internal helper to detach the playlist binding on user-driven mutations. + /// Internal helper to detach any playlist binding and notify observers. /// - /// This is called by public queue mutation methods (clear, enqueue, etc.) - /// to ensure that explicit user actions break the automatic refresh binding. - fn detach_binding_on_user_mutation(&self, renderer_id: &RendererId, reason: &str) { - let mut bindings = self.playlist_bindings.lock().unwrap(); - if let Some(binding) = bindings.remove(renderer_id) { + /// Invariant: every user-driven queue mutation **must** call this method so + /// that bindings never become out of sync with the local queue snapshot. + fn detach_playlist_binding(&self, renderer_id: &RendererId, reason: &str) { + let removed = { + let mut bindings = self.playlist_bindings.lock().unwrap(); + bindings.remove(renderer_id) + }; + + if let Some(binding) = removed { info!( renderer = renderer_id.0.as_str(), server = binding.server_id.0.as_str(), container = binding.container_id.as_str(), reason = reason, - "Playlist binding auto-detached due to user mutation" + "Playlist binding detached" + ); + self.emit_renderer_event(RendererEvent::BindingChanged { + id: renderer_id.clone(), + binding: None, + }); + } else { + debug!( + renderer = renderer_id.0.as_str(), + reason = reason, + "detach_playlist_binding: no binding to remove" ); } } @@ -1358,6 +1465,44 @@ impl ControlPoint { } } +#[cfg(feature = "pmoserver")] +fn convert_runtime_position(position: Option<&PlaybackPositionInfo>) -> (Option, Option) { + match position { + Some(info) => ( + parse_hms_to_ms(info.rel_time.as_deref()), + parse_hms_to_ms(info.track_duration.as_deref()), + ), + None => (None, None), + } +} + +#[cfg(feature = "pmoserver")] +fn playback_state_label(state: &PlaybackState) -> String { + match state { + PlaybackState::Stopped => "STOPPED".to_string(), + PlaybackState::Playing => "PLAYING".to_string(), + PlaybackState::Paused => "PAUSED".to_string(), + PlaybackState::Transitioning => "TRANSITIONING".to_string(), + PlaybackState::NoMedia => "NO_MEDIA".to_string(), + PlaybackState::Unknown(custom) => custom.clone(), + } +} + +#[cfg(feature = "pmoserver")] +fn parse_hms_to_ms(hms: Option<&str>) -> Option { + let value = hms?; + let parts: Vec<&str> = value.split(':').collect(); + if parts.len() != 3 { + return None; + } + + let hours: u64 = parts[0].parse().ok()?; + let minutes: u64 = parts[1].parse().ok()?; + let seconds: u64 = parts[2].parse().ok()?; + + Some((hours * 3600 + minutes * 60 + seconds) * 1000) +} + #[derive(Clone, Default)] struct RendererRuntimeSnapshot { state: Option, @@ -1465,6 +1610,20 @@ impl RuntimeState { .and_then(|entry| entry.snapshot.last_metadata.clone()) } + #[cfg(feature = "pmoserver")] + fn renderer_snapshot_bundle( + &self, + id: &RendererId, + ) -> (RendererRuntimeSnapshot, Vec, Option) { + let entries = self.entries.lock().unwrap(); + if let Some(entry) = entries.get(id) { + let (items, current_index) = entry.queue.full_snapshot(); + (entry.snapshot.clone(), items, current_index) + } else { + (RendererRuntimeSnapshot::default(), Vec::new(), None) + } + } + fn dequeue_next(&self, id: &RendererId) -> Option<(PlaybackItem, usize)> { let mut entries = self.entries.lock().unwrap(); let entry = entries.get_mut(id)?; @@ -2839,6 +2998,16 @@ fn playback_position_equal(a: &PlaybackPositionInfo, b: &PlaybackPositionInfo) - && a.track_uri == b.track_uri } +#[cfg(feature = "pmoserver")] +fn current_track_from_playback_item(item: &PlaybackItem) -> CurrentTrackMetadata { + CurrentTrackMetadata { + title: item.title.clone(), + artist: item.artist.clone(), + album: item.album.clone(), + album_art_uri: item.album_art_uri.clone(), + } +} + /// Extract TrackMetadata from DIDL-Lite XML in PlaybackPositionInfo. fn extract_track_metadata(position: &PlaybackPositionInfo) -> Option { let didl_xml = match position.track_metadata.as_ref() { diff --git a/pmocontrol/src/openapi.rs b/pmocontrol/src/openapi.rs index f4231681..e6de0c55 100644 --- a/pmocontrol/src/openapi.rs +++ b/pmocontrol/src/openapi.rs @@ -219,6 +219,30 @@ pub struct BrowseResponse { pub entries: Vec, } +// ============================================================================ +// SNAPSHOT AGGRÉGÉ +// ============================================================================ + +/// Alias de lisibilité pour les view-models déjà existants. +#[cfg(feature = "pmoserver")] +pub type RendererStateView = RendererState; +#[cfg(feature = "pmoserver")] +pub type QueueSnapshotView = QueueSnapshot; +#[cfg(feature = "pmoserver")] +pub type RendererBindingView = AttachedPlaylistInfo; + +/// Instantané complet et cohérent d'un renderer. +/// +/// Le ControlPoint est la source de vérité : ce snapshot agrège l'état, +/// la queue et le binding observés atomiquement côté serveur. +#[cfg(feature = "pmoserver")] +#[derive(Clone, Debug, Serialize, ToSchema)] +pub struct FullRendererSnapshot { + pub state: RendererStateView, + pub queue: QueueSnapshotView, + pub binding: Option, +} + // ============================================================================ // PAYLOADS DE COMMANDES // ============================================================================ @@ -239,6 +263,9 @@ pub struct AttachPlaylistRequest { pub server_id: String, /// ID du container playlist pub container_id: String, + /// Si true, démarre la lecture automatiquement après le refresh + #[serde(default)] + pub auto_play: bool, } /// Requête pour lire ou ajouter du contenu à la queue diff --git a/pmocontrol/src/openhome_client.rs b/pmocontrol/src/openhome_client.rs index 40a6b453..e9bb8fee 100644 --- a/pmocontrol/src/openhome_client.rs +++ b/pmocontrol/src/openhome_client.rs @@ -265,10 +265,7 @@ impl OhInfoClient { ); if let Some(ref xml) = metadata_xml { - debug!( - metadata_xml = xml.as_str(), - "OpenHome metadata XML content" - ); + debug!(metadata_xml = xml.as_str(), "OpenHome metadata XML content"); } Ok(OhInfoTrack { uri, metadata_xml }) diff --git a/pmocontrol/src/pmoserver_ext.rs b/pmocontrol/src/pmoserver_ext.rs index a732cf6d..b043fcb8 100644 --- a/pmocontrol/src/pmoserver_ext.rs +++ b/pmocontrol/src/pmoserver_ext.rs @@ -11,11 +11,10 @@ use crate::media_server::{MediaBrowser, MediaEntry, MediaResource, MusicServer, use crate::model::{RendererCapabilities, RendererId, RendererProtocol}; #[cfg(feature = "pmoserver")] use crate::openapi::{ - AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry, - CurrentTrackMetadata, ErrorResponse, MediaServerSummary, OpenHomePlaylistAddRequest, - OpenHomePlaylistSnapshot, PlayContentRequest, QueueItem, QueueSnapshot, - RendererCapabilitiesSummary, RendererProtocolSummary, RendererState, RendererSummary, - SuccessResponse, VolumeSetRequest, + AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry, ErrorResponse, + FullRendererSnapshot, MediaServerSummary, OpenHomePlaylistAddRequest, OpenHomePlaylistSnapshot, + PlayContentRequest, QueueItem, QueueSnapshot, RendererCapabilitiesSummary, + RendererProtocolSummary, RendererState, RendererSummary, SuccessResponse, VolumeSetRequest, }; #[cfg(feature = "pmoserver")] use crate::playback_queue::PlaybackItem; @@ -65,10 +64,6 @@ const QUEUE_COMMAND_TIMEOUT: Duration = Duration::from_secs(10); #[cfg(feature = "pmoserver")] const ATTACH_PLAYLIST_TIMEOUT: Duration = Duration::from_secs(60); -// Timeout for renderer state queries (multiple SOAP calls) -#[cfg(feature = "pmoserver")] -const STATE_QUERY_TIMEOUT: Duration = Duration::from_secs(8); - /// État partagé pour l'API ControlPoint #[cfg(feature = "pmoserver")] #[derive(Clone)] @@ -137,168 +132,38 @@ async fn get_renderer_state( Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { let rid = RendererId(renderer_id.clone()); - - let renderer = state + let snapshot = state .control_point - .music_renderer_by_id(&rid) - .ok_or_else(|| { - ( - StatusCode::NOT_FOUND, - Json(ErrorResponse { - error: format!("Renderer {} not found", renderer_id), - }), - ) - })?; + .renderer_full_snapshot(&rid) + .map_err(|err| map_snapshot_error(renderer_id, err))?; - let info = renderer.info(); - let renderer = renderer.clone(); + Ok(Json(snapshot.state)) +} - // Spawn blocking task for all SOAP calls to avoid blocking Tokio runtime - let state_task = tokio::task::spawn_blocking(move || { - // État de transport - let transport_state = renderer - .playback_state() - .ok() - .map(state_to_string) - .unwrap_or_else(|| "UNKNOWN".to_string()); - - // Position, durée et métadonnées depuis GetPositionInfo - let position_info = renderer.playback_position().ok(); - - let (position_ms, duration_ms) = position_info - .as_ref() - .and_then(|pos| { - let position = parse_hms_to_ms(pos.rel_time.as_deref()); - let duration = parse_hms_to_ms(pos.track_duration.as_deref()); - Some((position, duration)) - }) - .unwrap_or((None, None)); - - // Extraire les métadonnées depuis le XML track_metadata si disponible - let soap_metadata = position_info.and_then(|pos| { - match &pos.track_metadata { - Some(xml) => { - debug!("GetPositionInfo returned metadata XML: {}", xml); - use crate::openhome_client::parse_track_metadata_from_didl; - match parse_track_metadata_from_didl(xml) { - Some(metadata) => { - debug!("Successfully parsed metadata: title={:?}, artist={:?}", - metadata.title, metadata.artist); - Some(metadata) - } - None => { - debug!("Failed to parse metadata XML"); - None - } - } - } - None => { - debug!("GetPositionInfo returned no metadata XML"); - None - } - } - }); - - // Volume et mute - let volume = renderer - .volume() - .ok() - .and_then(|v| u8::try_from(v).ok()); - let mute = renderer.mute().ok(); - - (transport_state, position_ms, duration_ms, volume, mute, soap_metadata) - }); - - let (transport_state, position_ms, duration_ms, volume, mute, soap_metadata) = - time::timeout(STATE_QUERY_TIMEOUT, state_task) - .await - .map_err(|_| { - warn!( - "State query for renderer {} exceeded {:?}", - renderer_id, STATE_QUERY_TIMEOUT - ); - ( - StatusCode::GATEWAY_TIMEOUT, - Json(ErrorResponse { - error: format!( - "State query timed out after {}s", - STATE_QUERY_TIMEOUT.as_secs() - ), - }), - ) - })? - .map_err(|e| { - warn!("Task join error during state query: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { - error: format!("Internal task error: {}", e), - }), - ) - })?; - - // Queue (non-blocking, local data) - // Pour les renderers OpenHome, on utilise la playlist native au lieu de la PlaybackQueue - let queue_len = state +#[cfg(feature = "pmoserver")] +#[utoipa::path( + get, + path = "/renderers/{renderer_id}/full", + params( + ("renderer_id" = String, Path, description = "ID unique du renderer") + ), + responses( + (status = 200, description = "Snapshot complet du renderer", body = FullRendererSnapshot), + (status = 404, description = "Renderer non trouvé", body = ErrorResponse) + ), + tag = "control" +)] +async fn get_renderer_full_snapshot( + State(state): State, + Path(renderer_id): Path, +) -> Result, (StatusCode, Json)> { + let rid = RendererId(renderer_id.clone()); + let snapshot = state .control_point - .get_openhome_playlist_len(&rid) - .ok() - .or_else(|| { - state - .control_point - .get_queue_snapshot(&rid) - .ok() - .map(|q| q.len()) - }) - .unwrap_or(0); + .renderer_full_snapshot(&rid) + .map_err(|err| map_snapshot_error(renderer_id, err))?; - // Playlist binding (non-blocking, local data) - let attached_playlist = state - .control_point - .current_queue_playlist_binding(&rid) - .map( - |(server_id, container_id, has_seen_update)| AttachedPlaylistInfo { - server_id: server_id.0, - container_id, - has_seen_update, - }, - ); - - // Current track metadata: - // 1. Essayer d'abord le snapshot en mémoire (rapide, mis à jour par SSE) - // 2. Si None et qu'on a des métadonnées SOAP fraîches, les utiliser - let snapshot_metadata = state - .control_point - .get_current_track_metadata(&rid); - - debug!( - "Renderer {} - snapshot_metadata: {:?}, soap_metadata: {:?}", - renderer_id, - snapshot_metadata.as_ref().map(|m| (&m.title, &m.artist)), - soap_metadata.as_ref().map(|m| (&m.title, &m.artist)) - ); - - let current_track = snapshot_metadata - .or(soap_metadata) - .map(|metadata| CurrentTrackMetadata { - title: metadata.title, - artist: metadata.artist, - album: metadata.album, - album_art_uri: metadata.album_art_uri, - }); - - Ok(Json(RendererState { - id: info.id.0.clone(), - friendly_name: info.friendly_name.clone(), - transport_state, - position_ms, - duration_ms, - volume, - mute, - queue_len, - attached_playlist, - current_track, - })) + Ok(Json(snapshot)) } /// GET /control/renderers/{renderer_id}/queue - Récupère la queue d'un renderer @@ -324,53 +189,12 @@ async fn get_renderer_queue( Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { let rid = RendererId(renderer_id.clone()); - - // Verify renderer exists in registry - let _renderer = state + let snapshot = state .control_point - .music_renderer_by_id(&rid) - .ok_or_else(|| { - ( - StatusCode::NOT_FOUND, - Json(ErrorResponse { - error: format!("Renderer {} not found", renderer_id), - }), - ) - })?; + .renderer_full_snapshot(&rid) + .map_err(|err| map_snapshot_error(renderer_id, err))?; - // Get queue snapshot - if renderer not yet in runtime (just discovered), - // return empty queue instead of error - let (items, current_index) = state - .control_point - .get_full_queue_snapshot(&rid) - .unwrap_or_else(|_| { - debug!( - renderer = renderer_id.as_str(), - "Renderer not yet initialized in runtime, returning empty queue" - ); - (vec![], None) - }); - - let queue_items: Vec = items - .into_iter() - .enumerate() - .map(|(index, item)| QueueItem { - index, - uri: item.uri, - title: item.title, - artist: item.artist, - album: item.album, - album_art_uri: item.album_art_uri, - server_id: item.server_id.map(|s| s.0), - object_id: item.object_id, - }) - .collect(); - - Ok(Json(QueueSnapshot { - renderer_id, - items: queue_items, - current_index, - })) + Ok(Json(snapshot.queue)) } /// GET /control/renderers/{renderer_id}/binding - Récupère le binding playlist @@ -391,24 +215,17 @@ async fn get_renderer_binding( State(state): State, Path(renderer_id): Path, ) -> Result>, (StatusCode, Json)> { - let rid = RendererId(renderer_id); - - let binding = state + let rid = RendererId(renderer_id.clone()); + let snapshot = state .control_point - .current_queue_playlist_binding(&rid) - .map( - |(server_id, container_id, has_seen_update)| AttachedPlaylistInfo { - server_id: server_id.0, - container_id, - has_seen_update, - }, - ); + .renderer_full_snapshot(&rid) + .map_err(|err| map_snapshot_error(renderer_id, err))?; - Ok(Json(binding)) + Ok(Json(snapshot.binding)) } // ============================================================================ -// HANDLERS - CONTRÔLE TRANSPORT +// HANDLERS - TRANSPORT CONTROLS // ============================================================================ /// POST /control/renderers/{renderer_id}/play - Démarre la lecture @@ -431,7 +248,6 @@ async fn play_renderer( Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { let rid = RendererId(renderer_id.clone()); - let renderer = state .control_point .music_renderer_by_id(&rid) @@ -508,7 +324,6 @@ async fn pause_renderer( Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { let rid = RendererId(renderer_id.clone()); - let renderer = state .control_point .music_renderer_by_id(&rid) @@ -585,9 +400,7 @@ async fn stop_renderer( Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { let rid = RendererId(renderer_id.clone()); - - // Verify renderer exists - let _renderer = state + state .control_point .music_renderer_by_id(&rid) .ok_or_else(|| { @@ -599,9 +412,9 @@ async fn stop_renderer( ) })?; - let control_point = state.control_point.clone(); - let rid_clone = rid.clone(); - let stop_task = tokio::task::spawn_blocking(move || control_point.user_stop(&rid_clone)); + let control_point = Arc::clone(&state.control_point); + let rid_for_task = rid.clone(); + let stop_task = tokio::task::spawn_blocking(move || control_point.user_stop(&rid_for_task)); time::timeout(TRANSPORT_COMMAND_TIMEOUT, stop_task) .await @@ -644,7 +457,7 @@ async fn stop_renderer( })) } -/// POST /control/renderers/{renderer_id}/resume - Reprend la lecture depuis le morceau actuel +/// POST /control/renderers/{renderer_id}/resume - Reprend la lecture depuis la queue #[cfg(feature = "pmoserver")] #[utoipa::path( post, @@ -664,25 +477,36 @@ async fn resume_renderer( Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { let rid = RendererId(renderer_id.clone()); - let control_point = state.control_point.clone(); - let rid_clone = rid.clone(); + state + .control_point + .music_renderer_by_id(&rid) + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: format!("Renderer {} not found", renderer_id), + }), + ) + })?; + let control_point = Arc::clone(&state.control_point); + let rid_for_task = rid.clone(); let resume_task = - tokio::task::spawn_blocking(move || control_point.play_current_from_queue(&rid_clone)); + tokio::task::spawn_blocking(move || control_point.play_current_from_queue(&rid_for_task)); - time::timeout(QUEUE_COMMAND_TIMEOUT, resume_task) + time::timeout(TRANSPORT_COMMAND_TIMEOUT, resume_task) .await .map_err(|_| { warn!( "Resume command for renderer {} exceeded {:?}", - renderer_id, QUEUE_COMMAND_TIMEOUT + renderer_id, TRANSPORT_COMMAND_TIMEOUT ); ( StatusCode::GATEWAY_TIMEOUT, Json(ErrorResponse { error: format!( "Resume command timed out after {}s", - QUEUE_COMMAND_TIMEOUT.as_secs() + TRANSPORT_COMMAND_TIMEOUT.as_secs() ), }), ) @@ -697,21 +521,24 @@ async fn resume_renderer( ) })? .map_err(|e| { - warn!("Failed to resume renderer {}: {}", renderer_id, e); + warn!( + "Failed to resume playback for renderer {}: {}", + renderer_id, e + ); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { - error: format!("Failed to resume: {}", e), + error: format!("Failed to resume playback: {}", e), }), ) })?; Ok(Json(SuccessResponse { - message: "Playback resumed from current track".to_string(), + message: "Playback resumed".to_string(), })) } -/// POST /control/renderers/{renderer_id}/next - Passe au morceau suivant de la queue +/// POST /control/renderers/{renderer_id}/next - Passe au morceau suivant #[cfg(feature = "pmoserver")] #[utoipa::path( post, @@ -720,7 +547,7 @@ async fn resume_renderer( ("renderer_id" = String, Path, description = "ID unique du renderer") ), responses( - (status = 200, description = "Passage au suivant", body = SuccessResponse), + (status = 200, description = "Piste suivante lancée", body = SuccessResponse), (status = 404, description = "Renderer non trouvé", body = ErrorResponse), (status = 500, description = "Erreur lors de l'exécution", body = ErrorResponse) ), @@ -731,25 +558,36 @@ async fn next_renderer( Path(renderer_id): Path, ) -> Result, (StatusCode, Json)> { let rid = RendererId(renderer_id.clone()); - let control_point = state.control_point.clone(); - let rid_clone = rid.clone(); + state + .control_point + .music_renderer_by_id(&rid) + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: format!("Renderer {} not found", renderer_id), + }), + ) + })?; + let control_point = Arc::clone(&state.control_point); + let rid_for_task = rid.clone(); let next_task = - tokio::task::spawn_blocking(move || control_point.play_next_from_queue(&rid_clone)); + tokio::task::spawn_blocking(move || control_point.play_next_from_queue(&rid_for_task)); - time::timeout(QUEUE_COMMAND_TIMEOUT, next_task) + time::timeout(TRANSPORT_COMMAND_TIMEOUT, next_task) .await .map_err(|_| { warn!( "Next command for renderer {} exceeded {:?}", - renderer_id, QUEUE_COMMAND_TIMEOUT + renderer_id, TRANSPORT_COMMAND_TIMEOUT ); ( StatusCode::GATEWAY_TIMEOUT, Json(ErrorResponse { error: format!( "Next command timed out after {}s", - QUEUE_COMMAND_TIMEOUT.as_secs() + TRANSPORT_COMMAND_TIMEOUT.as_secs() ), }), ) @@ -765,26 +603,22 @@ async fn next_renderer( })? .map_err(|e| { warn!( - "Failed to advance queue for renderer {}: {}", + "Failed to skip to next track for renderer {}: {}", renderer_id, e ); ( StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { - error: format!("Failed to advance queue: {}", e), + error: format!("Failed to skip to next track: {}", e), }), ) })?; Ok(Json(SuccessResponse { - message: "Advanced to next track".to_string(), + message: "Skipped to next track".to_string(), })) } -// ============================================================================ -// HANDLERS - VOLUME -// ============================================================================ - /// POST /control/renderers/{renderer_id}/volume/set - Définit le volume #[cfg(feature = "pmoserver")] #[utoipa::path( @@ -1149,7 +983,7 @@ async fn attach_playlist_binding( // Spawn blocking task and wait for completion with timeout let attach_task = tokio::task::spawn_blocking(move || { - control_point.attach_queue_to_playlist(&rid, sid, container_id); + control_point.attach_queue_to_playlist_with_options(&rid, sid, container_id, req.auto_play) }); time::timeout(ATTACH_PLAYLIST_TIMEOUT, attach_task) @@ -1177,12 +1011,28 @@ async fn attach_playlist_binding( error: format!("Internal task error: {}", e), }), ) + })? + .map_err(|e| { + warn!( + renderer = renderer_id.as_str(), + server = req.server_id.as_str(), + container = req.container_id.as_str(), + error = %e, + "Failed to attach playlist" + ); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: format!("Failed to attach playlist: {}", e), + }), + ) })?; debug!( renderer = renderer_id.as_str(), server = req.server_id.as_str(), container = req.container_id.as_str(), + auto_play = req.auto_play, "Playlist attached via HTTP API" ); @@ -1541,7 +1391,7 @@ async fn play_content( let object_id_for_log = object_id.clone(); // Get renderer to verify it exists - let renderer = state + state .control_point .music_renderer_by_id(&rid) .ok_or_else(|| { @@ -1564,35 +1414,34 @@ async fn play_content( return Err(anyhow::anyhow!("No playable content found")); } - // Clear queue - control_point.clear_queue(&rid)?; - - // Enqueue items - control_point.enqueue_items(&rid, items.clone())?; - - // Start playback - // Pour les renderers OpenHome, play_current_from_queue() va gérer automatiquement - // la lecture depuis la playlist native si elle existe - control_point.play_current_from_queue(&rid)?; - - // Auto-bind if playing a container (playlist/album) - // Rule: if multiple items, it's a container that should be bound - // We use _without_refresh because the queue was already populated by enqueue_items above if items.len() > 1 { debug!( renderer = rid.0.as_str(), server = sid.0.as_str(), object = object_id.as_str(), item_count = items.len(), - "Auto-binding playlist to renderer queue (without initial refresh)" + "Auto-binding playlist to renderer queue (auto_play = true)" ); - control_point.attach_queue_to_playlist_without_refresh( + control_point.attach_queue_to_playlist_with_options( &rid, sid.clone(), object_id.clone(), - ); + true, + )?; + return Ok(()); } + // Clear queue + control_point.clear_queue(&rid)?; + + // Enqueue items + control_point.enqueue_items(&rid, items)?; + + // Start playback + // Pour les renderers OpenHome, play_current_from_queue() va gérer automatiquement + // la lecture depuis la playlist native si elle existe + control_point.play_current_from_queue(&rid)?; + Ok::<(), anyhow::Error>(()) }); @@ -1910,6 +1759,24 @@ async fn browse_container( // HELPERS // ============================================================================ +#[cfg(feature = "pmoserver")] +fn map_snapshot_error( + renderer_id: String, + err: anyhow::Error, +) -> (StatusCode, Json) { + warn!( + renderer = renderer_id.as_str(), + error = %err, + "Failed to build renderer snapshot" + ); + ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { + error: format!("Renderer {} not found", renderer_id), + }), + ) +} + #[cfg(feature = "pmoserver")] fn map_openhome_error( renderer_id: &RendererId, @@ -2063,21 +1930,6 @@ fn state_to_string(state: crate::PlaybackState) -> String { } } -#[cfg(feature = "pmoserver")] -fn parse_hms_to_ms(hms: Option<&str>) -> Option { - let hms = hms?; - let parts: Vec<&str> = hms.split(':').collect(); - if parts.len() != 3 { - return None; - } - - let hours: u64 = parts[0].parse().ok()?; - let minutes: u64 = parts[1].parse().ok()?; - let seconds: u64 = parts[2].parse().ok()?; - - Some((hours * 3600 + minutes * 60 + seconds) * 1000) -} - // ============================================================================ // ROUTER & TRAIT // ============================================================================ @@ -2089,6 +1941,10 @@ pub fn create_api_router(state: ControlPointState, control_point: Arc Result { - quick_xml::de::from_str(input) + let sanitized = sanitize_singleton_elements(input); + quick_xml::de::from_str(sanitized.as_ref()) } fn format_name() -> &'static str { @@ -536,6 +540,80 @@ fn text_element(name: &str, value: &str) -> Element { e } +const SINGLETON_ELEMENTS: &[&str] = &[ + "dc:title", + "title", + "dc:creator", + "creator", + "upnp:class", + "class", + "upnp:artist", + "artist", + "upnp:album", + "album", + "upnp:genre", + "genre", + "upnp:albumArtURI", + "albumArtURI", + "dc:date", + "date", + "upnp:originalTrackNumber", + "originalTrackNumber", +]; + +fn sanitize_singleton_elements(input: &str) -> Cow<'_, str> { + if !SINGLETON_ELEMENTS.iter().any(|tag| input.contains(tag)) { + return Cow::Borrowed(input); + } + + let mut cursor = Cursor::new(input.as_bytes()); + let mut root = match Element::parse(&mut cursor) { + Ok(elem) => elem, + Err(_) => return Cow::Borrowed(input), + }; + + if !dedup_singleton_children(&mut root) { + return Cow::Borrowed(input); + } + + let mut buf = Vec::new(); + if root.write(&mut buf).is_err() { + return Cow::Borrowed(input); + } + + String::from_utf8(buf) + .map(Cow::Owned) + .unwrap_or_else(|_| Cow::Borrowed(input)) +} + +fn dedup_singleton_children(element: &mut Element) -> bool { + let mut changed = false; + let mut seen: HashSet = HashSet::new(); + let mut idx = 0; + + while idx < element.children.len() { + let mut remove_current = false; + if let XMLNode::Element(child_elem) = &mut element.children[idx] { + if SINGLETON_ELEMENTS.contains(&child_elem.name.as_str()) + && !seen.insert(child_elem.name.clone()) + { + remove_current = true; + changed = true; + } else if dedup_singleton_children(child_elem) { + changed = true; + } + } + + if remove_current { + element.children.remove(idx); + } else { + idx += 1; + } + } + + changed +} + impl ToXmlElement for DIDLLite { fn to_xml_element(&self) -> Element { let mut root = Element::new("DIDL-Lite");