Debugging de la nouvelle implémentation.

This commit is contained in:
2026-01-03 08:19:23 +01:00
parent 9c90e706f1
commit 5320545d56
34 changed files with 1989 additions and 4133 deletions

View File

@@ -1,55 +0,0 @@
import type {
OpenHomePlaylistAddRequest,
OpenHomePlaylistSnapshot,
} from '@/services/pmocontrol/types'
const API_BASE = '/api/control'
export async function getOpenHomePlaylist(rendererId: string): Promise<OpenHomePlaylistSnapshot> {
const resp = await fetch(
`${API_BASE}/renderers/${encodeURIComponent(rendererId)}/oh/playlist`,
)
if (!resp.ok) {
throw new Error(`Failed to fetch OpenHome playlist: ${resp.status} ${resp.statusText}`)
}
return resp.json()
}
export async function clearOpenHomePlaylist(rendererId: string): Promise<void> {
const resp = await fetch(
`${API_BASE}/renderers/${encodeURIComponent(rendererId)}/oh/playlist/clear`,
{ method: 'POST' },
)
if (!resp.ok) {
throw new Error(`Failed to clear OpenHome playlist: ${resp.status} ${resp.statusText}`)
}
}
export async function addOpenHomeTrack(
rendererId: string,
payload: OpenHomePlaylistAddRequest,
): Promise<void> {
const resp = await fetch(
`${API_BASE}/renderers/${encodeURIComponent(rendererId)}/oh/playlist/add`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
)
if (!resp.ok) {
throw new Error(`Failed to add track to OpenHome playlist: ${resp.status} ${resp.statusText}`)
}
}
export async function playOpenHomeTrack(rendererId: string, trackId: number): Promise<void> {
const resp = await fetch(
`${API_BASE}/renderers/${encodeURIComponent(
rendererId,
)}/oh/playlist/play/${encodeURIComponent(trackId.toString())}`,
{ method: 'POST' },
)
if (!resp.ok) {
throw new Error(`Failed to play OpenHome track ${trackId}: ${resp.status} ${resp.statusText}`)
}
}

View File

