Debugging de la nouvelle implémentation.
This commit is contained in:
@@ -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}`)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(), ®istry)
|
||||
.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);
|
||||
}
|
||||
@@ -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 d’attente de réponse pour une commande TCP Arylic.
|
||||
enum ResponseMode<'a> {
|
||||
/// On n’attend 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
130
pmocontrol/src/control_point_corrections.md
Normal file
130
pmocontrol/src/control_point_corrections.md
Normal 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(®istry);
|
||||
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(®istry);
|
||||
|
||||
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(®istry);
|
||||
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.
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 d’un endpoint découvert.
|
||||
/// L’implé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 s’il
|
||||
/// 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 s’il
|
||||
/// 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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ¬ify.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, ¬ify.body) {
|
||||
for event in parse_notify_payload(&entry.device_id, ¬ify.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)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
)),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
30
tools/check_a_file.sh
Executable 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"
|
||||
}
|
||||
}'
|
||||
Reference in New Issue
Block a user