Des debug après la simplification
This commit is contained in:
@@ -36,18 +36,44 @@ impl WebAppExt for Server {
|
|||||||
where
|
where
|
||||||
W: RustEmbed + Clone + Send + Sync + 'static,
|
W: RustEmbed + Clone + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
let path = path.to_string();
|
let mount_path = normalize_mount_path(path);
|
||||||
|
mount_spa_with_trailing_slash_redirect::<W>(self, &mount_path).await;
|
||||||
self.add_spa::<W>(&path).await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn add_webapp_with_redirect<W>(&mut self, path: &str)
|
async fn add_webapp_with_redirect<W>(&mut self, path: &str)
|
||||||
where
|
where
|
||||||
W: RustEmbed + Clone + Send + Sync + 'static,
|
W: RustEmbed + Clone + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
let path = path.to_string();
|
let mount_path = normalize_mount_path(path);
|
||||||
|
|
||||||
self.add_spa::<W>(&path).await;
|
mount_spa_with_trailing_slash_redirect::<W>(self, &mount_path).await;
|
||||||
self.add_redirect("/", &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<W>(server: &mut Server, path: &str)
|
||||||
|
where
|
||||||
|
W: RustEmbed + Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
server.add_spa::<W>(path).await;
|
||||||
|
|
||||||
|
if path != "/" {
|
||||||
|
let trailing = format!("{}/", path.trim_end_matches('/'));
|
||||||
|
server.add_redirect(&trailing, path).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ const {
|
|||||||
addToQueue,
|
addToQueue,
|
||||||
attachAndPlayPlaylist,
|
attachAndPlayPlaylist,
|
||||||
attachPlaylist,
|
attachPlaylist,
|
||||||
fetchQueue
|
|
||||||
} = useRenderers()
|
} = useRenderers()
|
||||||
const uiStore = useUIStore()
|
const uiStore = useUIStore()
|
||||||
|
|
||||||
@@ -127,9 +126,7 @@ async function handlePlayContainer(containerId: string, rendererId: string) {
|
|||||||
|
|
||||||
async function handleQueueContainer(containerId: string, rendererId: string) {
|
async function handleQueueContainer(containerId: string, rendererId: string) {
|
||||||
try {
|
try {
|
||||||
// Attacher la playlist (sans démarrer la lecture)
|
|
||||||
await attachPlaylist(rendererId, props.serverId, containerId)
|
await attachPlaylist(rendererId, props.serverId, containerId)
|
||||||
await fetchQueue(rendererId, true)
|
|
||||||
uiStore.notifySuccess('Playlist attachée à la queue !')
|
uiStore.notifySuccess('Playlist attachée à la queue !')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
|
|||||||
@@ -1,292 +1,256 @@
|
|||||||
/**
|
/**
|
||||||
* Composable pour gérer les renderers
|
* Composable pour gérer les renderers.
|
||||||
* Architecture simple : l'API est la source de vérité, SSE déclenche des re-fetch
|
* 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 { api } from '../services/pmocontrol/api'
|
||||||
import { sse } from '../services/pmocontrol/sse'
|
import { sse } from '../services/pmocontrol/sse'
|
||||||
import type {
|
import type {
|
||||||
RendererSummary,
|
RendererSummary,
|
||||||
RendererState,
|
RendererState,
|
||||||
QueueSnapshot,
|
QueueSnapshot,
|
||||||
AttachedPlaylistInfo
|
AttachedPlaylistInfo,
|
||||||
|
FullRendererSnapshot,
|
||||||
} from '../services/pmocontrol/types'
|
} from '../services/pmocontrol/types'
|
||||||
|
|
||||||
// Cache global partagé entre toutes les instances du composable
|
interface RendererSnapshotState {
|
||||||
const renderersCache = ref<Map<string, RendererSummary>>(new Map())
|
snapshots: Map<string, FullRendererSnapshot>
|
||||||
const statesCache = ref<Map<string, RendererState>>(new Map())
|
lastSnapshotAt: Map<string, number>
|
||||||
const queuesCache = ref<Map<string, QueueSnapshot>>(new Map())
|
lastEventAt: Map<string, number>
|
||||||
const bindingsCache = ref<Map<string, AttachedPlaylistInfo | null>>(new Map())
|
loadingIds: Set<string>
|
||||||
|
selectedRendererId: string | null
|
||||||
// Timestamps pour éviter les re-fetch trop fréquents
|
|
||||||
const lastFetch = {
|
|
||||||
renderers: 0,
|
|
||||||
states: new Map<string, number>(),
|
|
||||||
queues: new Map<string, number>(),
|
|
||||||
bindings: new Map<string, number>()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const CACHE_DURATION_MS = 2000 // 2 secondes
|
const renderersCache = ref<Map<string, RendererSummary>>(new Map())
|
||||||
|
const RENDERERS_CACHE_MS = 2000
|
||||||
|
const lastRenderersFetch = ref(0)
|
||||||
|
|
||||||
|
const snapshotState = reactive<RendererSnapshotState>({
|
||||||
|
snapshots: reactive(new Map<string, FullRendererSnapshot>()),
|
||||||
|
lastSnapshotAt: reactive(new Map<string, number>()),
|
||||||
|
lastEventAt: reactive(new Map<string, number>()),
|
||||||
|
loadingIds: reactive(new Set<string>()),
|
||||||
|
selectedRendererId: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
// Connecter SSE une seule fois au module
|
|
||||||
let sseConnected = false
|
let sseConnected = false
|
||||||
function ensureSSEConnected() {
|
function ensureSSEConnected() {
|
||||||
if (sseConnected) return
|
if (sseConnected) return
|
||||||
|
|
||||||
sse.onRendererEvent((event) => {
|
sse.onRendererEvent((event) => {
|
||||||
const rendererId = event.renderer_id
|
const rendererId = event.renderer_id
|
||||||
|
const timestamp = Date.parse(event.timestamp ?? '') || Date.now()
|
||||||
switch (event.type) {
|
snapshotState.lastEventAt.set(rendererId, timestamp)
|
||||||
case 'state_changed':
|
const lastSnapshot = snapshotState.lastSnapshotAt.get(rendererId) ?? 0
|
||||||
case 'position_changed':
|
if (!snapshotState.snapshots.has(rendererId) || timestamp > lastSnapshot) {
|
||||||
case 'volume_changed':
|
void fetchRendererSnapshot(rendererId, { force: true })
|
||||||
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
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
sseConnected = true
|
sseConnected = true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const allRenderers = computed(() => Array.from(renderersCache.value.values()))
|
||||||
* Composable principal pour gérer les renderers
|
const onlineRenderers = computed(() => allRenderers.value.filter((r) => r.online))
|
||||||
*/
|
const allSnapshots = computed(() => Array.from(snapshotState.snapshots.values()))
|
||||||
export function useRenderers() {
|
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()
|
ensureSSEConnected()
|
||||||
|
|
||||||
const loading = ref(false)
|
|
||||||
const error = ref<string | null>(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()
|
const now = Date.now()
|
||||||
if (!force && now - lastFetch.renderers < CACHE_DURATION_MS) {
|
if (!force && now - lastRenderersFetch.value < RENDERERS_CACHE_MS) {
|
||||||
return // Cache encore valide
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
const data = await api.getRenderers()
|
const data = await api.getRenderers()
|
||||||
|
renderersCache.value = new Map(data.map((renderer) => [renderer.id, renderer]))
|
||||||
renderersCache.value.clear()
|
lastRenderersFetch.value = now
|
||||||
data.forEach(r => renderersCache.value.set(r.id, r))
|
} catch (err) {
|
||||||
lastFetch.renderers = now
|
error.value = err instanceof Error ? err.message : 'Erreur fetch renderers'
|
||||||
} catch (e) {
|
console.error('[useRenderers] Erreur fetch:', err)
|
||||||
error.value = e instanceof Error ? e.message : 'Erreur fetch renderers'
|
|
||||||
console.error('[useRenderers] Erreur fetch:', e)
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch renderer state
|
if (snapshotState.loadingIds.has(rendererId)) {
|
||||||
async function fetchRendererState(id: string, force = false) {
|
return
|
||||||
const now = Date.now()
|
|
||||||
const last = lastFetch.states.get(id) || 0
|
|
||||||
if (!force && now - last < CACHE_DURATION_MS) {
|
|
||||||
return // Cache encore valide
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
snapshotState.loadingIds.add(rendererId)
|
||||||
try {
|
try {
|
||||||
const state = await api.getRendererState(id)
|
const snapshot = await api.getRendererFullSnapshot(rendererId)
|
||||||
statesCache.value.set(id, state)
|
snapshotState.snapshots.set(rendererId, snapshot)
|
||||||
lastFetch.states.set(id, now)
|
snapshotState.lastSnapshotAt.set(rendererId, Date.now())
|
||||||
} catch (e) {
|
} catch (err) {
|
||||||
console.error(`[useRenderers] Erreur fetch state ${id}:`, e)
|
console.error(`[useRenderers] Erreur snapshot ${rendererId}:`, err)
|
||||||
}
|
} finally {
|
||||||
|
snapshotState.loadingIds.delete(rendererId)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch queue
|
// Transport controls
|
||||||
async function fetchQueue(id: string, force = false) {
|
async function play(id: string) {
|
||||||
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)
|
await api.play(id)
|
||||||
// SSE mettra à jour l'état automatiquement
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async function resumeOrPlayFromQueue(id: string) {
|
async function resumeOrPlayFromQueue(id: string) {
|
||||||
const state = statesCache.value.get(id)
|
const snapshot = snapshotState.snapshots.get(id)
|
||||||
if (!state) {
|
if (!snapshot) {
|
||||||
throw new Error(`Renderer ${id} non trouvé`)
|
throw new Error(`Renderer ${id} non trouvé`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const state = snapshot.state
|
||||||
if (state.transport_state === 'PAUSED') {
|
if (state.transport_state === 'PAUSED') {
|
||||||
return play(id)
|
return play(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((state.transport_state === 'STOPPED' || state.transport_state === 'NO_MEDIA') &&
|
if (
|
||||||
state.queue_len && state.queue_len > 0) {
|
['STOPPED', 'NO_MEDIA'].includes(state.transport_state) &&
|
||||||
|
snapshot.queue.items.length > 0
|
||||||
|
) {
|
||||||
return api.resume(id)
|
return api.resume(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error('La file d\'attente est vide. Ajoutez des morceaux avant de démarrer la lecture.')
|
throw new Error('La file d\'attente est vide. Ajoutez des morceaux avant de démarrer la lecture.')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pause(id: string) {
|
async function pause(id: string) {
|
||||||
await api.pause(id)
|
await api.pause(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stop(id: string) {
|
async function stop(id: string) {
|
||||||
await api.stop(id)
|
await api.stop(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function next(id: string) {
|
async function next(id: string) {
|
||||||
await api.next(id)
|
await api.next(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Volume controls
|
// Volume controls
|
||||||
async function setVolume(id: string, volume: number) {
|
async function setVolume(id: string, volume: number) {
|
||||||
await api.setVolume(id, volume)
|
await api.setVolume(id, volume)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function volumeUp(id: string) {
|
async function volumeUp(id: string) {
|
||||||
await api.volumeUp(id)
|
await api.volumeUp(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function volumeDown(id: string) {
|
async function volumeDown(id: string) {
|
||||||
await api.volumeDown(id)
|
await api.volumeDown(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleMute(id: string) {
|
async function toggleMute(id: string) {
|
||||||
await api.toggleMute(id)
|
await api.toggleMute(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Playlist binding
|
// Playlist binding
|
||||||
async function attachPlaylist(rendererId: string, serverId: string, containerId: string) {
|
async function attachPlaylist(
|
||||||
await api.attachPlaylist(rendererId, serverId, containerId)
|
rendererId: string,
|
||||||
// Re-fetch binding et queue
|
serverId: string,
|
||||||
await fetchBinding(rendererId, true)
|
containerId: string,
|
||||||
await fetchQueue(rendererId, true)
|
options?: { autoPlay?: boolean },
|
||||||
}
|
) {
|
||||||
|
await api.attachPlaylist(rendererId, serverId, containerId, options?.autoPlay ?? false)
|
||||||
|
}
|
||||||
|
|
||||||
async function detachPlaylist(rendererId: string) {
|
async function detachPlaylist(rendererId: string) {
|
||||||
await api.detachPlaylist(rendererId)
|
await api.detachPlaylist(rendererId)
|
||||||
bindingsCache.value.set(rendererId, null)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async function attachAndPlayPlaylist(rendererId: string, serverId: string, containerId: string) {
|
async function attachAndPlayPlaylist(
|
||||||
await api.attachPlaylist(rendererId, serverId, containerId)
|
rendererId: string,
|
||||||
await fetchBinding(rendererId, true)
|
serverId: string,
|
||||||
await fetchQueue(rendererId, true)
|
containerId: string,
|
||||||
}
|
) {
|
||||||
|
await attachPlaylist(rendererId, serverId, containerId, { autoPlay: true })
|
||||||
|
}
|
||||||
|
|
||||||
// Queue content
|
// Queue content
|
||||||
async function playContent(rendererId: string, serverId: string, objectId: string) {
|
async function playContent(rendererId: string, serverId: string, objectId: string) {
|
||||||
await api.playContent(rendererId, serverId, objectId)
|
await api.playContent(rendererId, serverId, objectId)
|
||||||
// SSE mettra à jour la queue
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async function addToQueue(rendererId: string, serverId: string, objectId: string) {
|
async function addToQueue(rendererId: string, serverId: string, objectId: string) {
|
||||||
await api.addToQueue(rendererId, serverId, objectId)
|
await api.addToQueue(rendererId, serverId, objectId)
|
||||||
// SSE mettra à jour la queue
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Getters pour un renderer spécifique
|
export function useRenderers() {
|
||||||
function getRendererById(id: string) {
|
ensureSSEConnected()
|
||||||
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 {
|
return {
|
||||||
// État
|
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
// Getters
|
// Collections
|
||||||
allRenderers,
|
allRenderers,
|
||||||
onlineRenderers,
|
onlineRenderers,
|
||||||
playingRenderers,
|
playingRenderers,
|
||||||
|
// Accessors
|
||||||
getRendererById,
|
getRendererById,
|
||||||
|
getSnapshotById,
|
||||||
getStateById,
|
getStateById,
|
||||||
getQueueById,
|
getQueueById,
|
||||||
getBindingById,
|
getBindingById,
|
||||||
// Actions fetch
|
isSnapshotLoading,
|
||||||
|
selectRenderer,
|
||||||
|
snapshotState,
|
||||||
|
// Fetchers
|
||||||
fetchRenderers,
|
fetchRenderers,
|
||||||
fetchRendererState,
|
fetchRendererSnapshot,
|
||||||
fetchQueue,
|
|
||||||
fetchBinding,
|
|
||||||
// Transport controls
|
// Transport controls
|
||||||
play,
|
play,
|
||||||
resumeOrPlayFromQueue,
|
resumeOrPlayFromQueue,
|
||||||
@@ -304,37 +268,32 @@ export function useRenderers() {
|
|||||||
attachAndPlayPlaylist,
|
attachAndPlayPlaylist,
|
||||||
// Queue content
|
// Queue content
|
||||||
playContent,
|
playContent,
|
||||||
addToQueue
|
addToQueue,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Composable pour un renderer spécifique (avec auto-refresh)
|
|
||||||
*/
|
|
||||||
export function useRenderer(rendererId: Ref<string>) {
|
export function useRenderer(rendererId: Ref<string>) {
|
||||||
ensureSSEConnected()
|
ensureSSEConnected()
|
||||||
|
|
||||||
const renderer = computed(() => renderersCache.value.get(rendererId.value))
|
const renderer = computed(() => renderersCache.value.get(rendererId.value))
|
||||||
const state = computed(() => statesCache.value.get(rendererId.value))
|
const snapshot = computed(() => snapshotState.snapshots.get(rendererId.value) ?? null)
|
||||||
const queue = computed(() => queuesCache.value.get(rendererId.value))
|
const state = computed(() => snapshot.value?.state ?? null)
|
||||||
const binding = computed(() => bindingsCache.value.get(rendererId.value))
|
const queue = computed(() => snapshot.value?.queue ?? null)
|
||||||
|
const binding = computed(() => snapshot.value?.binding ?? null)
|
||||||
|
|
||||||
// Auto-refresh au montage
|
async function refresh(force = true) {
|
||||||
const { fetchRendererState, fetchQueue, fetchBinding } = useRenderers()
|
|
||||||
|
|
||||||
async function refresh() {
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
fetchRendererState(rendererId.value, true),
|
fetchRenderers(force),
|
||||||
fetchQueue(rendererId.value, true),
|
fetchRendererSnapshot(rendererId.value, { force: true }),
|
||||||
fetchBinding(rendererId.value, true)
|
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
renderer,
|
renderer,
|
||||||
|
snapshot,
|
||||||
state,
|
state,
|
||||||
queue,
|
queue,
|
||||||
binding,
|
binding,
|
||||||
refresh
|
refresh,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import type {
|
import type {
|
||||||
RendererSummary,
|
RendererSummary,
|
||||||
RendererState,
|
RendererState,
|
||||||
|
FullRendererSnapshot,
|
||||||
QueueSnapshot,
|
QueueSnapshot,
|
||||||
AttachedPlaylistInfo,
|
AttachedPlaylistInfo,
|
||||||
MediaServerSummary,
|
MediaServerSummary,
|
||||||
@@ -68,6 +69,14 @@ class PMOControlAPI {
|
|||||||
return this.request<RendererState>(`/renderers/${encodeURIComponent(id)}`)
|
return this.request<RendererState>(`/renderers/${encodeURIComponent(id)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère le snapshot complet d'un renderer
|
||||||
|
* GET /api/control/renderers/{id}/full
|
||||||
|
*/
|
||||||
|
async getRendererFullSnapshot(id: string): Promise<FullRendererSnapshot> {
|
||||||
|
return this.request<FullRendererSnapshot>(`/renderers/${encodeURIComponent(id)}/full`)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Récupère la queue d'un renderer (avec current_index)
|
* Récupère la queue d'un renderer (avec current_index)
|
||||||
* GET /api/control/renderers/{id}/queue
|
* GET /api/control/renderers/{id}/queue
|
||||||
@@ -195,9 +204,14 @@ class PMOControlAPI {
|
|||||||
async attachPlaylist(
|
async attachPlaylist(
|
||||||
rendererId: string,
|
rendererId: string,
|
||||||
serverId: string,
|
serverId: string,
|
||||||
containerId: string
|
containerId: string,
|
||||||
|
autoPlay = false
|
||||||
): Promise<SuccessResponse> {
|
): Promise<SuccessResponse> {
|
||||||
const payload: AttachPlaylistRequest = { server_id: serverId, container_id: containerId }
|
const payload: AttachPlaylistRequest = {
|
||||||
|
server_id: serverId,
|
||||||
|
container_id: containerId,
|
||||||
|
auto_play: autoPlay,
|
||||||
|
}
|
||||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(rendererId)}/binding/attach`, {
|
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(rendererId)}/binding/attach`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
|
|||||||
@@ -77,6 +77,12 @@ export interface QueueSnapshot {
|
|||||||
current_index: number | null // Index de la piste en cours (null si rien en lecture)
|
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
|
// OPENHOME PLAYLIST
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -141,6 +147,7 @@ export interface VolumeSetRequest {
|
|||||||
export interface AttachPlaylistRequest {
|
export interface AttachPlaylistRequest {
|
||||||
server_id: string
|
server_id: string
|
||||||
container_id: string
|
container_id: string
|
||||||
|
auto_play?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlayContentRequest {
|
export interface PlayContentRequest {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const {
|
|||||||
onlineRenderers,
|
onlineRenderers,
|
||||||
getStateById,
|
getStateById,
|
||||||
fetchRenderers,
|
fetchRenderers,
|
||||||
fetchRendererState
|
fetchRendererSnapshot
|
||||||
} = useRenderers()
|
} = useRenderers()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -25,9 +25,8 @@ onMounted(async () => {
|
|||||||
await fetchRenderers()
|
await fetchRenderers()
|
||||||
await fetchServers()
|
await fetchServers()
|
||||||
|
|
||||||
// Charger les états de tous les renderers pour afficher les covers
|
|
||||||
for (const renderer of renderers.value) {
|
for (const renderer of renderers.value) {
|
||||||
fetchRendererState(renderer.id)
|
fetchRendererSnapshot(renderer.id, { force: true })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const uiStore = useUIStore()
|
|||||||
|
|
||||||
const rendererId = computed(() => route.params.id as string)
|
const rendererId = computed(() => route.params.id as string)
|
||||||
const { renderer, state, refresh } = useRenderer(toRef(() => rendererId.value))
|
const { renderer, state, refresh } = useRenderer(toRef(() => rendererId.value))
|
||||||
const { fetchRenderers } = useRenderers()
|
const { fetchRenderers, selectRenderer: selectRendererSnapshot } = useRenderers()
|
||||||
const openHomeSupported = computed(() => {
|
const openHomeSupported = computed(() => {
|
||||||
const current = renderer.value
|
const current = renderer.value
|
||||||
if (!current) return false
|
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
|
// Charger les données au montage si nécessaire
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Indiquer à uiStore quel renderer est sélectionné
|
|
||||||
uiStore.selectRenderer(rendererId.value)
|
uiStore.selectRenderer(rendererId.value)
|
||||||
|
selectRendererSnapshot(rendererId.value)
|
||||||
|
|
||||||
// Charger toutes les données du renderer
|
// Charger toutes les données du renderer
|
||||||
if (!renderer.value) {
|
if (!renderer.value) {
|
||||||
@@ -76,6 +76,7 @@ watch(rendererId, () => {
|
|||||||
// Nettoyer la sélection au démontage
|
// Nettoyer la sélection au démontage
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
uiStore.selectRenderer(null)
|
uiStore.selectRenderer(null)
|
||||||
|
selectRendererSnapshot(null)
|
||||||
})
|
})
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
|
|||||||
@@ -746,11 +746,9 @@ impl App {
|
|||||||
self.pending_binding_container.clone(),
|
self.pending_binding_container.clone(),
|
||||||
self.renderer_id(),
|
self.renderer_id(),
|
||||||
) {
|
) {
|
||||||
self.control_point.attach_queue_to_playlist(
|
self.control_point
|
||||||
&renderer_id,
|
.attach_queue_to_playlist(&renderer_id, server.id.clone(), container.clone())
|
||||||
server.id.clone(),
|
.context("Failed to attach queue to playlist")?;
|
||||||
container.clone(),
|
|
||||||
);
|
|
||||||
self.ui_state
|
self.ui_state
|
||||||
.set_status(format!("File liée à '{}'", container));
|
.set_status(format!("File liée à '{}'", container));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,11 +134,13 @@ fn main() -> Result<()> {
|
|||||||
.context("Failed to enqueue playback items")?;
|
.context("Failed to enqueue playback items")?;
|
||||||
|
|
||||||
// Attach queue to live playlist container
|
// Attach queue to live playlist container
|
||||||
control_point.attach_queue_to_playlist(
|
control_point
|
||||||
|
.attach_queue_to_playlist(
|
||||||
&renderer_id,
|
&renderer_id,
|
||||||
server_info.id.clone(),
|
server_info.id.clone(),
|
||||||
live_playlist_container.id.clone(),
|
live_playlist_container.id.clone(),
|
||||||
);
|
)
|
||||||
|
.context("Failed to attach queue to live playlist container")?;
|
||||||
println!(
|
println!(
|
||||||
"✓ Queue attached to Live Playlist container '{}' (id: {}) on server '{}'",
|
"✓ Queue attached to Live Playlist container '{}' (id: {}) on server '{}'",
|
||||||
live_playlist_container.title, live_playlist_container.id, server_info.friendly_name
|
live_playlist_container.title, live_playlist_container.id, server_info.friendly_name
|
||||||
|
|||||||
@@ -127,11 +127,9 @@ fn main() -> Result<()> {
|
|||||||
|
|
||||||
// Attach queue to playlist container if we found one
|
// Attach queue to playlist container if we found one
|
||||||
if let Some(container_id) = bound_container_id {
|
if let Some(container_id) = bound_container_id {
|
||||||
control_point.attach_queue_to_playlist(
|
control_point
|
||||||
&renderer_id,
|
.attach_queue_to_playlist(&renderer_id, server_info.id.clone(), container_id.clone())
|
||||||
server_info.id.clone(),
|
.context("Failed to attach queue to playlist container")?;
|
||||||
container_id.clone(),
|
|
||||||
);
|
|
||||||
println!(
|
println!(
|
||||||
"✓ Queue attached to playlist container {} on server {}",
|
"✓ Queue attached to playlist container {} on server {}",
|
||||||
container_id, server_info.friendly_name
|
container_id, server_info.friendly_name
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ use crate::media_server::{
|
|||||||
use crate::media_server_events::spawn_media_server_event_runtime;
|
use crate::media_server_events::spawn_media_server_event_runtime;
|
||||||
use crate::model::TrackMetadata;
|
use crate::model::TrackMetadata;
|
||||||
use crate::model::{MediaServerEvent, RendererEvent, RendererId, RendererInfo};
|
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_client::parse_track_metadata_from_didl;
|
||||||
use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
|
use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
|
||||||
use crate::openhome_renderer::{format_seconds, map_openhome_state};
|
use crate::openhome_renderer::{format_seconds, map_openhome_state};
|
||||||
@@ -68,6 +73,10 @@ pub enum OpenHomeAccessError {
|
|||||||
/// - lance un SsdpClient dans un thread,
|
/// - lance un SsdpClient dans un thread,
|
||||||
/// - passe les SsdpEvent au DiscoveryManager,
|
/// - passe les SsdpEvent au DiscoveryManager,
|
||||||
/// - applique les DeviceUpdate dans le DeviceRegistry.
|
/// - 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 {
|
pub struct ControlPoint {
|
||||||
registry: Arc<RwLock<DeviceRegistry>>,
|
registry: Arc<RwLock<DeviceRegistry>>,
|
||||||
event_bus: RendererEventBus,
|
event_bus: RendererEventBus,
|
||||||
@@ -575,7 +584,7 @@ impl ControlPoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// User-driven mutation: detach any playlist binding
|
// 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) {
|
if self.runtime.uses_openhome_playlist(renderer_id) {
|
||||||
let renderer = self.openhome_renderer(renderer_id)?;
|
let renderer = self.openhome_renderer(renderer_id)?;
|
||||||
@@ -628,7 +637,7 @@ impl ControlPoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// User-driven mutation: detach any playlist binding
|
// 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) {
|
if self.runtime.uses_openhome_playlist(renderer_id) {
|
||||||
self.enqueue_items_openhome(renderer_id, items)?;
|
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
|
/// Retourne les métadonnées courantes depuis le snapshot en mémoire
|
||||||
pub fn get_current_track_metadata(
|
pub fn get_current_track_metadata(&self, renderer_id: &RendererId) -> Option<TrackMetadata> {
|
||||||
&self,
|
|
||||||
renderer_id: &RendererId,
|
|
||||||
) -> Option<TrackMetadata> {
|
|
||||||
self.runtime.current_track_metadata(renderer_id)
|
self.runtime.current_track_metadata(renderer_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -726,6 +732,122 @@ impl ControlPoint {
|
|||||||
renderer.openhome_playlist_len()
|
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<FullRendererSnapshot> {
|
||||||
|
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<QueueItem> = 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<()> {
|
pub fn clear_openhome_playlist(&self, renderer_id: &RendererId) -> anyhow::Result<()> {
|
||||||
let renderer = self.openhome_renderer(renderer_id)?;
|
let renderer = self.openhome_renderer(renderer_id)?;
|
||||||
renderer.openhome_playlist_clear()?;
|
renderer.openhome_playlist_clear()?;
|
||||||
@@ -1048,37 +1170,35 @@ impl ControlPoint {
|
|||||||
renderer_id: &RendererId,
|
renderer_id: &RendererId,
|
||||||
server_id: ServerId,
|
server_id: ServerId,
|
||||||
container_id: String,
|
container_id: String,
|
||||||
) {
|
) -> anyhow::Result<()> {
|
||||||
self.attach_queue_to_playlist_internal(renderer_id, server_id, container_id, false);
|
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.
|
/// Attach a renderer queue to a playlist with explicit `auto_play` behaviour.
|
||||||
///
|
pub fn attach_queue_to_playlist_with_options(
|
||||||
/// 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(
|
|
||||||
&self,
|
&self,
|
||||||
renderer_id: &RendererId,
|
renderer_id: &RendererId,
|
||||||
server_id: ServerId,
|
server_id: ServerId,
|
||||||
container_id: String,
|
container_id: String,
|
||||||
) {
|
auto_play: bool,
|
||||||
self.attach_queue_to_playlist_internal(renderer_id, server_id, container_id, true);
|
) -> 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(
|
fn attach_queue_to_playlist_internal(
|
||||||
&self,
|
&self,
|
||||||
renderer_id: &RendererId,
|
renderer_id: &RendererId,
|
||||||
server_id: ServerId,
|
server_id: &ServerId,
|
||||||
container_id: String,
|
container_id: &str,
|
||||||
skip_initial_refresh: bool,
|
auto_play: bool,
|
||||||
) {
|
) -> anyhow::Result<()> {
|
||||||
let binding = PlaylistBinding {
|
let binding = PlaylistBinding {
|
||||||
server_id: server_id.clone(),
|
server_id: server_id.clone(),
|
||||||
container_id: container_id.clone(),
|
container_id: container_id.to_string(),
|
||||||
has_seen_update: false,
|
has_seen_update: false,
|
||||||
pending_refresh: !skip_initial_refresh,
|
pending_refresh: true,
|
||||||
auto_play_on_refresh: !skip_initial_refresh,
|
auto_play_on_refresh: auto_play,
|
||||||
};
|
};
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -1087,37 +1207,32 @@ impl ControlPoint {
|
|||||||
info!(
|
info!(
|
||||||
renderer = renderer_id.0.as_str(),
|
renderer = renderer_id.0.as_str(),
|
||||||
server = server_id.0.as_str(),
|
server = server_id.0.as_str(),
|
||||||
container = container_id.as_str(),
|
container = container_id,
|
||||||
skip_refresh = skip_initial_refresh,
|
auto_play,
|
||||||
"Queue attached to playlist container"
|
"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 {
|
self.emit_renderer_event(RendererEvent::BindingChanged {
|
||||||
id: renderer_id.clone(),
|
id: renderer_id.clone(),
|
||||||
binding: Some(binding),
|
binding: Some(binding),
|
||||||
});
|
});
|
||||||
|
|
||||||
if !skip_initial_refresh {
|
|
||||||
let mut auto_start_cb = |rid: &RendererId| self.start_queue_playback_if_idle(rid);
|
let mut auto_start_cb = |rid: &RendererId| self.start_queue_playback_if_idle(rid);
|
||||||
if let Err(err) = refresh_attached_queue_for(
|
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.registry,
|
||||||
&self.runtime,
|
&self.runtime,
|
||||||
&self.playlist_bindings,
|
&self.playlist_bindings,
|
||||||
renderer_id,
|
renderer_id,
|
||||||
&self.event_bus,
|
&self.event_bus,
|
||||||
Some(&mut auto_start_cb),
|
callback,
|
||||||
) {
|
)
|
||||||
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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Detach a renderer's queue from its associated playlist container.
|
/// 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
|
/// After calling this, the queue will no longer be automatically refreshed
|
||||||
/// from the server. If no binding existed, this is a no-op.
|
/// from the server. If no binding existed, this is a no-op.
|
||||||
pub fn detach_queue_playlist(&self, renderer_id: &RendererId) {
|
pub fn detach_queue_playlist(&self, renderer_id: &RendererId) {
|
||||||
let removed = {
|
self.detach_playlist_binding(renderer_id, "api_detach");
|
||||||
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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Query the current playlist binding for a renderer's queue, if any.
|
/// 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.)
|
/// Invariant: every user-driven queue mutation **must** call this method so
|
||||||
/// to ensure that explicit user actions break the automatic refresh binding.
|
/// that bindings never become out of sync with the local queue snapshot.
|
||||||
fn detach_binding_on_user_mutation(&self, renderer_id: &RendererId, reason: &str) {
|
fn detach_playlist_binding(&self, renderer_id: &RendererId, reason: &str) {
|
||||||
|
let removed = {
|
||||||
let mut bindings = self.playlist_bindings.lock().unwrap();
|
let mut bindings = self.playlist_bindings.lock().unwrap();
|
||||||
if let Some(binding) = bindings.remove(renderer_id) {
|
bindings.remove(renderer_id)
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(binding) = removed {
|
||||||
info!(
|
info!(
|
||||||
renderer = renderer_id.0.as_str(),
|
renderer = renderer_id.0.as_str(),
|
||||||
server = binding.server_id.0.as_str(),
|
server = binding.server_id.0.as_str(),
|
||||||
container = binding.container_id.as_str(),
|
container = binding.container_id.as_str(),
|
||||||
reason = reason,
|
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<u64>, Option<u64>) {
|
||||||
|
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<u64> {
|
||||||
|
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)]
|
#[derive(Clone, Default)]
|
||||||
struct RendererRuntimeSnapshot {
|
struct RendererRuntimeSnapshot {
|
||||||
state: Option<PlaybackState>,
|
state: Option<PlaybackState>,
|
||||||
@@ -1465,6 +1610,20 @@ impl RuntimeState {
|
|||||||
.and_then(|entry| entry.snapshot.last_metadata.clone())
|
.and_then(|entry| entry.snapshot.last_metadata.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
fn renderer_snapshot_bundle(
|
||||||
|
&self,
|
||||||
|
id: &RendererId,
|
||||||
|
) -> (RendererRuntimeSnapshot, Vec<PlaybackItem>, Option<usize>) {
|
||||||
|
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)> {
|
fn dequeue_next(&self, id: &RendererId) -> Option<(PlaybackItem, usize)> {
|
||||||
let mut entries = self.entries.lock().unwrap();
|
let mut entries = self.entries.lock().unwrap();
|
||||||
let entry = entries.get_mut(id)?;
|
let entry = entries.get_mut(id)?;
|
||||||
@@ -2839,6 +2998,16 @@ fn playback_position_equal(a: &PlaybackPositionInfo, b: &PlaybackPositionInfo) -
|
|||||||
&& a.track_uri == b.track_uri
|
&& 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.
|
/// Extract TrackMetadata from DIDL-Lite XML in PlaybackPositionInfo.
|
||||||
fn extract_track_metadata(position: &PlaybackPositionInfo) -> Option<TrackMetadata> {
|
fn extract_track_metadata(position: &PlaybackPositionInfo) -> Option<TrackMetadata> {
|
||||||
let didl_xml = match position.track_metadata.as_ref() {
|
let didl_xml = match position.track_metadata.as_ref() {
|
||||||
|
|||||||
@@ -219,6 +219,30 @@ pub struct BrowseResponse {
|
|||||||
pub entries: Vec<ContainerEntry>,
|
pub entries: Vec<ContainerEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 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<RendererBindingView>,
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// PAYLOADS DE COMMANDES
|
// PAYLOADS DE COMMANDES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -239,6 +263,9 @@ pub struct AttachPlaylistRequest {
|
|||||||
pub server_id: String,
|
pub server_id: String,
|
||||||
/// ID du container playlist
|
/// ID du container playlist
|
||||||
pub container_id: String,
|
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
|
/// Requête pour lire ou ajouter du contenu à la queue
|
||||||
|
|||||||
@@ -265,10 +265,7 @@ impl OhInfoClient {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if let Some(ref xml) = metadata_xml {
|
if let Some(ref xml) = metadata_xml {
|
||||||
debug!(
|
debug!(metadata_xml = xml.as_str(), "OpenHome metadata XML content");
|
||||||
metadata_xml = xml.as_str(),
|
|
||||||
"OpenHome metadata XML content"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(OhInfoTrack { uri, metadata_xml })
|
Ok(OhInfoTrack { uri, metadata_xml })
|
||||||
|
|||||||
@@ -11,11 +11,10 @@ use crate::media_server::{MediaBrowser, MediaEntry, MediaResource, MusicServer,
|
|||||||
use crate::model::{RendererCapabilities, RendererId, RendererProtocol};
|
use crate::model::{RendererCapabilities, RendererId, RendererProtocol};
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use crate::openapi::{
|
use crate::openapi::{
|
||||||
AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry,
|
AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry, ErrorResponse,
|
||||||
CurrentTrackMetadata, ErrorResponse, MediaServerSummary, OpenHomePlaylistAddRequest,
|
FullRendererSnapshot, MediaServerSummary, OpenHomePlaylistAddRequest, OpenHomePlaylistSnapshot,
|
||||||
OpenHomePlaylistSnapshot, PlayContentRequest, QueueItem, QueueSnapshot,
|
PlayContentRequest, QueueItem, QueueSnapshot, RendererCapabilitiesSummary,
|
||||||
RendererCapabilitiesSummary, RendererProtocolSummary, RendererState, RendererSummary,
|
RendererProtocolSummary, RendererState, RendererSummary, SuccessResponse, VolumeSetRequest,
|
||||||
SuccessResponse, VolumeSetRequest,
|
|
||||||
};
|
};
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use crate::playback_queue::PlaybackItem;
|
use crate::playback_queue::PlaybackItem;
|
||||||
@@ -65,10 +64,6 @@ const QUEUE_COMMAND_TIMEOUT: Duration = Duration::from_secs(10);
|
|||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
const ATTACH_PLAYLIST_TIMEOUT: Duration = Duration::from_secs(60);
|
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
|
/// État partagé pour l'API ControlPoint
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -137,168 +132,38 @@ async fn get_renderer_state(
|
|||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
) -> Result<Json<RendererState>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<RendererState>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = RendererId(renderer_id.clone());
|
let rid = RendererId(renderer_id.clone());
|
||||||
|
let snapshot = state
|
||||||
let renderer = state
|
|
||||||
.control_point
|
.control_point
|
||||||
.music_renderer_by_id(&rid)
|
.renderer_full_snapshot(&rid)
|
||||||
.ok_or_else(|| {
|
.map_err(|err| map_snapshot_error(renderer_id, err))?;
|
||||||
(
|
|
||||||
StatusCode::NOT_FOUND,
|
|
||||||
Json(ErrorResponse {
|
|
||||||
error: format!("Renderer {} not found", renderer_id),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let info = renderer.info();
|
Ok(Json(snapshot.state))
|
||||||
let renderer = renderer.clone();
|
}
|
||||||
|
|
||||||
// Spawn blocking task for all SOAP calls to avoid blocking Tokio runtime
|
#[cfg(feature = "pmoserver")]
|
||||||
let state_task = tokio::task::spawn_blocking(move || {
|
#[utoipa::path(
|
||||||
// État de transport
|
get,
|
||||||
let transport_state = renderer
|
path = "/renderers/{renderer_id}/full",
|
||||||
.playback_state()
|
params(
|
||||||
.ok()
|
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||||
.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()
|
|
||||||
),
|
),
|
||||||
}),
|
responses(
|
||||||
)
|
(status = 200, description = "Snapshot complet du renderer", body = FullRendererSnapshot),
|
||||||
})?
|
(status = 404, description = "Renderer non trouvé", body = ErrorResponse)
|
||||||
.map_err(|e| {
|
),
|
||||||
warn!("Task join error during state query: {}", e);
|
tag = "control"
|
||||||
(
|
)]
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
async fn get_renderer_full_snapshot(
|
||||||
Json(ErrorResponse {
|
State(state): State<ControlPointState>,
|
||||||
error: format!("Internal task error: {}", e),
|
Path(renderer_id): Path<String>,
|
||||||
}),
|
) -> Result<Json<FullRendererSnapshot>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
)
|
let rid = RendererId(renderer_id.clone());
|
||||||
})?;
|
let snapshot = state
|
||||||
|
|
||||||
// Queue (non-blocking, local data)
|
|
||||||
// Pour les renderers OpenHome, on utilise la playlist native au lieu de la PlaybackQueue
|
|
||||||
let queue_len = state
|
|
||||||
.control_point
|
.control_point
|
||||||
.get_openhome_playlist_len(&rid)
|
.renderer_full_snapshot(&rid)
|
||||||
.ok()
|
.map_err(|err| map_snapshot_error(renderer_id, err))?;
|
||||||
.or_else(|| {
|
|
||||||
state
|
|
||||||
.control_point
|
|
||||||
.get_queue_snapshot(&rid)
|
|
||||||
.ok()
|
|
||||||
.map(|q| q.len())
|
|
||||||
})
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
// Playlist binding (non-blocking, local data)
|
Ok(Json(snapshot))
|
||||||
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,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /control/renderers/{renderer_id}/queue - Récupère la queue d'un renderer
|
/// 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<String>,
|
Path(renderer_id): Path<String>,
|
||||||
) -> Result<Json<QueueSnapshot>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<QueueSnapshot>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = RendererId(renderer_id.clone());
|
let rid = RendererId(renderer_id.clone());
|
||||||
|
let snapshot = state
|
||||||
// Verify renderer exists in registry
|
|
||||||
let _renderer = state
|
|
||||||
.control_point
|
.control_point
|
||||||
.music_renderer_by_id(&rid)
|
.renderer_full_snapshot(&rid)
|
||||||
.ok_or_else(|| {
|
.map_err(|err| map_snapshot_error(renderer_id, err))?;
|
||||||
(
|
|
||||||
StatusCode::NOT_FOUND,
|
|
||||||
Json(ErrorResponse {
|
|
||||||
error: format!("Renderer {} not found", renderer_id),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Get queue snapshot - if renderer not yet in runtime (just discovered),
|
Ok(Json(snapshot.queue))
|
||||||
// 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<QueueItem> = 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,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /control/renderers/{renderer_id}/binding - Récupère le binding playlist
|
/// GET /control/renderers/{renderer_id}/binding - Récupère le binding playlist
|
||||||
@@ -391,24 +215,17 @@ async fn get_renderer_binding(
|
|||||||
State(state): State<ControlPointState>,
|
State(state): State<ControlPointState>,
|
||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
) -> Result<Json<Option<AttachedPlaylistInfo>>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<Option<AttachedPlaylistInfo>>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = RendererId(renderer_id);
|
let rid = RendererId(renderer_id.clone());
|
||||||
|
let snapshot = state
|
||||||
let binding = state
|
|
||||||
.control_point
|
.control_point
|
||||||
.current_queue_playlist_binding(&rid)
|
.renderer_full_snapshot(&rid)
|
||||||
.map(
|
.map_err(|err| map_snapshot_error(renderer_id, err))?;
|
||||||
|(server_id, container_id, has_seen_update)| AttachedPlaylistInfo {
|
|
||||||
server_id: server_id.0,
|
|
||||||
container_id,
|
|
||||||
has_seen_update,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(Json(binding))
|
Ok(Json(snapshot.binding))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// HANDLERS - CONTRÔLE TRANSPORT
|
// HANDLERS - TRANSPORT CONTROLS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/// POST /control/renderers/{renderer_id}/play - Démarre la lecture
|
/// POST /control/renderers/{renderer_id}/play - Démarre la lecture
|
||||||
@@ -431,7 +248,6 @@ async fn play_renderer(
|
|||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = RendererId(renderer_id.clone());
|
let rid = RendererId(renderer_id.clone());
|
||||||
|
|
||||||
let renderer = state
|
let renderer = state
|
||||||
.control_point
|
.control_point
|
||||||
.music_renderer_by_id(&rid)
|
.music_renderer_by_id(&rid)
|
||||||
@@ -508,7 +324,6 @@ async fn pause_renderer(
|
|||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = RendererId(renderer_id.clone());
|
let rid = RendererId(renderer_id.clone());
|
||||||
|
|
||||||
let renderer = state
|
let renderer = state
|
||||||
.control_point
|
.control_point
|
||||||
.music_renderer_by_id(&rid)
|
.music_renderer_by_id(&rid)
|
||||||
@@ -585,9 +400,7 @@ async fn stop_renderer(
|
|||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = RendererId(renderer_id.clone());
|
let rid = RendererId(renderer_id.clone());
|
||||||
|
state
|
||||||
// Verify renderer exists
|
|
||||||
let _renderer = state
|
|
||||||
.control_point
|
.control_point
|
||||||
.music_renderer_by_id(&rid)
|
.music_renderer_by_id(&rid)
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
@@ -599,9 +412,9 @@ async fn stop_renderer(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let control_point = state.control_point.clone();
|
let control_point = Arc::clone(&state.control_point);
|
||||||
let rid_clone = rid.clone();
|
let rid_for_task = rid.clone();
|
||||||
let stop_task = tokio::task::spawn_blocking(move || control_point.user_stop(&rid_clone));
|
let stop_task = tokio::task::spawn_blocking(move || control_point.user_stop(&rid_for_task));
|
||||||
|
|
||||||
time::timeout(TRANSPORT_COMMAND_TIMEOUT, stop_task)
|
time::timeout(TRANSPORT_COMMAND_TIMEOUT, stop_task)
|
||||||
.await
|
.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")]
|
#[cfg(feature = "pmoserver")]
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
@@ -664,25 +477,36 @@ async fn resume_renderer(
|
|||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = RendererId(renderer_id.clone());
|
let rid = RendererId(renderer_id.clone());
|
||||||
let control_point = state.control_point.clone();
|
state
|
||||||
let rid_clone = rid.clone();
|
.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 =
|
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
|
.await
|
||||||
.map_err(|_| {
|
.map_err(|_| {
|
||||||
warn!(
|
warn!(
|
||||||
"Resume command for renderer {} exceeded {:?}",
|
"Resume command for renderer {} exceeded {:?}",
|
||||||
renderer_id, QUEUE_COMMAND_TIMEOUT
|
renderer_id, TRANSPORT_COMMAND_TIMEOUT
|
||||||
);
|
);
|
||||||
(
|
(
|
||||||
StatusCode::GATEWAY_TIMEOUT,
|
StatusCode::GATEWAY_TIMEOUT,
|
||||||
Json(ErrorResponse {
|
Json(ErrorResponse {
|
||||||
error: format!(
|
error: format!(
|
||||||
"Resume command timed out after {}s",
|
"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| {
|
.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,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
Json(ErrorResponse {
|
Json(ErrorResponse {
|
||||||
error: format!("Failed to resume: {}", e),
|
error: format!("Failed to resume playback: {}", e),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(Json(SuccessResponse {
|
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")]
|
#[cfg(feature = "pmoserver")]
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
@@ -720,7 +547,7 @@ async fn resume_renderer(
|
|||||||
("renderer_id" = String, Path, description = "ID unique du renderer")
|
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||||
),
|
),
|
||||||
responses(
|
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 = 404, description = "Renderer non trouvé", body = ErrorResponse),
|
||||||
(status = 500, description = "Erreur lors de l'exécution", 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<String>,
|
Path(renderer_id): Path<String>,
|
||||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = RendererId(renderer_id.clone());
|
let rid = RendererId(renderer_id.clone());
|
||||||
let control_point = state.control_point.clone();
|
state
|
||||||
let rid_clone = rid.clone();
|
.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 =
|
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
|
.await
|
||||||
.map_err(|_| {
|
.map_err(|_| {
|
||||||
warn!(
|
warn!(
|
||||||
"Next command for renderer {} exceeded {:?}",
|
"Next command for renderer {} exceeded {:?}",
|
||||||
renderer_id, QUEUE_COMMAND_TIMEOUT
|
renderer_id, TRANSPORT_COMMAND_TIMEOUT
|
||||||
);
|
);
|
||||||
(
|
(
|
||||||
StatusCode::GATEWAY_TIMEOUT,
|
StatusCode::GATEWAY_TIMEOUT,
|
||||||
Json(ErrorResponse {
|
Json(ErrorResponse {
|
||||||
error: format!(
|
error: format!(
|
||||||
"Next command timed out after {}s",
|
"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| {
|
.map_err(|e| {
|
||||||
warn!(
|
warn!(
|
||||||
"Failed to advance queue for renderer {}: {}",
|
"Failed to skip to next track for renderer {}: {}",
|
||||||
renderer_id, e
|
renderer_id, e
|
||||||
);
|
);
|
||||||
(
|
(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
Json(ErrorResponse {
|
Json(ErrorResponse {
|
||||||
error: format!("Failed to advance queue: {}", e),
|
error: format!("Failed to skip to next track: {}", e),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(Json(SuccessResponse {
|
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
|
/// POST /control/renderers/{renderer_id}/volume/set - Définit le volume
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
@@ -1149,7 +983,7 @@ async fn attach_playlist_binding(
|
|||||||
|
|
||||||
// Spawn blocking task and wait for completion with timeout
|
// Spawn blocking task and wait for completion with timeout
|
||||||
let attach_task = tokio::task::spawn_blocking(move || {
|
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)
|
time::timeout(ATTACH_PLAYLIST_TIMEOUT, attach_task)
|
||||||
@@ -1177,12 +1011,28 @@ async fn attach_playlist_binding(
|
|||||||
error: format!("Internal task error: {}", e),
|
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!(
|
debug!(
|
||||||
renderer = renderer_id.as_str(),
|
renderer = renderer_id.as_str(),
|
||||||
server = req.server_id.as_str(),
|
server = req.server_id.as_str(),
|
||||||
container = req.container_id.as_str(),
|
container = req.container_id.as_str(),
|
||||||
|
auto_play = req.auto_play,
|
||||||
"Playlist attached via HTTP API"
|
"Playlist attached via HTTP API"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1541,7 +1391,7 @@ async fn play_content(
|
|||||||
let object_id_for_log = object_id.clone();
|
let object_id_for_log = object_id.clone();
|
||||||
|
|
||||||
// Get renderer to verify it exists
|
// Get renderer to verify it exists
|
||||||
let renderer = state
|
state
|
||||||
.control_point
|
.control_point
|
||||||
.music_renderer_by_id(&rid)
|
.music_renderer_by_id(&rid)
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
@@ -1564,35 +1414,34 @@ async fn play_content(
|
|||||||
return Err(anyhow::anyhow!("No playable content found"));
|
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 {
|
if items.len() > 1 {
|
||||||
debug!(
|
debug!(
|
||||||
renderer = rid.0.as_str(),
|
renderer = rid.0.as_str(),
|
||||||
server = sid.0.as_str(),
|
server = sid.0.as_str(),
|
||||||
object = object_id.as_str(),
|
object = object_id.as_str(),
|
||||||
item_count = items.len(),
|
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,
|
&rid,
|
||||||
sid.clone(),
|
sid.clone(),
|
||||||
object_id.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>(())
|
Ok::<(), anyhow::Error>(())
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1910,6 +1759,24 @@ async fn browse_container(
|
|||||||
// HELPERS
|
// HELPERS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
fn map_snapshot_error(
|
||||||
|
renderer_id: String,
|
||||||
|
err: anyhow::Error,
|
||||||
|
) -> (StatusCode, Json<ErrorResponse>) {
|
||||||
|
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")]
|
#[cfg(feature = "pmoserver")]
|
||||||
fn map_openhome_error(
|
fn map_openhome_error(
|
||||||
renderer_id: &RendererId,
|
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<u64> {
|
|
||||||
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
|
// ROUTER & TRAIT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -2089,6 +1941,10 @@ pub fn create_api_router(state: ControlPointState, control_point: Arc<ControlPoi
|
|||||||
// Renderers
|
// Renderers
|
||||||
.route("/renderers", get(list_renderers))
|
.route("/renderers", get(list_renderers))
|
||||||
.route("/renderers/{renderer_id}", get(get_renderer_state))
|
.route("/renderers/{renderer_id}", get(get_renderer_state))
|
||||||
|
.route(
|
||||||
|
"/renderers/{renderer_id}/full",
|
||||||
|
get(get_renderer_full_snapshot),
|
||||||
|
)
|
||||||
.route("/renderers/{renderer_id}/queue", get(get_renderer_queue))
|
.route("/renderers/{renderer_id}/queue", get(get_renderer_queue))
|
||||||
.route(
|
.route(
|
||||||
"/renderers/{renderer_id}/binding",
|
"/renderers/{renderer_id}/binding",
|
||||||
|
|||||||
@@ -9,6 +9,10 @@
|
|||||||
//! - GET /api/control/events/renderers - Événements renderers uniquement
|
//! - GET /api/control/events/renderers - Événements renderers uniquement
|
||||||
//! - GET /api/control/events/servers - Événements serveurs uniquement
|
//! - GET /api/control/events/servers - Événements serveurs uniquement
|
||||||
//! - GET /api/control/events - Tous les événements (agrégés)
|
//! - GET /api/control/events - Tous les événements (agrégés)
|
||||||
|
//!
|
||||||
|
//! ⚠️ Les payloads SSE servent uniquement de signaux de rafraîchissement :
|
||||||
|
//! l'UI doit toujours refetch l'instantané complet auprès du ControlPoint,
|
||||||
|
//! seule source de vérité de l'état renderer.
|
||||||
|
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use crate::PlaybackState;
|
use crate::PlaybackState;
|
||||||
|
|||||||
@@ -5,7 +5,10 @@
|
|||||||
use bevy_reflect::Reflect;
|
use bevy_reflect::Reflect;
|
||||||
use pmoutils::ToXmlElement;
|
use pmoutils::ToXmlElement;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::borrow::Cow;
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
|
use std::io::Cursor;
|
||||||
use xmltree::{Element, XMLNode};
|
use xmltree::{Element, XMLNode};
|
||||||
|
|
||||||
// ============= Couche d'abstraction générique =============
|
// ============= Couche d'abstraction générique =============
|
||||||
@@ -70,7 +73,8 @@ impl MediaMetadataParser for DIDLLite {
|
|||||||
type Error = quick_xml::de::DeError;
|
type Error = quick_xml::de::DeError;
|
||||||
|
|
||||||
fn parse(input: &str) -> Result<Self, Self::Error> {
|
fn parse(input: &str) -> Result<Self, Self::Error> {
|
||||||
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 {
|
fn format_name() -> &'static str {
|
||||||
@@ -536,6 +540,80 @@ fn text_element(name: &str, value: &str) -> Element {
|
|||||||
e
|
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<String> = 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 {
|
impl ToXmlElement for DIDLLite {
|
||||||
fn to_xml_element(&self) -> Element {
|
fn to_xml_element(&self) -> Element {
|
||||||
let mut root = Element::new("DIDL-Lite");
|
let mut root = Element::new("DIDL-Lite");
|
||||||
|
|||||||
Reference in New Issue
Block a user