@@ -5,55 +5,65 @@
// RENDERERS
// ============================================================================
export type RendererProtocolSummary = 'upnp' | 'openhome' | 'hybrid'
export type RendererProtocolSummary =
| "upnp"
| "openhome"
| "hybrid"
| "chromecast";
export interface RendererCapabilitiesSummary {
has_avtransport: boolean
has_avtransport_set_next: boolean
has_rendering_control: boolean
has_connection_manager: boolean
has_linkplay_http: boolean
has_arylic_tcp: boolean
has_oh_playlist: boolean
has_oh_volume: boolean
has_oh_info: boolean
has_oh_time: boolean
has_oh_radio: boolean
has_avtransport: boolean;
has_avtransport_set_next: boolean;
has_rendering_control: boolean;
has_connection_manager: boolean;
has_linkplay_http: boolean;
has_arylic_tcp: boolean;
has_oh_playlist: boolean;
has_oh_volume: boolean;
has_oh_info: boolean;
has_oh_time: boolean;
has_oh_radio: boolean;
}
export interface RendererSummary {
id: string
friendly_name: string
model_name: string
protocol: RendererProtocolSummary
capabilities: RendererCapabilitiesSummary
online: boolean
id: string;
friendly_name: string;
model_name: string;
protocol: RendererProtocolSummary;
capabilities: RendererCapabilitiesSummary;
online: boolean;
}
export interface RendererState {
id: string
friendly_name: string
transport_state: 'PLAYING' | 'PAUSED' | 'STOPPED' | 'TRANSITIONING' | 'NO_MEDIA' | 'UNKNOWN'
position_ms: number | null
duration_ms: number | null
volume: number | null // 0-100
mute: boolean | null
queue_len: number
attached_playlist: AttachedPlaylistInfo | null
current_track: CurrentTrackMetadata | null
id: string;
friendly_name: string;
transport_state:
| "PLAYING"
| "PAUSED"
| "STOPPED"
| "TRANSITIONING"
| "NO_MEDIA"
| "UNKNOWN";
position_ms: number | null;
duration_ms: number | null;
volume: number | null; // 0-100
mute: boolean | null;
queue_len: number;
attached_playlist: AttachedPlaylistInfo | null;
current_track: CurrentTrackMetadata | null;
}
export interface CurrentTrackMetadata {
title: string | null
artist: string | null
album: string | null
album_art_uri: string | null
title: string | null;
artist: string | null;
album: string | null;
album_art_uri: string | null;
}
export interface AttachedPlaylistInfo {
server_id: string
container_id: string
has_seen_update: boolean
server_id: string;
container_id: string;
has_seen_update: boolean;
}
// ============================================================================
@@ -61,53 +71,26 @@ export interface AttachedPlaylistInfo {
// ============================================================================
export interface QueueItem {
index: number // 0-based
uri: string
title: string | null
artist: string | null
album: string | null
album_art_uri: string | null
server_id: string | null
object_id: string | null
index: number; // 0-based
uri: string;
title: string | null;
artist: string | null;
album: string | null;
album_art_uri: string | null;
server_id: string | null;
object_id: string | null;
}
export interface QueueSnapshot {
renderer_id: string
items: QueueItem[]
current_index: number | null // Index de la piste en cours (null si rien en lecture)
renderer_id: string;
items: QueueItem[];
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
// ============================================================================
export interface OpenHomePlaylistTrack {
id: number
uri: string
title: string | null
artist: string | null
album: string | null
album_art_uri: string | null
}
export interface OpenHomePlaylistSnapshot {
renderer_id: string
current_id: number | null
current_index: number | null
tracks: OpenHomePlaylistTrack[]
}
export interface OpenHomePlaylistAddRequest {
uri: string
metadata: string
after_id?: number | null
play?: boolean
state: RendererState;
queue: QueueSnapshot;
binding: AttachedPlaylistInfo | null;
}
// ============================================================================
@@ -115,26 +98,26 @@ export interface OpenHomePlaylistAddRequest {
// ============================================================================
export interface MediaServerSummary {
id: string
friendly_name: string
model_name: string
online: boolean
id: string;
friendly_name: string;
model_name: string;
online: boolean;
}
export interface ContainerEntry {
id: string
title: string
class: string // UPnP class
is_container: boolean
child_count: number | null
artist: string | null
album: string | null
album_art_uri: string | null // ⚠️ Nom exact: album_art_uri
id: string;
title: string;
class: string; // UPnP class
is_container: boolean;
child_count: number | null;
artist: string | null;
album: string | null;
album_art_uri: string | null; // ⚠️ Nom exact: album_art_uri
}
export interface BrowseResponse {
container_id: string
entries: ContainerEntry[]
container_id: string;
entries: ContainerEntry[];
}
// ============================================================================
@@ -142,26 +125,26 @@ export interface BrowseResponse {
// ============================================================================
export interface VolumeSetRequest {
volume: number // 0-100
volume: number; // 0-100
}
export interface AttachPlaylistRequest {
server_id: string
container_id: string
auto_play?: boolean
server_id: string;
container_id: string;
auto_play?: boolean;
}
export interface PlayContentRequest {
server_id: string
object_id: string
server_id: string;
object_id: string;
}
export interface SuccessResponse {
message: string
message: string;
}
export interface ErrorResponse {
error: string
error: string;
}
// ============================================================================
@@ -169,40 +152,105 @@ export interface ErrorResponse {
// ============================================================================
export type RendererEventPayload =
| { type: 'state_changed'; renderer_id: string; state: string; timestamp: string }
| { type: 'position_changed'; renderer_id: string; track: number | null; rel_time: string | null; track_duration: string | null; timestamp: string }
| { type: 'volume_changed'; renderer_id: string; volume: number; timestamp: string }
| { type: 'mute_changed'; renderer_id: string; mute: boolean; timestamp: string }
| { type: 'metadata_changed'; renderer_id: string; title: string | null; artist: string | null; album: string | null; album_art_uri: string | null; timestamp: string }
| { type: 'queue_updated'; renderer_id: string; queue_length: number; timestamp: string }
| { type: 'binding_changed'; renderer_id: string; server_id: string | null; container_id: string | null; timestamp: string }
| { type: 'online'; renderer_id: string; friendly_name: string; model_name: string; manufacturer: string; timestamp: string }
| { type: 'offline'; renderer_id: string; timestamp: string }
| {
type: "state_changed";
renderer_id: string;
state: string;
timestamp: string;
}
| {
type: "position_changed";
renderer_id: string;
track: number | null;
rel_time: string | null;
track_duration: string | null;
timestamp: string;
}
| {
type: "volume_changed";
renderer_id: string;
volume: number;
timestamp: string;
}
| {
type: "mute_changed";
renderer_id: string;
mute: boolean;
timestamp: string;
}
| {
type: "metadata_changed";
renderer_id: string;
title: string | null;
artist: string | null;
album: string | null;
album_art_uri: string | null;
timestamp: string;
}
| {
type: "queue_updated";
renderer_id: string;
queue_length: number;
timestamp: string;
}
| {
type: "binding_changed";
renderer_id: string;
server_id: string | null;
container_id: string | null;
timestamp: string;
}
| {
type: "online";
renderer_id: string;
friendly_name: string;
model_name: string;
manufacturer: string;
timestamp: string;
}
| { type: "offline"; renderer_id: string; timestamp: string };
export type MediaServerEventPayload =
| { type: 'global_updated'; server_id: string; system_update_id: number | null; timestamp: string }
| { type: 'containers_updated'; server_id: string; container_ids: string[]; timestamp: string }
| { type: 'online'; server_id: string; friendly_name: string; model_name: string; manufacturer: string; timestamp: string }
| { type: 'offline'; server_id: string; timestamp: string }
| {
type: "global_updated";
server_id: string;
system_update_id: number | null;
timestamp: string;
}
| {
type: "containers_updated";
server_id: string;
container_ids: string[];
timestamp: string;
}
| {
type: "online";
server_id: string;
friendly_name: string;
model_name: string;
manufacturer: string;
timestamp: string;
}
| { type: "offline"; server_id: string; timestamp: string };
export type UnifiedEventPayload =
| { category: 'renderer' } & RendererEventPayload
| { category: 'media_server' } & MediaServerEventPayload
| ({ category: "renderer" } & RendererEventPayload)
| ({ category: "media_server" } & MediaServerEventPayload);
// ============================================================================
// MÉTADONNÉES PISTE
// ============================================================================
export interface TrackMetadata {
title: string | null
artist: string | null
album: string | null
album_art_uri: string | null
duration_ms: number | null
title: string | null;
artist: string | null;
album: string | null;
album_art_uri: string | null;
duration_ms: number | null;
}
export interface PositionInfo {
track: number | null
rel_time: string | null // Format HH:MM:SS
track_duration: string | null // Format HH:MM:SS
track: number | null;
rel_time: string | null; // Format HH:MM:SS
track_duration: string | null; // Format HH:MM:SS
}

View File

@@ -1,516 +1,302 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch, toRef } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRenderer, useRenderers } from '@/composables/useRenderers'
import { useUIStore } from '@/stores/ui'
import CurrentTrack from '@/components/pmocontrol/CurrentTrack.vue'
import TransportControls from '@/components/pmocontrol/TransportControls.vue'
import VolumeControl from '@/components/pmocontrol/VolumeControl.vue'
import QueueViewer from '@/components/pmocontrol/QueueViewer.vue'
import PlaylistBindingPanel from '@/components/pmocontrol/PlaylistBindingPanel.vue'
import StatusBadge from '@/components/pmocontrol/StatusBadge.vue'
import { ArrowLeft, Radio } from 'lucide-vue-next'
import {
addOpenHomeTrack,
clearOpenHomePlaylist,
getOpenHomePlaylist,
playOpenHomeTrack,
} from '@/services/openhomePlaylist'
import type { OpenHomePlaylistSnapshot } from '@/services/pmocontrol/types'
import { computed, onMounted, onUnmounted, toRef } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useRenderer, useRenderers } from "@/composables/useRenderers";
import { useUIStore } from "@/stores/ui";
import CurrentTrack from "@/components/pmocontrol/CurrentTrack.vue";
import TransportControls from "@/components/pmocontrol/TransportControls.vue";
import VolumeControl from "@/components/pmocontrol/VolumeControl.vue";
import QueueViewer from "@/components/pmocontrol/QueueViewer.vue";
import PlaylistBindingPanel from "@/components/pmocontrol/PlaylistBindingPanel.vue";
import StatusBadge from "@/components/pmocontrol/StatusBadge.vue";
import { ArrowLeft, Radio } from "lucide-vue-next";
const route = useRoute()
const router = useRouter()
const uiStore = useUIStore()
const route = useRoute();
const router = useRouter();
const uiStore = useUIStore();
const rendererId = computed(() => route.params.id as string)
const { renderer, state, refresh } = useRenderer(toRef(() => rendererId.value))
const { fetchRenderers, selectRenderer: selectRendererSnapshot } = useRenderers()
const openHomeSupported = computed(() => {
const current = renderer.value
if (!current) return false
const caps = current.capabilities
return (
current.protocol === 'openhome' ||
current.protocol === 'hybrid' ||
caps?.has_oh_playlist === true
)
})
const ohPlaylist = ref<OpenHomePlaylistSnapshot | null>(null)
const ohLoading = ref(false)
const ohError = ref<string | null>(null)
const newOhUri = ref('')
const newOhMeta = ref('')
const canAddOhTrack = computed(() => newOhUri.value.trim().length > 0)
const rendererId = computed(() => route.params.id as string);
const { renderer, state, refresh } = useRenderer(toRef(() => rendererId.value));
const { fetchRenderers, selectRenderer: selectRendererSnapshot } =
useRenderers();
// Charger les données au montage si nécessaire
onMounted(async () => {
uiStore.selectRenderer(rendererId.value)
selectRendererSnapshot(rendererId.value)
uiStore.selectRenderer(rendererId.value);
selectRendererSnapshot(rendererId.value);
// Charger toutes les données du renderer
if (!renderer.value) {
await fetchRenderers()
}
await refresh()
})
watch(
openHomeSupported,
async supported => {
if (supported) {
await refreshOhPlaylist()
} else {
ohPlaylist.value = null
// Charger toutes les données du renderer
if (!renderer.value) {
await fetchRenderers();
}
},
{ immediate: true },
)
watch(rendererId, () => {
ohPlaylist.value = null
ohError.value = null
newOhUri.value = ''
newOhMeta.value = ''
})
await refresh();
});
// Nettoyer la sélection au démontage
onUnmounted(() => {
uiStore.selectRenderer(null)
selectRendererSnapshot(null)
})
uiStore.selectRenderer(null);
selectRendererSnapshot(null);
});
function goBack() {
router.push('/')
router.push("/");
}
const protocolLabel = computed(() => {
if (!renderer.value) return ''
switch (renderer.value.protocol) {
case 'upnp':
return 'UPnP AV'
case 'openhome':
return 'OpenHome'
case 'hybrid':
return 'Hybrid (UPnP + OpenHome)'
default:
return 'Inconnu'
}
})
async function refreshOhPlaylist() {
if (!renderer.value || !openHomeSupported.value) return
ohLoading.value = true
ohError.value = null
try {
ohPlaylist.value = await getOpenHomePlaylist(renderer.value.id)
} catch (e) {
ohError.value =
e instanceof Error ? e.message : 'Failed to load OpenHome playlist'
} finally {
ohLoading.value = false
}
}
async function handleOhClear() {
if (!renderer.value) return
try {
await clearOpenHomePlaylist(renderer.value.id)
await refreshOhPlaylist()
} catch (e) {
ohError.value =
e instanceof Error ? e.message : 'Failed to clear OpenHome playlist'
}
}
async function handleOhPlay(trackId: number) {
if (!renderer.value) return
try {
await playOpenHomeTrack(renderer.value.id, trackId)
await refreshOhPlaylist()
} catch (e) {
ohError.value =
e instanceof Error ? e.message : `Failed to play OpenHome track ${trackId}`
}
}
async function handleOhAdd() {
if (!renderer.value || !canAddOhTrack.value) return
try {
await addOpenHomeTrack(renderer.value.id, {
uri: newOhUri.value.trim(),
metadata: newOhMeta.value,
play: false,
})
newOhUri.value = ''
newOhMeta.value = ''
await refreshOhPlaylist()
} catch (e) {
ohError.value =
e instanceof Error ? e.message : 'Failed to add track to OpenHome playlist'
}
}
if (!renderer.value) return "";
switch (renderer.value.protocol) {
case "upnp":
return "UPnP AV";
case "openhome":
return "OpenHome";
case "hybrid":
return "Hybrid (UPnP + OpenHome)";
case "chromecast":
return "Chromecast";
default:
return "Inconnu";
}
});
</script>
<template>
<div class="renderer-view">
<!-- Header -->
<header class="renderer-header">
<button class="btn-back" @click="goBack" title="Retour au dashboard">
<ArrowLeft :size="20" />
</button>
<div class="header-content">
<div class="renderer-info">
<Radio :size="24" class="renderer-icon" />
<div class="renderer-details">
<h1 class="renderer-name">{{ renderer?.friendly_name || 'Chargement...' }}</h1>
<p class="renderer-model">{{ renderer?.model_name }} {{ protocolLabel }}</p>
</div>
<div class="renderer-view">
<!-- Header -->
<header class="renderer-header">
<button
class="btn-back"
@click="goBack"
title="Retour au dashboard"
>
<ArrowLeft :size="20" />
</button>
<div class="header-content">
<div class="renderer-info">
<Radio :size="24" class="renderer-icon" />
<div class="renderer-details">
<h1 class="renderer-name">
{{ renderer?.friendly_name || "Chargement..." }}
</h1>
<p class="renderer-model">
{{ renderer?.model_name }} {{ protocolLabel }}
</p>
</div>
</div>
<StatusBadge v-if="state" :status="state.transport_state" />
</div>
</header>
<!-- Loading state -->
<div v-if="!renderer || !state" class="loading-state">
<p>Chargement du renderer...</p>
</div>
<StatusBadge v-if="state" :status="state.transport_state" />
</div>
</header>
<!-- Loading state -->
<div v-if="!renderer || !state" class="loading-state">
<p>Chargement du renderer...</p>
</div>
<!-- Main content -->
<div v-else class="renderer-content">
<!-- Left column (Desktop) / Top (Mobile) -->
<div class="left-column">
<!-- Current Track -->
<section class="content-section">
<CurrentTrack :rendererId="rendererId" />
</section>
<!-- Main content -->
<div v-else class="renderer-content">
<!-- Left column (Desktop) / Top (Mobile) -->
<div class="left-column">
<!-- Current Track -->
<section class="content-section">
<CurrentTrack :rendererId="rendererId" />
</section>
<!-- Transport Controls -->
<section class="content-section">
<TransportControls :rendererId="rendererId" />
</section>
<!-- Transport Controls -->
<section class="content-section">
<TransportControls :rendererId="rendererId" />
</section>
<!-- Volume Control -->
<section class="content-section">
<h3 class="section-subtitle">Volume</h3>
<VolumeControl :rendererId="rendererId" />
</section>
<!-- Volume Control -->
<section class="content-section">
<h3 class="section-subtitle">Volume</h3>
<VolumeControl :rendererId="rendererId" />
</section>
<!-- Playlist Binding -->
<section class="content-section">
<PlaylistBindingPanel :rendererId="rendererId" />
</section>
<section v-if="openHomeSupported" class="content-section openhome-playlist">
<h2>OpenHome Playlist</h2>
<div v-if="ohLoading">Chargement de la playlist</div>
<div v-else-if="ohError" class="error">{{ ohError }}</div>
<div v-else-if="ohPlaylist && ohPlaylist.tracks.length === 0">
Playlist vide.
</div>
<div v-else-if="ohPlaylist">
<table class="oh-playlist-table">
<thead>
<tr>
<th>#</th>
<th>Titre</th>
<th>Artiste</th>
<th>Album</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr
v-for="track in ohPlaylist.tracks"
:key="track.id"
:class="{ current: ohPlaylist.current_id === track.id }"
>
<td>{{ track.id }}</td>
<td>{{ track.title || '—' }}</td>
<td>{{ track.artist || '—' }}</td>
<td>{{ track.album || '—' }}</td>
<td class="actions-cell">
<button class="btn btn-secondary btn-icon" @click="handleOhPlay(track.id)" title="Lire ce morceau">
</button>
</td>
</tr>
</tbody>
</table>
<div class="oh-controls">
<button class="btn btn-secondary" @click="refreshOhPlaylist">
🔁 Rafraîchir
</button>
<button class="btn btn-danger" @click="handleOhClear">
🗑 Effacer la playlist
</button>
<!-- Playlist Binding -->
<section class="content-section">
<PlaylistBindingPanel :rendererId="rendererId" />
</section>
</div>
<div class="oh-add-form">
<input v-model="newOhUri" placeholder="URI à ajouter" />
<textarea v-model="newOhMeta" placeholder="DIDL-Lite (optionnel)" rows="2"></textarea>
<button class="btn btn-primary" @click="handleOhAdd" :disabled="!canAddOhTrack">
Ajouter
</button>
<!-- Right column (Desktop) / Bottom (Mobile) -->
<div class="right-column">
<section class="content-section queue-section">
<QueueViewer :rendererId="rendererId" />
</section>
</div>
</div>
</section>
</div>
<!-- Right column (Desktop) / Bottom (Mobile) -->
<div class="right-column">
<section class="content-section queue-section">
<QueueViewer :rendererId="rendererId" />
</section>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.renderer-view {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
padding: var(--spacing-lg);
max-width: 1400px;
margin: 0 auto;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
padding: var(--spacing-lg);
max-width: 1400px;
margin: 0 auto;
width: 100%;
height: 100%;
}
/* Header */
.renderer-header {
display: flex;
align-items: flex-start;
gap: var(--spacing-md);
display: flex;
align-items: flex-start;
gap: var(--spacing-md);
}
.btn-back {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: none;
border: none;
border-radius: var(--radius-md);
color: var(--color-text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: none;
border: none;
border-radius: var(--radius-md);
color: var(--color-text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
flex-shrink: 0;
}
.btn-back:hover {
background-color: var(--color-bg-secondary);
color: var(--color-text);
background-color: var(--color-bg-secondary);
color: var(--color-text);
}
.header-content {
flex: 1;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-md);
flex-wrap: wrap;
flex: 1;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-md);
flex-wrap: wrap;
}
.renderer-info {
display: flex;
align-items: center;
gap: var(--spacing-md);
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.renderer-icon {
color: var(--color-primary);
flex-shrink: 0;
color: var(--color-primary);
flex-shrink: 0;
}
.renderer-details {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.renderer-name {
font-size: var(--text-2xl);
font-weight: 700;
color: var(--color-text);
margin: 0;
font-size: var(--text-2xl);
font-weight: 700;
color: var(--color-text);
margin: 0;
}
.renderer-model {
font-size: var(--text-sm);
color: var(--color-text-secondary);
margin: 0;
font-size: var(--text-sm);
color: var(--color-text-secondary);
margin: 0;
}
/* Loading */
.loading-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--text-base);
color: var(--color-text-secondary);
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--text-base);
color: var(--color-text-secondary);
}
/* Content */
.renderer-content {
flex: 1;
display: grid;
gap: var(--spacing-xl);
grid-template-columns: 1fr;
min-height: 0;
flex: 1;
display: grid;
gap: var(--spacing-xl);
grid-template-columns: 1fr;
min-height: 0;
}
.left-column,
.right-column {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
min-height: 0;
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
min-height: 0;
}
.content-section {
background-color: var(--color-bg-secondary);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
border: 1px solid var(--color-border);
background-color: var(--color-bg-secondary);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
border: 1px solid var(--color-border);
}
.queue-section {
flex: 1;
min-height: 400px;
display: flex;
flex-direction: column;
flex: 1;
min-height: 400px;
display: flex;
flex-direction: column;
}
.section-subtitle {
font-size: var(--text-base);
font-weight: 600;
color: var(--color-text);
margin: 0 0 var(--spacing-md);
}
.openhome-playlist h2 {
margin: 0 0 var(--spacing-md);
font-size: var(--text-lg);
}
.oh-playlist-table {
width: 100%;
border-collapse: collapse;
margin-bottom: var(--spacing-md);
}
.oh-playlist-table th,
.oh-playlist-table td {
padding: var(--spacing-xs);
border-bottom: 1px solid var(--color-border);
font-size: var(--text-sm);
}
.oh-playlist-table tbody tr:hover {
background-color: var(--color-bg-tertiary);
}
.oh-playlist-table tr.current {
background-color: rgba(16, 185, 129, 0.15);
}
.actions-cell {
text-align: center;
}
.btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
}
.oh-controls {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-md);
}
.oh-add-form {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.oh-add-form input,
.oh-add-form textarea {
width: 100%;
padding: var(--spacing-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background-color: var(--color-bg-tertiary);
color: var(--color-text);
}
.oh-add-form button {
align-self: flex-start;
}
.error {
color: var(--status-error, #dc2626);
font-weight: 600;
font-size: var(--text-base);
font-weight: 600;
color: var(--color-text);
margin: 0 0 var(--spacing-md);
}
/* Responsive - Desktop */
@media (min-width: 1024px) {
.renderer-content {
grid-template-columns: 400px 1fr;
}
.renderer-content {
grid-template-columns: 400px 1fr;
}
.queue-section {
min-height: 0;
}
.queue-section {
min-height: 0;
}
}
/* Responsive - Tablet */
@media (min-width: 768px) and (max-width: 1023px) {
.renderer-content {
grid-template-columns: 1fr;
}
.renderer-content {
grid-template-columns: 1fr;
}
.left-column {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-lg);
}
.left-column {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-lg);
}
.queue-section {
grid-column: 1 / -1;
}
.queue-section {
grid-column: 1 / -1;
}
}
/* Responsive - Mobile */
@media (max-width: 767px) {
.renderer-view {
padding: var(--spacing-md);
}
.renderer-view {
padding: var(--spacing-md);
}
.renderer-name {
font-size: var(--text-xl);
}
.renderer-name {
font-size: var(--text-xl);
}
.renderer-info {
flex-wrap: wrap;
}
.renderer-info {
flex-wrap: wrap;
}
.queue-section {
min-height: 300px;
}
.queue-section {
min-height: 300px;
}
}
</style>

View File

@@ -1,539 +0,0 @@
//! End-to-end queue demo that prefers the PMOMusic media server and exercises
//! the ControlPoint playback queue API.
use std::collections::VecDeque;
use std::env;
use std::process;
use std::thread;
use std::time::Duration;
use anyhow::{Context, Result};
use pmocontrol::model::TrackMetadata;
use pmocontrol::{
ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, UpnpMediaServer,
MusicRendererBackend, UpnpMediaServer, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, RendererInfo,
};
const DEFAULT_TIMEOUT_SECS: u64 = 5;
const DEFAULT_DISCOVERY_SECS: u64 = 5;
const DEFAULT_MAX_TRACKS: usize = 3;
const MONITOR_DURATION_SECS: u64 = 600;
const MONITOR_POLL_SECS: u64 = 5;
const MAX_BROWSE_DEPTH: usize = 2;
fn main() -> Result<()> {
let _ = tracing_subscriber::fmt::try_init();
let config = CliConfig::parse_from_env().unwrap_or_else(|err| {
eprintln!("Error parsing arguments: {err}");
print_usage_and_exit();
});
if config.max_tracks == 0 {
eprintln!("--max-tracks must be >= 1");
process::exit(1);
}
println!(
"Starting queue_pmomusic_demo with timeout={}s discovery={}s max_tracks={}",
config.timeout_secs, config.discovery_secs, config.max_tracks
);
// ControlPoint::spawn starts the HttpXmlDescriptionProvider + DiscoveryManager combo.
let control_point =
ControlPoint::spawn(config.timeout_secs).context("Failed to start control point")?;
println!(
"Discovery running for {} seconds before selecting devices...",
config.discovery_secs
);
thread::sleep(Duration::from_secs(config.discovery_secs));
let registry = control_point.registry();
let (renderer, server_info) = {
let reg = registry.read().expect("registry poisoned");
let renderer_candidates: Vec<RendererInfo> = reg
.list_renderers()
.into_iter()
.filter(|info| !is_pmomusic_renderer(info))
.collect();
let renderer = pick_renderer(renderer_candidates)
.unwrap_or_else(|| no_renderer_and_exit("No suitable renderer found after discovery."));
let server = pick_media_server(reg.list_servers())
.unwrap_or_else(|| no_server_and_exit("No media server with ContentDirectory."));
(renderer, server)
};
println!(
"Selected renderer \"{}\" (protocol={:?}, id={})",
renderer.friendly_name, renderer.protocol, renderer.id.0
);
println!(
"Selected media server \"{}\" at {} (id={})",
server_info.friendly_name, server_info.location, server_info.id.0
);
let renderer_instance = MusicRendererBackend::from_renderer_info(renderer.clone(), &registry)
.expect("Selected renderer is not usable by MusicRenderer façade");
let supports_set_next = renderer_instance
.as_upnp()
.map(|upnp| upnp.supports_set_next())
.unwrap_or(false);
println!(
"Renderer \"{}\": AVTransport present = {}, SetNextAVTransportURI supported = {}",
renderer.friendly_name, renderer.capabilities.has_avtransport, supports_set_next
);
let timeout = Duration::from_secs(config.timeout_secs);
let server =
UpnpMediaServer::from_info(&server_info, timeout).context("Failed to init MusicServer")?;
let root_entries = server
.browse_root()
.context("Failed to browse ContentDirectory root")?;
println!("Root returned {} entries", root_entries.len());
// Try to find a playlist container first
let (playback_items, bound_container_id) =
collect_playable_items_with_binding(&server, &root_entries, config.max_tracks)
.context("Failed to derive playable items from ContentDirectory root/children")?;
if playback_items.is_empty() {
println!("No playable tracks were found on the selected server.");
process::exit(1);
}
println!(
"Discovered {} playable items; enqueuing…",
playback_items.len()
);
if let Some(ref container_id) = bound_container_id {
println!(
"Found playlist container '{}' to bind queue to",
container_id
);
} else {
println!("No playlist container found; queue will not be bound to server");
}
let mut planned_queue: VecDeque<PlaybackItem>;
let renderer_id = renderer.id.clone();
control_point
.clear_queue(&renderer_id)
.context("Failed to clear playback queue")?;
control_point
.enqueue_items(&renderer_id, playback_items)
.context("Failed to enqueue playback items")?;
// 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())
.context("Failed to attach queue to playlist container")?;
println!(
"✓ Queue attached to playlist container {} on server {}",
container_id, server_info.friendly_name
);
}
let snapshot = control_point
.get_queue_snapshot(&renderer_id)
.context("Failed to snapshot queue after enqueue")?;
print_queue_snapshot(&snapshot);
planned_queue = snapshot.clone().into();
control_point
.play_next_from_queue(&renderer_id)
.context("Failed to start playback from queue")?;
let mut current_track = planned_queue.pop_front();
let remaining = control_point
.get_queue_snapshot(&renderer_id)
.context("Failed to snapshot queue after play_next_from_queue")?;
planned_queue = remaining.clone().into();
println!(
"Playback started on \"{}\"; {} tracks remaining in queue.",
renderer.friendly_name,
remaining.len()
);
println!(
"Monitoring queue auto-advance for {} seconds (poll every {}s)…",
MONITOR_DURATION_SECS, MONITOR_POLL_SECS
);
// Subscribe to media server events to observe playlist updates
let media_event_rx = control_point.subscribe_media_server_events();
let poll_count = MONITOR_DURATION_SECS / MONITOR_POLL_SECS;
for tick in 0..poll_count {
thread::sleep(Duration::from_secs(MONITOR_POLL_SECS));
// Drain any MediaServerEvent that arrived since last poll
loop {
match media_event_rx.try_recv() {
Ok(MediaServerEvent::GlobalUpdated {
server_id,
system_update_id,
}) => {
println!(
" 📢 MediaServer {} global update (SystemUpdateID={:?})",
server_id.0, system_update_id
);
}
Ok(MediaServerEvent::ContainersUpdated {
server_id,
container_ids,
}) => {
println!(
" 📢 MediaServer {} containers updated: {:?}",
server_id.0, container_ids
);
// Check if our bound container was updated
if let Some((bound_server, bound_container, _)) =
control_point.current_queue_playlist_binding(&renderer_id)
{
if bound_server == server_id && container_ids.contains(&bound_container) {
println!(
" 🔄 Bound playlist container '{}' was updated, queue will refresh automatically",
bound_container
);
}
}
}
Err(_) => break, // No more events, continue with normal monitoring
}
}
let snapshot = control_point
.get_queue_snapshot(&renderer_id)
.context("Queue snapshot failed during monitoring loop")?;
let new_plan: VecDeque<PlaybackItem> = snapshot.clone().into();
if planned_queue.len() > new_plan.len() {
let removed = planned_queue.len() - new_plan.len();
for _ in 0..removed {
current_track = planned_queue.pop_front();
}
}
planned_queue = new_plan;
let playback_info = control_point
.music_renderer_by_id(&renderer_id)
.and_then(|renderer| renderer.playback_position().ok());
let title = current_track_title(current_track.as_ref());
if let Some(info) = playback_info {
println!(
"[tick {tick}] Queue length = {} | now playing: {} [{}]",
snapshot.len(),
title,
format_playback_position(&info)
);
} else {
println!(
"[tick {tick}] Queue length = {} | now playing: {} [position unavailable]",
snapshot.len(),
title
);
}
}
println!("Monitoring finished, exiting.");
Ok(())
}
#[derive(Debug)]
struct CliConfig {
timeout_secs: u64,
discovery_secs: u64,
max_tracks: usize,
}
impl CliConfig {
fn parse_from_env() -> Result<Self, String> {
let mut timeout_secs = DEFAULT_TIMEOUT_SECS;
let mut discovery_secs = DEFAULT_DISCOVERY_SECS;
let mut max_tracks = DEFAULT_MAX_TRACKS;
let mut args = env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--timeout-secs" => {
let value = args
.next()
.ok_or_else(|| "--timeout-secs requires a value".to_string())?;
timeout_secs = value.parse().map_err(|err| {
format!("Invalid value for --timeout-secs ({value}): {err}")
})?;
}
"--discovery-secs" => {
let value = args
.next()
.ok_or_else(|| "--discovery-secs requires a value".to_string())?;
discovery_secs = value.parse().map_err(|err| {
format!("Invalid value for --discovery-secs ({value}): {err}")
})?;
}
"--max-tracks" => {
let value = args
.next()
.ok_or_else(|| "--max-tracks requires a value".to_string())?;
max_tracks = value.parse().map_err(|err| {
format!("Invalid value for --max-tracks ({value}): {err}")
})?;
}
"--help" | "-h" => {
print_usage_and_exit();
}
unknown => {
return Err(format!("Unknown argument: {unknown}"));
}
}
}
Ok(Self {
timeout_secs,
discovery_secs,
max_tracks,
})
}
}
fn pick_renderer(renderers: Vec<RendererInfo>) -> Option<RendererInfo> {
let mut candidates: Vec<RendererInfo> = renderers;
if candidates.is_empty() {
return None;
}
println!("Renderer candidates:");
for (idx, info) in candidates.iter().enumerate() {
println!(
" [{}] {} | model={} | location={} | online={}",
idx, info.friendly_name, info.model_name, info.location, info.online
);
}
let selected = candidates.remove(0);
println!(
"Automatically selecting renderer index 0: {}",
selected.friendly_name
);
Some(selected)
}
fn pick_media_server(servers: Vec<UpnpMediaServer>) -> Option<UpnpMediaServer> {
let mut candidates: Vec<UpnpMediaServer> = servers
.into_iter()
.filter(|info| info.has_content_directory)
.filter(|info| info.content_directory_control_url.is_some())
.collect();
if candidates.is_empty() {
return None;
}
if let Some(idx) = candidates.iter().position(is_pmomusic_server) {
let server = candidates.remove(idx);
println!(
"Preferring PMOMusic server \"{}\" (server header: {}).",
server.friendly_name, server.server_header
);
Some(server)
} else {
println!("No PMOMusic server discovered; falling back to first ContentDirectory server.");
Some(candidates.remove(0))
}
}
fn is_pmomusic_server(info: &UpnpMediaServer) -> bool {
let name = info.friendly_name.to_ascii_lowercase();
let header = info.server_header.to_ascii_lowercase();
name.contains("pmomusic") || header.contains("pmomusic")
}
fn is_pmomusic_renderer(info: &RendererInfo) -> bool {
info.friendly_name
.to_ascii_lowercase()
.contains("pmomusic audio renderer")
}
fn collect_playable_items_with_binding(
server: &UpnpMediaServer,
entries: &[MediaEntry],
max_tracks: usize,
) -> Result<(Vec<PlaybackItem>, Option<String>)> {
// First, try to find a playlist container
let playlist_container = entries.iter().find(|entry| {
entry.is_container
&& entry
.class
.to_ascii_lowercase()
.contains("object.container.playlistcontainer")
});
if let Some(playlist) = playlist_container {
println!(
"Found playlist container: '{}' (id: {}, class: {})",
playlist.title, playlist.id, playlist.class
);
// Browse the playlist container
match server.browse_children(&playlist.id, 0, max_tracks as u32) {
Ok(children) => {
let mut items = Vec::new();
for entry in &children {
if let Some(item) = playback_item_from_entry(server, entry) {
items.push(item);
if items.len() >= max_tracks {
break;
}
}
}
if !items.is_empty() {
return Ok((items, Some(playlist.id.clone())));
}
println!(
"Playlist container '{}' is empty, falling back to general browse",
playlist.title
);
}
Err(err) => {
println!(
"Failed to browse playlist container '{}': {}, falling back",
playlist.title, err
);
}
}
} else {
println!("No playlist container found in root entries, using fallback");
}
// Fallback: collect from any container/item
let mut items = Vec::new();
for entry in entries {
gather_items_from_entry(server, entry, max_tracks, 0, &mut items)?;
if items.len() >= max_tracks {
break;
}
}
Ok((items, None))
}
fn gather_items_from_entry(
server: &UpnpMediaServer,
entry: &MediaEntry,
max_tracks: usize,
depth: usize,
out: &mut Vec<PlaybackItem>,
) -> Result<()> {
if out.len() >= max_tracks {
return Ok(());
}
if entry.is_container {
if depth >= MAX_BROWSE_DEPTH {
return Ok(());
}
match server.browse_children(&entry.id, 0, 50) {
Ok(children) => {
for child in children {
gather_items_from_entry(server, &child, max_tracks, depth + 1, out)?;
if out.len() >= max_tracks {
break;
}
}
}
Err(err) => {
tracing::warn!(
container_id = entry.id.as_str(),
error = %err,
"Failed to browse child container"
);
}
}
return Ok(());
}
if let Some(item) = playback_item_from_entry(server, entry) {
out.push(item);
}
Ok(())
}
fn playback_item_from_entry(server: &UpnpMediaServer, entry: &MediaEntry) -> Option<PlaybackItem> {
if entry.title.to_ascii_lowercase().contains("live stream") {
return None;
}
let resource = entry.resources.iter().find(|res| res.is_audio())?;
let metadata = TrackMetadata {
title: Some(entry.title.clone()),
artist: entry.artist.clone(),
album: entry.album.clone(),
genre: entry.genre.clone(),
album_art_uri: entry.album_art_uri.clone(),
date: entry.date.clone(),
track_number: entry.track_number.clone(),
creator: entry.creator.clone(),
};
Some(PlaybackItem {
media_server_id: server.id().clone(),
didl_id: entry.id.clone(),
uri: resource.uri.clone(),
protocol_info: resource.protocol_info.clone(),
metadata: Some(metadata),
})
}
fn print_queue_snapshot(items: &[PlaybackItem]) {
println!("Current queue snapshot ({} items):", items.len());
for (idx, item) in items.iter().enumerate() {
let label = item
.metadata
.as_ref()
.and_then(|meta| meta.title.as_deref())
.unwrap_or_else(|| item.uri.as_str());
println!(" [{}] {} -> {}", idx, label, item.uri);
}
if items.is_empty() {
println!(" <queue is empty>");
}
}
fn current_track_title(item: Option<&PlaybackItem>) -> String {
match item {
Some(track) => track
.metadata
.as_ref()
.and_then(|meta| meta.title.as_deref())
.unwrap_or_else(|| track.uri.as_str())
.to_string(),
None => "<unknown>".to_string(),
}
}
fn format_playback_position(info: &PlaybackPositionInfo) -> String {
let rel = info.rel_time.as_deref().unwrap_or("-");
let dur = info.track_duration.as_deref().unwrap_or("-");
format!("{rel} / {dur}")
}
fn no_renderer_and_exit(message: &str) -> ! {
println!("{message}");
process::exit(1);
}
fn no_server_and_exit(message: &str) -> ! {
println!("{message}");
process::exit(1);
}
fn print_usage_and_exit() -> ! {
println!(
"Usage: cargo run -p pmocontrol --example queue_pmomusic_demo -- [--timeout-secs N] [--discovery-secs N] [--max-tracks N]"
);
process::exit(1);
}

View File

@@ -1,4 +1,10 @@
use std::{io::{Read, Write}, net::{Shutdown, TcpStream, ToSocketAddrs}, sync::{Mutex, OnceLock}, thread, time::{Duration, Instant}};
use std::{
io::{Read, Write},
net::{Shutdown, TcpStream, ToSocketAddrs},
sync::{Mutex, OnceLock},
thread,
time::{Duration, Instant},
};
use tracing::{debug, warn};
@@ -18,7 +24,6 @@ fn last_command_time() -> &'static Mutex<Instant> {
LAST_COMMAND_TIME.get_or_init(|| Mutex::new(Instant::now()))
}
/// Mode dattente de réponse pour une commande TCP Arylic.
enum ResponseMode<'a> {
/// On nattend aucune réponse (fire-and-forget).
@@ -40,16 +45,14 @@ fn send_command_with_mode(
let packet = encode_packet(payload);
stream.write_all(&packet).map_err(|_| {
ControlPointError::ArilycTcpError(
format!(
ControlPointError::ArilycTcpError(format!(
"Failed to write Arylic TCP packet for {}: {}",
host, payload
))
})?;
stream.flush().map_err(|_| {
ControlPointError::ArilycTcpError(
format!(
ControlPointError::ArilycTcpError(format!(
"Failed to flush Arylic TCP stream for {} (command {})",
host, payload
))
@@ -91,8 +94,7 @@ fn send_command_with_mode(
Err(ControlPointError::ArilycTcpError(format!(
"No expected response for optional command {} on {}",
payload,
host
payload, host
)))
}
}
@@ -110,9 +112,7 @@ fn read_expected_response(
Err(err) => {
return Err(ControlPointError::ArilycTcpError(format!(
"Failed to read Arylic TCP response for {} (command {}): {}",
host,
payload,
err
host, payload, err
)));
}
};
@@ -128,8 +128,7 @@ fn read_expected_response(
Err(ControlPointError::ArilycTcpError(format!(
"No expected response for command {} on {}",
payload,
host
payload, host
)))
}
@@ -171,11 +170,15 @@ pub fn send_command_optional(
)
}
pub fn send_command_no_response(host: &str, port: u16, timeout: Duration, payload: &str) -> Result<(), ControlPointError> {
pub fn send_command_no_response(
host: &str,
port: u16,
timeout: Duration,
payload: &str,
) -> Result<(), ControlPointError> {
send_command_with_mode(host, port, timeout, payload, ResponseMode::None).map(|_| ())
}
fn connect(host: &str, port: u16, timeout: Duration) -> Result<TcpStream, ControlPointError> {
if let Ok(mut last_time) = last_command_time().lock() {
let elapsed = last_time.elapsed();
@@ -197,18 +200,15 @@ fn connect(host: &str, port: u16, timeout: Duration) -> Result<TcpStream, Contro
};
let mut last_err = None;
for addr in address
.to_socket_addrs()
.map_err(|_| {
ControlPointError::ArilycTcpError(format!("Failed to resolve {}:{}", host, port))
})?
{
for addr in address.to_socket_addrs().map_err(|_| {
ControlPointError::ArilycTcpError(format!("Failed to resolve {}:{}", host, port))
})? {
match TcpStream::connect_timeout(&addr, timeout) {
Ok(stream) => {
stream
.set_read_timeout(Some(timeout))
.and_then(|_| stream.set_write_timeout(Some(timeout)))
.map_err(|err| {
.map_err(|_| {
ControlPointError::ArilycTcpError(format!(
"Failed to set socket timeouts for {}",
address
@@ -251,29 +251,37 @@ fn encode_packet(payload: &str) -> Vec<u8> {
fn read_packet(stream: &mut TcpStream) -> Result<String, ControlPointError> {
let mut header = [0u8; 4];
stream.read_exact(&mut header)
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?;
stream
.read_exact(&mut header)
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}", e)))?;
if header != PACKET_HEADER {
return Err(ControlPointError::ArilycTcpError(format!("Invalid Arylic packet header: {:x?}", header)));
return Err(ControlPointError::ArilycTcpError(format!(
"Invalid Arylic packet header: {:x?}",
header
)));
}
let mut len_buf = [0u8; 4];
stream.read_exact(&mut len_buf)
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?;
stream
.read_exact(&mut len_buf)
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}", e)))?;
let len = u32::from_le_bytes(len_buf) as usize;
let mut checksum_buf = [0u8; 4];
stream.read_exact(&mut checksum_buf)
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?;
stream
.read_exact(&mut checksum_buf)
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}", e)))?;
let expected_checksum = u32::from_le_bytes(checksum_buf);
let mut reserved = [0u8; 8];
stream.read_exact(&mut reserved)
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?;
stream
.read_exact(&mut reserved)
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}", e)))?;
let mut payload = vec![0u8; len];
stream.read_exact(&mut payload)
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?;
stream
.read_exact(&mut payload)
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}", e)))?;
let actual_checksum = payload.iter().fold(0u32, |acc, b| acc + (*b as u32));
if actual_checksum != expected_checksum {
@@ -284,5 +292,5 @@ fn read_packet(stream: &mut TcpStream) -> Result<String, ControlPointError> {
}
Ok(String::from_utf8(payload)
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?)
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}", e)))?)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,130 @@
# Corrections à appliquer à control_point.rs
## Changements effectués avec succès :
1. ✅ Thread de découverte UPNP : utilise maintenant `UpnpDiscoveryManager`
2. ✅ Boucle de polling : utilise `Arc<MusicRenderer>` directement avec traits
3. ✅ Suppression du thread OpenHome event forwarder
## Corrections restantes à faire manuellement :
### 1. Thread de vérification des timeouts (ligne ~173)
**Remplacer :**
```rust
let registry_for_presence = Arc::clone(&registry);
let event_bus_for_presence = event_bus.clone();
let media_event_bus_for_presence = media_event_bus.clone();
thread::spawn(move || {
use ureq::Agent;
// ... tout le code de vérification HTTP manuelle ...
});
```
**Par :**
```rust
let registry_for_timeout = Arc::clone(&registry);
thread::spawn(move || {
loop {
thread::sleep(Duration::from_secs(60));
// Le registry vérifie les timeouts et émet automatiquement les événements Offline
if let Ok(mut reg) = registry_for_timeout.write() {
reg.check_timeouts();
}
}
});
```
### 2. Thread Chromecast mDNS (à ajouter après le thread de timeout)
**Ajouter :**
```rust
// Thread de découverte mDNS pour Chromecast
let registry_for_mdns = Arc::clone(&registry);
let udn_cache_for_mdns = Arc::clone(&udn_cache);
thread::spawn(move || {
use crate::discovery::ChromecastDiscoveryManager;
use futures_util::StreamExt;
let mut discovery_manager =
ChromecastDiscoveryManager::new(registry_for_mdns, udn_cache_for_mdns);
debug!("Starting mDNS discovery thread for Chromecast devices");
const SERVICE_NAME: &str = "_googlecast._tcp.local";
async_std::task::block_on(async {
match mdns::discover::all(SERVICE_NAME, Duration::from_secs(15)) {
Ok(discovery) => {
let stream = discovery.listen();
futures_util::pin_mut!(stream);
debug!("mDNS discovery stream started for Chromecast devices");
while let Some(result) = stream.next().await {
match result {
Ok(response) => {
debug!(
"Received mDNS response with {} records",
response.records().count()
);
discovery_manager.handle_mdns_response(response);
}
Err(e) => {
warn!("mDNS discovery error: {}", e);
}
}
}
warn!("mDNS discovery stream ended unexpectedly");
}
Err(e) => {
error!("Failed to start mDNS discovery: {}", e);
}
}
});
});
```
### 3. Correction de la boucle de polling (ligne ~200)
**Le type doit être :**
```rust
let renderers: Vec<Arc<MusicRenderer>> = infos // Pas Vec<MusicRenderer>
```
### 4. Retour du constructeur (fin de fonction)
**Remplacer :**
```rust
Ok(Self {
registry,
event_bus,
media_event_bus,
runtime,
})
```
**Par :**
```rust
Ok(Self {
registry,
udn_cache,
event_bus,
media_event_bus,
runtime,
})
```
## Suppressions à faire :
- [ ] Supprimer toutes les fonctions OpenHome (à partir de `spawn_openhome_event_runtime`)
- [ ] Supprimer `OpenHomeAccessError` enum
- [ ] Supprimer `OPENHOME_SNAPSHOT_CACHE_TTL` constante
- [ ] Nettoyer les imports obsolètes en haut du fichier
## Note importante :
Le code ne compilera pas tant que tous les imports obsolètes ne seront pas nettoyés, mais la structure sera correcte.

View File

@@ -7,7 +7,7 @@
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::{Arc, Mutex};
use std::sync::{Arc, Mutex, RwLock};
use crate::DeviceId;
use crate::DeviceRegistry;
@@ -17,13 +17,13 @@ use tracing::{debug, warn};
/// Gestionnaire des événements mDNS pour Chromecast.
pub struct ChromecastDiscoveryManager {
device_registry: Arc<Mutex<DeviceRegistry>>,
device_registry: Arc<RwLock<DeviceRegistry>>,
udn_cache: Arc<Mutex<UDNRegistry>>,
}
impl ChromecastDiscoveryManager {
pub fn new(
device_registry: Arc<Mutex<DeviceRegistry>>,
device_registry: Arc<RwLock<DeviceRegistry>>,
udn_cache: Arc<Mutex<UDNRegistry>>,
) -> Self {
Self {
@@ -173,7 +173,7 @@ impl ChromecastDiscoveryManager {
// Register the renderer
self.device_registry
.lock()
.write()
.expect("DeviceRegistry mutex lock failed")
.push_renderer(&renderer_info, default_max_age as u32);
}

View File

@@ -1,7 +1,7 @@
use std::{
collections::HashMap,
sync::{Arc, Mutex},
time::{Instant, SystemTime},
time::Instant,
};
struct UDNSeen {
@@ -20,18 +20,21 @@ impl UDNRegistry {
}
}
/// Returns `true` if the UDN has been seen for at least half of its lifetime
/// Returns `true` if we should fetch/process this UDN (either first time or more than half max_age elapsed)
pub fn should_fetch(registry: Arc<Mutex<UDNRegistry>>, udn: &str, max_age: u64) -> bool {
let now = Instant::now();
let mut r = registry.lock().expect("UDNRegistry mutex lock failed");
if let Some(seen) = r.seen.get_mut(udn) {
// If more than half the max_age has elapsed, we should fetch/process again
if now.duration_since(seen.last_seen).as_secs() > max_age / 2 {
false
} else {
seen.last_seen = now;
true
} else {
// Too soon, skip this SSDP event
false
}
} else {
// First time seeing this UDN, insert and fetch
r.seen.insert(
udn.to_string(),
UDNSeen {

View File

@@ -1,30 +1,8 @@
use crate::{RendererInfo, UpnpMediaServer};
pub mod arylic;
pub mod chromecast_discovery;
pub mod manager;
pub mod upnp_discovery;
pub mod upnp_provider;
pub mod chromecast_discovery;
pub mod arylic;
/// Fournit les descriptions haut niveau à partir dun endpoint découvert.
/// Limplémentation pourra, plus tard, faire un HTTP GET sur `location`
/// et parser la description pour remplir RendererInfo / MediaServerInfo.
pub trait DeviceDescriptionProvider: Send + Sync {
/// Construit un RendererInfo pour cet endpoint, ou None sil
/// ne correspond pas à un renderer audio intéressant.
fn build_renderer_info(
&self,
udn: &str,
location: &str,
server_header: &str,
) -> Option<RendererInfo>;
/// Construit un MediaServerInfo pour cet endpoint, ou None sil
/// ne correspond pas à un media server (ou pas intéressant).
fn build_server_info(
&self,
udn: &str,
location: &str,
server_header: &str,
) -> Option<UpnpMediaServer>;
}
pub use chromecast_discovery::ChromecastDiscoveryManager;
pub use upnp_discovery::UpnpDiscoveryManager;

View File

@@ -1,19 +1,27 @@
use crate::{DeviceRegistry, discovery::upnp_provider::ParsedDeviceDescription};
use pmoupnp::ssdp::SsdpEvent;
use std::sync::{Arc, Mutex};
use std::sync::{Arc, Mutex, RwLock};
use crate::discovery::manager::UDNRegistry;
/// Gestionnaire des événements SSDP -> DeviceUpdate.
pub struct UpnpDiscoveryManager {
device_registry: Arc<Mutex<DeviceRegistry>>,
device_registry: Arc<RwLock<DeviceRegistry>>,
udn_cache: Arc<Mutex<UDNRegistry>>,
}
impl UpnpDiscoveryManager {
// Dans handle_ssdp_event (upnp_discovery.rs)
fn handle_ssdp_event(&mut self, event: SsdpEvent) {
pub fn new(device_registry: Arc<RwLock<DeviceRegistry>>,
udn_cache: Arc<Mutex<UDNRegistry>>,
) -> Self {
Self {
device_registry,
udn_cache,
}
}
pub fn handle_ssdp_event(&mut self, event: SsdpEvent) {
let (alive, usn, location, max_age, server_header) = match event {
SsdpEvent::Alive {
usn,
@@ -39,24 +47,20 @@ impl UpnpDiscoveryManager {
// ✅ Fetch + parse
if let Ok(info) = ParsedDeviceDescription::new(&udn, &location, &server_header,5) {
if let Some(renderer_info) = info.build_renderer() {
self.device_registry
.lock()
.expect("UDNRegistry mutex lock failed")
.push_renderer(&renderer_info,max_age);
} else {
if let Some(server_info) = info.build_server() {
self.device_registry
.lock()
.expect("UDNRegistry mutex lock failed")
.push_server(&server_info,max_age);
if let Ok(mut reg) = self.device_registry.write() {
reg.push_renderer(&renderer_info, max_age);
}
} else if let Some(server_info) = info.build_server() {
if let Ok(mut reg) = self.device_registry.write() {
reg.push_server(&server_info, max_age);
}
}
}
}}
}
} else {
self.device_registry
.lock()
.expect("UDNRegistry mutex lock failed")
.device_says_byebye(&udn);
if let Ok(mut reg) = self.device_registry.write() {
reg.device_says_byebye(&udn);
}
}
}
}

View File

@@ -24,11 +24,11 @@ pub enum ControlPointError {
#[error("Missing {0} element in SOAP body")]
UpnpMissingReturnValue(String),
#[error("Invalid {0} value: {1}")]
UpnpBadReturnValue(String,String),
UpnpBadReturnValue(String, String),
#[error("Soap Error: Upnp action call {0}")]
SoapAction(String),
#[error("{0} returned UPnP error {1}: {2} (HTTP status {3})")]
SoapUpnpParseError(String,u32, String, u32),
SoapUpnpParseError(String, u32, String, u32),
#[error("{0} failed with HTTP status {1} and body: {2}")]
SoapActionWrongBody(String, u32, String),
#[error("Soap Error: No envelop for action {0}")]
@@ -45,23 +45,26 @@ pub enum ControlPointError {
QueueError(String),
#[error("Invalid time format: {0}")]
InvalidTimeFormat(String),
#[error("Error on snapshot: {0}")]
SnapshotError(String),
#[error("Error on ControlPoint: {0}")]
ControlPoint(String),
}
impl ControlPointError {
pub fn upnp_operation_not_supported(operation: &str, service: &str) -> Self {
ControlPointError::UpnpOperationNotSupported(operation.to_string(),service.to_string())
ControlPointError::UpnpOperationNotSupported(operation.to_string(), service.to_string())
}
pub fn upnp_missing_return_value(value: &str) -> Self {
ControlPointError::UpnpMissingReturnValue(value.to_string())
}
pub fn upnp_bad_return_value(name : &str, value : &str ) -> Self {
ControlPointError::UpnpBadReturnValue(name.to_string(),value.to_string())
pub fn upnp_bad_return_value(name: &str, value: &str) -> Self {
ControlPointError::UpnpBadReturnValue(name.to_string(), value.to_string())
}
pub fn arilyc_tcp_error(message : &str ) -> Self {
pub fn arilyc_tcp_error(message: &str) -> Self {
ControlPointError::ArilycTcpError(message.to_string())
}
}
}

View File

@@ -1,14 +1,29 @@
use crate::DeviceId;
use crate::model::DeviceBasicInfo;
pub trait DeviceIdentity {
fn id(&self) -> DeviceId ;
fn id(&self) -> DeviceId;
fn udn(&self) -> &str;
fn friendly_name(&self) -> &str ;
fn model_name(&self) -> &str ;
fn manufacturer(&self) -> &str ;
fn location(&self) -> &str ;
fn friendly_name(&self) -> &str;
fn model_name(&self) -> &str;
fn manufacturer(&self) -> &str;
fn location(&self) -> &str;
fn server_header(&self) -> &str;
fn is_a_media_server(&self) -> bool { false }
fn is_a_music_renderer(&self) -> bool { false }
}
fn is_a_media_server(&self) -> bool {
false
}
fn is_a_music_renderer(&self) -> bool {
false
}
/// Returns basic device information suitable for event notifications
fn basic_info(&self) -> DeviceBasicInfo {
DeviceBasicInfo {
id: self.id(),
friendly_name: self.friendly_name().to_string(),
model_name: self.model_name().to_string(),
manufacturer: self.manufacturer().to_string(),
}
}
}

View File

@@ -1,23 +1,21 @@
mod events;
mod media_server_events;
pub mod queue;
pub mod discovery;
pub mod upnp_clients;
pub mod arylic_client;
pub mod linkplay_client;
pub mod control_point;
pub mod discovery;
pub mod errors;
pub mod identity;
pub mod linkplay_client;
pub mod linkplay_utils;
pub mod media_server;
pub mod model;
pub mod music_renderer;
pub mod online;
pub mod queue;
pub mod registry;
pub mod soap_client;
pub mod online;
pub mod identity;
pub mod linkplay_utils;
pub mod upnp_clients;
// pmoserver extension (optional)
#[cfg(feature = "pmoserver")]
@@ -32,20 +30,18 @@ use std::time::Duration;
#[cfg(feature = "pmoserver")]
pub use pmoserver_ext::ControlPointExt;
pub use control_point::{ControlPoint, PlaylistBinding};
pub use media_server::{
MediaBrowser, MediaEntry, MediaResource, UpnpMediaServer,
};
pub use control_point::ControlPoint;
pub use media_server::{MediaBrowser, MediaEntry, MediaResource, UpnpMediaServer};
pub use queue::{EnqueueMode, PlaybackItem, QueueSnapshot};
pub use model::{
MediaServerEvent, RendererCapabilities, RendererEvent, RendererInfo, RendererProtocol,
MediaServerEvent, PlaybackSource, RendererCapabilities, RendererEvent, RendererInfo,
RendererProtocol,
};
pub use registry::{DeviceRegistry, DeviceUpdate};
pub use soap_client::invoke_upnp_action;
pub use online::DeviceOnline;
pub use soap_client::invoke_upnp_action;
pub use identity::DeviceIdentity;
@@ -53,4 +49,3 @@ pub use identity::DeviceIdentity;
pub struct DeviceId(pub String);
const DEFAULT_HTTP_TIMEOUT: Duration = Duration::from_secs(30);

View File

@@ -1,10 +1,16 @@
use std::time::Duration;
use serde::Deserialize;
use tracing::debug;
use ureq::Agent;
use crate::{errors::ControlPointError, model::PlaybackState, music_renderer::{PlaybackPositionInfo, time_utils::{format_hhmmss, ms_to_seconds}}};
use crate::{
errors::ControlPointError,
model::PlaybackState,
music_renderer::{
PlaybackPositionInfo,
time_utils::{format_hhmmss, ms_to_seconds},
},
};
const STATUS_COMMAND: &str = "getPlayerStatus";
@@ -101,32 +107,41 @@ pub fn percent_encode(input: &str) -> String {
out
}
pub fn fetch_status_for_host(host: &str, timeout: Duration) -> Result<LinkPlayStatus, ControlPointError> {
pub fn fetch_status_for_host(
host: &str,
timeout: Duration,
) -> Result<LinkPlayStatus, ControlPointError> {
let url = format!("http://{}/httpapi.asp?command={}", host, STATUS_COMMAND);
let mut response = build_agent(timeout)
.get(&url)
.call()
.map_err(|_| ControlPointError::ArilycTcpError(format!("HTTP request failed for LinkPlay status on {}", host)))?;
let mut response = build_agent(timeout).get(&url).call().map_err(|_| {
ControlPointError::ArilycTcpError(format!(
"HTTP request failed for LinkPlay status on {}",
host
))
})?;
let body = response
.body_mut()
.read_to_string()
.map_err(|e| ControlPointError::ArilycTcpError(format!("Failed to read LinkPlay status body : {}",e)))?;
let body = response.body_mut().read_to_string().map_err(|e| {
ControlPointError::ArilycTcpError(format!("Failed to read LinkPlay status body : {}", e))
})?;
parse_linkplay_status(&body)
}
fn parse_linkplay_status(body: &str) -> Result<LinkPlayStatus, ControlPointError> {
let raw: LinkPlayStatusRaw = serde_json::from_str(body)
.map_err(|e| ControlPointError::LinkPlayError(format!("Failed to parse LinkPlay status JSON: {}", e)))?;
let raw: LinkPlayStatusRaw = serde_json::from_str(body).map_err(|e| {
ControlPointError::LinkPlayError(format!("Failed to parse LinkPlay status JSON: {}", e))
})?;
let curpos_ms = raw.curpos.parse::<u64>()
.map_err(|_| ControlPointError::LinkPlayError(format!("Invalid curpos value: {}", raw.curpos)))?;
let curpos_ms = raw.curpos.parse::<u64>().map_err(|_| {
ControlPointError::LinkPlayError(format!("Invalid curpos value: {}", raw.curpos))
})?;
let totlen_ms = raw.totlen.parse::<u64>()
.map_err(|_| ControlPointError::LinkPlayError(format!("Invalid totlen value: {}", raw.totlen)))?;
let totlen_ms = raw.totlen.parse::<u64>().map_err(|_| {
ControlPointError::LinkPlayError(format!("Invalid totlen value: {}", raw.totlen))
})?;
let volume = raw.vol.parse::<u16>()
let volume = raw
.vol
.parse::<u16>()
.map_err(|_| ControlPointError::LinkPlayError(format!("Invalid vol value: {}", raw.vol)))?
.min(100);
@@ -134,11 +149,15 @@ fn parse_linkplay_status(body: &str) -> Result<LinkPlayStatus, ControlPointError
"1" => true,
"0" => false,
other => {
return Err(ControlPointError::LinkPlayError(format!("Invalid mute value: {}", other)));
return Err(ControlPointError::LinkPlayError(format!(
"Invalid mute value: {}",
other
)));
}
};
let track_index = raw.plicurr
let track_index = raw
.plicurr
.and_then(|s| s.parse::<u32>().ok())
.filter(|idx| *idx > 0);
@@ -151,6 +170,3 @@ fn parse_linkplay_status(body: &str) -> Result<LinkPlayStatus, ControlPointError
mute,
})
}

View File

@@ -12,7 +12,7 @@ use crate::model::TrackMetadata;
use crate::online::{DeviceConnectionState, DeviceOnline};
use crate::queue::PlaybackItem;
use crate::soap_client::{SoapCallResult, invoke_upnp_action_with_timeout};
use crate::{DEFAULT_HTTP_TIMEOUT, DeviceId, DeviceIdentity, RendererInfo};
use crate::{DEFAULT_HTTP_TIMEOUT, DeviceId, DeviceIdentity};
/// Snapshot of a media server discovered through UPnP SSDP.
#[derive(Clone, Debug)]
@@ -70,7 +70,7 @@ impl UpnpMediaServer {
content_directory_service_type: Option<String>,
content_directory_control_url: Option<String>,
) -> Arc<Self> {
Arc::new(Self::new(
Arc::new(UpnpMediaServer::new(
id,
udn,
friendly_name,
@@ -110,6 +110,10 @@ impl UpnpMediaServer {
map_didl_entries(&didl_xml)
}
fn has_content_directory(&self) -> bool {
self.has_content_directory
}
fn search_impl(
&self,
container_id: &str,
@@ -384,9 +388,15 @@ pub enum MusicServer {
}
impl MusicServer {
pub fn from_server_info(info: &UpnpMediaServer) -> Result<MusicServer, ControlPointError> {
pub fn from_server_info(info: &UpnpMediaServer) -> Result<MusicServer, ControlPointError> {
Ok(MusicServer::Upnp(info.clone()))
}
pub fn has_content_directory(&self) -> bool {
match self {
MusicServer::Upnp(u) => u.has_content_directory(),
}
}
}
impl DeviceOnline for MusicServer {
@@ -502,7 +512,7 @@ impl MediaBrowser for MusicServer {
/// This function filters out containers and entries without audio resources,
/// returning None for items that cannot be played.
pub fn playback_item_from_entry(
server: &UpnpMediaServer,
server: Arc<MusicServer>,
entry: &MediaEntry,
) -> Option<PlaybackItem> {
// Ignore containers

View File

@@ -8,20 +8,23 @@ use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use crossbeam_channel::{Receiver, Sender, unbounded};
use tracing::{debug, info, warn};
use tracing::{debug, error, info, warn};
use ureq::{Agent, http};
use xmltree::{Element, XMLNode};
use crate::DeviceId;
use crate::events::MediaServerEventBus;
use crate::media_server::{UpnpMediaServer};
use crate::media_server::MusicServer;
use crate::model::MediaServerEvent;
use crate::upnp_clients::resolve_control_url;
use crate::registry::DeviceRegistry;
use crate::{DeviceOnline,DeviceIdentity};
use crate::upnp_clients::resolve_control_url;
use crate::{DeviceId, DeviceIdentity, DeviceOnline};
const SUBSCRIPTION_TIMEOUT_SECS: u64 = 300;
const RENEWAL_SAFETY_MARGIN_SECS: u64 = 60;
const HTTP_READ_TIMEOUT_SECS: u64 = 5;
const WORKER_LOOP_INTERVAL_MILLIS: u64 = 250;
const RETRY_DELAY_SECS: u64 = 15;
const SUBSCRIPTION_RESET_DELAY_SECS: u64 = 5;
/// Launch the media server event runtime responsible for subscribing
/// to ContentDirectory updates and forwarding notifications on the bus.
@@ -67,11 +70,47 @@ struct IncomingNotify {
body: Vec<u8>,
}
impl IncomingNotify {
fn validate_sid(&self, expected: &Option<String>) -> bool {
match (&self.sid, expected) {
(Some(received), Some(expected)) => expected.eq_ignore_ascii_case(received),
_ => false,
}
}
}
/// Manages retry timing for subscription operations
struct RetryPolicy {
retry_after: Instant,
}
impl RetryPolicy {
fn new() -> Self {
Self {
retry_after: Instant::now(),
}
}
fn should_retry(&self) -> bool {
Instant::now() >= self.retry_after
}
fn defer_retry(&mut self) {
self.retry_after = Instant::now() + Duration::from_secs(RETRY_DELAY_SECS);
}
fn schedule_soon(&mut self) {
self.retry_after = Instant::now() + Duration::from_secs(SUBSCRIPTION_RESET_DELAY_SECS);
}
}
fn run_http_listener(listener: TcpListener, notify_tx: Sender<IncomingNotify>) {
for stream in listener.incoming() {
match stream {
Ok(mut stream) => {
if let Err(err) = stream.set_read_timeout(Some(Duration::from_secs(5))) {
if let Err(err) =
stream.set_read_timeout(Some(Duration::from_secs(HTTP_READ_TIMEOUT_SECS)))
{
warn!("Failed to set read timeout on notify connection: {}", err);
}
@@ -209,7 +248,7 @@ impl MediaServerEventWorker {
self.drain_notifications();
self.refresh_servers();
self.renew_expiring();
thread::sleep(Duration::from_millis(250));
thread::sleep(Duration::from_millis(WORKER_LOOP_INTERVAL_MILLIS));
}
}
@@ -220,54 +259,60 @@ impl MediaServerEventWorker {
}
fn refresh_servers(&mut self) {
let server_infos = {
let servers = {
let reg = self.registry.read().unwrap();
reg.list_servers()
match reg.list_servers() {
Ok(servers) => servers,
Err(e) => {
error!("Failed to list servers: {}", e);
return;
}
}
};
let mut active: HashSet<DeviceId> = HashSet::new();
for info in server_infos {
if !info.is_online() || !info.has_content_directory {
for server in servers {
if !server.is_online() || !server.has_content_directory() {
continue;
}
active.insert(info.id.clone());
active.insert(server.id());
let entry = self
.subscriptions
.entry(info.id.clone())
.or_insert_with(|| SubscriptionState::new(info.clone()));
entry.update(info);
.entry(server.id())
.or_insert_with(|| SubscriptionState::from_music_server(&server));
entry.update_from_server(&server);
self.path_index
.insert(entry.callback_path.clone(), entry.info.id.clone());
.insert(entry.callback_path.clone(), entry.device_id.clone());
if entry.event_sub_url.is_none() {
if entry.should_retry() {
match fetch_event_sub_url(&entry.info.location, self.http_timeout) {
if entry.retry_policy.should_retry() {
match fetch_event_sub_url(&entry.location, self.http_timeout) {
Ok(Some(url)) => {
debug!(
server = entry.info.friendly_name.as_str(),
server = entry.friendly_name.as_str(),
callback = url.as_str(),
"ContentDirectory eventSub URL resolved"
);
entry.event_sub_url = Some(url);
entry.retry_after = Instant::now();
entry.retry_policy = RetryPolicy::new();
}
Ok(None) => {
debug!(
server = entry.info.friendly_name.as_str(),
server = entry.friendly_name.as_str(),
"No ContentDirectory eventSub URL found"
);
entry.defer_retry();
entry.retry_policy.defer_retry();
continue;
}
Err(err) => {
warn!(
server = entry.info.friendly_name.as_str(),
server = entry.friendly_name.as_str(),
error = %err,
"Failed to fetch ContentDirectory eventSub URL"
);
entry.defer_retry();
entry.retry_policy.defer_retry();
continue;
}
}
@@ -276,21 +321,21 @@ impl MediaServerEventWorker {
}
}
if entry.sid.is_none() && entry.should_retry() {
if entry.sid.is_none() && entry.retry_policy.should_retry() {
if let Err(err) =
Self::subscribe_entry(self.listener_port, self.http_timeout, entry)
{
warn!(
server = entry.info.friendly_name.as_str(),
server = entry.friendly_name.as_str(),
error = %err,
"ContentDirectory SUBSCRIBE failed"
);
entry.defer_retry();
entry.retry_policy.defer_retry();
}
}
}
let stale_ids: Vec<ServerId> = self
let stale_ids: Vec<DeviceId> = self
.subscriptions
.keys()
.filter(|id| !active.contains(*id))
@@ -320,7 +365,7 @@ impl MediaServerEventWorker {
if let Some(entry) = self.subscriptions.get_mut(&id) {
if let Err(err) = Self::renew_entry(self.http_timeout, entry) {
warn!(
server = entry.info.friendly_name.as_str(),
server = entry.friendly_name.as_str(),
error = %err,
"Failed to renew ContentDirectory subscription"
);
@@ -340,8 +385,10 @@ impl MediaServerEventWorker {
.as_ref()
.context("EventSub URL missing for server")?;
let (host_header, timeout_header) = build_subscribe_headers(event_url)?;
let (remote_host, remote_port) =
parse_host_port(event_url).context("Cannot extract host for SUBSCRIBE")?;
parse_host_port(event_url).context("Cannot extract host for callback")?;
let local_ip = determine_local_ip(&remote_host, remote_port)
.context("Cannot determine local IP for callback")?;
@@ -353,13 +400,11 @@ impl MediaServerEventWorker {
);
debug!(
server = entry.info.friendly_name.as_str(),
server = entry.friendly_name.as_str(),
callback = callback_url.as_str(),
"Subscribing to ContentDirectory events"
);
let host_header = format!("{}:{}", remote_host, remote_port);
let timeout_header = format!("Second-{}", SUBSCRIPTION_TIMEOUT_SECS);
let callback_header = format!("<{}>", callback_url);
let request = http::Request::builder()
@@ -393,10 +438,10 @@ impl MediaServerEventWorker {
entry.sid = Some(sid);
entry.expires_at = Some(Instant::now() + timeout);
entry.retry_after = Instant::now() + Duration::from_secs(5);
entry.retry_policy.schedule_soon();
info!(
server = entry.info.friendly_name.as_str(),
server = entry.friendly_name.as_str(),
"Subscribed to ContentDirectory events (timeout {}s)",
timeout.as_secs()
);
@@ -414,10 +459,9 @@ impl MediaServerEventWorker {
.as_ref()
.cloned()
.context("SID missing for renew")?;
let (remote_host, remote_port) =
parse_host_port(event_url).context("Cannot extract host for renew")?;
let host_header = format!("{}:{}", remote_host, remote_port);
let timeout_header = format!("Second-{}", SUBSCRIPTION_TIMEOUT_SECS);
let (host_header, timeout_header) = build_subscribe_headers(event_url)?;
let request = http::Request::builder()
.method("SUBSCRIBE")
.uri(event_url)
@@ -441,7 +485,7 @@ impl MediaServerEventWorker {
.unwrap_or(Duration::from_secs(SUBSCRIPTION_TIMEOUT_SECS));
entry.expires_at = Some(Instant::now() + timeout);
debug!(
server = entry.info.friendly_name.as_str(),
server = entry.friendly_name.as_str(),
"Renewed ContentDirectory subscription"
);
Ok(())
@@ -470,7 +514,7 @@ impl MediaServerEventWorker {
Ok(req) => req,
Err(err) => {
warn!(
server = entry.info.friendly_name.as_str(),
server = entry.friendly_name.as_str(),
error = %err,
"Failed to build UNSUBSCRIBE request"
);
@@ -482,12 +526,12 @@ impl MediaServerEventWorker {
Ok(response) => {
if response.status().is_success() {
debug!(
server = entry.info.friendly_name.as_str(),
server = entry.friendly_name.as_str(),
"Unsubscribed from ContentDirectory events"
);
} else {
warn!(
server = entry.info.friendly_name.as_str(),
server = entry.friendly_name.as_str(),
status = %response.status(),
"UNSUBSCRIBE returned non-success status"
);
@@ -495,7 +539,7 @@ impl MediaServerEventWorker {
}
Err(err) => {
warn!(
server = entry.info.friendly_name.as_str(),
server = entry.friendly_name.as_str(),
error = %err,
"UNSUBSCRIBE request failed"
);
@@ -513,32 +557,30 @@ impl MediaServerEventWorker {
return;
};
if let (Some(expected), Some(received)) = (&entry.sid, &notify.sid) {
if !expected.eq_ignore_ascii_case(received) {
debug!(
server = entry.info.friendly_name.as_str(),
expected_sid = expected.as_str(),
received_sid = received.as_str(),
"Ignoring notify with mismatched SID"
);
return;
}
if !notify.validate_sid(&entry.sid) {
debug!(
server = entry.friendly_name.as_str(),
expected_sid = entry.sid.as_deref().unwrap_or("none"),
received_sid = notify.sid.as_deref().unwrap_or("none"),
"Ignoring notify with mismatched SID"
);
return;
}
for event in parse_notify_payload(&entry.info.id, &notify.body) {
for event in parse_notify_payload(&entry.device_id, &notify.body) {
match &event {
MediaServerEvent::GlobalUpdated {
system_update_id, ..
} => {
debug!(
server = entry.info.friendly_name.as_str(),
server = entry.friendly_name.as_str(),
update_id = system_update_id.unwrap_or_default(),
"Broadcasting MediaServerEvent::GlobalUpdated"
);
}
MediaServerEvent::ContainersUpdated { container_ids, .. } => {
debug!(
server = entry.info.friendly_name.as_str(),
server = entry.friendly_name.as_str(),
changed_containers = container_ids.join(",").as_str(),
"Broadcasting MediaServerEvent::ContainersUpdated"
);
@@ -553,52 +595,63 @@ impl MediaServerEventWorker {
}
struct SubscriptionState {
info: UpnpMediaServer,
device_id: DeviceId,
location: String,
friendly_name: String,
event_sub_url: Option<String>,
sid: Option<String>,
expires_at: Option<Instant>,
callback_path: String,
retry_after: Instant,
retry_policy: RetryPolicy,
}
impl SubscriptionState {
fn new(info: UpnpMediaServer) -> Self {
/// Creates a new subscription state from a MusicServer
fn from_music_server(server: &MusicServer) -> Self {
Self {
callback_path: build_callback_path(&info.id),
info,
callback_path: build_callback_path(&server.id()),
device_id: server.id(),
location: server.location().to_string(),
friendly_name: server.friendly_name().to_string(),
event_sub_url: None,
sid: None,
expires_at: None,
retry_after: Instant::now(),
retry_policy: RetryPolicy::new(),
}
}
fn update(&mut self, info: UpnpMediaServer) {
if self.info.location != info.location {
/// Updates the subscription state from a MusicServer
fn update_from_server(&mut self, server: &MusicServer) {
let new_location = server.location();
if self.location != new_location {
// Location changed - invalidate subscription
self.event_sub_url = None;
self.sid = None;
self.expires_at = None;
self.retry_after = Instant::now();
self.retry_policy = RetryPolicy::new();
}
self.info = info;
}
fn should_retry(&self) -> bool {
Instant::now() >= self.retry_after
}
fn defer_retry(&mut self) {
self.retry_after = Instant::now() + Duration::from_secs(15);
self.device_id = server.id();
self.location = new_location.to_string();
self.friendly_name = server.friendly_name().to_string();
}
fn reset_subscription(&mut self) {
self.sid = None;
self.expires_at = None;
self.retry_after = Instant::now() + Duration::from_secs(5);
self.retry_policy.schedule_soon();
}
}
fn build_callback_path(id: &ServerId) -> String {
/// Builds HTTP headers common to SUBSCRIBE requests
fn build_subscribe_headers(event_url: &str) -> Result<(String, String)> {
let (remote_host, remote_port) =
parse_host_port(event_url).context("Cannot extract host for SUBSCRIBE")?;
let host_header = format!("{}:{}", remote_host, remote_port);
let timeout_header = format!("Second-{}", SUBSCRIPTION_TIMEOUT_SECS);
Ok((host_header, timeout_header))
}
fn build_callback_path(id: &DeviceId) -> String {
let mut sanitized = String::new();
for ch in id.0.chars() {
if ch.is_ascii_alphanumeric() {
@@ -615,7 +668,7 @@ fn build_callback_path(id: &ServerId) -> String {
format!("/media-server-events/{}-{:x}", sanitized, suffix)
}
fn parse_notify_payload(server_id: &ServerId, body: &[u8]) -> Vec<MediaServerEvent> {
fn parse_notify_payload(server_id: &DeviceId, body: &[u8]) -> Vec<MediaServerEvent> {
let mut events = Vec::new();
let reader = std::io::Cursor::new(body);
let Ok(root) = Element::parse(reader) else {
@@ -629,27 +682,21 @@ fn parse_notify_payload(server_id: &ServerId, body: &[u8]) -> Vec<MediaServerEve
let mut system_update_id: Option<u32> = None;
let mut container_ids: Vec<String> = Vec::new();
for property in root.children.iter().filter_map(|node| match node {
XMLNode::Element(elem) => Some(elem),
_ => None,
}) {
for child in property.children.iter().filter_map(|node| match node {
XMLNode::Element(elem) => Some(elem),
_ => None,
}) {
if child.name == "SystemUpdateID" {
if let Some(text) = child.get_text() {
let trimmed = text.trim();
if let Ok(value) = trimmed.parse::<u32>() {
system_update_id = Some(value);
} else {
system_update_id = None;
// Navigate through property elements
for property in xml_children(&root) {
for child in xml_children(property) {
match child.name.as_str() {
"SystemUpdateID" => {
system_update_id = child
.get_text()
.and_then(|text| text.trim().parse::<u32>().ok());
}
"ContainerUpdateIDs" => {
if let Some(text) = child.get_text() {
container_ids = parse_container_update_ids(text.as_ref());
}
}
} else if child.name == "ContainerUpdateIDs" {
if let Some(text) = child.get_text() {
container_ids = parse_container_update_ids(text.as_ref());
}
_ => {}
}
}
}
@@ -671,6 +718,14 @@ fn parse_notify_payload(server_id: &ServerId, body: &[u8]) -> Vec<MediaServerEve
events
}
/// Helper to iterate over XML element children (filters out non-element nodes)
fn xml_children(element: &Element) -> impl Iterator<Item = &Element> {
element.children.iter().filter_map(|node| match node {
XMLNode::Element(elem) => Some(elem),
_ => None,
})
}
fn parse_container_update_ids(raw: &str) -> Vec<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
@@ -702,14 +757,9 @@ fn parse_container_update_ids(raw: &str) -> Vec<String> {
}
fn child_text(element: &Element, name: &str) -> Option<String> {
for node in &element.children {
if let XMLNode::Element(child) = node {
if child.name == name {
return child.get_text().map(|cow| cow.into_owned());
}
}
}
None
xml_children(element)
.find(|child| child.name == name)
.and_then(|child| child.get_text().map(|cow| cow.into_owned()))
}
fn fetch_event_sub_url(location: &str, timeout: Duration) -> Result<Option<String>> {
@@ -734,20 +784,18 @@ fn fetch_event_sub_url(location: &str, timeout: Duration) -> Result<Option<Strin
None => return Ok(None),
};
for node in &service_list.children {
if let XMLNode::Element(service) = node {
let Some(service_type) = child_text(service, "serviceType") else {
continue;
};
if !service_type
.to_ascii_lowercase()
.contains("urn:schemas-upnp-org:service:contentdirectory:")
{
continue;
}
if let Some(event_sub) = child_text(service, "eventSubURL") {
return Ok(Some(resolve_control_url(location, &event_sub)));
}
for service in xml_children(service_list) {
let Some(service_type) = child_text(service, "serviceType") else {
continue;
};
if !service_type
.to_ascii_lowercase()
.contains("urn:schemas-upnp-org:service:contentdirectory:")
{
continue;
}
if let Some(event_sub) = child_text(service, "eventSubURL") {
return Ok(Some(resolve_control_url(location, &event_sub)));
}
}

View File

@@ -1,8 +1,28 @@
use crate::control_point::PlaylistBinding;
use crate::media_server::UpnpMediaServer;
use crate::music_renderer::PlaybackPositionInfo;
use crate::music_renderer::{PlaybackPositionInfo, PlaylistBinding};
use crate::{DeviceId, DeviceIdentity};
/// Basic device information for event notifications
/// Contains only the essential identification fields
#[derive(Clone, Debug)]
pub struct DeviceBasicInfo {
pub id: DeviceId,
pub friendly_name: String,
pub model_name: String,
pub manufacturer: String,
}
impl DeviceBasicInfo {
/// Create from any type implementing DeviceIdentity
pub fn from_device<D: DeviceIdentity>(device: &D) -> Self {
Self {
id: device.id(),
friendly_name: device.friendly_name().to_string(),
model_name: device.model_name().to_string(),
manufacturer: device.manufacturer().to_string(),
}
}
}
/// High-level playback state across backends.
#[derive(Clone, Debug)]
pub enum PlaybackState {
@@ -48,6 +68,21 @@ impl PlaybackState {
}
}
/// Indicates the source of the current playback.
///
/// This helps the control point distinguish between playback initiated
/// from the queue vs external sources (e.g., user playing from another app).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum PlaybackSource {
/// No playback active or source unknown.
#[default]
None,
/// Playback was started from the control point's queue.
FromQueue,
/// Playback was started externally (e.g., from another app).
External,
}
#[derive(Clone, Debug, PartialEq)]
pub struct TrackMetadata {
pub title: Option<String>,
@@ -401,7 +436,7 @@ pub enum RendererEvent {
},
Online {
id: DeviceId,
info: RendererInfo,
info: DeviceBasicInfo,
},
Offline {
id: DeviceId,
@@ -420,7 +455,7 @@ pub enum MediaServerEvent {
},
Online {
server_id: DeviceId,
info: UpnpMediaServer,
info: DeviceBasicInfo,
},
Offline {
server_id: DeviceId,

View File

@@ -4,17 +4,19 @@ use serde::Deserialize;
use tracing::debug;
use crate::DeviceIdentity;
use crate::arylic_client::{ARYLIC_TCP_PORT, DEFAULT_TIMEOUT_SECS, send_command_no_response, send_command_optional, send_command_required};
use crate::arylic_client::{
ARYLIC_TCP_PORT, DEFAULT_TIMEOUT_SECS, send_command_no_response, send_command_optional,
send_command_required,
};
use crate::errors::ControlPointError;
use crate::linkplay_client::extract_linkplay_host;
use crate::model::{PlaybackState, RendererInfo};
use crate::music_renderer::RendererFromMediaRendererInfo;
use crate::music_renderer::capabilities::{
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl,
VolumeControl,
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, VolumeControl,
};
use crate::music_renderer::time_utils::{ms_to_seconds, format_hhmmss, parse_hhmmss_strict};
use crate::music_renderer::musicrenderer::MusicRendererBackend;
use crate::music_renderer::time_utils::{format_hhmmss, ms_to_seconds, parse_hhmmss_strict};
/// Raw response from Arylic MCU+PINFGET command
#[derive(Debug, Deserialize)]
@@ -145,7 +147,7 @@ impl TransportControl for ArylicTcpRenderer {
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
let _ = parse_hhmmss_strict(hhmmss)?;
Err(ControlPointError::ArilycTcpError(
"Arylic TCP seek_rel_time is not implemented yet for this device.".to_string()
"Arylic TCP seek_rel_time is not implemented yet for this device.".to_string(),
))
}
}
@@ -255,59 +257,77 @@ fn parse_playback_info(payload: &str) -> Result<ArylicPlaybackInfo, ControlPoint
ControlPointError::ArilycTcpError(format!("Unexpected playback info prefix: {}", payload))
})?;
let json_blob = json_blob.trim_end_matches('&').trim();
// Extract JSON object between { and }, ignoring trailing & and other garbage
// Arylic devices send: AXX+PLY+INF{...json...}&
let json_start = json_blob.find('{').ok_or_else(|| {
ControlPointError::ArilycTcpError(format!(
"No JSON object found in playback info: {}",
payload
))
})?;
let raw: ArylicPlaybackInfoRaw = serde_json::from_str(json_blob)
.map_err(|e| ControlPointError::ArilycTcpError(format!("Failed to parse Arylic playback info JSON: {}", e)))?;
let json_end = json_blob.rfind('}').ok_or_else(|| {
ControlPointError::ArilycTcpError(format!(
"No JSON object end found in playback info: {}",
payload
))
})?;
let curpos_ms = raw.curpos.parse::<u64>()
.map_err(|_| ControlPointError::ArilycTcpError(format!("Invalid curpos value: {}", raw.curpos)))?;
let json_blob = &json_blob[json_start..=json_end];
let totlen_ms = raw.totlen.parse::<u64>()
.map_err(|_| ControlPointError::ArilycTcpError(format!("Invalid totlen value: {}", raw.totlen)))?;
let raw: ArylicPlaybackInfoRaw = serde_json::from_str(json_blob).map_err(|e| {
ControlPointError::ArilycTcpError(format!(
"Failed to parse Arylic playback info JSON: {}",
e
))
})?;
let volume = raw.vol.and_then(|raw_vol| {
match raw_vol.parse::<u16>() {
Ok(value) => Some(value.min(100)),
Err(err) => {
debug!("Invalid Arylic `vol` value {}: {}", raw_vol, err);
None
}
let curpos_ms = raw.curpos.parse::<u64>().map_err(|_| {
ControlPointError::ArilycTcpError(format!("Invalid curpos value: {}", raw.curpos))
})?;
let totlen_ms = raw.totlen.parse::<u64>().map_err(|_| {
ControlPointError::ArilycTcpError(format!("Invalid totlen value: {}", raw.totlen))
})?;
let volume = raw.vol.and_then(|raw_vol| match raw_vol.parse::<u16>() {
Ok(value) => Some(value.min(100)),
Err(err) => {
debug!("Invalid Arylic `vol` value {}: {}", raw_vol, err);
None
}
});
let mute = raw.mute.and_then(|raw_mute| {
match raw_mute.as_str() {
"1" => Some(true),
"0" => Some(false),
other => {
debug!("Invalid Arylic `mute` value {}", other);
None
}
let mute = raw.mute.and_then(|raw_mute| match raw_mute.as_str() {
"1" => Some(true),
"0" => Some(false),
other => {
debug!("Invalid Arylic `mute` value {}", other);
None
}
});
let playlist_size = raw.plicount.and_then(|raw_count| {
match raw_count.parse::<u32>() {
let playlist_size = raw
.plicount
.and_then(|raw_count| match raw_count.parse::<u32>() {
Ok(count) if count > 0 => Some(count),
Ok(_) => None,
Err(err) => {
debug!("Invalid Arylic `plicount` value {}: {}", raw_count, err);
None
}
}
});
});
let track_index = raw.plicurr.and_then(|raw_idx| {
match raw_idx.parse::<u32>() {
let track_index = raw
.plicurr
.and_then(|raw_idx| match raw_idx.parse::<u32>() {
Ok(idx) if idx > 0 => Some(idx),
Ok(_) => None,
Err(err) => {
debug!("Invalid Arylic `plicurr` value {}: {}", raw_idx, err);
None
}
}
});
});
Ok(ArylicPlaybackInfo {
status_raw: raw.status,

View File

@@ -17,7 +17,7 @@ use std::sync::{Arc, Mutex};
pub use crate::music_renderer::capabilities::{
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
};
pub use crate::music_renderer::musicrenderer::MusicRenderer;
pub use crate::music_renderer::musicrenderer::{MusicRenderer, PlaylistBinding};
use crate::{
RendererInfo, errors::ControlPointError, music_renderer::musicrenderer::MusicRendererBackend,
};

View File

@@ -9,25 +9,44 @@
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
use crate::control_point::PlaylistBinding;
use crate::errors::ControlPointError;
use crate::model::{RendererInfo, RendererProtocol, TrackMetadata, PlaybackState};
use crate::model::{PlaybackSource, PlaybackState, RendererInfo, RendererProtocol, TrackMetadata};
use crate::music_renderer::RendererFromMediaRendererInfo;
use crate::music_renderer::arylic_tcp::ArylicTcpRenderer;
use crate::music_renderer::capabilities::{
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl,
VolumeControl,
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, VolumeControl,
};
use crate::music_renderer::chromecast_renderer::ChromecastRenderer;
use crate::music_renderer::linkplay_renderer::LinkPlayRenderer;
use crate::music_renderer::openhome_renderer::OpenHomeRenderer;
use crate::music_renderer::upnp_renderer::UpnpRenderer;
use crate::online::DeviceConnectionState;
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueFromRendererInfo, QueueSnapshot};
use crate::queue::{
EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueFromRendererInfo, QueueSnapshot,
};
use crate::{DeviceId, DeviceIdentity, DeviceOnline};
use tracing::warn;
/// Describes a renderer's attachment to a media server playlist container.
///
/// When a renderer is attached to a playlist, the ControlPoint monitors the container
/// for updates and automatically refreshes the queue when changes are detected.
#[derive(Clone, Debug)]
pub struct PlaylistBinding {
/// MediaServer that owns the playlist container.
pub server_id: DeviceId,
/// DIDL-Lite object id of the playlist container.
pub container_id: String,
/// True once at least one ContainerUpdateIDs notification has been seen.
pub(crate) has_seen_update: bool,
/// Flag used internally to signal that the queue should be refreshed
/// from the server container.
pub(crate) pending_refresh: bool,
/// Whether the next refresh should auto-start playback if the renderer is idle.
pub(crate) auto_play_on_refresh: bool,
}
/// Backend-agnostic façade exposing transport, volume, and status contracts.
#[derive(Clone, Debug)]
pub enum MusicRendererBackend {
@@ -49,6 +68,17 @@ pub enum MusicRendererBackend {
},
}
/// Internal state for tracking playback and control flow.
#[derive(Debug, Clone, Default)]
struct MusicRendererState {
/// Last known track metadata (cached to avoid repeated queries).
last_metadata: Option<TrackMetadata>,
/// Source of the current playback (queue vs external).
playback_source: PlaybackSource,
/// Flag to distinguish user-requested stop from automatic events.
user_stop_requested: bool,
}
#[derive(Debug, Clone)]
pub struct MusicRenderer {
info: RendererInfo,
@@ -56,6 +86,7 @@ pub struct MusicRenderer {
backend: Arc<Mutex<MusicRendererBackend>>,
queue: Arc<Mutex<MusicQueue>>,
playlist_binding: Arc<Mutex<Option<PlaylistBinding>>>,
state: Arc<Mutex<MusicRendererState>>,
}
impl MusicRenderer {
@@ -72,6 +103,7 @@ impl MusicRenderer {
backend,
queue,
playlist_binding: Arc::new(Mutex::new(None)),
state: Arc::new(Mutex::new(MusicRendererState::default())),
};
Arc::new(renderer)
@@ -88,6 +120,7 @@ impl MusicRenderer {
backend,
queue,
playlist_binding: Arc::new(Mutex::new(None)),
state: Arc::new(Mutex::new(MusicRendererState::default())),
};
Ok(renderer)
}
@@ -179,6 +212,23 @@ impl MusicRenderer {
.queue_snapshot()
}
/// Get the current queue item without advancing.
/// Returns the item and count of remaining items after current.
pub fn peek_current(&self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
self.queue
.lock()
.expect("Queue mutex poisoned")
.peek_current()
}
/// Get the count of items remaining after the current index.
pub fn upcoming_len(&self) -> Result<usize, ControlPointError> {
self.queue
.lock()
.expect("Queue mutex poisoned")
.upcoming_len()
}
/// Play the current item from the queue.
pub fn play_current_from_queue(&self) -> Result<(), ControlPointError> {
let queue = self.queue.lock().expect("Queue mutex poisoned");
@@ -248,6 +298,22 @@ impl MusicRenderer {
self.backend.lock().expect("Backend mutex poisoned").stop()
}
/// Set the next URI for gapless playback (UPnP AVTransport only).
///
/// Returns Ok if the backend supports this feature and it succeeded.
/// Returns Err if not supported or if it failed.
pub fn set_next_uri(&self, uri: &str, metadata: &str) -> Result<(), ControlPointError> {
let backend = self.backend.lock().expect("Backend mutex poisoned");
match &*backend {
MusicRendererBackend::Upnp(upnp) => upnp.set_next_uri(uri, metadata),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.set_next_uri(uri, metadata),
_ => Err(ControlPointError::ControlPoint(
"SetNextURI not supported by this backend".to_string(),
)),
}
}
/// Transport control: seek to relative time
pub fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
self.backend
@@ -303,17 +369,126 @@ impl MusicRenderer {
/// Sets the playlist binding for this renderer.
pub fn set_playlist_binding(&self, binding: Option<PlaylistBinding>) {
*self.playlist_binding.lock().expect("Playlist binding mutex poisoned") = binding;
*self
.playlist_binding
.lock()
.expect("Playlist binding mutex poisoned") = binding;
}
/// Gets the current playlist binding, if any.
pub fn get_playlist_binding(&self) -> Option<PlaylistBinding> {
self.playlist_binding.lock().expect("Playlist binding mutex poisoned").clone()
self.playlist_binding
.lock()
.expect("Playlist binding mutex poisoned")
.clone()
}
/// Clears the playlist binding.
pub fn clear_playlist_binding(&self) {
*self.playlist_binding.lock().expect("Playlist binding mutex poisoned") = None;
*self
.playlist_binding
.lock()
.expect("Playlist binding mutex poisoned") = None;
}
/// Marks the current playlist binding for refresh if it matches the given server and container.
/// Returns true if a matching binding was found and marked.
pub fn mark_binding_for_refresh(&self, server_id: &DeviceId, container_ids: &[String]) -> bool {
let mut binding_guard = self
.playlist_binding
.lock()
.expect("Playlist binding mutex poisoned");
if let Some(binding) = binding_guard.as_mut() {
if &binding.server_id == server_id && container_ids.contains(&binding.container_id) {
binding.pending_refresh = true;
binding.has_seen_update = true;
return true;
}
}
false
}
/// Checks if the binding has pending_refresh flag set.
/// Returns false if no binding exists.
pub fn has_pending_refresh(&self) -> bool {
self.playlist_binding
.lock()
.expect("Playlist binding mutex poisoned")
.as_ref()
.map(|b| b.pending_refresh)
.unwrap_or(false)
}
/// Resets the pending_refresh flag to false.
/// Does nothing if no binding exists.
pub fn reset_pending_refresh(&self) {
if let Some(binding) = self
.playlist_binding
.lock()
.expect("Playlist binding mutex poisoned")
.as_mut()
{
binding.pending_refresh = false;
}
}
/// Marks the binding as pending refresh.
/// Returns true if a binding exists and was marked, false otherwise.
pub fn mark_pending_refresh(&self) -> bool {
self.playlist_binding
.lock()
.expect("Playlist binding mutex poisoned")
.as_mut()
.map(|binding| {
binding.pending_refresh = true;
true
})
.unwrap_or(false)
}
/// Consumes and returns the auto_play_on_refresh flag, resetting it to false.
/// Returns false if no binding exists.
pub fn consume_auto_play(&self) -> bool {
self.playlist_binding
.lock()
.expect("Playlist binding mutex poisoned")
.as_mut()
.map(|binding| {
let auto_play = binding.auto_play_on_refresh;
binding.auto_play_on_refresh = false;
auto_play
})
.unwrap_or(false)
}
/// Add items to the queue using the specified enqueue mode.
pub fn enqueue_items(
&self,
items: Vec<PlaybackItem>,
mode: EnqueueMode,
) -> Result<(), ControlPointError> {
let mut queue = self.queue.lock().expect("Queue mutex poisoned");
queue.enqueue_items(items, mode)
}
/// Synchronize the queue with new items while preserving the current track.
///
/// This method intelligently updates the queue:
/// - If the current track is in the new items, it keeps playing at the new position
/// - If the current track is NOT in the new items, it's preserved as the first item
/// - If there's no current track, the queue is simply replaced
pub fn sync_queue(&self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
let mut queue = self.queue.lock().expect("Queue mutex poisoned");
queue.sync_queue(items)
}
/// Set the current queue index (for advanced use).
/// Note: This does NOT start playback. Use select_queue_track() to play.
pub fn set_queue_index(&self, index: Option<usize>) -> Result<(), ControlPointError> {
let mut queue = self.queue.lock().expect("Queue mutex poisoned");
queue.set_index(index)
}
/// Clears the renderer's queue using the generic QueueBackend trait.
@@ -340,7 +515,7 @@ impl MusicRenderer {
// The generic implementation cannot support this without more context
// (media_server_id, didl_id, protocol_info, etc.).
Err(ControlPointError::QueueError(
"add_track_to_queue requires backend-specific implementation".to_string()
"add_track_to_queue requires backend-specific implementation".to_string(),
))
}
@@ -360,7 +535,8 @@ impl MusicRenderer {
queue.set_index(Some(position))?;
// Get the item to play
let item = queue.get_item(position)?
let item = queue
.get_item(position)?
.ok_or_else(|| ControlPointError::QueueError("Track not found".to_string()))?;
drop(queue);
@@ -396,7 +572,8 @@ impl MusicRenderer {
let queue = self.queue.lock().expect("Queue mutex poisoned");
// Get current track ID using generic QueueBackend trait
let track_id = queue.current_track()?
let track_id = queue
.current_track()?
.ok_or_else(|| ControlPointError::QueueError("No current track".to_string()))?;
drop(queue);
@@ -416,6 +593,57 @@ impl MusicRenderer {
pub fn get_queue_mut(&self) -> std::sync::MutexGuard<'_, MusicQueue> {
self.queue.lock().unwrap()
}
// --- Playback State Management ---
/// Gets the last known track metadata.
pub fn last_metadata(&self) -> Option<TrackMetadata> {
self.state.lock().unwrap().last_metadata.clone()
}
/// Sets the last known track metadata.
pub fn set_last_metadata(&self, metadata: Option<TrackMetadata>) {
self.state.lock().unwrap().last_metadata = metadata;
}
/// Gets the current playback source.
pub fn playback_source(&self) -> PlaybackSource {
self.state.lock().unwrap().playback_source
}
/// Sets the playback source.
pub fn set_playback_source(&self, source: PlaybackSource) {
self.state.lock().unwrap().playback_source = source;
}
/// Checks if currently playing from queue.
pub fn is_playing_from_queue(&self) -> bool {
matches!(
self.state.lock().unwrap().playback_source,
PlaybackSource::FromQueue
)
}
/// Marks playback as external if currently idle (source is None).
pub fn mark_external_if_idle(&self) {
let mut state = self.state.lock().unwrap();
if matches!(state.playback_source, PlaybackSource::None) {
state.playback_source = PlaybackSource::External;
}
}
/// Marks that the user requested a stop (to prevent auto-advance).
pub fn mark_user_stop_requested(&self) {
self.state.lock().unwrap().user_stop_requested = true;
}
/// Checks and clears the user stop requested flag.
pub fn check_and_clear_user_stop_requested(&self) -> bool {
let mut state = self.state.lock().unwrap();
let was_requested = state.user_stop_requested;
state.user_stop_requested = false;
was_requested
}
}
/// Helper function to build DIDL-Lite metadata XML from TrackMetadata

View File

@@ -1,12 +1,11 @@
use crate::DeviceIdentity;
use crate::music_renderer::capabilities::{
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl,
VolumeControl,
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, VolumeControl,
};
use crate::music_renderer::time_utils::{parse_time_flexible, format_hhmmss_u32};
use crate::music_renderer::time_utils::{format_hhmmss_u32, parse_time_flexible};
use crate::errors::ControlPointError;
use crate::model::{RendererInfo, PlaybackState};
use crate::model::{PlaybackState, RendererInfo};
use crate::music_renderer::RendererFromMediaRendererInfo;
use crate::music_renderer::musicrenderer::MusicRendererBackend;
use crate::music_renderer::openhome::{
@@ -49,7 +48,6 @@ impl OpenHomeRenderer {
}
}
pub fn has_playlist(&self) -> bool {
self.playlist.is_some()
}
@@ -71,10 +69,9 @@ impl OpenHomeRenderer {
}
fn playlist_client_for(&self, op: &str) -> Result<&OhPlaylistClient, ControlPointError> {
let playlist = self
.playlist
.as_ref()
.ok_or_else(|| ControlPointError::upnp_operation_not_supported(op, "OpenHome Playlist"))?;
let playlist = self.playlist.as_ref().ok_or_else(|| {
ControlPointError::upnp_operation_not_supported(op, "OpenHome Playlist")
})?;
self.ensure_playlist_source_selected()?;
Ok(playlist)
}
@@ -97,10 +94,9 @@ impl OpenHomeRenderer {
.ok_or_else(|| ControlPointError::upnp_operation_not_supported(op, "OpenHome Volume"))
}
fn ensure_playlist_source_selected(&self) -> Result<(),ControlPointError> {
fn ensure_playlist_source_selected(&self) -> Result<(), ControlPointError> {
if let Some(product) = &self.product_client {
product
.ensure_playlist_source_selected()
product.ensure_playlist_source_selected()
} else {
Ok(())
}
@@ -132,7 +128,6 @@ impl OpenHomeRenderer {
// })
// }
/// Retourne la longueur de la playlist OpenHome sans récupérer toutes les métadonnées.
/// Plus rapide que snapshot_openhome_playlist() pour juste connaître le nombre de pistes.
pub(crate) fn openhome_playlist_len(&self) -> Result<usize, ControlPointError> {
@@ -183,11 +178,8 @@ impl OpenHomeRenderer {
}
}
impl RendererFromMediaRendererInfo for OpenHomeRenderer {
fn from_renderer_info(
info: &RendererInfo,
) -> Result<Self, ControlPointError> {
fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
let renderer = OpenHomeRenderer::new(
build_playlist_client(&info),
build_info_client(&info),
@@ -210,7 +202,6 @@ impl RendererFromMediaRendererInfo for OpenHomeRenderer {
fn to_backend(self) -> MusicRendererBackend {
MusicRendererBackend::OpenHome(self)
}
}
impl TransportControl for OpenHomeRenderer {
@@ -228,6 +219,9 @@ impl TransportControl for OpenHomeRenderer {
// Reuse the same insertion logic as the queue path so that we honor
// renderer expectations (IdArray sequencing, etc.).
self.add_track_openhome(uri, meta, None, true)?;
// Start playback (like UPnP renderer does with avt.play())
playlist.play()?;
Ok(())
}
@@ -336,4 +330,3 @@ pub(crate) fn map_openhome_state(raw: &str) -> PlaybackState {
other => PlaybackState::Unknown(other.to_string()),
}
}

View File

@@ -1,9 +1,9 @@
use std::{
sync::{Arc, Mutex},
time::{SystemTime},
time::SystemTime,
};
#[derive(Clone,Debug)]
#[derive(Clone, Debug)]
pub struct DeviceConnectionState {
online: bool,
last_seen: SystemTime,
@@ -20,8 +20,11 @@ pub trait DeviceOnline {
impl DeviceConnectionState {
pub fn new() -> Self {
DeviceConnectionState {
online: false, last_seen: std::time::UNIX_EPOCH, max_age: 1800 }
DeviceConnectionState {
online: true,
last_seen: SystemTime::now(),
max_age: 1800,
}
}
pub fn make() -> Arc<Mutex<Self>> {
@@ -43,6 +46,7 @@ impl DeviceConnectionState {
pub fn has_been_seen_now(&mut self, max_age: u32) {
self.last_seen = SystemTime::now();
self.max_age = max_age;
self.online = true;
}
pub fn mark_as_offline(&mut self) {

View File

@@ -149,28 +149,6 @@ pub struct QueueSnapshot {
pub current_index: Option<usize>,
}
// ============================================================================
// OPENHOME PLAYLIST
// ============================================================================
#[cfg(feature = "pmoserver")]
pub use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
/// Requête pour ajouter un track à la playlist OpenHome
#[cfg(feature = "pmoserver")]
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct OpenHomePlaylistAddRequest {
/// URI du flux à insérer
pub uri: String,
/// Métadonnées DIDL-Lite complètes
pub metadata: String,
/// ID devant lequel insérer (None => fin de playlist)
pub after_id: Option<u32>,
/// Si true, démarre immédiatement la lecture du track inséré
#[serde(default)]
pub play: bool,
}
// ============================================================================
// MEDIA SERVERS
// ============================================================================
@@ -318,20 +296,22 @@ pub struct ErrorResponse {
description = r#"
# API REST pour le Control Point PMOMusic
Cette API permet de contrôler les renderers UPnP et de naviguer dans les serveurs de médias.
Cette API permet de contrôler les renderers audio et de naviguer dans les serveurs de médias de manière agnostique du backend.
## Fonctionnalités
### Renderers
- **Découverte** : Liste des renderers disponibles
- **Découverte** : Liste des renderers disponibles (tous types: UPnP AV, OpenHome, LinkPlay, Chromecast)
- **État** : Récupération de l'état détaillé d'un renderer
- **Contrôle transport** : Play, pause, stop, next
- **Contrôle transport** : Play, pause, stop, resume, next
- **Contrôle volume** : Lecture et modification du volume / mute
- **Queue** : Gestion de la queue de lecture
- **Queue unifiée** : Gestion de la queue de lecture (indépendante du backend)
- **Navigation dans la queue** : Saut à un index spécifique
### Playlists
- **Binding** : Attachement de la queue à un container playlist d'un serveur
- **Synchronisation automatique** : Mise à jour de la queue lors des changements côté serveur
- **Auto-play** : Option pour démarrer la lecture automatiquement
### Serveurs de médias
- **Découverte** : Liste des serveurs disponibles
@@ -339,11 +319,12 @@ Cette API permet de contrôler les renderers UPnP et de naviguer dans les serveu
## Architecture
Le Control Point PMOMusic est un point de contrôle UPnP qui :
1. Découvre automatiquement les renderers et serveurs via SSDP
2. Maintient un registre des devices actifs
3. Permet le contrôle unifié des renderers (UPnP AV, LinkPlay, Arylic TCP)
4. Gère une queue de lecture locale avec synchronisation optionnelle
Le Control Point PMOMusic est un point de contrôle multi-backend qui :
1. Découvre automatiquement les renderers et serveurs via SSDP et mDNS
2. Maintient un registre unifié des devices actifs
3. Abstrait les différences entre backends (UPnP AV, OpenHome, LinkPlay, Arylic TCP, Chromecast)
4. Gère une queue de lecture unifiée avec synchronisation optionnelle aux playlists serveur
5. Expose une API REST cohérente indépendante du type de renderer
## Exemples d'utilisation
@@ -352,12 +333,33 @@ Le Control Point PMOMusic est un point de contrôle UPnP qui :
GET /control/renderers
```
### Contrôler un renderer
### Obtenir l'état complet d'un renderer
```
GET /control/renderers/{renderer_id}/full
```
### Contrôler la lecture
```
POST /control/renderers/{renderer_id}/play
POST /control/renderers/{renderer_id}/pause
POST /control/renderers/{renderer_id}/stop
POST /control/renderers/{renderer_id}/resume
POST /control/renderers/{renderer_id}/next
```
### Naviguer dans la queue
```
POST /control/renderers/{renderer_id}/queue/seek
Body: {"index": 5}
```
### Contrôler le volume
```
POST /control/renderers/{renderer_id}/volume/set
Body: {"volume": 50}
POST /control/renderers/{renderer_id}/volume/up
POST /control/renderers/{renderer_id}/volume/down
POST /control/renderers/{renderer_id}/mute/toggle
```
### Attacher une playlist
@@ -365,7 +367,17 @@ POST /control/renderers/{renderer_id}/volume/set
POST /control/renderers/{renderer_id}/binding/attach
Body: {
"server_id": "uuid:...",
"container_id": "0$/Music/MyPlaylist"
"container_id": "0$/Music/MyPlaylist",
"auto_play": true
}
```
### Jouer du contenu
```
POST /control/renderers/{renderer_id}/queue/play
Body: {
"server_id": "uuid:...",
"object_id": "0$/Music/Track.flac"
}
```
@@ -384,16 +396,15 @@ GET /control/servers/{server_id}/containers/{container_id}
paths(
crate::pmoserver_ext::list_renderers,
crate::pmoserver_ext::get_renderer_state,
crate::pmoserver_ext::get_renderer_full_snapshot,
crate::pmoserver_ext::get_renderer_queue,
crate::pmoserver_ext::get_renderer_binding,
crate::pmoserver_ext::get_openhome_playlist,
crate::pmoserver_ext::clear_openhome_playlist,
crate::pmoserver_ext::add_openhome_playlist_item,
crate::pmoserver_ext::play_openhome_track,
crate::pmoserver_ext::play_renderer,
crate::pmoserver_ext::pause_renderer,
crate::pmoserver_ext::stop_renderer,
crate::pmoserver_ext::resume_renderer,
crate::pmoserver_ext::next_renderer,
crate::pmoserver_ext::seek_queue_index,
crate::pmoserver_ext::set_renderer_volume,
crate::pmoserver_ext::volume_up_renderer,
crate::pmoserver_ext::volume_down_renderer,
@@ -415,17 +426,16 @@ GET /control/servers/{server_id}/containers/{container_id}
RendererState,
CurrentTrackMetadata,
AttachedPlaylistInfo,
FullRendererSnapshot,
QueueItem,
QueueSnapshot,
OpenHomePlaylistSnapshot,
OpenHomePlaylistTrack,
OpenHomePlaylistAddRequest,
MediaServerSummary,
ContainerEntry,
BrowseResponse,
VolumeSetRequest,
AttachPlaylistRequest,
PlayContentRequest,
SeekQueueRequest,
SuccessResponse,
ErrorResponse,
)),

View File

@@ -4,27 +4,22 @@
//! et naviguer dans les serveurs de médias.
#[cfg(feature = "pmoserver")]
use crate::control_point::{
ControlPoint, OpenHomeAccessError, OPENHOME_SNAPSHOT_CACHE_TTL,
};
use crate::control_point::ControlPoint;
#[cfg(feature = "pmoserver")]
use crate::media_server::{
playback_item_from_entry, MediaBrowser, MediaEntry, UpnpMediaServer, ServerId,
};
use crate::media_server::{MediaBrowser, playback_item_from_entry};
#[cfg(feature = "pmoserver")]
use crate::model::{RendererCapabilities, ServiceId, RendererProtocol, TrackMetadata};
use crate::model::{RendererCapabilities, RendererProtocol};
#[cfg(feature = "pmoserver")]
use crate::openapi::{
AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry, ErrorResponse,
FullRendererSnapshot, MediaServerSummary, OpenHomePlaylistAddRequest, OpenHomePlaylistSnapshot,
PlayContentRequest, QueueItem, QueueSnapshot, RendererCapabilitiesSummary,
RendererProtocolSummary, RendererState, RendererSummary, SeekQueueRequest, SuccessResponse,
VolumeSetRequest,
FullRendererSnapshot, MediaServerSummary, PlayContentRequest, QueueSnapshot,
RendererCapabilitiesSummary, RendererProtocolSummary, RendererState, RendererSummary,
SeekQueueRequest, SuccessResponse, VolumeSetRequest,
};
#[cfg(feature = "pmoserver")]
use crate::queue_backend::PlaybackItem;
use crate::queue::PlaybackItem;
#[cfg(feature = "pmoserver")]
use crate::{PlaybackPosition, PlaybackStatus, TransportControl, VolumeControl};
use crate::{DeviceId, DeviceIdentity, DeviceOnline};
#[cfg(feature = "pmoserver")]
use async_trait::async_trait;
@@ -49,8 +44,6 @@ use utoipa::OpenApi;
#[cfg(feature = "pmoserver")]
const BROWSE_PAGE_SIZE: u32 = 100;
#[cfg(feature = "pmoserver")]
const MEDIA_SERVER_SOAP_TIMEOUT: Duration = Duration::from_secs(15);
#[cfg(feature = "pmoserver")]
const BROWSE_REQUEST_TIMEOUT: Duration = Duration::from_secs(20);
// Timeouts for simple commands (play/pause/stop)
@@ -105,12 +98,12 @@ async fn list_renderers(State(state): State<ControlPointState>) -> Json<Vec<Rend
.map(|r| {
let info = r.info();
RendererSummary {
id: info.id.0.clone(),
friendly_name: info.friendly_name.clone(),
model_name: info.model_name.clone(),
protocol: protocol_summary(&info.protocol),
capabilities: capability_summary(&info.capabilities),
online: info.online,
id: r.id().0.clone(),
friendly_name: r.friendly_name().to_string(),
model_name: r.model_name().to_string(),
protocol: protocol_summary(&info.protocol()),
capabilities: capability_summary(&info.capabilities()),
online: r.is_online(),
}
})
.collect();
@@ -136,7 +129,7 @@ async fn get_renderer_state(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
) -> Result<Json<RendererState>, (StatusCode, Json<ErrorResponse>)> {
let rid = ServiceId(renderer_id.clone());
let rid = DeviceId(renderer_id.clone());
let snapshot = state
.control_point
.renderer_full_snapshot(&rid)
@@ -162,7 +155,7 @@ async fn get_renderer_full_snapshot(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
) -> Result<Json<FullRendererSnapshot>, (StatusCode, Json<ErrorResponse>)> {
let rid = ServiceId(renderer_id.clone());
let rid = DeviceId(renderer_id.clone());
let snapshot = state
.control_point
.renderer_full_snapshot(&rid)
@@ -193,7 +186,7 @@ async fn get_renderer_queue(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
) -> Result<Json<QueueSnapshot>, (StatusCode, Json<ErrorResponse>)> {
let rid = ServiceId(renderer_id.clone());
let rid = DeviceId(renderer_id.clone());
let snapshot = state
.control_point
.renderer_full_snapshot(&rid)
@@ -220,7 +213,7 @@ async fn get_renderer_binding(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
) -> Result<Json<Option<AttachedPlaylistInfo>>, (StatusCode, Json<ErrorResponse>)> {
let rid = ServiceId(renderer_id.clone());
let rid = DeviceId(renderer_id.clone());
let snapshot = state
.control_point
.renderer_full_snapshot(&rid)
@@ -252,7 +245,7 @@ async fn play_renderer(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = ServiceId(renderer_id.clone());
let rid = DeviceId(renderer_id.clone());
let renderer = state
.control_point
.music_renderer_by_id(&rid)
@@ -328,7 +321,7 @@ async fn pause_renderer(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = ServiceId(renderer_id.clone());
let rid = DeviceId(renderer_id.clone());
let renderer = state
.control_point
.music_renderer_by_id(&rid)
@@ -404,7 +397,7 @@ async fn stop_renderer(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = ServiceId(renderer_id.clone());
let rid = DeviceId(renderer_id.clone());
state
.control_point
.music_renderer_by_id(&rid)
@@ -481,7 +474,7 @@ async fn resume_renderer(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = ServiceId(renderer_id.clone());
let rid = DeviceId(renderer_id.clone());
state
.control_point
.music_renderer_by_id(&rid)
@@ -562,7 +555,7 @@ async fn next_renderer(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = ServiceId(renderer_id.clone());
let rid = DeviceId(renderer_id.clone());
state
.control_point
.music_renderer_by_id(&rid)
@@ -643,7 +636,7 @@ async fn seek_queue_index(
Path(renderer_id): Path<String>,
Json(payload): Json<SeekQueueRequest>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = ServiceId(renderer_id.clone());
let rid = DeviceId(renderer_id.clone());
state
.control_point
.music_renderer_by_id(&rid)
@@ -659,9 +652,8 @@ async fn seek_queue_index(
let control_point = Arc::clone(&state.control_point);
let rid_for_task = rid.clone();
let index = payload.index;
let seek_task = tokio::task::spawn_blocking(move || {
control_point.play_queue_index(&rid_for_task, index)
});
let seek_task =
tokio::task::spawn_blocking(move || control_point.play_queue_index(&rid_for_task, index));
time::timeout(TRANSPORT_COMMAND_TIMEOUT, seek_task)
.await
@@ -729,7 +721,7 @@ async fn set_renderer_volume(
Path(renderer_id): Path<String>,
Json(req): Json<VolumeSetRequest>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = ServiceId(renderer_id.clone());
let rid = DeviceId(renderer_id.clone());
let renderer = state
.control_point
@@ -807,7 +799,7 @@ async fn volume_up_renderer(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = ServiceId(renderer_id.clone());
let rid = DeviceId(renderer_id.clone());
let renderer = state
.control_point
@@ -892,7 +884,7 @@ async fn volume_down_renderer(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = ServiceId(renderer_id.clone());
let rid = DeviceId(renderer_id.clone());
let renderer = state
.control_point
@@ -977,7 +969,7 @@ async fn toggle_mute_renderer(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = ServiceId(renderer_id.clone());
let rid = DeviceId(renderer_id.clone());
let renderer = state
.control_point
@@ -1064,8 +1056,8 @@ async fn attach_playlist_binding(
Path(renderer_id): Path<String>,
Json(req): Json<AttachPlaylistRequest>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = ServiceId(renderer_id.clone());
let sid = ServerId(req.server_id.clone());
let rid = DeviceId(renderer_id.clone());
let sid = DeviceId(req.server_id.clone());
let container_id = req.container_id.clone();
let control_point = Arc::clone(&state.control_point);
@@ -1146,7 +1138,7 @@ async fn detach_playlist_binding(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
) -> Json<SuccessResponse> {
let rid = ServiceId(renderer_id.clone());
let rid = DeviceId(renderer_id.clone());
state.control_point.detach_queue_playlist(&rid);
@@ -1160,298 +1152,6 @@ async fn detach_playlist_binding(
})
}
// ============================================================================
// HANDLERS - OPENHOME PLAYLIST
// ============================================================================
/// GET /control/renderers/{renderer_id}/oh/playlist - Snapshot de la playlist OH
#[cfg(feature = "pmoserver")]
#[utoipa::path(
get,
path = "/renderers/{renderer_id}/oh/playlist",
params(
("renderer_id" = String, Path, description = "ID unique du renderer")
),
responses(
(status = 200, description = "Playlist OpenHome", body = OpenHomePlaylistSnapshot),
(status = 404, description = "Renderer non trouvé ou sans service OH", body = ErrorResponse)
),
tag = "control"
)]
// async fn get_openhome_playlist(
// State(state): State<ControlPointState>,
// Path(renderer_id): Path<String>,
// ) -> Result<Json<OpenHomePlaylistSnapshot>, (StatusCode, Json<ErrorResponse>)> {
// let rid = RendererId(renderer_id.clone());
// let control_point = Arc::clone(&state.control_point);
// let rid_for_task = rid.clone();
// let fetch_task = tokio::task::spawn_blocking(move || {
// control_point.get_cached_openhome_playlist_snapshot(
// &rid_for_task,
// OPENHOME_SNAPSHOT_CACHE_TTL,
// )
// });
// let snapshot = fetch_task
// .await
// .map_err(|e| {
// warn!(
// renderer = renderer_id.as_str(),
// error = %e,
// "Join error while fetching OpenHome playlist"
// );
// (
// StatusCode::INTERNAL_SERVER_ERROR,
// Json(ErrorResponse {
// error: format!("Internal task error: {}", e),
// }),
// )
// })?
// .map_err(|e| {
// warn!(
// renderer = renderer_id.as_str(),
// error = %e,
// "Failed to read OpenHome playlist"
// );
// map_openhome_error(&rid, e, "read OpenHome playlist")
// })?;
// Ok(Json(snapshot))
// }
/// POST /control/renderers/{renderer_id}/oh/playlist/clear - Vide la playlist OH
#[cfg(feature = "pmoserver")]
#[utoipa::path(
post,
path = "/renderers/{renderer_id}/oh/playlist/clear",
params(
("renderer_id" = String, Path, description = "ID unique du renderer")
),
responses(
(status = 200, description = "Playlist vidée", body = SuccessResponse),
(status = 404, description = "Renderer non trouvé ou sans service OH", body = ErrorResponse)
),
tag = "control"
)]
async fn clear_openhome_playlist(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = ServiceId(renderer_id.clone());
let control_point = Arc::clone(&state.control_point);
let rid_for_task = rid.clone();
let clear_task =
tokio::task::spawn_blocking(move || control_point.clear_renderer_queue(&rid_for_task));
time::timeout(QUEUE_COMMAND_TIMEOUT, clear_task)
.await
.map_err(|_| {
warn!(
renderer = renderer_id.as_str(),
timeout = QUEUE_COMMAND_TIMEOUT.as_secs(),
"Clearing OpenHome playlist timed out"
);
(
StatusCode::GATEWAY_TIMEOUT,
Json(ErrorResponse {
error: format!(
"Clear playlist timed out after {}s",
QUEUE_COMMAND_TIMEOUT.as_secs()
),
}),
)
})?
.map_err(|e| {
warn!(
renderer = renderer_id.as_str(),
error = %e,
"Join error while clearing OpenHome playlist"
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Internal task error: {}", e),
}),
)
})?
.map_err(|e| {
warn!(
renderer = renderer_id.as_str(),
error = %e,
"Failed to clear OpenHome playlist"
);
map_openhome_error(&rid, e, "clear OpenHome playlist")
})?;
Ok(Json(SuccessResponse {
message: "OpenHome playlist cleared".to_string(),
}))
}
/// POST /control/renderers/{renderer_id}/oh/playlist/add - Ajoute un track OH
#[cfg(feature = "pmoserver")]
#[utoipa::path(
post,
path = "/renderers/{renderer_id}/oh/playlist/add",
params(
("renderer_id" = String, Path, description = "ID unique du renderer")
),
request_body = OpenHomePlaylistAddRequest,
responses(
(status = 200, description = "Track ajouté", body = SuccessResponse),
(status = 404, description = "Renderer non trouvé ou sans service OH", body = ErrorResponse)
),
tag = "control"
)]
async fn add_openhome_playlist_item(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
Json(req): Json<OpenHomePlaylistAddRequest>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = ServiceId(renderer_id.clone());
let control_point = Arc::clone(&state.control_point);
let rid_for_task = rid.clone();
let add_task = tokio::task::spawn_blocking(move || {
control_point.add_track_to_renderer(
&rid_for_task,
&req.uri,
&req.metadata,
req.after_id,
req.play,
)
.map(|_| ()) // Ignore the track_id result for backward compatibility
});
time::timeout(QUEUE_COMMAND_TIMEOUT, add_task)
.await
.map_err(|_| {
warn!(
renderer = renderer_id.as_str(),
timeout = QUEUE_COMMAND_TIMEOUT.as_secs(),
"Adding OpenHome track timed out"
);
(
StatusCode::GATEWAY_TIMEOUT,
Json(ErrorResponse {
error: format!(
"Add track timed out after {}s",
QUEUE_COMMAND_TIMEOUT.as_secs()
),
}),
)
})?
.map_err(|e| {
warn!(
renderer = renderer_id.as_str(),
error = %e,
"Join error while adding OpenHome track"
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Internal task error: {}", e),
}),
)
})?
.map_err(|e| {
warn!(
renderer = renderer_id.as_str(),
error = %e,
"Failed to add OpenHome track"
);
map_openhome_error(&rid, e, "add OpenHome track")
})?;
Ok(Json(SuccessResponse {
message: "Track added to OpenHome playlist".to_string(),
}))
}
/// POST /control/renderers/{renderer_id}/oh/playlist/play/{track_id} - PlayId OH
#[cfg(feature = "pmoserver")]
#[utoipa::path(
post,
path = "/renderers/{renderer_id}/oh/playlist/play/{track_id}",
params(
("renderer_id" = String, Path, description = "ID unique du renderer"),
("track_id" = String, Path, description = "ID OpenHome du morceau")
),
responses(
(status = 200, description = "Lecture démarrée", body = SuccessResponse),
(status = 404, description = "Renderer non trouvé ou sans service OH", body = ErrorResponse)
),
tag = "control"
)]
async fn play_openhome_track(
State(state): State<ControlPointState>,
Path((renderer_id, track_id)): Path<(String, String)>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = ServiceId(renderer_id.clone());
let parsed_id = track_id.parse::<u32>().map_err(|e| {
(
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: format!("Invalid track id '{}': {}", track_id, e),
}),
)
})?;
let control_point = Arc::clone(&state.control_point);
let rid_for_task = rid.clone();
let play_task = tokio::task::spawn_blocking(move || {
control_point.select_renderer_track(&rid_for_task, parsed_id)
});
time::timeout(QUEUE_COMMAND_TIMEOUT, play_task)
.await
.map_err(|_| {
warn!(
renderer = renderer_id.as_str(),
timeout = QUEUE_COMMAND_TIMEOUT.as_secs(),
"PlayId command timed out"
);
(
StatusCode::GATEWAY_TIMEOUT,
Json(ErrorResponse {
error: format!(
"Play track timed out after {}s",
QUEUE_COMMAND_TIMEOUT.as_secs()
),
}),
)
})?
.map_err(|e| {
warn!(
renderer = renderer_id.as_str(),
error = %e,
"Join error while playing OpenHome track"
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Internal task error: {}", e),
}),
)
})?
.map_err(|e| {
warn!(
renderer = renderer_id.as_str(),
error = %e,
track_id = parsed_id,
"Failed to start OpenHome track"
);
map_openhome_error(&rid, e, "play OpenHome track")
})?;
Ok(Json(SuccessResponse {
message: format!("Playing OpenHome track {}", parsed_id),
}))
}
// ============================================================================
// HANDLERS - QUEUE CONTENT
// ============================================================================
@@ -1477,8 +1177,8 @@ async fn play_content(
Path(renderer_id): Path<String>,
Json(req): Json<PlayContentRequest>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = ServiceId(renderer_id.clone());
let sid = ServerId(req.server_id.clone());
let rid = DeviceId(renderer_id.clone());
let sid = DeviceId(req.server_id.clone());
let object_id = req.object_id.clone();
let object_id_for_log = object_id.clone();
@@ -1606,8 +1306,8 @@ async fn add_to_queue(
Path(renderer_id): Path<String>,
Json(req): Json<PlayContentRequest>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = ServiceId(renderer_id.clone());
let sid = ServerId(req.server_id.clone());
let rid = DeviceId(renderer_id.clone());
let sid = DeviceId(req.server_id.clone());
let object_id = req.object_id.clone();
let object_id_for_log = object_id.clone();
@@ -1707,15 +1407,15 @@ async fn add_to_queue(
tag = "control"
)]
async fn list_servers(State(state): State<ControlPointState>) -> Json<Vec<MediaServerSummary>> {
let servers = state.control_point.list_media_servers();
let servers = state.control_point.list_media_servers().unwrap_or_default();
let summaries: Vec<MediaServerSummary> = servers
.into_iter()
.map(|s| MediaServerSummary {
id: s.id.0,
friendly_name: s.friendly_name,
model_name: s.model_name,
online: s.online,
id: s.id().0.clone(),
friendly_name: s.friendly_name().to_string(),
model_name: s.model_name().to_string(),
online: s.is_online(),
})
.collect();
@@ -1742,9 +1442,9 @@ async fn browse_container(
State(state): State<ControlPointState>,
Path((server_id, container_id)): Path<(String, String)>,
) -> Result<Json<BrowseResponse>, (StatusCode, Json<ErrorResponse>)> {
let sid = ServerId(server_id.clone());
let sid = DeviceId(server_id.clone());
let server_info = state.control_point.media_server(&sid).ok_or_else(|| {
let server = state.control_point.media_server(&sid).ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(ErrorResponse {
@@ -1753,7 +1453,7 @@ async fn browse_container(
)
})?;
if !server_info.online {
if !server.is_online() {
return Err((
StatusCode::SERVICE_UNAVAILABLE,
Json(ErrorResponse {
@@ -1762,7 +1462,7 @@ async fn browse_container(
));
}
if !server_info.has_content_directory {
if !server.has_content_directory() {
return Err((
StatusCode::NOT_IMPLEMENTED,
Json(ErrorResponse {
@@ -1771,21 +1471,11 @@ async fn browse_container(
));
}
let music_server =
UpnpMediaServer::from_info(&server_info, MEDIA_SERVER_SOAP_TIMEOUT).map_err(|e| {
warn!("Failed to create MusicServer for {}: {}", server_id, e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Failed to initialize server: {}", e),
}),
)
})?;
// Use spawn_blocking to avoid blocking the async runtime with synchronous SOAP calls
let container_id_clone = container_id.clone();
let server_clone = server.clone();
let browse_task = tokio::task::spawn_blocking(move || {
music_server.browse_children(&container_id_clone, 0, BROWSE_PAGE_SIZE)
server_clone.browse_children(&container_id_clone, 0, BROWSE_PAGE_SIZE)
});
let entries = time::timeout(BROWSE_REQUEST_TIMEOUT, browse_task)
@@ -1869,32 +1559,6 @@ fn map_snapshot_error(
)
}
#[cfg(feature = "pmoserver")]
fn map_openhome_error(
renderer_id: &ServiceId,
err: anyhow::Error,
context: &str,
) -> (StatusCode, Json<ErrorResponse>) {
if err.downcast_ref::<OpenHomeAccessError>().is_some() {
(
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: err.to_string(),
}),
)
} else {
(
StatusCode::BAD_GATEWAY,
Json(ErrorResponse {
error: format!(
"Failed to {context} for renderer {}: {}",
renderer_id.0, err
),
}),
)
}
}
/// Helper to fetch playback items from a media server object (container or item).
///
/// This function browses the server to get the entries and converts them to PlaybackItem.
@@ -1902,30 +1566,27 @@ fn map_openhome_error(
#[cfg(feature = "pmoserver")]
fn fetch_playback_items(
control_point: &ControlPoint,
server_id: &ServerId,
server_id: &DeviceId,
object_id: &str,
) -> anyhow::Result<Vec<PlaybackItem>> {
// Get server info from registry
let server_info = control_point
// Get server from registry
let server = control_point
.media_server(server_id)
.ok_or_else(|| anyhow::anyhow!("Server {} not found", server_id.0))?;
if !server_info.online {
if !server.is_online() {
return Err(anyhow::anyhow!("Server {} is offline", server_id.0));
}
if !server_info.has_content_directory {
if !server.has_content_directory() {
return Err(anyhow::anyhow!(
"Server {} does not support ContentDirectory",
server_id.0
));
}
// Create MusicServer
let music_server = UpnpMediaServer::from_info(&server_info, MEDIA_SERVER_SOAP_TIMEOUT)?;
// Browse the object to get entries
let entries = music_server.browse_children(object_id, 0, BROWSE_PAGE_SIZE)?;
let entries = server.browse_children(object_id, 0, BROWSE_PAGE_SIZE)?;
debug!(
server_id = server_id.0.as_str(),
@@ -1939,7 +1600,7 @@ fn fetch_playback_items(
// Convert to PlaybackItem
let items: Vec<PlaybackItem> = entries
.iter()
.filter_map(|entry| playback_item_from_entry(&music_server, entry))
.filter_map(|entry| playback_item_from_entry(server.clone(), entry))
.collect();
if items.is_empty() && !entries.is_empty() {
@@ -2009,7 +1670,10 @@ pub fn create_api_router(state: ControlPointState, control_point: Arc<ControlPoi
.route("/renderers/{renderer_id}/resume", post(resume_renderer))
.route("/renderers/{renderer_id}/next", post(next_renderer))
// Queue control
.route("/renderers/{renderer_id}/queue/seek", post(seek_queue_index))
.route(
"/renderers/{renderer_id}/queue/seek",
post(seek_queue_index),
)
// Volume control
.route(
"/renderers/{renderer_id}/volume/set",
@@ -2036,23 +1700,6 @@ pub fn create_api_router(state: ControlPointState, control_point: Arc<ControlPoi
"/renderers/{renderer_id}/binding/detach",
post(detach_playlist_binding),
)
// OpenHome playlist
.route(
"/renderers/{renderer_id}/oh/playlist",
get(get_openhome_playlist),
)
.route(
"/renderers/{renderer_id}/oh/playlist/clear",
post(clear_openhome_playlist),
)
.route(
"/renderers/{renderer_id}/oh/playlist/add",
post(add_openhome_playlist_item),
)
.route(
"/renderers/{renderer_id}/oh/playlist/play/{track_id}",
post(play_openhome_track),
)
// Queue content
.route("/renderers/{renderer_id}/queue/play", post(play_content))
.route("/renderers/{renderer_id}/queue/add", post(add_to_queue))

View File

@@ -1,18 +1,18 @@
mod music_queue;
mod backend;
mod snapshot;
mod openhome;
mod interne;
mod music_queue;
mod openhome;
mod snapshot;
use std::sync::{Arc, Mutex};
pub use backend::{EnqueueMode, QueueBackend};
pub use music_queue::MusicQueue;
pub use backend::{QueueBackend, EnqueueMode};
pub use snapshot::{PlaybackItem, QueueSnapshot};
// Internal queue implementations - not part of the public API
pub(crate) use openhome::OpenHomeQueue;
pub(crate) use interne::InternalQueue;
pub(crate) use openhome::OpenHomeQueue;
use crate::{RendererInfo, errors::ControlPointError};
@@ -23,9 +23,7 @@ pub trait QueueFromRendererInfo {
fn to_backend(self) -> MusicQueue;
fn build_from_renderer_info(
renderer: &RendererInfo,
) -> Result<MusicQueue, ControlPointError>
fn build_from_renderer_info(renderer: &RendererInfo) -> Result<MusicQueue, ControlPointError>
where
Self: Sized,
{
@@ -42,4 +40,4 @@ pub trait QueueFromRendererInfo {
let backend = Self::build_from_renderer_info(renderer)?;
Ok(Arc::new(Mutex::new(backend)))
}
}
}

View File

@@ -5,7 +5,8 @@ use tracing::debug;
use crate::errors::ControlPointError;
use crate::upnp_clients::{
OPENHOME_PLAYLIST_HEAD_ID, OhInfoClient, OhPlaylistClient, OhProductClient, OhTrack, OhTrackEntry
OPENHOME_PLAYLIST_HEAD_ID, OhInfoClient, OhPlaylistClient, OhProductClient, OhTrack,
OhTrackEntry,
};
// use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
use crate::queue::{
@@ -253,8 +254,8 @@ impl OpenHomeQueue {
self.delete_marked_items(&old_ids_before, &keep_old_before, "BEFORE pivot")?;
// Rebuild the playlist: [BEFORE, PIVOT, AFTER]
// Rebuild BEFORE part
let previous_id = self.rebuild_playlist_section(
// Rebuild BEFORE part (we don't need the returned previous_id)
self.rebuild_playlist_section(
new_before,
&keep_new_before,
&old_ids_before,
@@ -263,7 +264,7 @@ impl OpenHomeQueue {
"BEFORE pivot",
)?;
// Add PIVOT (keeps its ID!)
// PIVOT keeps its ID and position - it's the anchor point
let previous_id = pivot_id as u32;
debug!(
renderer = self.renderer_id.0.as_str(),
@@ -513,7 +514,9 @@ impl QueueBackend for OpenHomeQueue {
}
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
Ok(Some(self.playlist_client.id()?))
let id = self.playlist_client.id()?;
// OpenHome returns 0 when no track is selected/playing
if id == 0 { Ok(None) } else { Ok(Some(id)) }
}
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {

View File

@@ -106,12 +106,12 @@ impl DeviceOnline for DeviceItem {
}
impl DeviceRegistry {
pub fn new(renderer_bus: RendererEventBus, server_bus: MediaServerEventBus) -> Self {
pub fn new(renderer_bus: &RendererEventBus, server_bus: &MediaServerEventBus) -> Self {
Self {
devices: HashMap::new(),
udn_index: HashMap::new(),
renderer_bus,
server_bus,
renderer_bus: renderer_bus.clone(),
server_bus: server_bus.clone(),
}
}
@@ -168,8 +168,8 @@ impl DeviceRegistry {
if !was_online {
self.renderer_bus.broadcast(RendererEvent::Online {
id: device_id,
info: info.clone(),
id: device_id.clone(),
info: info.basic_info(),
});
}
return;
@@ -182,8 +182,8 @@ impl DeviceRegistry {
// Broadcast sur le bon bus
self.renderer_bus.broadcast(RendererEvent::Online {
id: device_id,
info: info.clone(),
id: device_id.clone(),
info: info.basic_info(),
});
}
} else {
@@ -201,7 +201,7 @@ impl DeviceRegistry {
// Broadcast sur le bon bus
self.renderer_bus.broadcast(RendererEvent::Online {
id: device_id,
info: info.clone(),
info: info.basic_info(),
});
}
}
@@ -217,8 +217,8 @@ impl DeviceRegistry {
if !was_online {
self.server_bus.broadcast(MediaServerEvent::Online {
server_id: device_id,
info: info.clone(),
server_id: device_id.clone(),
info: info.basic_info(),
});
}
@@ -232,8 +232,8 @@ impl DeviceRegistry {
// Broadcast sur le bon bus
self.server_bus.broadcast(MediaServerEvent::Online {
server_id: device_id,
info: info.clone(),
server_id: device_id.clone(),
info: info.basic_info(),
});
}
} else {
@@ -251,7 +251,7 @@ impl DeviceRegistry {
// Broadcast sur le bon bus
self.server_bus.broadcast(MediaServerEvent::Online {
server_id: device_id,
info: info.clone(),
info: info.basic_info(),
});
}
}

View File

@@ -19,8 +19,6 @@ use crate::control_point::ControlPoint;
#[cfg(feature = "pmoserver")]
use crate::model::{MediaServerEvent, RendererEvent};
#[cfg(feature = "pmoserver")]
use crate::registry::DeviceRegistryRead;
#[cfg(feature = "pmoserver")]
use async_stream::stream;
#[cfg(feature = "pmoserver")]
use axum::{
@@ -34,6 +32,9 @@ use serde::Serialize;
#[cfg(feature = "pmoserver")]
use std::sync::Arc;
use crate::{DeviceIdentity, DeviceOnline};
use tracing::error;
// ============================================================================
// PAYLOADS SSE
// ============================================================================
@@ -175,17 +176,23 @@ pub async fn renderer_events_sse(
let initial_renderers = {
let registry = control_point.registry();
let reg = registry.read().unwrap();
reg.list_renderers()
match reg.list_renderers() {
Ok(renderers) => renderers,
Err(e) => {
error!("Failed to list renderers: {}", e);
Vec::new()
}
}
};
for info in initial_renderers {
if info.is_online() {
for renderer in initial_renderers {
if renderer.is_online() {
let timestamp = chrono::Utc::now();
let payload = RendererEventPayload::Online {
renderer_id: info.id().0,
friendly_name: info.friendly_name().to_string(),
model_name: info. ().to_string(),
manufacturer: info.manufacturer().to_string(),
renderer_id: renderer.id().0,
friendly_name: renderer.friendly_name().to_string(),
model_name: renderer.model_name().to_string(),
manufacturer: renderer.manufacturer().to_string(),
timestamp,
};
@@ -258,9 +265,9 @@ pub async fn renderer_events_sse(
RendererEvent::Online { id, info } => {
RendererEventPayload::Online {
renderer_id: id.0,
friendly_name: info.friendly_name,
model_name: info.model_name,
manufacturer: info.manufacturer,
friendly_name: info.friendly_name.clone(),
model_name: info.model_name.clone(),
manufacturer: info.manufacturer.clone(),
timestamp,
}
}
@@ -318,17 +325,23 @@ pub async fn media_server_events_sse(
let initial_servers = {
let registry = control_point.registry();
let reg = registry.read().unwrap();
reg.list_servers()
match reg.list_servers() {
Ok(servers) => servers,
Err(e) => {
error!("Failed to list servers: {}", e);
Vec::new()
}
}
};
for info in initial_servers {
if info.online {
for server in initial_servers {
if server.is_online() {
let timestamp = chrono::Utc::now();
let payload = MediaServerEventPayload::Online {
server_id: info.id.0.clone(),
friendly_name: info.friendly_name.clone(),
model_name: info.model_name.clone(),
manufacturer: info.manufacturer.clone(),
server_id: server.id().0,
friendly_name: server.friendly_name().to_string(),
model_name: server.model_name().to_string(),
manufacturer: server.manufacturer().to_string(),
timestamp,
};
@@ -360,9 +373,9 @@ pub async fn media_server_events_sse(
MediaServerEvent::Online { server_id, info } => {
MediaServerEventPayload::Online {
server_id: server_id.0,
friendly_name: info.friendly_name,
model_name: info.model_name,
manufacturer: info.manufacturer,
friendly_name: info.friendly_name.clone(),
model_name: info.model_name.clone(),
manufacturer: info.manufacturer.clone(),
timestamp,
}
}
@@ -429,18 +442,32 @@ pub async fn all_events_sse(State(control_point): State<Arc<ControlPoint>>) -> i
let (initial_renderers, initial_servers) = {
let registry = control_point.registry();
let reg = registry.read().unwrap();
(reg.list_renderers(), reg.list_servers())
let renderers = match reg.list_renderers() {
Ok(r) => r,
Err(e) => {
error!("Failed to list renderers: {}", e);
Vec::new()
}
};
let servers = match reg.list_servers() {
Ok(s) => s,
Err(e) => {
error!("Failed to list servers: {}", e);
Vec::new()
}
};
(renderers, servers)
};
// Send renderer Online events
for info in initial_renderers {
if info.online {
for renderer in initial_renderers {
if renderer.is_online() {
let timestamp = chrono::Utc::now();
let renderer_payload = RendererEventPayload::Online {
renderer_id: info.id.0.clone(),
friendly_name: info.friendly_name.clone(),
model_name: info.model_name.clone(),
manufacturer: info.manufacturer.clone(),
renderer_id: renderer.id().0,
friendly_name: renderer.friendly_name().to_string(),
model_name: renderer.model_name().to_string(),
manufacturer: renderer.manufacturer().to_string(),
timestamp,
};
let payload = UnifiedEventPayload::Renderer(renderer_payload);
@@ -452,14 +479,14 @@ pub async fn all_events_sse(State(control_point): State<Arc<ControlPoint>>) -> i
}
// Send server Online events
for info in initial_servers {
if info.online {
for server in initial_servers {
if server.is_online() {
let timestamp = chrono::Utc::now();
let server_payload = MediaServerEventPayload::Online {
server_id: info.id.0.clone(),
friendly_name: info.friendly_name.clone(),
model_name: info.model_name.clone(),
manufacturer: info.manufacturer.clone(),
server_id: server.id().0,
friendly_name: server.friendly_name().to_string(),
model_name: server.model_name().to_string(),
manufacturer: server.manufacturer().to_string(),
timestamp,
};
let payload = UnifiedEventPayload::MediaServer(server_payload);
@@ -535,9 +562,9 @@ pub async fn all_events_sse(State(control_point): State<Arc<ControlPoint>>) -> i
RendererEvent::Online { id, info } => {
RendererEventPayload::Online {
renderer_id: id.0,
friendly_name: info.friendly_name,
model_name: info.model_name,
manufacturer: info.manufacturer,
friendly_name: info.friendly_name.clone(),
model_name: info.model_name.clone(),
manufacturer: info.manufacturer.clone(),
timestamp,
}
}
@@ -576,9 +603,9 @@ pub async fn all_events_sse(State(control_point): State<Arc<ControlPoint>>) -> i
MediaServerEvent::Online { server_id, info } => {
MediaServerEventPayload::Online {
server_id: server_id.0,
friendly_name: info.friendly_name,
model_name: info.model_name,
manufacturer: info.manufacturer,
friendly_name: info.friendly_name.clone(),
model_name: info.model_name.clone(),
manufacturer: info.manufacturer.clone(),
timestamp,
}
}

View File

@@ -5,10 +5,10 @@ use std::time::Duration;
use crate::{
errors::ControlPointError,
soap_client::{
extract_child_text, handle_action_response, invoke_upnp_action, invoke_upnp_action_with_timeout
extract_child_text, handle_action_response, invoke_upnp_action,
invoke_upnp_action_with_timeout,
},
};
use anyhow::{Result, anyhow};
use pmoupnp::soap::SoapEnvelope;
use xmltree::{Element, XMLNode};
@@ -54,10 +54,11 @@ impl AvTransportClient {
)));
}
let envelope = call_result
.envelope
.as_ref()
.ok_or_else(|| ControlPointError::UpnpError(format!("Missing SOAP envelope in GetTransportInfo response")))?;
let envelope = call_result.envelope.as_ref().ok_or_else(|| {
ControlPointError::UpnpError(format!(
"Missing SOAP envelope in GetTransportInfo response"
))
})?;
parse_transport_info(envelope)
}
@@ -181,7 +182,11 @@ impl AvTransportClient {
fn parse_transport_info(envelope: &SoapEnvelope) -> Result<TransportInfo, ControlPointError> {
let response = find_child_with_suffix(&envelope.body.content, "GetTransportInfoResponse")
.ok_or_else(|| ControlPointError::UpnpError(format!("Missing GetTransportInfoResponse element in SOAP body")))?;
.ok_or_else(|| {
ControlPointError::UpnpError(format!(
"Missing GetTransportInfoResponse element in SOAP body"
))
})?;
let current_transport_state = extract_child_text(response, "CurrentTransportState")?;
let current_transport_status = extract_child_text(response, "CurrentTransportStatus")?;
@@ -268,7 +273,6 @@ fn find_child_with_suffix<'a>(parent: &'a Element, suffix: &str) -> Option<&'a E
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -382,10 +386,11 @@ impl AvTransportClient {
)));
}
let envelope = call_result
.envelope
.as_ref()
.ok_or_else(|| ControlPointError::UpnpError(format!("Missing SOAP envelope in GetPositionInfo response")))?;
let envelope = call_result.envelope.as_ref().ok_or_else(|| {
ControlPointError::UpnpError(format!(
"Missing SOAP envelope in GetPositionInfo response"
))
})?;
parse_position_info(envelope)
}
@@ -393,7 +398,9 @@ impl AvTransportClient {
fn parse_position_info(envelope: &SoapEnvelope) -> Result<PositionInfo, ControlPointError> {
let response = find_child_with_suffix(&envelope.body.content, "GetPositionInfoResponse")
.ok_or_else(|| ControlPointError::UpnpError(format!("Missing GetPositionInfoResponse element")))?;
.ok_or_else(|| {
ControlPointError::UpnpError(format!("Missing GetPositionInfoResponse element"))
})?;
// Helpers allow missing text (AVTransport allows empty durations)
fn opt(parent: &Element, name: &str) -> Option<String> {

View File

@@ -1,8 +1,10 @@
use anyhow::{Result, anyhow};
use crate::{errors::ControlPointError, soap_client::{
SoapCallResult, ensure_success, extract_child_text, extract_child_text_allow_empty, find_child_with_suffix, invoke_upnp_action, parse_upnp_error
}};
use crate::{
errors::ControlPointError,
soap_client::{
ensure_success, extract_child_text, extract_child_text_allow_empty, find_child_with_suffix,
invoke_upnp_action, parse_upnp_error,
},
};
#[derive(Debug, Clone)]
pub struct ConnectionManagerClient {
@@ -48,22 +50,25 @@ impl ConnectionManagerClient {
ensure_success("GetProtocolInfo", &call_result)?;
let envelope = call_result
.envelope
.as_ref()
.ok_or_else(|| ControlPointError::UpnpError(format!("Missing SOAP envelope in GetProtocolInfo response")))?;
let envelope = call_result.envelope.as_ref().ok_or_else(|| {
ControlPointError::UpnpError(format!(
"Missing SOAP envelope in GetProtocolInfo response"
))
})?;
if let Some(err) = parse_upnp_error(envelope) {
return Err(ControlPointError::UpnpError(format!(
"GetProtocolInfo returned UPnP error {}: {} (HTTP status {})",
err.error_code,
err.error_description,
call_result.status
err.error_code, err.error_description, call_result.status
)));
}
let response = find_child_with_suffix(&envelope.body.content, "GetProtocolInfoResponse")
.ok_or_else(|| ControlPointError::UpnpError(format!("Missing GetProtocolInfoResponse element in SOAP body")))?;
.ok_or_else(|| {
ControlPointError::UpnpError(format!(
"Missing GetProtocolInfoResponse element in SOAP body"
))
})?;
let source_text = extract_child_text_allow_empty(response, "Source")?;
let sink_text = extract_child_text_allow_empty(response, "Sink")?;
@@ -85,24 +90,25 @@ impl ConnectionManagerClient {
ensure_success("GetCurrentConnectionIDs", &call_result)?;
let envelope = call_result
.envelope
.as_ref()
.ok_or_else(|| ControlPointError::UpnpError(format!("Missing SOAP envelope in GetCurrentConnectionIDs response")))?;
let envelope = call_result.envelope.as_ref().ok_or_else(|| {
ControlPointError::UpnpError(format!(
"Missing SOAP envelope in GetCurrentConnectionIDs response"
))
})?;
if let Some(err) = parse_upnp_error(envelope) {
return Err(ControlPointError::UpnpError(format!(
"GetCurrentConnectionIDs returned UPnP error {}: {} (HTTP status {})",
err.error_code,
err.error_description,
call_result.status
err.error_code, err.error_description, call_result.status
)));
}
let response =
find_child_with_suffix(&envelope.body.content, "GetCurrentConnectionIDsResponse")
.ok_or_else(|| {
ControlPointError::UpnpError(format!("Missing GetCurrentConnectionIDsResponse element in SOAP body"))
ControlPointError::UpnpError(format!(
"Missing GetCurrentConnectionIDsResponse element in SOAP body"
))
})?;
let ids_text = extract_child_text_allow_empty(response, "ConnectionIDs")?;
@@ -118,9 +124,9 @@ impl ConnectionManagerClient {
if value.is_empty() {
continue;
}
let parsed = value
.parse::<i32>()
.map_err(|_| ControlPointError::UpnpError(format!("Invalid ConnectionID value: {}", value)))?;
let parsed = value.parse::<i32>().map_err(|_| {
ControlPointError::UpnpError(format!("Invalid ConnectionID value: {}", value))
})?;
ids.push(parsed);
}
@@ -128,7 +134,10 @@ impl ConnectionManagerClient {
}
/// GetCurrentConnectionInfo
pub fn get_current_connection_info(&self, connection_id: i32) -> Result<ConnectionInfo, ControlPointError> {
pub fn get_current_connection_info(
&self,
connection_id: i32,
) -> Result<ConnectionInfo, ControlPointError> {
let connection_id_str = connection_id.to_string();
let args = [("ConnectionID", connection_id_str.as_str())];
@@ -141,33 +150,38 @@ impl ConnectionManagerClient {
ensure_success("GetCurrentConnectionInfo", &call_result)?;
let envelope = call_result
.envelope
.as_ref()
.ok_or_else(|| ControlPointError::UpnpError(format!("Missing SOAP envelope in GetCurrentConnectionInfo response")))?;
let envelope = call_result.envelope.as_ref().ok_or_else(|| {
ControlPointError::UpnpError(format!(
"Missing SOAP envelope in GetCurrentConnectionInfo response"
))
})?;
if let Some(err) = parse_upnp_error(envelope) {
return Err(ControlPointError::UpnpError(format!(
"GetCurrentConnectionInfo returned UPnP error {}: {} (HTTP status {})",
err.error_code,
err.error_description,
call_result.status
err.error_code, err.error_description, call_result.status
)));
}
let response =
find_child_with_suffix(&envelope.body.content, "GetCurrentConnectionInfoResponse")
.ok_or_else(|| {
ControlPointError::UpnpError(format!("Missing GetCurrentConnectionInfoResponse element in SOAP body"))
ControlPointError::UpnpError(format!(
"Missing GetCurrentConnectionInfoResponse element in SOAP body"
))
})?;
let rcs_id = extract_child_text(response, "RcsID")?
.parse::<i32>()
.map_err(|_| ControlPointError::UpnpError(format!("Invalid RcsID value in response")))?;
.map_err(|_| {
ControlPointError::UpnpError(format!("Invalid RcsID value in response"))
})?;
let av_transport_id = extract_child_text(response, "AVTransportID")?
.parse::<i32>()
.map_err(|_| ControlPointError::UpnpError(format!("Invalid AVTransportID value in response")))?;
.map_err(|_| {
ControlPointError::UpnpError(format!("Invalid AVTransportID value in response"))
})?;
let protocol_info = extract_child_text_allow_empty(response, "ProtocolInfo")?;
let peer_connection_manager =
@@ -175,7 +189,9 @@ impl ConnectionManagerClient {
let peer_connection_id = extract_child_text(response, "PeerConnectionID")?
.parse::<i32>()
.map_err(|_| ControlPointError::UpnpError(format!("Invalid PeerConnectionID value in response")))?;
.map_err(|_| {
ControlPointError::UpnpError(format!("Invalid PeerConnectionID value in response"))
})?;
let direction = extract_child_text(response, "Direction")?;
let status = extract_child_text(response, "Status")?;

View File

@@ -1,11 +1,10 @@
use crate::{
errors::ControlPointError,
soap_client::{
SoapCallResult, ensure_success, extract_child_text, find_child_with_suffix,
handle_action_response, invoke_upnp_action, parse_upnp_error,
ensure_success, extract_child_text, find_child_with_suffix, handle_action_response,
invoke_upnp_action, parse_upnp_error,
},
};
use anyhow::{Result, anyhow};
use tracing::debug;
#[derive(Debug, Clone)]
@@ -96,22 +95,23 @@ impl RenderingControlClient {
ensure_success("GetMute", &call_result)?;
let envelope = call_result
.envelope
.as_ref()
.ok_or_else(|| ControlPointError::UpnpError(format!("Missing SOAP envelope in GetMute response")))?;
let envelope = call_result.envelope.as_ref().ok_or_else(|| {
ControlPointError::UpnpError(format!("Missing SOAP envelope in GetMute response"))
})?;
if let Some(err) = parse_upnp_error(envelope) {
return Err(ControlPointError::UpnpError(format!(
"GetMute returned UPnP error {}: {} (HTTP status {})",
err.error_code,
err.error_description,
call_result.status
err.error_code, err.error_description, call_result.status
)));
}
let response = find_child_with_suffix(&envelope.body.content, "GetMuteResponse")
.ok_or_else(|| ControlPointError::UpnpError(format!("Missing GetMuteResponse element in SOAP body")))?;
.ok_or_else(|| {
ControlPointError::UpnpError(format!(
"Missing GetMuteResponse element in SOAP body"
))
})?;
let text = extract_child_text(response, "CurrentMute")?;
let mute = match text.as_str() {

View File

@@ -5,11 +5,11 @@
use bevy_reflect::Reflect;
use pmoutils::ToXmlElement;
use serde::{Deserialize, Serialize};
use std::time::SystemTime;
use std::{borrow::Cow, time::Instant};
use std::borrow::Cow;
use std::collections::HashSet;
use std::fmt::Write;
use std::io::Cursor;
use std::time::SystemTime;
use xmltree::{Element, XMLNode};
// ============= Couche d'abstraction générique =============

30
tools/check_a_file.sh Executable file
View File

@@ -0,0 +1,30 @@
#!/bin/bash
crate=$1
pattern=$2
cargo check -p "${crate}" 2>&1 \
| awk -v pattern="${pattern}" '
/^warning/ || /^error/ {
if (inmsg && file ~ pattern) {
print message"\n===============\n"
}
inmsg = 1
start = 1
message = $0
next
}
start {
file = $NF
start = 0
message = message"\n"$0
next
}
inmsg {
message = message"\n"$0
}
END {
if (inmsg && file ~ pattern) {
print message"\n===============\n"
}
}'