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
|
// RENDERERS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export type RendererProtocolSummary = 'upnp' | 'openhome' | 'hybrid'
|
export type RendererProtocolSummary =
|
||||||
|
| "upnp"
|
||||||
|
| "openhome"
|
||||||
|
| "hybrid"
|
||||||
|
| "chromecast";
|
||||||
|
|
||||||
export interface RendererCapabilitiesSummary {
|
export interface RendererCapabilitiesSummary {
|
||||||
has_avtransport: boolean
|
has_avtransport: boolean;
|
||||||
has_avtransport_set_next: boolean
|
has_avtransport_set_next: boolean;
|
||||||
has_rendering_control: boolean
|
has_rendering_control: boolean;
|
||||||
has_connection_manager: boolean
|
has_connection_manager: boolean;
|
||||||
has_linkplay_http: boolean
|
has_linkplay_http: boolean;
|
||||||
has_arylic_tcp: boolean
|
has_arylic_tcp: boolean;
|
||||||
has_oh_playlist: boolean
|
has_oh_playlist: boolean;
|
||||||
has_oh_volume: boolean
|
has_oh_volume: boolean;
|
||||||
has_oh_info: boolean
|
has_oh_info: boolean;
|
||||||
has_oh_time: boolean
|
has_oh_time: boolean;
|
||||||
has_oh_radio: boolean
|
has_oh_radio: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RendererSummary {
|
export interface RendererSummary {
|
||||||
id: string
|
id: string;
|
||||||
friendly_name: string
|
friendly_name: string;
|
||||||
model_name: string
|
model_name: string;
|
||||||
protocol: RendererProtocolSummary
|
protocol: RendererProtocolSummary;
|
||||||
capabilities: RendererCapabilitiesSummary
|
capabilities: RendererCapabilitiesSummary;
|
||||||
online: boolean
|
online: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RendererState {
|
export interface RendererState {
|
||||||
id: string
|
id: string;
|
||||||
friendly_name: string
|
friendly_name: string;
|
||||||
transport_state: 'PLAYING' | 'PAUSED' | 'STOPPED' | 'TRANSITIONING' | 'NO_MEDIA' | 'UNKNOWN'
|
transport_state:
|
||||||
position_ms: number | null
|
| "PLAYING"
|
||||||
duration_ms: number | null
|
| "PAUSED"
|
||||||
volume: number | null // 0-100
|
| "STOPPED"
|
||||||
mute: boolean | null
|
| "TRANSITIONING"
|
||||||
queue_len: number
|
| "NO_MEDIA"
|
||||||
attached_playlist: AttachedPlaylistInfo | null
|
| "UNKNOWN";
|
||||||
current_track: CurrentTrackMetadata | null
|
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 {
|
export interface CurrentTrackMetadata {
|
||||||
title: string | null
|
title: string | null;
|
||||||
artist: string | null
|
artist: string | null;
|
||||||
album: string | null
|
album: string | null;
|
||||||
album_art_uri: string | null
|
album_art_uri: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AttachedPlaylistInfo {
|
export interface AttachedPlaylistInfo {
|
||||||
server_id: string
|
server_id: string;
|
||||||
container_id: string
|
container_id: string;
|
||||||
has_seen_update: boolean
|
has_seen_update: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -61,53 +71,26 @@ export interface AttachedPlaylistInfo {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export interface QueueItem {
|
export interface QueueItem {
|
||||||
index: number // 0-based
|
index: number; // 0-based
|
||||||
uri: string
|
uri: string;
|
||||||
title: string | null
|
title: string | null;
|
||||||
artist: string | null
|
artist: string | null;
|
||||||
album: string | null
|
album: string | null;
|
||||||
album_art_uri: string | null
|
album_art_uri: string | null;
|
||||||
server_id: string | null
|
server_id: string | null;
|
||||||
object_id: string | null
|
object_id: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QueueSnapshot {
|
export interface QueueSnapshot {
|
||||||
renderer_id: string
|
renderer_id: string;
|
||||||
items: QueueItem[]
|
items: QueueItem[];
|
||||||
current_index: number | null // Index de la piste en cours (null si rien en lecture)
|
current_index: number | null; // Index de la piste en cours (null si rien en lecture)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FullRendererSnapshot {
|
export interface FullRendererSnapshot {
|
||||||
state: RendererState
|
state: RendererState;
|
||||||
queue: QueueSnapshot
|
queue: QueueSnapshot;
|
||||||
binding: AttachedPlaylistInfo | null
|
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -115,26 +98,26 @@ export interface OpenHomePlaylistAddRequest {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export interface MediaServerSummary {
|
export interface MediaServerSummary {
|
||||||
id: string
|
id: string;
|
||||||
friendly_name: string
|
friendly_name: string;
|
||||||
model_name: string
|
model_name: string;
|
||||||
online: boolean
|
online: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContainerEntry {
|
export interface ContainerEntry {
|
||||||
id: string
|
id: string;
|
||||||
title: string
|
title: string;
|
||||||
class: string // UPnP class
|
class: string; // UPnP class
|
||||||
is_container: boolean
|
is_container: boolean;
|
||||||
child_count: number | null
|
child_count: number | null;
|
||||||
artist: string | null
|
artist: string | null;
|
||||||
album: string | null
|
album: string | null;
|
||||||
album_art_uri: string | null // ⚠️ Nom exact: album_art_uri
|
album_art_uri: string | null; // ⚠️ Nom exact: album_art_uri
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BrowseResponse {
|
export interface BrowseResponse {
|
||||||
container_id: string
|
container_id: string;
|
||||||
entries: ContainerEntry[]
|
entries: ContainerEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -142,26 +125,26 @@ export interface BrowseResponse {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export interface VolumeSetRequest {
|
export interface VolumeSetRequest {
|
||||||
volume: number // 0-100
|
volume: number; // 0-100
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AttachPlaylistRequest {
|
export interface AttachPlaylistRequest {
|
||||||
server_id: string
|
server_id: string;
|
||||||
container_id: string
|
container_id: string;
|
||||||
auto_play?: boolean
|
auto_play?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlayContentRequest {
|
export interface PlayContentRequest {
|
||||||
server_id: string
|
server_id: string;
|
||||||
object_id: string
|
object_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SuccessResponse {
|
export interface SuccessResponse {
|
||||||
message: string
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ErrorResponse {
|
export interface ErrorResponse {
|
||||||
error: string
|
error: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -169,40 +152,105 @@ export interface ErrorResponse {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export type RendererEventPayload =
|
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: "state_changed";
|
||||||
| { type: 'volume_changed'; renderer_id: string; volume: number; timestamp: string }
|
renderer_id: string;
|
||||||
| { type: 'mute_changed'; renderer_id: string; mute: boolean; timestamp: string }
|
state: string;
|
||||||
| { type: 'metadata_changed'; renderer_id: string; title: string | null; artist: string | null; album: string | null; album_art_uri: string | null; timestamp: string }
|
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: "position_changed";
|
||||||
| { type: 'offline'; renderer_id: string; timestamp: string }
|
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 =
|
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: "global_updated";
|
||||||
| { type: 'online'; server_id: string; friendly_name: string; model_name: string; manufacturer: string; timestamp: string }
|
server_id: string;
|
||||||
| { type: 'offline'; server_id: string; timestamp: 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 =
|
export type UnifiedEventPayload =
|
||||||
| { category: 'renderer' } & RendererEventPayload
|
| ({ category: "renderer" } & RendererEventPayload)
|
||||||
| { category: 'media_server' } & MediaServerEventPayload
|
| ({ category: "media_server" } & MediaServerEventPayload);
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// MÉTADONNÉES PISTE
|
// MÉTADONNÉES PISTE
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export interface TrackMetadata {
|
export interface TrackMetadata {
|
||||||
title: string | null
|
title: string | null;
|
||||||
artist: string | null
|
artist: string | null;
|
||||||
album: string | null
|
album: string | null;
|
||||||
album_art_uri: string | null
|
album_art_uri: string | null;
|
||||||
duration_ms: number | null
|
duration_ms: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PositionInfo {
|
export interface PositionInfo {
|
||||||
track: number | null
|
track: number | null;
|
||||||
rel_time: string | null // Format HH:MM:SS
|
rel_time: string | null; // Format HH:MM:SS
|
||||||
track_duration: string | null // Format HH:MM:SS
|
track_duration: string | null; // Format HH:MM:SS
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,516 +1,302 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref, watch, toRef } from 'vue'
|
import { computed, onMounted, onUnmounted, toRef } from "vue";
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { useRenderer, useRenderers } from '@/composables/useRenderers'
|
import { useRenderer, useRenderers } from "@/composables/useRenderers";
|
||||||
import { useUIStore } from '@/stores/ui'
|
import { useUIStore } from "@/stores/ui";
|
||||||
import CurrentTrack from '@/components/pmocontrol/CurrentTrack.vue'
|
import CurrentTrack from "@/components/pmocontrol/CurrentTrack.vue";
|
||||||
import TransportControls from '@/components/pmocontrol/TransportControls.vue'
|
import TransportControls from "@/components/pmocontrol/TransportControls.vue";
|
||||||
import VolumeControl from '@/components/pmocontrol/VolumeControl.vue'
|
import VolumeControl from "@/components/pmocontrol/VolumeControl.vue";
|
||||||
import QueueViewer from '@/components/pmocontrol/QueueViewer.vue'
|
import QueueViewer from "@/components/pmocontrol/QueueViewer.vue";
|
||||||
import PlaylistBindingPanel from '@/components/pmocontrol/PlaylistBindingPanel.vue'
|
import PlaylistBindingPanel from "@/components/pmocontrol/PlaylistBindingPanel.vue";
|
||||||
import StatusBadge from '@/components/pmocontrol/StatusBadge.vue'
|
import StatusBadge from "@/components/pmocontrol/StatusBadge.vue";
|
||||||
import { ArrowLeft, Radio } from 'lucide-vue-next'
|
import { ArrowLeft, Radio } from "lucide-vue-next";
|
||||||
import {
|
|
||||||
addOpenHomeTrack,
|
|
||||||
clearOpenHomePlaylist,
|
|
||||||
getOpenHomePlaylist,
|
|
||||||
playOpenHomeTrack,
|
|
||||||
} from '@/services/openhomePlaylist'
|
|
||||||
import type { OpenHomePlaylistSnapshot } from '@/services/pmocontrol/types'
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute();
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const uiStore = useUIStore()
|
const uiStore = useUIStore();
|
||||||
|
|
||||||
const rendererId = computed(() => route.params.id as string)
|
const rendererId = computed(() => route.params.id as string);
|
||||||
const { renderer, state, refresh } = useRenderer(toRef(() => rendererId.value))
|
const { renderer, state, refresh } = useRenderer(toRef(() => rendererId.value));
|
||||||
const { fetchRenderers, selectRenderer: selectRendererSnapshot } = useRenderers()
|
const { fetchRenderers, selectRenderer: selectRendererSnapshot } =
|
||||||
const openHomeSupported = computed(() => {
|
useRenderers();
|
||||||
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)
|
|
||||||
|
|
||||||
// Charger les données au montage si nécessaire
|
// Charger les données au montage si nécessaire
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
uiStore.selectRenderer(rendererId.value)
|
uiStore.selectRenderer(rendererId.value);
|
||||||
selectRendererSnapshot(rendererId.value)
|
selectRendererSnapshot(rendererId.value);
|
||||||
|
|
||||||
// Charger toutes les données du renderer
|
// Charger toutes les données du renderer
|
||||||
if (!renderer.value) {
|
if (!renderer.value) {
|
||||||
await fetchRenderers()
|
await fetchRenderers();
|
||||||
}
|
|
||||||
await refresh()
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
openHomeSupported,
|
|
||||||
async supported => {
|
|
||||||
if (supported) {
|
|
||||||
await refreshOhPlaylist()
|
|
||||||
} else {
|
|
||||||
ohPlaylist.value = null
|
|
||||||
}
|
}
|
||||||
},
|
await refresh();
|
||||||
{ immediate: true },
|
});
|
||||||
)
|
|
||||||
|
|
||||||
watch(rendererId, () => {
|
|
||||||
ohPlaylist.value = null
|
|
||||||
ohError.value = null
|
|
||||||
newOhUri.value = ''
|
|
||||||
newOhMeta.value = ''
|
|
||||||
})
|
|
||||||
|
|
||||||
// Nettoyer la sélection au démontage
|
// Nettoyer la sélection au démontage
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
uiStore.selectRenderer(null)
|
uiStore.selectRenderer(null);
|
||||||
selectRendererSnapshot(null)
|
selectRendererSnapshot(null);
|
||||||
})
|
});
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
router.push('/')
|
router.push("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
const protocolLabel = computed(() => {
|
const protocolLabel = computed(() => {
|
||||||
if (!renderer.value) return ''
|
if (!renderer.value) return "";
|
||||||
switch (renderer.value.protocol) {
|
switch (renderer.value.protocol) {
|
||||||
case 'upnp':
|
case "upnp":
|
||||||
return 'UPnP AV'
|
return "UPnP AV";
|
||||||
case 'openhome':
|
case "openhome":
|
||||||
return 'OpenHome'
|
return "OpenHome";
|
||||||
case 'hybrid':
|
case "hybrid":
|
||||||
return 'Hybrid (UPnP + OpenHome)'
|
return "Hybrid (UPnP + OpenHome)";
|
||||||
default:
|
case "chromecast":
|
||||||
return 'Inconnu'
|
return "Chromecast";
|
||||||
}
|
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'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="renderer-view">
|
<div class="renderer-view">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="renderer-header">
|
<header class="renderer-header">
|
||||||
<button class="btn-back" @click="goBack" title="Retour au dashboard">
|
<button
|
||||||
<ArrowLeft :size="20" />
|
class="btn-back"
|
||||||
</button>
|
@click="goBack"
|
||||||
<div class="header-content">
|
title="Retour au dashboard"
|
||||||
<div class="renderer-info">
|
>
|
||||||
<Radio :size="24" class="renderer-icon" />
|
<ArrowLeft :size="20" />
|
||||||
<div class="renderer-details">
|
</button>
|
||||||
<h1 class="renderer-name">{{ renderer?.friendly_name || 'Chargement...' }}</h1>
|
<div class="header-content">
|
||||||
<p class="renderer-model">{{ renderer?.model_name }} • {{ protocolLabel }}</p>
|
<div class="renderer-info">
|
||||||
</div>
|
<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>
|
</div>
|
||||||
<StatusBadge v-if="state" :status="state.transport_state" />
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Loading state -->
|
<!-- Main content -->
|
||||||
<div v-if="!renderer || !state" class="loading-state">
|
<div v-else class="renderer-content">
|
||||||
<p>Chargement du renderer...</p>
|
<!-- Left column (Desktop) / Top (Mobile) -->
|
||||||
</div>
|
<div class="left-column">
|
||||||
|
<!-- Current Track -->
|
||||||
|
<section class="content-section">
|
||||||
|
<CurrentTrack :rendererId="rendererId" />
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Main content -->
|
<!-- Transport Controls -->
|
||||||
<div v-else class="renderer-content">
|
<section class="content-section">
|
||||||
<!-- Left column (Desktop) / Top (Mobile) -->
|
<TransportControls :rendererId="rendererId" />
|
||||||
<div class="left-column">
|
</section>
|
||||||
<!-- Current Track -->
|
|
||||||
<section class="content-section">
|
|
||||||
<CurrentTrack :rendererId="rendererId" />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Transport Controls -->
|
<!-- Volume Control -->
|
||||||
<section class="content-section">
|
<section class="content-section">
|
||||||
<TransportControls :rendererId="rendererId" />
|
<h3 class="section-subtitle">Volume</h3>
|
||||||
</section>
|
<VolumeControl :rendererId="rendererId" />
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Volume Control -->
|
<!-- Playlist Binding -->
|
||||||
<section class="content-section">
|
<section class="content-section">
|
||||||
<h3 class="section-subtitle">Volume</h3>
|
<PlaylistBindingPanel :rendererId="rendererId" />
|
||||||
<VolumeControl :rendererId="rendererId" />
|
</section>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="oh-add-form">
|
<!-- Right column (Desktop) / Bottom (Mobile) -->
|
||||||
<input v-model="newOhUri" placeholder="URI à ajouter" />
|
<div class="right-column">
|
||||||
<textarea v-model="newOhMeta" placeholder="DIDL-Lite (optionnel)" rows="2"></textarea>
|
<section class="content-section queue-section">
|
||||||
<button class="btn btn-primary" @click="handleOhAdd" :disabled="!canAddOhTrack">
|
<QueueViewer :rendererId="rendererId" />
|
||||||
➕ Ajouter
|
</section>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.renderer-view {
|
.renderer-view {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--spacing-lg);
|
gap: var(--spacing-lg);
|
||||||
padding: var(--spacing-lg);
|
padding: var(--spacing-lg);
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header */
|
/* Header */
|
||||||
.renderer-header {
|
.renderer-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: var(--spacing-md);
|
gap: var(--spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-back {
|
.btn-back {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all var(--transition-fast);
|
transition: all var(--transition-fast);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-back:hover {
|
.btn-back:hover {
|
||||||
background-color: var(--color-bg-secondary);
|
background-color: var(--color-bg-secondary);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-content {
|
.header-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--spacing-md);
|
gap: var(--spacing-md);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.renderer-info {
|
.renderer-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--spacing-md);
|
gap: var(--spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.renderer-icon {
|
.renderer-icon {
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.renderer-details {
|
.renderer-details {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--spacing-xs);
|
gap: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.renderer-name {
|
.renderer-name {
|
||||||
font-size: var(--text-2xl);
|
font-size: var(--text-2xl);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.renderer-model {
|
.renderer-model {
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Loading */
|
/* Loading */
|
||||||
.loading-state {
|
.loading-state {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: var(--text-base);
|
font-size: var(--text-base);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Content */
|
/* Content */
|
||||||
.renderer-content {
|
.renderer-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--spacing-xl);
|
gap: var(--spacing-xl);
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.left-column,
|
.left-column,
|
||||||
.right-column {
|
.right-column {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--spacing-lg);
|
gap: var(--spacing-lg);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-section {
|
.content-section {
|
||||||
background-color: var(--color-bg-secondary);
|
background-color: var(--color-bg-secondary);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: var(--spacing-lg);
|
padding: var(--spacing-lg);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue-section {
|
.queue-section {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 400px;
|
min-height: 400px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-subtitle {
|
.section-subtitle {
|
||||||
font-size: var(--text-base);
|
font-size: var(--text-base);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
margin: 0 0 var(--spacing-md);
|
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive - Desktop */
|
/* Responsive - Desktop */
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.renderer-content {
|
.renderer-content {
|
||||||
grid-template-columns: 400px 1fr;
|
grid-template-columns: 400px 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue-section {
|
.queue-section {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive - Tablet */
|
/* Responsive - Tablet */
|
||||||
@media (min-width: 768px) and (max-width: 1023px) {
|
@media (min-width: 768px) and (max-width: 1023px) {
|
||||||
.renderer-content {
|
.renderer-content {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.left-column {
|
.left-column {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
gap: var(--spacing-lg);
|
gap: var(--spacing-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue-section {
|
.queue-section {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive - Mobile */
|
/* Responsive - Mobile */
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.renderer-view {
|
.renderer-view {
|
||||||
padding: var(--spacing-md);
|
padding: var(--spacing-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.renderer-name {
|
.renderer-name {
|
||||||
font-size: var(--text-xl);
|
font-size: var(--text-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.renderer-info {
|
.renderer-info {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.queue-section {
|
.queue-section {
|
||||||
min-height: 300px;
|
min-height: 300px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</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};
|
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()))
|
LAST_COMMAND_TIME.get_or_init(|| Mutex::new(Instant::now()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Mode d’attente de réponse pour une commande TCP Arylic.
|
/// Mode d’attente de réponse pour une commande TCP Arylic.
|
||||||
enum ResponseMode<'a> {
|
enum ResponseMode<'a> {
|
||||||
/// On n’attend aucune réponse (fire-and-forget).
|
/// On n’attend aucune réponse (fire-and-forget).
|
||||||
@@ -40,16 +45,14 @@ fn send_command_with_mode(
|
|||||||
let packet = encode_packet(payload);
|
let packet = encode_packet(payload);
|
||||||
|
|
||||||
stream.write_all(&packet).map_err(|_| {
|
stream.write_all(&packet).map_err(|_| {
|
||||||
ControlPointError::ArilycTcpError(
|
ControlPointError::ArilycTcpError(format!(
|
||||||
format!(
|
|
||||||
"Failed to write Arylic TCP packet for {}: {}",
|
"Failed to write Arylic TCP packet for {}: {}",
|
||||||
host, payload
|
host, payload
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
stream.flush().map_err(|_| {
|
stream.flush().map_err(|_| {
|
||||||
ControlPointError::ArilycTcpError(
|
ControlPointError::ArilycTcpError(format!(
|
||||||
format!(
|
|
||||||
"Failed to flush Arylic TCP stream for {} (command {})",
|
"Failed to flush Arylic TCP stream for {} (command {})",
|
||||||
host, payload
|
host, payload
|
||||||
))
|
))
|
||||||
@@ -91,8 +94,7 @@ fn send_command_with_mode(
|
|||||||
|
|
||||||
Err(ControlPointError::ArilycTcpError(format!(
|
Err(ControlPointError::ArilycTcpError(format!(
|
||||||
"No expected response for optional command {} on {}",
|
"No expected response for optional command {} on {}",
|
||||||
payload,
|
payload, host
|
||||||
host
|
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,9 +112,7 @@ fn read_expected_response(
|
|||||||
Err(err) => {
|
Err(err) => {
|
||||||
return Err(ControlPointError::ArilycTcpError(format!(
|
return Err(ControlPointError::ArilycTcpError(format!(
|
||||||
"Failed to read Arylic TCP response for {} (command {}): {}",
|
"Failed to read Arylic TCP response for {} (command {}): {}",
|
||||||
host,
|
host, payload, err
|
||||||
payload,
|
|
||||||
err
|
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -128,8 +128,7 @@ fn read_expected_response(
|
|||||||
|
|
||||||
Err(ControlPointError::ArilycTcpError(format!(
|
Err(ControlPointError::ArilycTcpError(format!(
|
||||||
"No expected response for command {} on {}",
|
"No expected response for command {} on {}",
|
||||||
payload,
|
payload, host
|
||||||
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(|_| ())
|
send_command_with_mode(host, port, timeout, payload, ResponseMode::None).map(|_| ())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn connect(host: &str, port: u16, timeout: Duration) -> Result<TcpStream, ControlPointError> {
|
fn connect(host: &str, port: u16, timeout: Duration) -> Result<TcpStream, ControlPointError> {
|
||||||
if let Ok(mut last_time) = last_command_time().lock() {
|
if let Ok(mut last_time) = last_command_time().lock() {
|
||||||
let elapsed = last_time.elapsed();
|
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;
|
let mut last_err = None;
|
||||||
for addr in address
|
for addr in address.to_socket_addrs().map_err(|_| {
|
||||||
.to_socket_addrs()
|
ControlPointError::ArilycTcpError(format!("Failed to resolve {}:{}", host, port))
|
||||||
.map_err(|_| {
|
})? {
|
||||||
ControlPointError::ArilycTcpError(format!("Failed to resolve {}:{}", host, port))
|
|
||||||
})?
|
|
||||||
{
|
|
||||||
match TcpStream::connect_timeout(&addr, timeout) {
|
match TcpStream::connect_timeout(&addr, timeout) {
|
||||||
Ok(stream) => {
|
Ok(stream) => {
|
||||||
stream
|
stream
|
||||||
.set_read_timeout(Some(timeout))
|
.set_read_timeout(Some(timeout))
|
||||||
.and_then(|_| stream.set_write_timeout(Some(timeout)))
|
.and_then(|_| stream.set_write_timeout(Some(timeout)))
|
||||||
.map_err(|err| {
|
.map_err(|_| {
|
||||||
ControlPointError::ArilycTcpError(format!(
|
ControlPointError::ArilycTcpError(format!(
|
||||||
"Failed to set socket timeouts for {}",
|
"Failed to set socket timeouts for {}",
|
||||||
address
|
address
|
||||||
@@ -251,29 +251,37 @@ fn encode_packet(payload: &str) -> Vec<u8> {
|
|||||||
|
|
||||||
fn read_packet(stream: &mut TcpStream) -> Result<String, ControlPointError> {
|
fn read_packet(stream: &mut TcpStream) -> Result<String, ControlPointError> {
|
||||||
let mut header = [0u8; 4];
|
let mut header = [0u8; 4];
|
||||||
stream.read_exact(&mut header)
|
stream
|
||||||
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?;
|
.read_exact(&mut header)
|
||||||
|
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}", e)))?;
|
||||||
if header != PACKET_HEADER {
|
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];
|
let mut len_buf = [0u8; 4];
|
||||||
stream.read_exact(&mut len_buf)
|
stream
|
||||||
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?;
|
.read_exact(&mut len_buf)
|
||||||
|
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}", e)))?;
|
||||||
let len = u32::from_le_bytes(len_buf) as usize;
|
let len = u32::from_le_bytes(len_buf) as usize;
|
||||||
|
|
||||||
let mut checksum_buf = [0u8; 4];
|
let mut checksum_buf = [0u8; 4];
|
||||||
stream.read_exact(&mut checksum_buf)
|
stream
|
||||||
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?;
|
.read_exact(&mut checksum_buf)
|
||||||
|
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}", e)))?;
|
||||||
let expected_checksum = u32::from_le_bytes(checksum_buf);
|
let expected_checksum = u32::from_le_bytes(checksum_buf);
|
||||||
|
|
||||||
let mut reserved = [0u8; 8];
|
let mut reserved = [0u8; 8];
|
||||||
stream.read_exact(&mut reserved)
|
stream
|
||||||
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?;
|
.read_exact(&mut reserved)
|
||||||
|
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}", e)))?;
|
||||||
|
|
||||||
let mut payload = vec![0u8; len];
|
let mut payload = vec![0u8; len];
|
||||||
stream.read_exact(&mut payload)
|
stream
|
||||||
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}",e)))?;
|
.read_exact(&mut payload)
|
||||||
|
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}", e)))?;
|
||||||
|
|
||||||
let actual_checksum = payload.iter().fold(0u32, |acc, b| acc + (*b as u32));
|
let actual_checksum = payload.iter().fold(0u32, |acc, b| acc + (*b as u32));
|
||||||
if actual_checksum != expected_checksum {
|
if actual_checksum != expected_checksum {
|
||||||
@@ -284,5 +292,5 @@ fn read_packet(stream: &mut TcpStream) -> Result<String, ControlPointError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Ok(String::from_utf8(payload)
|
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::collections::HashMap;
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex, RwLock};
|
||||||
|
|
||||||
use crate::DeviceId;
|
use crate::DeviceId;
|
||||||
use crate::DeviceRegistry;
|
use crate::DeviceRegistry;
|
||||||
@@ -17,13 +17,13 @@ use tracing::{debug, warn};
|
|||||||
|
|
||||||
/// Gestionnaire des événements mDNS pour Chromecast.
|
/// Gestionnaire des événements mDNS pour Chromecast.
|
||||||
pub struct ChromecastDiscoveryManager {
|
pub struct ChromecastDiscoveryManager {
|
||||||
device_registry: Arc<Mutex<DeviceRegistry>>,
|
device_registry: Arc<RwLock<DeviceRegistry>>,
|
||||||
udn_cache: Arc<Mutex<UDNRegistry>>,
|
udn_cache: Arc<Mutex<UDNRegistry>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChromecastDiscoveryManager {
|
impl ChromecastDiscoveryManager {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
device_registry: Arc<Mutex<DeviceRegistry>>,
|
device_registry: Arc<RwLock<DeviceRegistry>>,
|
||||||
udn_cache: Arc<Mutex<UDNRegistry>>,
|
udn_cache: Arc<Mutex<UDNRegistry>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -173,7 +173,7 @@ impl ChromecastDiscoveryManager {
|
|||||||
|
|
||||||
// Register the renderer
|
// Register the renderer
|
||||||
self.device_registry
|
self.device_registry
|
||||||
.lock()
|
.write()
|
||||||
.expect("DeviceRegistry mutex lock failed")
|
.expect("DeviceRegistry mutex lock failed")
|
||||||
.push_renderer(&renderer_info, default_max_age as u32);
|
.push_renderer(&renderer_info, default_max_age as u32);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::{
|
use std::{
|
||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
sync::{Arc, Mutex},
|
sync::{Arc, Mutex},
|
||||||
time::{Instant, SystemTime},
|
time::Instant,
|
||||||
};
|
};
|
||||||
|
|
||||||
struct UDNSeen {
|
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 {
|
pub fn should_fetch(registry: Arc<Mutex<UDNRegistry>>, udn: &str, max_age: u64) -> bool {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
let mut r = registry.lock().expect("UDNRegistry mutex lock failed");
|
let mut r = registry.lock().expect("UDNRegistry mutex lock failed");
|
||||||
if let Some(seen) = r.seen.get_mut(udn) {
|
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 {
|
if now.duration_since(seen.last_seen).as_secs() > max_age / 2 {
|
||||||
false
|
|
||||||
} else {
|
|
||||||
seen.last_seen = now;
|
seen.last_seen = now;
|
||||||
true
|
true
|
||||||
|
} else {
|
||||||
|
// Too soon, skip this SSDP event
|
||||||
|
false
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// First time seeing this UDN, insert and fetch
|
||||||
r.seen.insert(
|
r.seen.insert(
|
||||||
udn.to_string(),
|
udn.to_string(),
|
||||||
UDNSeen {
|
UDNSeen {
|
||||||
|
|||||||
@@ -1,30 +1,8 @@
|
|||||||
use crate::{RendererInfo, UpnpMediaServer};
|
pub mod arylic;
|
||||||
|
pub mod chromecast_discovery;
|
||||||
pub mod manager;
|
pub mod manager;
|
||||||
pub mod upnp_discovery;
|
pub mod upnp_discovery;
|
||||||
pub mod upnp_provider;
|
pub mod upnp_provider;
|
||||||
pub mod chromecast_discovery;
|
|
||||||
pub mod arylic;
|
|
||||||
|
|
||||||
/// Fournit les descriptions haut niveau à partir d’un endpoint découvert.
|
pub use chromecast_discovery::ChromecastDiscoveryManager;
|
||||||
/// L’implémentation pourra, plus tard, faire un HTTP GET sur `location`
|
pub use upnp_discovery::UpnpDiscoveryManager;
|
||||||
/// 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>;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
use crate::{DeviceRegistry, discovery::upnp_provider::ParsedDeviceDescription};
|
use crate::{DeviceRegistry, discovery::upnp_provider::ParsedDeviceDescription};
|
||||||
use pmoupnp::ssdp::SsdpEvent;
|
use pmoupnp::ssdp::SsdpEvent;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex, RwLock};
|
||||||
|
|
||||||
use crate::discovery::manager::UDNRegistry;
|
use crate::discovery::manager::UDNRegistry;
|
||||||
|
|
||||||
/// Gestionnaire des événements SSDP -> DeviceUpdate.
|
/// Gestionnaire des événements SSDP -> DeviceUpdate.
|
||||||
|
|
||||||
pub struct UpnpDiscoveryManager {
|
pub struct UpnpDiscoveryManager {
|
||||||
device_registry: Arc<Mutex<DeviceRegistry>>,
|
device_registry: Arc<RwLock<DeviceRegistry>>,
|
||||||
udn_cache: Arc<Mutex<UDNRegistry>>,
|
udn_cache: Arc<Mutex<UDNRegistry>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UpnpDiscoveryManager {
|
impl UpnpDiscoveryManager {
|
||||||
// Dans handle_ssdp_event (upnp_discovery.rs)
|
pub fn new(device_registry: Arc<RwLock<DeviceRegistry>>,
|
||||||
fn handle_ssdp_event(&mut self, event: SsdpEvent) {
|
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 {
|
let (alive, usn, location, max_age, server_header) = match event {
|
||||||
SsdpEvent::Alive {
|
SsdpEvent::Alive {
|
||||||
usn,
|
usn,
|
||||||
@@ -39,24 +47,20 @@ impl UpnpDiscoveryManager {
|
|||||||
// ✅ Fetch + parse
|
// ✅ Fetch + parse
|
||||||
if let Ok(info) = ParsedDeviceDescription::new(&udn, &location, &server_header,5) {
|
if let Ok(info) = ParsedDeviceDescription::new(&udn, &location, &server_header,5) {
|
||||||
if let Some(renderer_info) = info.build_renderer() {
|
if let Some(renderer_info) = info.build_renderer() {
|
||||||
self.device_registry
|
if let Ok(mut reg) = self.device_registry.write() {
|
||||||
.lock()
|
reg.push_renderer(&renderer_info, max_age);
|
||||||
.expect("UDNRegistry mutex lock failed")
|
}
|
||||||
.push_renderer(&renderer_info,max_age);
|
} else if let Some(server_info) = info.build_server() {
|
||||||
} else {
|
if let Ok(mut reg) = self.device_registry.write() {
|
||||||
if let Some(server_info) = info.build_server() {
|
reg.push_server(&server_info, max_age);
|
||||||
self.device_registry
|
}
|
||||||
.lock()
|
|
||||||
.expect("UDNRegistry mutex lock failed")
|
|
||||||
.push_server(&server_info,max_age);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}
|
||||||
} else {
|
} else {
|
||||||
self.device_registry
|
if let Ok(mut reg) = self.device_registry.write() {
|
||||||
.lock()
|
reg.device_says_byebye(&udn);
|
||||||
.expect("UDNRegistry mutex lock failed")
|
}
|
||||||
.device_says_byebye(&udn);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ pub enum ControlPointError {
|
|||||||
#[error("Missing {0} element in SOAP body")]
|
#[error("Missing {0} element in SOAP body")]
|
||||||
UpnpMissingReturnValue(String),
|
UpnpMissingReturnValue(String),
|
||||||
#[error("Invalid {0} value: {1}")]
|
#[error("Invalid {0} value: {1}")]
|
||||||
UpnpBadReturnValue(String,String),
|
UpnpBadReturnValue(String, String),
|
||||||
#[error("Soap Error: Upnp action call {0}")]
|
#[error("Soap Error: Upnp action call {0}")]
|
||||||
SoapAction(String),
|
SoapAction(String),
|
||||||
#[error("{0} returned UPnP error {1}: {2} (HTTP status {3})")]
|
#[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}")]
|
#[error("{0} failed with HTTP status {1} and body: {2}")]
|
||||||
SoapActionWrongBody(String, u32, String),
|
SoapActionWrongBody(String, u32, String),
|
||||||
#[error("Soap Error: No envelop for action {0}")]
|
#[error("Soap Error: No envelop for action {0}")]
|
||||||
@@ -45,23 +45,26 @@ pub enum ControlPointError {
|
|||||||
QueueError(String),
|
QueueError(String),
|
||||||
#[error("Invalid time format: {0}")]
|
#[error("Invalid time format: {0}")]
|
||||||
InvalidTimeFormat(String),
|
InvalidTimeFormat(String),
|
||||||
|
#[error("Error on snapshot: {0}")]
|
||||||
|
SnapshotError(String),
|
||||||
|
#[error("Error on ControlPoint: {0}")]
|
||||||
|
ControlPoint(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ControlPointError {
|
impl ControlPointError {
|
||||||
pub fn upnp_operation_not_supported(operation: &str, service: &str) -> Self {
|
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 {
|
pub fn upnp_missing_return_value(value: &str) -> Self {
|
||||||
ControlPointError::UpnpMissingReturnValue(value.to_string())
|
ControlPointError::UpnpMissingReturnValue(value.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn upnp_bad_return_value(name : &str, value : &str ) -> Self {
|
pub fn upnp_bad_return_value(name: &str, value: &str) -> Self {
|
||||||
ControlPointError::UpnpBadReturnValue(name.to_string(),value.to_string())
|
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())
|
ControlPointError::ArilycTcpError(message.to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,29 @@
|
|||||||
use crate::DeviceId;
|
use crate::DeviceId;
|
||||||
|
use crate::model::DeviceBasicInfo;
|
||||||
|
|
||||||
pub trait DeviceIdentity {
|
pub trait DeviceIdentity {
|
||||||
fn id(&self) -> DeviceId ;
|
fn id(&self) -> DeviceId;
|
||||||
fn udn(&self) -> &str;
|
fn udn(&self) -> &str;
|
||||||
fn friendly_name(&self) -> &str ;
|
fn friendly_name(&self) -> &str;
|
||||||
fn model_name(&self) -> &str ;
|
fn model_name(&self) -> &str;
|
||||||
fn manufacturer(&self) -> &str ;
|
fn manufacturer(&self) -> &str;
|
||||||
fn location(&self) -> &str ;
|
fn location(&self) -> &str;
|
||||||
fn server_header(&self) -> &str;
|
fn server_header(&self) -> &str;
|
||||||
|
|
||||||
fn is_a_media_server(&self) -> bool { false }
|
fn is_a_media_server(&self) -> bool {
|
||||||
fn is_a_music_renderer(&self) -> bool { false }
|
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 events;
|
||||||
mod media_server_events;
|
mod media_server_events;
|
||||||
|
|
||||||
pub mod queue;
|
|
||||||
pub mod discovery;
|
|
||||||
pub mod upnp_clients;
|
|
||||||
pub mod arylic_client;
|
pub mod arylic_client;
|
||||||
pub mod linkplay_client;
|
|
||||||
pub mod control_point;
|
pub mod control_point;
|
||||||
|
pub mod discovery;
|
||||||
pub mod errors;
|
pub mod errors;
|
||||||
|
pub mod identity;
|
||||||
|
pub mod linkplay_client;
|
||||||
|
pub mod linkplay_utils;
|
||||||
pub mod media_server;
|
pub mod media_server;
|
||||||
pub mod model;
|
pub mod model;
|
||||||
pub mod music_renderer;
|
pub mod music_renderer;
|
||||||
|
pub mod online;
|
||||||
|
pub mod queue;
|
||||||
pub mod registry;
|
pub mod registry;
|
||||||
pub mod soap_client;
|
pub mod soap_client;
|
||||||
pub mod online;
|
pub mod upnp_clients;
|
||||||
pub mod identity;
|
|
||||||
pub mod linkplay_utils;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// pmoserver extension (optional)
|
// pmoserver extension (optional)
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
@@ -32,20 +30,18 @@ use std::time::Duration;
|
|||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
pub use pmoserver_ext::ControlPointExt;
|
pub use pmoserver_ext::ControlPointExt;
|
||||||
|
|
||||||
|
pub use control_point::ControlPoint;
|
||||||
pub use control_point::{ControlPoint, PlaylistBinding};
|
pub use media_server::{MediaBrowser, MediaEntry, MediaResource, UpnpMediaServer};
|
||||||
pub use media_server::{
|
|
||||||
MediaBrowser, MediaEntry, MediaResource, UpnpMediaServer,
|
|
||||||
};
|
|
||||||
pub use queue::{EnqueueMode, PlaybackItem, QueueSnapshot};
|
pub use queue::{EnqueueMode, PlaybackItem, QueueSnapshot};
|
||||||
|
|
||||||
pub use model::{
|
pub use model::{
|
||||||
MediaServerEvent, RendererCapabilities, RendererEvent, RendererInfo, RendererProtocol,
|
MediaServerEvent, PlaybackSource, RendererCapabilities, RendererEvent, RendererInfo,
|
||||||
|
RendererProtocol,
|
||||||
};
|
};
|
||||||
pub use registry::{DeviceRegistry, DeviceUpdate};
|
pub use registry::{DeviceRegistry, DeviceUpdate};
|
||||||
|
|
||||||
pub use soap_client::invoke_upnp_action;
|
|
||||||
pub use online::DeviceOnline;
|
pub use online::DeviceOnline;
|
||||||
|
pub use soap_client::invoke_upnp_action;
|
||||||
|
|
||||||
pub use identity::DeviceIdentity;
|
pub use identity::DeviceIdentity;
|
||||||
|
|
||||||
@@ -53,4 +49,3 @@ pub use identity::DeviceIdentity;
|
|||||||
pub struct DeviceId(pub String);
|
pub struct DeviceId(pub String);
|
||||||
|
|
||||||
const DEFAULT_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
|
const DEFAULT_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tracing::debug;
|
|
||||||
use ureq::Agent;
|
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";
|
const STATUS_COMMAND: &str = "getPlayerStatus";
|
||||||
|
|
||||||
@@ -101,32 +107,41 @@ pub fn percent_encode(input: &str) -> String {
|
|||||||
out
|
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 url = format!("http://{}/httpapi.asp?command={}", host, STATUS_COMMAND);
|
||||||
let mut response = build_agent(timeout)
|
let mut response = build_agent(timeout).get(&url).call().map_err(|_| {
|
||||||
.get(&url)
|
ControlPointError::ArilycTcpError(format!(
|
||||||
.call()
|
"HTTP request failed for LinkPlay status on {}",
|
||||||
.map_err(|_| ControlPointError::ArilycTcpError(format!("HTTP request failed for LinkPlay status on {}", host)))?;
|
host
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
let body = response
|
let body = response.body_mut().read_to_string().map_err(|e| {
|
||||||
.body_mut()
|
ControlPointError::ArilycTcpError(format!("Failed to read LinkPlay status body : {}", e))
|
||||||
.read_to_string()
|
})?;
|
||||||
.map_err(|e| ControlPointError::ArilycTcpError(format!("Failed to read LinkPlay status body : {}",e)))?;
|
|
||||||
|
|
||||||
parse_linkplay_status(&body)
|
parse_linkplay_status(&body)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_linkplay_status(body: &str) -> Result<LinkPlayStatus, ControlPointError> {
|
fn parse_linkplay_status(body: &str) -> Result<LinkPlayStatus, ControlPointError> {
|
||||||
let raw: LinkPlayStatusRaw = serde_json::from_str(body)
|
let raw: LinkPlayStatusRaw = serde_json::from_str(body).map_err(|e| {
|
||||||
.map_err(|e| ControlPointError::LinkPlayError(format!("Failed to parse LinkPlay status JSON: {}", e)))?;
|
ControlPointError::LinkPlayError(format!("Failed to parse LinkPlay status JSON: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
let curpos_ms = raw.curpos.parse::<u64>()
|
let curpos_ms = raw.curpos.parse::<u64>().map_err(|_| {
|
||||||
.map_err(|_| ControlPointError::LinkPlayError(format!("Invalid curpos value: {}", raw.curpos)))?;
|
ControlPointError::LinkPlayError(format!("Invalid curpos value: {}", raw.curpos))
|
||||||
|
})?;
|
||||||
|
|
||||||
let totlen_ms = raw.totlen.parse::<u64>()
|
let totlen_ms = raw.totlen.parse::<u64>().map_err(|_| {
|
||||||
.map_err(|_| ControlPointError::LinkPlayError(format!("Invalid totlen value: {}", raw.totlen)))?;
|
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)))?
|
.map_err(|_| ControlPointError::LinkPlayError(format!("Invalid vol value: {}", raw.vol)))?
|
||||||
.min(100);
|
.min(100);
|
||||||
|
|
||||||
@@ -134,11 +149,15 @@ fn parse_linkplay_status(body: &str) -> Result<LinkPlayStatus, ControlPointError
|
|||||||
"1" => true,
|
"1" => true,
|
||||||
"0" => false,
|
"0" => false,
|
||||||
other => {
|
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())
|
.and_then(|s| s.parse::<u32>().ok())
|
||||||
.filter(|idx| *idx > 0);
|
.filter(|idx| *idx > 0);
|
||||||
|
|
||||||
@@ -151,6 +170,3 @@ fn parse_linkplay_status(body: &str) -> Result<LinkPlayStatus, ControlPointError
|
|||||||
mute,
|
mute,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use crate::model::TrackMetadata;
|
|||||||
use crate::online::{DeviceConnectionState, DeviceOnline};
|
use crate::online::{DeviceConnectionState, DeviceOnline};
|
||||||
use crate::queue::PlaybackItem;
|
use crate::queue::PlaybackItem;
|
||||||
use crate::soap_client::{SoapCallResult, invoke_upnp_action_with_timeout};
|
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.
|
/// Snapshot of a media server discovered through UPnP SSDP.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -70,7 +70,7 @@ impl UpnpMediaServer {
|
|||||||
content_directory_service_type: Option<String>,
|
content_directory_service_type: Option<String>,
|
||||||
content_directory_control_url: Option<String>,
|
content_directory_control_url: Option<String>,
|
||||||
) -> Arc<Self> {
|
) -> Arc<Self> {
|
||||||
Arc::new(Self::new(
|
Arc::new(UpnpMediaServer::new(
|
||||||
id,
|
id,
|
||||||
udn,
|
udn,
|
||||||
friendly_name,
|
friendly_name,
|
||||||
@@ -110,6 +110,10 @@ impl UpnpMediaServer {
|
|||||||
map_didl_entries(&didl_xml)
|
map_didl_entries(&didl_xml)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn has_content_directory(&self) -> bool {
|
||||||
|
self.has_content_directory
|
||||||
|
}
|
||||||
|
|
||||||
fn search_impl(
|
fn search_impl(
|
||||||
&self,
|
&self,
|
||||||
container_id: &str,
|
container_id: &str,
|
||||||
@@ -384,9 +388,15 @@ pub enum MusicServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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()))
|
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 {
|
impl DeviceOnline for MusicServer {
|
||||||
@@ -502,7 +512,7 @@ impl MediaBrowser for MusicServer {
|
|||||||
/// This function filters out containers and entries without audio resources,
|
/// This function filters out containers and entries without audio resources,
|
||||||
/// returning None for items that cannot be played.
|
/// returning None for items that cannot be played.
|
||||||
pub fn playback_item_from_entry(
|
pub fn playback_item_from_entry(
|
||||||
server: &UpnpMediaServer,
|
server: Arc<MusicServer>,
|
||||||
entry: &MediaEntry,
|
entry: &MediaEntry,
|
||||||
) -> Option<PlaybackItem> {
|
) -> Option<PlaybackItem> {
|
||||||
// Ignore containers
|
// Ignore containers
|
||||||
|
|||||||
@@ -8,20 +8,23 @@ use std::time::{Duration, Instant};
|
|||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use crossbeam_channel::{Receiver, Sender, unbounded};
|
use crossbeam_channel::{Receiver, Sender, unbounded};
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
use ureq::{Agent, http};
|
use ureq::{Agent, http};
|
||||||
use xmltree::{Element, XMLNode};
|
use xmltree::{Element, XMLNode};
|
||||||
|
|
||||||
use crate::DeviceId;
|
|
||||||
use crate::events::MediaServerEventBus;
|
use crate::events::MediaServerEventBus;
|
||||||
use crate::media_server::{UpnpMediaServer};
|
use crate::media_server::MusicServer;
|
||||||
use crate::model::MediaServerEvent;
|
use crate::model::MediaServerEvent;
|
||||||
use crate::upnp_clients::resolve_control_url;
|
|
||||||
use crate::registry::DeviceRegistry;
|
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 SUBSCRIPTION_TIMEOUT_SECS: u64 = 300;
|
||||||
const RENEWAL_SAFETY_MARGIN_SECS: u64 = 60;
|
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
|
/// Launch the media server event runtime responsible for subscribing
|
||||||
/// to ContentDirectory updates and forwarding notifications on the bus.
|
/// to ContentDirectory updates and forwarding notifications on the bus.
|
||||||
@@ -67,11 +70,47 @@ struct IncomingNotify {
|
|||||||
body: Vec<u8>,
|
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>) {
|
fn run_http_listener(listener: TcpListener, notify_tx: Sender<IncomingNotify>) {
|
||||||
for stream in listener.incoming() {
|
for stream in listener.incoming() {
|
||||||
match stream {
|
match stream {
|
||||||
Ok(mut 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);
|
warn!("Failed to set read timeout on notify connection: {}", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,7 +248,7 @@ impl MediaServerEventWorker {
|
|||||||
self.drain_notifications();
|
self.drain_notifications();
|
||||||
self.refresh_servers();
|
self.refresh_servers();
|
||||||
self.renew_expiring();
|
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) {
|
fn refresh_servers(&mut self) {
|
||||||
let server_infos = {
|
let servers = {
|
||||||
let reg = self.registry.read().unwrap();
|
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();
|
let mut active: HashSet<DeviceId> = HashSet::new();
|
||||||
|
|
||||||
for info in server_infos {
|
for server in servers {
|
||||||
if !info.is_online() || !info.has_content_directory {
|
if !server.is_online() || !server.has_content_directory() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
active.insert(info.id.clone());
|
active.insert(server.id());
|
||||||
let entry = self
|
let entry = self
|
||||||
.subscriptions
|
.subscriptions
|
||||||
.entry(info.id.clone())
|
.entry(server.id())
|
||||||
.or_insert_with(|| SubscriptionState::new(info.clone()));
|
.or_insert_with(|| SubscriptionState::from_music_server(&server));
|
||||||
entry.update(info);
|
entry.update_from_server(&server);
|
||||||
self.path_index
|
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.event_sub_url.is_none() {
|
||||||
if entry.should_retry() {
|
if entry.retry_policy.should_retry() {
|
||||||
match fetch_event_sub_url(&entry.info.location, self.http_timeout) {
|
match fetch_event_sub_url(&entry.location, self.http_timeout) {
|
||||||
Ok(Some(url)) => {
|
Ok(Some(url)) => {
|
||||||
debug!(
|
debug!(
|
||||||
server = entry.info.friendly_name.as_str(),
|
server = entry.friendly_name.as_str(),
|
||||||
callback = url.as_str(),
|
callback = url.as_str(),
|
||||||
"ContentDirectory eventSub URL resolved"
|
"ContentDirectory eventSub URL resolved"
|
||||||
);
|
);
|
||||||
entry.event_sub_url = Some(url);
|
entry.event_sub_url = Some(url);
|
||||||
entry.retry_after = Instant::now();
|
entry.retry_policy = RetryPolicy::new();
|
||||||
}
|
}
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
debug!(
|
debug!(
|
||||||
server = entry.info.friendly_name.as_str(),
|
server = entry.friendly_name.as_str(),
|
||||||
"No ContentDirectory eventSub URL found"
|
"No ContentDirectory eventSub URL found"
|
||||||
);
|
);
|
||||||
entry.defer_retry();
|
entry.retry_policy.defer_retry();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!(
|
warn!(
|
||||||
server = entry.info.friendly_name.as_str(),
|
server = entry.friendly_name.as_str(),
|
||||||
error = %err,
|
error = %err,
|
||||||
"Failed to fetch ContentDirectory eventSub URL"
|
"Failed to fetch ContentDirectory eventSub URL"
|
||||||
);
|
);
|
||||||
entry.defer_retry();
|
entry.retry_policy.defer_retry();
|
||||||
continue;
|
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) =
|
if let Err(err) =
|
||||||
Self::subscribe_entry(self.listener_port, self.http_timeout, entry)
|
Self::subscribe_entry(self.listener_port, self.http_timeout, entry)
|
||||||
{
|
{
|
||||||
warn!(
|
warn!(
|
||||||
server = entry.info.friendly_name.as_str(),
|
server = entry.friendly_name.as_str(),
|
||||||
error = %err,
|
error = %err,
|
||||||
"ContentDirectory SUBSCRIBE failed"
|
"ContentDirectory SUBSCRIBE failed"
|
||||||
);
|
);
|
||||||
entry.defer_retry();
|
entry.retry_policy.defer_retry();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let stale_ids: Vec<ServerId> = self
|
let stale_ids: Vec<DeviceId> = self
|
||||||
.subscriptions
|
.subscriptions
|
||||||
.keys()
|
.keys()
|
||||||
.filter(|id| !active.contains(*id))
|
.filter(|id| !active.contains(*id))
|
||||||
@@ -320,7 +365,7 @@ impl MediaServerEventWorker {
|
|||||||
if let Some(entry) = self.subscriptions.get_mut(&id) {
|
if let Some(entry) = self.subscriptions.get_mut(&id) {
|
||||||
if let Err(err) = Self::renew_entry(self.http_timeout, entry) {
|
if let Err(err) = Self::renew_entry(self.http_timeout, entry) {
|
||||||
warn!(
|
warn!(
|
||||||
server = entry.info.friendly_name.as_str(),
|
server = entry.friendly_name.as_str(),
|
||||||
error = %err,
|
error = %err,
|
||||||
"Failed to renew ContentDirectory subscription"
|
"Failed to renew ContentDirectory subscription"
|
||||||
);
|
);
|
||||||
@@ -340,8 +385,10 @@ impl MediaServerEventWorker {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.context("EventSub URL missing for server")?;
|
.context("EventSub URL missing for server")?;
|
||||||
|
|
||||||
|
let (host_header, timeout_header) = build_subscribe_headers(event_url)?;
|
||||||
|
|
||||||
let (remote_host, remote_port) =
|
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)
|
let local_ip = determine_local_ip(&remote_host, remote_port)
|
||||||
.context("Cannot determine local IP for callback")?;
|
.context("Cannot determine local IP for callback")?;
|
||||||
|
|
||||||
@@ -353,13 +400,11 @@ impl MediaServerEventWorker {
|
|||||||
);
|
);
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
server = entry.info.friendly_name.as_str(),
|
server = entry.friendly_name.as_str(),
|
||||||
callback = callback_url.as_str(),
|
callback = callback_url.as_str(),
|
||||||
"Subscribing to ContentDirectory events"
|
"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 callback_header = format!("<{}>", callback_url);
|
||||||
|
|
||||||
let request = http::Request::builder()
|
let request = http::Request::builder()
|
||||||
@@ -393,10 +438,10 @@ impl MediaServerEventWorker {
|
|||||||
|
|
||||||
entry.sid = Some(sid);
|
entry.sid = Some(sid);
|
||||||
entry.expires_at = Some(Instant::now() + timeout);
|
entry.expires_at = Some(Instant::now() + timeout);
|
||||||
entry.retry_after = Instant::now() + Duration::from_secs(5);
|
entry.retry_policy.schedule_soon();
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
server = entry.info.friendly_name.as_str(),
|
server = entry.friendly_name.as_str(),
|
||||||
"Subscribed to ContentDirectory events (timeout {}s)",
|
"Subscribed to ContentDirectory events (timeout {}s)",
|
||||||
timeout.as_secs()
|
timeout.as_secs()
|
||||||
);
|
);
|
||||||
@@ -414,10 +459,9 @@ impl MediaServerEventWorker {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.cloned()
|
.cloned()
|
||||||
.context("SID missing for renew")?;
|
.context("SID missing for renew")?;
|
||||||
let (remote_host, remote_port) =
|
|
||||||
parse_host_port(event_url).context("Cannot extract host for renew")?;
|
let (host_header, timeout_header) = build_subscribe_headers(event_url)?;
|
||||||
let host_header = format!("{}:{}", remote_host, remote_port);
|
|
||||||
let timeout_header = format!("Second-{}", SUBSCRIPTION_TIMEOUT_SECS);
|
|
||||||
let request = http::Request::builder()
|
let request = http::Request::builder()
|
||||||
.method("SUBSCRIBE")
|
.method("SUBSCRIBE")
|
||||||
.uri(event_url)
|
.uri(event_url)
|
||||||
@@ -441,7 +485,7 @@ impl MediaServerEventWorker {
|
|||||||
.unwrap_or(Duration::from_secs(SUBSCRIPTION_TIMEOUT_SECS));
|
.unwrap_or(Duration::from_secs(SUBSCRIPTION_TIMEOUT_SECS));
|
||||||
entry.expires_at = Some(Instant::now() + timeout);
|
entry.expires_at = Some(Instant::now() + timeout);
|
||||||
debug!(
|
debug!(
|
||||||
server = entry.info.friendly_name.as_str(),
|
server = entry.friendly_name.as_str(),
|
||||||
"Renewed ContentDirectory subscription"
|
"Renewed ContentDirectory subscription"
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -470,7 +514,7 @@ impl MediaServerEventWorker {
|
|||||||
Ok(req) => req,
|
Ok(req) => req,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!(
|
warn!(
|
||||||
server = entry.info.friendly_name.as_str(),
|
server = entry.friendly_name.as_str(),
|
||||||
error = %err,
|
error = %err,
|
||||||
"Failed to build UNSUBSCRIBE request"
|
"Failed to build UNSUBSCRIBE request"
|
||||||
);
|
);
|
||||||
@@ -482,12 +526,12 @@ impl MediaServerEventWorker {
|
|||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
if response.status().is_success() {
|
if response.status().is_success() {
|
||||||
debug!(
|
debug!(
|
||||||
server = entry.info.friendly_name.as_str(),
|
server = entry.friendly_name.as_str(),
|
||||||
"Unsubscribed from ContentDirectory events"
|
"Unsubscribed from ContentDirectory events"
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
warn!(
|
warn!(
|
||||||
server = entry.info.friendly_name.as_str(),
|
server = entry.friendly_name.as_str(),
|
||||||
status = %response.status(),
|
status = %response.status(),
|
||||||
"UNSUBSCRIBE returned non-success status"
|
"UNSUBSCRIBE returned non-success status"
|
||||||
);
|
);
|
||||||
@@ -495,7 +539,7 @@ impl MediaServerEventWorker {
|
|||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!(
|
warn!(
|
||||||
server = entry.info.friendly_name.as_str(),
|
server = entry.friendly_name.as_str(),
|
||||||
error = %err,
|
error = %err,
|
||||||
"UNSUBSCRIBE request failed"
|
"UNSUBSCRIBE request failed"
|
||||||
);
|
);
|
||||||
@@ -513,32 +557,30 @@ impl MediaServerEventWorker {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
if let (Some(expected), Some(received)) = (&entry.sid, ¬ify.sid) {
|
if !notify.validate_sid(&entry.sid) {
|
||||||
if !expected.eq_ignore_ascii_case(received) {
|
debug!(
|
||||||
debug!(
|
server = entry.friendly_name.as_str(),
|
||||||
server = entry.info.friendly_name.as_str(),
|
expected_sid = entry.sid.as_deref().unwrap_or("none"),
|
||||||
expected_sid = expected.as_str(),
|
received_sid = notify.sid.as_deref().unwrap_or("none"),
|
||||||
received_sid = received.as_str(),
|
"Ignoring notify with mismatched SID"
|
||||||
"Ignoring notify with mismatched SID"
|
);
|
||||||
);
|
return;
|
||||||
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 {
|
match &event {
|
||||||
MediaServerEvent::GlobalUpdated {
|
MediaServerEvent::GlobalUpdated {
|
||||||
system_update_id, ..
|
system_update_id, ..
|
||||||
} => {
|
} => {
|
||||||
debug!(
|
debug!(
|
||||||
server = entry.info.friendly_name.as_str(),
|
server = entry.friendly_name.as_str(),
|
||||||
update_id = system_update_id.unwrap_or_default(),
|
update_id = system_update_id.unwrap_or_default(),
|
||||||
"Broadcasting MediaServerEvent::GlobalUpdated"
|
"Broadcasting MediaServerEvent::GlobalUpdated"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
MediaServerEvent::ContainersUpdated { container_ids, .. } => {
|
MediaServerEvent::ContainersUpdated { container_ids, .. } => {
|
||||||
debug!(
|
debug!(
|
||||||
server = entry.info.friendly_name.as_str(),
|
server = entry.friendly_name.as_str(),
|
||||||
changed_containers = container_ids.join(",").as_str(),
|
changed_containers = container_ids.join(",").as_str(),
|
||||||
"Broadcasting MediaServerEvent::ContainersUpdated"
|
"Broadcasting MediaServerEvent::ContainersUpdated"
|
||||||
);
|
);
|
||||||
@@ -553,52 +595,63 @@ impl MediaServerEventWorker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct SubscriptionState {
|
struct SubscriptionState {
|
||||||
info: UpnpMediaServer,
|
device_id: DeviceId,
|
||||||
|
location: String,
|
||||||
|
friendly_name: String,
|
||||||
event_sub_url: Option<String>,
|
event_sub_url: Option<String>,
|
||||||
sid: Option<String>,
|
sid: Option<String>,
|
||||||
expires_at: Option<Instant>,
|
expires_at: Option<Instant>,
|
||||||
callback_path: String,
|
callback_path: String,
|
||||||
retry_after: Instant,
|
retry_policy: RetryPolicy,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SubscriptionState {
|
impl SubscriptionState {
|
||||||
fn new(info: UpnpMediaServer) -> Self {
|
/// Creates a new subscription state from a MusicServer
|
||||||
|
fn from_music_server(server: &MusicServer) -> Self {
|
||||||
Self {
|
Self {
|
||||||
callback_path: build_callback_path(&info.id),
|
callback_path: build_callback_path(&server.id()),
|
||||||
info,
|
device_id: server.id(),
|
||||||
|
location: server.location().to_string(),
|
||||||
|
friendly_name: server.friendly_name().to_string(),
|
||||||
event_sub_url: None,
|
event_sub_url: None,
|
||||||
sid: None,
|
sid: None,
|
||||||
expires_at: None,
|
expires_at: None,
|
||||||
retry_after: Instant::now(),
|
retry_policy: RetryPolicy::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, info: UpnpMediaServer) {
|
/// Updates the subscription state from a MusicServer
|
||||||
if self.info.location != info.location {
|
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.event_sub_url = None;
|
||||||
self.sid = None;
|
self.sid = None;
|
||||||
self.expires_at = None;
|
self.expires_at = None;
|
||||||
self.retry_after = Instant::now();
|
self.retry_policy = RetryPolicy::new();
|
||||||
}
|
}
|
||||||
self.info = info;
|
self.device_id = server.id();
|
||||||
}
|
self.location = new_location.to_string();
|
||||||
|
self.friendly_name = server.friendly_name().to_string();
|
||||||
fn should_retry(&self) -> bool {
|
|
||||||
Instant::now() >= self.retry_after
|
|
||||||
}
|
|
||||||
|
|
||||||
fn defer_retry(&mut self) {
|
|
||||||
self.retry_after = Instant::now() + Duration::from_secs(15);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reset_subscription(&mut self) {
|
fn reset_subscription(&mut self) {
|
||||||
self.sid = None;
|
self.sid = None;
|
||||||
self.expires_at = 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();
|
let mut sanitized = String::new();
|
||||||
for ch in id.0.chars() {
|
for ch in id.0.chars() {
|
||||||
if ch.is_ascii_alphanumeric() {
|
if ch.is_ascii_alphanumeric() {
|
||||||
@@ -615,7 +668,7 @@ fn build_callback_path(id: &ServerId) -> String {
|
|||||||
format!("/media-server-events/{}-{:x}", sanitized, suffix)
|
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 mut events = Vec::new();
|
||||||
let reader = std::io::Cursor::new(body);
|
let reader = std::io::Cursor::new(body);
|
||||||
let Ok(root) = Element::parse(reader) else {
|
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 system_update_id: Option<u32> = None;
|
||||||
let mut container_ids: Vec<String> = Vec::new();
|
let mut container_ids: Vec<String> = Vec::new();
|
||||||
|
|
||||||
for property in root.children.iter().filter_map(|node| match node {
|
// Navigate through property elements
|
||||||
XMLNode::Element(elem) => Some(elem),
|
for property in xml_children(&root) {
|
||||||
_ => None,
|
for child in xml_children(property) {
|
||||||
}) {
|
match child.name.as_str() {
|
||||||
for child in property.children.iter().filter_map(|node| match node {
|
"SystemUpdateID" => {
|
||||||
XMLNode::Element(elem) => Some(elem),
|
system_update_id = child
|
||||||
_ => None,
|
.get_text()
|
||||||
}) {
|
.and_then(|text| text.trim().parse::<u32>().ok());
|
||||||
if child.name == "SystemUpdateID" {
|
}
|
||||||
if let Some(text) = child.get_text() {
|
"ContainerUpdateIDs" => {
|
||||||
let trimmed = text.trim();
|
if let Some(text) = child.get_text() {
|
||||||
if let Ok(value) = trimmed.parse::<u32>() {
|
container_ids = parse_container_update_ids(text.as_ref());
|
||||||
system_update_id = Some(value);
|
|
||||||
} else {
|
|
||||||
system_update_id = None;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} 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
|
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> {
|
fn parse_container_update_ids(raw: &str) -> Vec<String> {
|
||||||
let trimmed = raw.trim();
|
let trimmed = raw.trim();
|
||||||
if trimmed.is_empty() {
|
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> {
|
fn child_text(element: &Element, name: &str) -> Option<String> {
|
||||||
for node in &element.children {
|
xml_children(element)
|
||||||
if let XMLNode::Element(child) = node {
|
.find(|child| child.name == name)
|
||||||
if child.name == name {
|
.and_then(|child| child.get_text().map(|cow| cow.into_owned()))
|
||||||
return child.get_text().map(|cow| cow.into_owned());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fetch_event_sub_url(location: &str, timeout: Duration) -> Result<Option<String>> {
|
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),
|
None => return Ok(None),
|
||||||
};
|
};
|
||||||
|
|
||||||
for node in &service_list.children {
|
for service in xml_children(service_list) {
|
||||||
if let XMLNode::Element(service) = node {
|
let Some(service_type) = child_text(service, "serviceType") else {
|
||||||
let Some(service_type) = child_text(service, "serviceType") else {
|
continue;
|
||||||
continue;
|
};
|
||||||
};
|
if !service_type
|
||||||
if !service_type
|
.to_ascii_lowercase()
|
||||||
.to_ascii_lowercase()
|
.contains("urn:schemas-upnp-org:service:contentdirectory:")
|
||||||
.contains("urn:schemas-upnp-org:service:contentdirectory:")
|
{
|
||||||
{
|
continue;
|
||||||
continue;
|
}
|
||||||
}
|
if let Some(event_sub) = child_text(service, "eventSubURL") {
|
||||||
if let Some(event_sub) = child_text(service, "eventSubURL") {
|
return Ok(Some(resolve_control_url(location, &event_sub)));
|
||||||
return Ok(Some(resolve_control_url(location, &event_sub)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,28 @@
|
|||||||
use crate::control_point::PlaylistBinding;
|
use crate::music_renderer::{PlaybackPositionInfo, PlaylistBinding};
|
||||||
use crate::media_server::UpnpMediaServer;
|
|
||||||
use crate::music_renderer::PlaybackPositionInfo;
|
|
||||||
use crate::{DeviceId, DeviceIdentity};
|
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.
|
/// High-level playback state across backends.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub enum PlaybackState {
|
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)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct TrackMetadata {
|
pub struct TrackMetadata {
|
||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
@@ -401,7 +436,7 @@ pub enum RendererEvent {
|
|||||||
},
|
},
|
||||||
Online {
|
Online {
|
||||||
id: DeviceId,
|
id: DeviceId,
|
||||||
info: RendererInfo,
|
info: DeviceBasicInfo,
|
||||||
},
|
},
|
||||||
Offline {
|
Offline {
|
||||||
id: DeviceId,
|
id: DeviceId,
|
||||||
@@ -420,7 +455,7 @@ pub enum MediaServerEvent {
|
|||||||
},
|
},
|
||||||
Online {
|
Online {
|
||||||
server_id: DeviceId,
|
server_id: DeviceId,
|
||||||
info: UpnpMediaServer,
|
info: DeviceBasicInfo,
|
||||||
},
|
},
|
||||||
Offline {
|
Offline {
|
||||||
server_id: DeviceId,
|
server_id: DeviceId,
|
||||||
|
|||||||
@@ -4,17 +4,19 @@ use serde::Deserialize;
|
|||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
use crate::DeviceIdentity;
|
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::errors::ControlPointError;
|
||||||
use crate::linkplay_client::extract_linkplay_host;
|
use crate::linkplay_client::extract_linkplay_host;
|
||||||
use crate::model::{PlaybackState, RendererInfo};
|
use crate::model::{PlaybackState, RendererInfo};
|
||||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||||
use crate::music_renderer::capabilities::{
|
use crate::music_renderer::capabilities::{
|
||||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl,
|
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, VolumeControl,
|
||||||
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::musicrenderer::MusicRendererBackend;
|
||||||
|
use crate::music_renderer::time_utils::{format_hhmmss, ms_to_seconds, parse_hhmmss_strict};
|
||||||
|
|
||||||
/// Raw response from Arylic MCU+PINFGET command
|
/// Raw response from Arylic MCU+PINFGET command
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -145,7 +147,7 @@ impl TransportControl for ArylicTcpRenderer {
|
|||||||
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
||||||
let _ = parse_hhmmss_strict(hhmmss)?;
|
let _ = parse_hhmmss_strict(hhmmss)?;
|
||||||
Err(ControlPointError::ArilycTcpError(
|
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))
|
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)
|
let json_end = json_blob.rfind('}').ok_or_else(|| {
|
||||||
.map_err(|e| ControlPointError::ArilycTcpError(format!("Failed to parse Arylic playback info JSON: {}", e)))?;
|
ControlPointError::ArilycTcpError(format!(
|
||||||
|
"No JSON object end found in playback info: {}",
|
||||||
|
payload
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
let curpos_ms = raw.curpos.parse::<u64>()
|
let json_blob = &json_blob[json_start..=json_end];
|
||||||
.map_err(|_| ControlPointError::ArilycTcpError(format!("Invalid curpos value: {}", raw.curpos)))?;
|
|
||||||
|
|
||||||
let totlen_ms = raw.totlen.parse::<u64>()
|
let raw: ArylicPlaybackInfoRaw = serde_json::from_str(json_blob).map_err(|e| {
|
||||||
.map_err(|_| ControlPointError::ArilycTcpError(format!("Invalid totlen value: {}", raw.totlen)))?;
|
ControlPointError::ArilycTcpError(format!(
|
||||||
|
"Failed to parse Arylic playback info JSON: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
let volume = raw.vol.and_then(|raw_vol| {
|
let curpos_ms = raw.curpos.parse::<u64>().map_err(|_| {
|
||||||
match raw_vol.parse::<u16>() {
|
ControlPointError::ArilycTcpError(format!("Invalid curpos value: {}", raw.curpos))
|
||||||
Ok(value) => Some(value.min(100)),
|
})?;
|
||||||
Err(err) => {
|
|
||||||
debug!("Invalid Arylic `vol` value {}: {}", raw_vol, err);
|
let totlen_ms = raw.totlen.parse::<u64>().map_err(|_| {
|
||||||
None
|
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| {
|
let mute = raw.mute.and_then(|raw_mute| match raw_mute.as_str() {
|
||||||
match raw_mute.as_str() {
|
"1" => Some(true),
|
||||||
"1" => Some(true),
|
"0" => Some(false),
|
||||||
"0" => Some(false),
|
other => {
|
||||||
other => {
|
debug!("Invalid Arylic `mute` value {}", other);
|
||||||
debug!("Invalid Arylic `mute` value {}", other);
|
None
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let playlist_size = raw.plicount.and_then(|raw_count| {
|
let playlist_size = raw
|
||||||
match raw_count.parse::<u32>() {
|
.plicount
|
||||||
|
.and_then(|raw_count| match raw_count.parse::<u32>() {
|
||||||
Ok(count) if count > 0 => Some(count),
|
Ok(count) if count > 0 => Some(count),
|
||||||
Ok(_) => None,
|
Ok(_) => None,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
debug!("Invalid Arylic `plicount` value {}: {}", raw_count, err);
|
debug!("Invalid Arylic `plicount` value {}: {}", raw_count, err);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
|
||||||
|
|
||||||
let track_index = raw.plicurr.and_then(|raw_idx| {
|
let track_index = raw
|
||||||
match raw_idx.parse::<u32>() {
|
.plicurr
|
||||||
|
.and_then(|raw_idx| match raw_idx.parse::<u32>() {
|
||||||
Ok(idx) if idx > 0 => Some(idx),
|
Ok(idx) if idx > 0 => Some(idx),
|
||||||
Ok(_) => None,
|
Ok(_) => None,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
debug!("Invalid Arylic `plicurr` value {}: {}", raw_idx, err);
|
debug!("Invalid Arylic `plicurr` value {}: {}", raw_idx, err);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
|
||||||
|
|
||||||
Ok(ArylicPlaybackInfo {
|
Ok(ArylicPlaybackInfo {
|
||||||
status_raw: raw.status,
|
status_raw: raw.status,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ use std::sync::{Arc, Mutex};
|
|||||||
pub use crate::music_renderer::capabilities::{
|
pub use crate::music_renderer::capabilities::{
|
||||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
|
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
|
||||||
};
|
};
|
||||||
pub use crate::music_renderer::musicrenderer::MusicRenderer;
|
pub use crate::music_renderer::musicrenderer::{MusicRenderer, PlaylistBinding};
|
||||||
use crate::{
|
use crate::{
|
||||||
RendererInfo, errors::ControlPointError, music_renderer::musicrenderer::MusicRendererBackend,
|
RendererInfo, errors::ControlPointError, music_renderer::musicrenderer::MusicRendererBackend,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,25 +9,44 @@
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
|
|
||||||
use crate::control_point::PlaylistBinding;
|
|
||||||
use crate::errors::ControlPointError;
|
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::RendererFromMediaRendererInfo;
|
||||||
use crate::music_renderer::arylic_tcp::ArylicTcpRenderer;
|
use crate::music_renderer::arylic_tcp::ArylicTcpRenderer;
|
||||||
use crate::music_renderer::capabilities::{
|
use crate::music_renderer::capabilities::{
|
||||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl,
|
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, VolumeControl,
|
||||||
VolumeControl,
|
|
||||||
};
|
};
|
||||||
use crate::music_renderer::chromecast_renderer::ChromecastRenderer;
|
use crate::music_renderer::chromecast_renderer::ChromecastRenderer;
|
||||||
use crate::music_renderer::linkplay_renderer::LinkPlayRenderer;
|
use crate::music_renderer::linkplay_renderer::LinkPlayRenderer;
|
||||||
use crate::music_renderer::openhome_renderer::OpenHomeRenderer;
|
use crate::music_renderer::openhome_renderer::OpenHomeRenderer;
|
||||||
use crate::music_renderer::upnp_renderer::UpnpRenderer;
|
use crate::music_renderer::upnp_renderer::UpnpRenderer;
|
||||||
use crate::online::DeviceConnectionState;
|
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 crate::{DeviceId, DeviceIdentity, DeviceOnline};
|
||||||
|
|
||||||
use tracing::warn;
|
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.
|
/// Backend-agnostic façade exposing transport, volume, and status contracts.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub enum MusicRendererBackend {
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct MusicRenderer {
|
pub struct MusicRenderer {
|
||||||
info: RendererInfo,
|
info: RendererInfo,
|
||||||
@@ -56,6 +86,7 @@ pub struct MusicRenderer {
|
|||||||
backend: Arc<Mutex<MusicRendererBackend>>,
|
backend: Arc<Mutex<MusicRendererBackend>>,
|
||||||
queue: Arc<Mutex<MusicQueue>>,
|
queue: Arc<Mutex<MusicQueue>>,
|
||||||
playlist_binding: Arc<Mutex<Option<PlaylistBinding>>>,
|
playlist_binding: Arc<Mutex<Option<PlaylistBinding>>>,
|
||||||
|
state: Arc<Mutex<MusicRendererState>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MusicRenderer {
|
impl MusicRenderer {
|
||||||
@@ -72,6 +103,7 @@ impl MusicRenderer {
|
|||||||
backend,
|
backend,
|
||||||
queue,
|
queue,
|
||||||
playlist_binding: Arc::new(Mutex::new(None)),
|
playlist_binding: Arc::new(Mutex::new(None)),
|
||||||
|
state: Arc::new(Mutex::new(MusicRendererState::default())),
|
||||||
};
|
};
|
||||||
|
|
||||||
Arc::new(renderer)
|
Arc::new(renderer)
|
||||||
@@ -88,6 +120,7 @@ impl MusicRenderer {
|
|||||||
backend,
|
backend,
|
||||||
queue,
|
queue,
|
||||||
playlist_binding: Arc::new(Mutex::new(None)),
|
playlist_binding: Arc::new(Mutex::new(None)),
|
||||||
|
state: Arc::new(Mutex::new(MusicRendererState::default())),
|
||||||
};
|
};
|
||||||
Ok(renderer)
|
Ok(renderer)
|
||||||
}
|
}
|
||||||
@@ -179,6 +212,23 @@ impl MusicRenderer {
|
|||||||
.queue_snapshot()
|
.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.
|
/// Play the current item from the queue.
|
||||||
pub fn play_current_from_queue(&self) -> Result<(), ControlPointError> {
|
pub fn play_current_from_queue(&self) -> Result<(), ControlPointError> {
|
||||||
let queue = self.queue.lock().expect("Queue mutex poisoned");
|
let queue = self.queue.lock().expect("Queue mutex poisoned");
|
||||||
@@ -248,6 +298,22 @@ impl MusicRenderer {
|
|||||||
self.backend.lock().expect("Backend mutex poisoned").stop()
|
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
|
/// Transport control: seek to relative time
|
||||||
pub fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
pub fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
||||||
self.backend
|
self.backend
|
||||||
@@ -303,17 +369,126 @@ impl MusicRenderer {
|
|||||||
|
|
||||||
/// Sets the playlist binding for this renderer.
|
/// Sets the playlist binding for this renderer.
|
||||||
pub fn set_playlist_binding(&self, binding: Option<PlaylistBinding>) {
|
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.
|
/// Gets the current playlist binding, if any.
|
||||||
pub fn get_playlist_binding(&self) -> Option<PlaylistBinding> {
|
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.
|
/// Clears the playlist binding.
|
||||||
pub fn clear_playlist_binding(&self) {
|
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.
|
/// 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
|
// The generic implementation cannot support this without more context
|
||||||
// (media_server_id, didl_id, protocol_info, etc.).
|
// (media_server_id, didl_id, protocol_info, etc.).
|
||||||
Err(ControlPointError::QueueError(
|
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))?;
|
queue.set_index(Some(position))?;
|
||||||
|
|
||||||
// Get the item to play
|
// 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()))?;
|
.ok_or_else(|| ControlPointError::QueueError("Track not found".to_string()))?;
|
||||||
drop(queue);
|
drop(queue);
|
||||||
|
|
||||||
@@ -396,7 +572,8 @@ impl MusicRenderer {
|
|||||||
let queue = self.queue.lock().expect("Queue mutex poisoned");
|
let queue = self.queue.lock().expect("Queue mutex poisoned");
|
||||||
|
|
||||||
// Get current track ID using generic QueueBackend trait
|
// 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()))?;
|
.ok_or_else(|| ControlPointError::QueueError("No current track".to_string()))?;
|
||||||
|
|
||||||
drop(queue);
|
drop(queue);
|
||||||
@@ -416,6 +593,57 @@ impl MusicRenderer {
|
|||||||
pub fn get_queue_mut(&self) -> std::sync::MutexGuard<'_, MusicQueue> {
|
pub fn get_queue_mut(&self) -> std::sync::MutexGuard<'_, MusicQueue> {
|
||||||
self.queue.lock().unwrap()
|
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
|
/// Helper function to build DIDL-Lite metadata XML from TrackMetadata
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
use crate::DeviceIdentity;
|
use crate::DeviceIdentity;
|
||||||
use crate::music_renderer::capabilities::{
|
use crate::music_renderer::capabilities::{
|
||||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl,
|
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, VolumeControl,
|
||||||
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::errors::ControlPointError;
|
||||||
use crate::model::{RendererInfo, PlaybackState};
|
use crate::model::{PlaybackState, RendererInfo};
|
||||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||||
use crate::music_renderer::musicrenderer::MusicRendererBackend;
|
use crate::music_renderer::musicrenderer::MusicRendererBackend;
|
||||||
use crate::music_renderer::openhome::{
|
use crate::music_renderer::openhome::{
|
||||||
@@ -49,7 +48,6 @@ impl OpenHomeRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
pub fn has_playlist(&self) -> bool {
|
pub fn has_playlist(&self) -> bool {
|
||||||
self.playlist.is_some()
|
self.playlist.is_some()
|
||||||
}
|
}
|
||||||
@@ -71,10 +69,9 @@ impl OpenHomeRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn playlist_client_for(&self, op: &str) -> Result<&OhPlaylistClient, ControlPointError> {
|
fn playlist_client_for(&self, op: &str) -> Result<&OhPlaylistClient, ControlPointError> {
|
||||||
let playlist = self
|
let playlist = self.playlist.as_ref().ok_or_else(|| {
|
||||||
.playlist
|
ControlPointError::upnp_operation_not_supported(op, "OpenHome Playlist")
|
||||||
.as_ref()
|
})?;
|
||||||
.ok_or_else(|| ControlPointError::upnp_operation_not_supported(op, "OpenHome Playlist"))?;
|
|
||||||
self.ensure_playlist_source_selected()?;
|
self.ensure_playlist_source_selected()?;
|
||||||
Ok(playlist)
|
Ok(playlist)
|
||||||
}
|
}
|
||||||
@@ -97,10 +94,9 @@ impl OpenHomeRenderer {
|
|||||||
.ok_or_else(|| ControlPointError::upnp_operation_not_supported(op, "OpenHome Volume"))
|
.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 {
|
if let Some(product) = &self.product_client {
|
||||||
product
|
product.ensure_playlist_source_selected()
|
||||||
.ensure_playlist_source_selected()
|
|
||||||
} else {
|
} else {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -132,7 +128,6 @@ impl OpenHomeRenderer {
|
|||||||
// })
|
// })
|
||||||
// }
|
// }
|
||||||
|
|
||||||
|
|
||||||
/// Retourne la longueur de la playlist OpenHome sans récupérer toutes les métadonnées.
|
/// 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.
|
/// Plus rapide que snapshot_openhome_playlist() pour juste connaître le nombre de pistes.
|
||||||
pub(crate) fn openhome_playlist_len(&self) -> Result<usize, ControlPointError> {
|
pub(crate) fn openhome_playlist_len(&self) -> Result<usize, ControlPointError> {
|
||||||
@@ -183,11 +178,8 @@ impl OpenHomeRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
impl RendererFromMediaRendererInfo for OpenHomeRenderer {
|
impl RendererFromMediaRendererInfo for OpenHomeRenderer {
|
||||||
fn from_renderer_info(
|
fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||||
info: &RendererInfo,
|
|
||||||
) -> Result<Self, ControlPointError> {
|
|
||||||
let renderer = OpenHomeRenderer::new(
|
let renderer = OpenHomeRenderer::new(
|
||||||
build_playlist_client(&info),
|
build_playlist_client(&info),
|
||||||
build_info_client(&info),
|
build_info_client(&info),
|
||||||
@@ -210,7 +202,6 @@ impl RendererFromMediaRendererInfo for OpenHomeRenderer {
|
|||||||
fn to_backend(self) -> MusicRendererBackend {
|
fn to_backend(self) -> MusicRendererBackend {
|
||||||
MusicRendererBackend::OpenHome(self)
|
MusicRendererBackend::OpenHome(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TransportControl for OpenHomeRenderer {
|
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
|
// Reuse the same insertion logic as the queue path so that we honor
|
||||||
// renderer expectations (IdArray sequencing, etc.).
|
// renderer expectations (IdArray sequencing, etc.).
|
||||||
self.add_track_openhome(uri, meta, None, true)?;
|
self.add_track_openhome(uri, meta, None, true)?;
|
||||||
|
|
||||||
|
// Start playback (like UPnP renderer does with avt.play())
|
||||||
|
playlist.play()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,4 +330,3 @@ pub(crate) fn map_openhome_state(raw: &str) -> PlaybackState {
|
|||||||
other => PlaybackState::Unknown(other.to_string()),
|
other => PlaybackState::Unknown(other.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
use std::{
|
use std::{
|
||||||
sync::{Arc, Mutex},
|
sync::{Arc, Mutex},
|
||||||
time::{SystemTime},
|
time::SystemTime,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone,Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct DeviceConnectionState {
|
pub struct DeviceConnectionState {
|
||||||
online: bool,
|
online: bool,
|
||||||
last_seen: SystemTime,
|
last_seen: SystemTime,
|
||||||
@@ -20,8 +20,11 @@ pub trait DeviceOnline {
|
|||||||
|
|
||||||
impl DeviceConnectionState {
|
impl DeviceConnectionState {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
DeviceConnectionState {
|
DeviceConnectionState {
|
||||||
online: false, last_seen: std::time::UNIX_EPOCH, max_age: 1800 }
|
online: true,
|
||||||
|
last_seen: SystemTime::now(),
|
||||||
|
max_age: 1800,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn make() -> Arc<Mutex<Self>> {
|
pub fn make() -> Arc<Mutex<Self>> {
|
||||||
@@ -43,6 +46,7 @@ impl DeviceConnectionState {
|
|||||||
pub fn has_been_seen_now(&mut self, max_age: u32) {
|
pub fn has_been_seen_now(&mut self, max_age: u32) {
|
||||||
self.last_seen = SystemTime::now();
|
self.last_seen = SystemTime::now();
|
||||||
self.max_age = max_age;
|
self.max_age = max_age;
|
||||||
|
self.online = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn mark_as_offline(&mut self) {
|
pub fn mark_as_offline(&mut self) {
|
||||||
|
|||||||
@@ -149,28 +149,6 @@ pub struct QueueSnapshot {
|
|||||||
pub current_index: Option<usize>,
|
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
|
// MEDIA SERVERS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -318,20 +296,22 @@ pub struct ErrorResponse {
|
|||||||
description = r#"
|
description = r#"
|
||||||
# API REST pour le Control Point PMOMusic
|
# 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
|
## Fonctionnalités
|
||||||
|
|
||||||
### Renderers
|
### 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
|
- **É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
|
- **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
|
### Playlists
|
||||||
- **Binding** : Attachement de la queue à un container playlist d'un serveur
|
- **Binding** : Attachement de la queue à un container playlist d'un serveur
|
||||||
- **Synchronisation automatique** : Mise à jour de la queue lors des changements côté 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
|
### Serveurs de médias
|
||||||
- **Découverte** : Liste des serveurs disponibles
|
- **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
|
## Architecture
|
||||||
|
|
||||||
Le Control Point PMOMusic est un point de contrôle UPnP qui :
|
Le Control Point PMOMusic est un point de contrôle multi-backend qui :
|
||||||
1. Découvre automatiquement les renderers et serveurs via SSDP
|
1. Découvre automatiquement les renderers et serveurs via SSDP et mDNS
|
||||||
2. Maintient un registre des devices actifs
|
2. Maintient un registre unifié des devices actifs
|
||||||
3. Permet le contrôle unifié des renderers (UPnP AV, LinkPlay, Arylic TCP)
|
3. Abstrait les différences entre backends (UPnP AV, OpenHome, LinkPlay, Arylic TCP, Chromecast)
|
||||||
4. Gère une queue de lecture locale avec synchronisation optionnelle
|
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
|
## Exemples d'utilisation
|
||||||
|
|
||||||
@@ -352,12 +333,33 @@ Le Control Point PMOMusic est un point de contrôle UPnP qui :
|
|||||||
GET /control/renderers
|
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}/play
|
||||||
POST /control/renderers/{renderer_id}/pause
|
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
|
POST /control/renderers/{renderer_id}/volume/set
|
||||||
Body: {"volume": 50}
|
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
|
### Attacher une playlist
|
||||||
@@ -365,7 +367,17 @@ POST /control/renderers/{renderer_id}/volume/set
|
|||||||
POST /control/renderers/{renderer_id}/binding/attach
|
POST /control/renderers/{renderer_id}/binding/attach
|
||||||
Body: {
|
Body: {
|
||||||
"server_id": "uuid:...",
|
"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(
|
paths(
|
||||||
crate::pmoserver_ext::list_renderers,
|
crate::pmoserver_ext::list_renderers,
|
||||||
crate::pmoserver_ext::get_renderer_state,
|
crate::pmoserver_ext::get_renderer_state,
|
||||||
|
crate::pmoserver_ext::get_renderer_full_snapshot,
|
||||||
crate::pmoserver_ext::get_renderer_queue,
|
crate::pmoserver_ext::get_renderer_queue,
|
||||||
crate::pmoserver_ext::get_renderer_binding,
|
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::play_renderer,
|
||||||
crate::pmoserver_ext::pause_renderer,
|
crate::pmoserver_ext::pause_renderer,
|
||||||
crate::pmoserver_ext::stop_renderer,
|
crate::pmoserver_ext::stop_renderer,
|
||||||
|
crate::pmoserver_ext::resume_renderer,
|
||||||
crate::pmoserver_ext::next_renderer,
|
crate::pmoserver_ext::next_renderer,
|
||||||
|
crate::pmoserver_ext::seek_queue_index,
|
||||||
crate::pmoserver_ext::set_renderer_volume,
|
crate::pmoserver_ext::set_renderer_volume,
|
||||||
crate::pmoserver_ext::volume_up_renderer,
|
crate::pmoserver_ext::volume_up_renderer,
|
||||||
crate::pmoserver_ext::volume_down_renderer,
|
crate::pmoserver_ext::volume_down_renderer,
|
||||||
@@ -415,17 +426,16 @@ GET /control/servers/{server_id}/containers/{container_id}
|
|||||||
RendererState,
|
RendererState,
|
||||||
CurrentTrackMetadata,
|
CurrentTrackMetadata,
|
||||||
AttachedPlaylistInfo,
|
AttachedPlaylistInfo,
|
||||||
|
FullRendererSnapshot,
|
||||||
QueueItem,
|
QueueItem,
|
||||||
QueueSnapshot,
|
QueueSnapshot,
|
||||||
OpenHomePlaylistSnapshot,
|
|
||||||
OpenHomePlaylistTrack,
|
|
||||||
OpenHomePlaylistAddRequest,
|
|
||||||
MediaServerSummary,
|
MediaServerSummary,
|
||||||
ContainerEntry,
|
ContainerEntry,
|
||||||
BrowseResponse,
|
BrowseResponse,
|
||||||
VolumeSetRequest,
|
VolumeSetRequest,
|
||||||
AttachPlaylistRequest,
|
AttachPlaylistRequest,
|
||||||
PlayContentRequest,
|
PlayContentRequest,
|
||||||
|
SeekQueueRequest,
|
||||||
SuccessResponse,
|
SuccessResponse,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
)),
|
)),
|
||||||
|
|||||||
@@ -4,27 +4,22 @@
|
|||||||
//! et naviguer dans les serveurs de médias.
|
//! et naviguer dans les serveurs de médias.
|
||||||
|
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use crate::control_point::{
|
use crate::control_point::ControlPoint;
|
||||||
ControlPoint, OpenHomeAccessError, OPENHOME_SNAPSHOT_CACHE_TTL,
|
|
||||||
};
|
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use crate::media_server::{
|
use crate::media_server::{MediaBrowser, playback_item_from_entry};
|
||||||
playback_item_from_entry, MediaBrowser, MediaEntry, UpnpMediaServer, ServerId,
|
|
||||||
};
|
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use crate::model::{RendererCapabilities, ServiceId, RendererProtocol, TrackMetadata};
|
use crate::model::{RendererCapabilities, RendererProtocol};
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use crate::openapi::{
|
use crate::openapi::{
|
||||||
AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry, ErrorResponse,
|
AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry, ErrorResponse,
|
||||||
FullRendererSnapshot, MediaServerSummary, OpenHomePlaylistAddRequest, OpenHomePlaylistSnapshot,
|
FullRendererSnapshot, MediaServerSummary, PlayContentRequest, QueueSnapshot,
|
||||||
PlayContentRequest, QueueItem, QueueSnapshot, RendererCapabilitiesSummary,
|
RendererCapabilitiesSummary, RendererProtocolSummary, RendererState, RendererSummary,
|
||||||
RendererProtocolSummary, RendererState, RendererSummary, SeekQueueRequest, SuccessResponse,
|
SeekQueueRequest, SuccessResponse, VolumeSetRequest,
|
||||||
VolumeSetRequest,
|
|
||||||
};
|
};
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use crate::queue_backend::PlaybackItem;
|
use crate::queue::PlaybackItem;
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use crate::{PlaybackPosition, PlaybackStatus, TransportControl, VolumeControl};
|
use crate::{DeviceId, DeviceIdentity, DeviceOnline};
|
||||||
|
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@@ -49,8 +44,6 @@ use utoipa::OpenApi;
|
|||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
const BROWSE_PAGE_SIZE: u32 = 100;
|
const BROWSE_PAGE_SIZE: u32 = 100;
|
||||||
#[cfg(feature = "pmoserver")]
|
#[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);
|
const BROWSE_REQUEST_TIMEOUT: Duration = Duration::from_secs(20);
|
||||||
|
|
||||||
// Timeouts for simple commands (play/pause/stop)
|
// Timeouts for simple commands (play/pause/stop)
|
||||||
@@ -105,12 +98,12 @@ async fn list_renderers(State(state): State<ControlPointState>) -> Json<Vec<Rend
|
|||||||
.map(|r| {
|
.map(|r| {
|
||||||
let info = r.info();
|
let info = r.info();
|
||||||
RendererSummary {
|
RendererSummary {
|
||||||
id: info.id.0.clone(),
|
id: r.id().0.clone(),
|
||||||
friendly_name: info.friendly_name.clone(),
|
friendly_name: r.friendly_name().to_string(),
|
||||||
model_name: info.model_name.clone(),
|
model_name: r.model_name().to_string(),
|
||||||
protocol: protocol_summary(&info.protocol),
|
protocol: protocol_summary(&info.protocol()),
|
||||||
capabilities: capability_summary(&info.capabilities),
|
capabilities: capability_summary(&info.capabilities()),
|
||||||
online: info.online,
|
online: r.is_online(),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -136,7 +129,7 @@ async fn get_renderer_state(
|
|||||||
State(state): State<ControlPointState>,
|
State(state): State<ControlPointState>,
|
||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
) -> Result<Json<RendererState>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<RendererState>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = ServiceId(renderer_id.clone());
|
let rid = DeviceId(renderer_id.clone());
|
||||||
let snapshot = state
|
let snapshot = state
|
||||||
.control_point
|
.control_point
|
||||||
.renderer_full_snapshot(&rid)
|
.renderer_full_snapshot(&rid)
|
||||||
@@ -162,7 +155,7 @@ async fn get_renderer_full_snapshot(
|
|||||||
State(state): State<ControlPointState>,
|
State(state): State<ControlPointState>,
|
||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
) -> Result<Json<FullRendererSnapshot>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<FullRendererSnapshot>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = ServiceId(renderer_id.clone());
|
let rid = DeviceId(renderer_id.clone());
|
||||||
let snapshot = state
|
let snapshot = state
|
||||||
.control_point
|
.control_point
|
||||||
.renderer_full_snapshot(&rid)
|
.renderer_full_snapshot(&rid)
|
||||||
@@ -193,7 +186,7 @@ async fn get_renderer_queue(
|
|||||||
State(state): State<ControlPointState>,
|
State(state): State<ControlPointState>,
|
||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
) -> Result<Json<QueueSnapshot>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<QueueSnapshot>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = ServiceId(renderer_id.clone());
|
let rid = DeviceId(renderer_id.clone());
|
||||||
let snapshot = state
|
let snapshot = state
|
||||||
.control_point
|
.control_point
|
||||||
.renderer_full_snapshot(&rid)
|
.renderer_full_snapshot(&rid)
|
||||||
@@ -220,7 +213,7 @@ async fn get_renderer_binding(
|
|||||||
State(state): State<ControlPointState>,
|
State(state): State<ControlPointState>,
|
||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
) -> Result<Json<Option<AttachedPlaylistInfo>>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<Option<AttachedPlaylistInfo>>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = ServiceId(renderer_id.clone());
|
let rid = DeviceId(renderer_id.clone());
|
||||||
let snapshot = state
|
let snapshot = state
|
||||||
.control_point
|
.control_point
|
||||||
.renderer_full_snapshot(&rid)
|
.renderer_full_snapshot(&rid)
|
||||||
@@ -252,7 +245,7 @@ async fn play_renderer(
|
|||||||
State(state): State<ControlPointState>,
|
State(state): State<ControlPointState>,
|
||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = ServiceId(renderer_id.clone());
|
let rid = DeviceId(renderer_id.clone());
|
||||||
let renderer = state
|
let renderer = state
|
||||||
.control_point
|
.control_point
|
||||||
.music_renderer_by_id(&rid)
|
.music_renderer_by_id(&rid)
|
||||||
@@ -328,7 +321,7 @@ async fn pause_renderer(
|
|||||||
State(state): State<ControlPointState>,
|
State(state): State<ControlPointState>,
|
||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = ServiceId(renderer_id.clone());
|
let rid = DeviceId(renderer_id.clone());
|
||||||
let renderer = state
|
let renderer = state
|
||||||
.control_point
|
.control_point
|
||||||
.music_renderer_by_id(&rid)
|
.music_renderer_by_id(&rid)
|
||||||
@@ -404,7 +397,7 @@ async fn stop_renderer(
|
|||||||
State(state): State<ControlPointState>,
|
State(state): State<ControlPointState>,
|
||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = ServiceId(renderer_id.clone());
|
let rid = DeviceId(renderer_id.clone());
|
||||||
state
|
state
|
||||||
.control_point
|
.control_point
|
||||||
.music_renderer_by_id(&rid)
|
.music_renderer_by_id(&rid)
|
||||||
@@ -481,7 +474,7 @@ async fn resume_renderer(
|
|||||||
State(state): State<ControlPointState>,
|
State(state): State<ControlPointState>,
|
||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = ServiceId(renderer_id.clone());
|
let rid = DeviceId(renderer_id.clone());
|
||||||
state
|
state
|
||||||
.control_point
|
.control_point
|
||||||
.music_renderer_by_id(&rid)
|
.music_renderer_by_id(&rid)
|
||||||
@@ -562,7 +555,7 @@ async fn next_renderer(
|
|||||||
State(state): State<ControlPointState>,
|
State(state): State<ControlPointState>,
|
||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = ServiceId(renderer_id.clone());
|
let rid = DeviceId(renderer_id.clone());
|
||||||
state
|
state
|
||||||
.control_point
|
.control_point
|
||||||
.music_renderer_by_id(&rid)
|
.music_renderer_by_id(&rid)
|
||||||
@@ -643,7 +636,7 @@ async fn seek_queue_index(
|
|||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
Json(payload): Json<SeekQueueRequest>,
|
Json(payload): Json<SeekQueueRequest>,
|
||||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = ServiceId(renderer_id.clone());
|
let rid = DeviceId(renderer_id.clone());
|
||||||
state
|
state
|
||||||
.control_point
|
.control_point
|
||||||
.music_renderer_by_id(&rid)
|
.music_renderer_by_id(&rid)
|
||||||
@@ -659,9 +652,8 @@ async fn seek_queue_index(
|
|||||||
let control_point = Arc::clone(&state.control_point);
|
let control_point = Arc::clone(&state.control_point);
|
||||||
let rid_for_task = rid.clone();
|
let rid_for_task = rid.clone();
|
||||||
let index = payload.index;
|
let index = payload.index;
|
||||||
let seek_task = tokio::task::spawn_blocking(move || {
|
let seek_task =
|
||||||
control_point.play_queue_index(&rid_for_task, index)
|
tokio::task::spawn_blocking(move || control_point.play_queue_index(&rid_for_task, index));
|
||||||
});
|
|
||||||
|
|
||||||
time::timeout(TRANSPORT_COMMAND_TIMEOUT, seek_task)
|
time::timeout(TRANSPORT_COMMAND_TIMEOUT, seek_task)
|
||||||
.await
|
.await
|
||||||
@@ -729,7 +721,7 @@ async fn set_renderer_volume(
|
|||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
Json(req): Json<VolumeSetRequest>,
|
Json(req): Json<VolumeSetRequest>,
|
||||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = ServiceId(renderer_id.clone());
|
let rid = DeviceId(renderer_id.clone());
|
||||||
|
|
||||||
let renderer = state
|
let renderer = state
|
||||||
.control_point
|
.control_point
|
||||||
@@ -807,7 +799,7 @@ async fn volume_up_renderer(
|
|||||||
State(state): State<ControlPointState>,
|
State(state): State<ControlPointState>,
|
||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = ServiceId(renderer_id.clone());
|
let rid = DeviceId(renderer_id.clone());
|
||||||
|
|
||||||
let renderer = state
|
let renderer = state
|
||||||
.control_point
|
.control_point
|
||||||
@@ -892,7 +884,7 @@ async fn volume_down_renderer(
|
|||||||
State(state): State<ControlPointState>,
|
State(state): State<ControlPointState>,
|
||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = ServiceId(renderer_id.clone());
|
let rid = DeviceId(renderer_id.clone());
|
||||||
|
|
||||||
let renderer = state
|
let renderer = state
|
||||||
.control_point
|
.control_point
|
||||||
@@ -977,7 +969,7 @@ async fn toggle_mute_renderer(
|
|||||||
State(state): State<ControlPointState>,
|
State(state): State<ControlPointState>,
|
||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = ServiceId(renderer_id.clone());
|
let rid = DeviceId(renderer_id.clone());
|
||||||
|
|
||||||
let renderer = state
|
let renderer = state
|
||||||
.control_point
|
.control_point
|
||||||
@@ -1064,8 +1056,8 @@ async fn attach_playlist_binding(
|
|||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
Json(req): Json<AttachPlaylistRequest>,
|
Json(req): Json<AttachPlaylistRequest>,
|
||||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = ServiceId(renderer_id.clone());
|
let rid = DeviceId(renderer_id.clone());
|
||||||
let sid = ServerId(req.server_id.clone());
|
let sid = DeviceId(req.server_id.clone());
|
||||||
let container_id = req.container_id.clone();
|
let container_id = req.container_id.clone();
|
||||||
let control_point = Arc::clone(&state.control_point);
|
let control_point = Arc::clone(&state.control_point);
|
||||||
|
|
||||||
@@ -1146,7 +1138,7 @@ async fn detach_playlist_binding(
|
|||||||
State(state): State<ControlPointState>,
|
State(state): State<ControlPointState>,
|
||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
) -> Json<SuccessResponse> {
|
) -> Json<SuccessResponse> {
|
||||||
let rid = ServiceId(renderer_id.clone());
|
let rid = DeviceId(renderer_id.clone());
|
||||||
|
|
||||||
state.control_point.detach_queue_playlist(&rid);
|
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
|
// HANDLERS - QUEUE CONTENT
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -1477,8 +1177,8 @@ async fn play_content(
|
|||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
Json(req): Json<PlayContentRequest>,
|
Json(req): Json<PlayContentRequest>,
|
||||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = ServiceId(renderer_id.clone());
|
let rid = DeviceId(renderer_id.clone());
|
||||||
let sid = ServerId(req.server_id.clone());
|
let sid = DeviceId(req.server_id.clone());
|
||||||
let object_id = req.object_id.clone();
|
let object_id = req.object_id.clone();
|
||||||
let object_id_for_log = 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>,
|
Path(renderer_id): Path<String>,
|
||||||
Json(req): Json<PlayContentRequest>,
|
Json(req): Json<PlayContentRequest>,
|
||||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = ServiceId(renderer_id.clone());
|
let rid = DeviceId(renderer_id.clone());
|
||||||
let sid = ServerId(req.server_id.clone());
|
let sid = DeviceId(req.server_id.clone());
|
||||||
let object_id = req.object_id.clone();
|
let object_id = req.object_id.clone();
|
||||||
let object_id_for_log = object_id.clone();
|
let object_id_for_log = object_id.clone();
|
||||||
|
|
||||||
@@ -1707,15 +1407,15 @@ async fn add_to_queue(
|
|||||||
tag = "control"
|
tag = "control"
|
||||||
)]
|
)]
|
||||||
async fn list_servers(State(state): State<ControlPointState>) -> Json<Vec<MediaServerSummary>> {
|
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
|
let summaries: Vec<MediaServerSummary> = servers
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|s| MediaServerSummary {
|
.map(|s| MediaServerSummary {
|
||||||
id: s.id.0,
|
id: s.id().0.clone(),
|
||||||
friendly_name: s.friendly_name,
|
friendly_name: s.friendly_name().to_string(),
|
||||||
model_name: s.model_name,
|
model_name: s.model_name().to_string(),
|
||||||
online: s.online,
|
online: s.is_online(),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -1742,9 +1442,9 @@ async fn browse_container(
|
|||||||
State(state): State<ControlPointState>,
|
State(state): State<ControlPointState>,
|
||||||
Path((server_id, container_id)): Path<(String, String)>,
|
Path((server_id, container_id)): Path<(String, String)>,
|
||||||
) -> Result<Json<BrowseResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> 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,
|
StatusCode::NOT_FOUND,
|
||||||
Json(ErrorResponse {
|
Json(ErrorResponse {
|
||||||
@@ -1753,7 +1453,7 @@ async fn browse_container(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if !server_info.online {
|
if !server.is_online() {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
Json(ErrorResponse {
|
Json(ErrorResponse {
|
||||||
@@ -1762,7 +1462,7 @@ async fn browse_container(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if !server_info.has_content_directory {
|
if !server.has_content_directory() {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::NOT_IMPLEMENTED,
|
StatusCode::NOT_IMPLEMENTED,
|
||||||
Json(ErrorResponse {
|
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
|
// Use spawn_blocking to avoid blocking the async runtime with synchronous SOAP calls
|
||||||
let container_id_clone = container_id.clone();
|
let container_id_clone = container_id.clone();
|
||||||
|
let server_clone = server.clone();
|
||||||
let browse_task = tokio::task::spawn_blocking(move || {
|
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)
|
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).
|
/// 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.
|
/// 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")]
|
#[cfg(feature = "pmoserver")]
|
||||||
fn fetch_playback_items(
|
fn fetch_playback_items(
|
||||||
control_point: &ControlPoint,
|
control_point: &ControlPoint,
|
||||||
server_id: &ServerId,
|
server_id: &DeviceId,
|
||||||
object_id: &str,
|
object_id: &str,
|
||||||
) -> anyhow::Result<Vec<PlaybackItem>> {
|
) -> anyhow::Result<Vec<PlaybackItem>> {
|
||||||
// Get server info from registry
|
// Get server from registry
|
||||||
let server_info = control_point
|
let server = control_point
|
||||||
.media_server(server_id)
|
.media_server(server_id)
|
||||||
.ok_or_else(|| anyhow::anyhow!("Server {} not found", server_id.0))?;
|
.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));
|
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!(
|
return Err(anyhow::anyhow!(
|
||||||
"Server {} does not support ContentDirectory",
|
"Server {} does not support ContentDirectory",
|
||||||
server_id.0
|
server_id.0
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create MusicServer
|
|
||||||
let music_server = UpnpMediaServer::from_info(&server_info, MEDIA_SERVER_SOAP_TIMEOUT)?;
|
|
||||||
|
|
||||||
// Browse the object to get entries
|
// 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!(
|
debug!(
|
||||||
server_id = server_id.0.as_str(),
|
server_id = server_id.0.as_str(),
|
||||||
@@ -1939,7 +1600,7 @@ fn fetch_playback_items(
|
|||||||
// Convert to PlaybackItem
|
// Convert to PlaybackItem
|
||||||
let items: Vec<PlaybackItem> = entries
|
let items: Vec<PlaybackItem> = entries
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|entry| playback_item_from_entry(&music_server, entry))
|
.filter_map(|entry| playback_item_from_entry(server.clone(), entry))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if items.is_empty() && !entries.is_empty() {
|
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}/resume", post(resume_renderer))
|
||||||
.route("/renderers/{renderer_id}/next", post(next_renderer))
|
.route("/renderers/{renderer_id}/next", post(next_renderer))
|
||||||
// Queue control
|
// Queue control
|
||||||
.route("/renderers/{renderer_id}/queue/seek", post(seek_queue_index))
|
.route(
|
||||||
|
"/renderers/{renderer_id}/queue/seek",
|
||||||
|
post(seek_queue_index),
|
||||||
|
)
|
||||||
// Volume control
|
// Volume control
|
||||||
.route(
|
.route(
|
||||||
"/renderers/{renderer_id}/volume/set",
|
"/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",
|
"/renderers/{renderer_id}/binding/detach",
|
||||||
post(detach_playlist_binding),
|
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
|
// Queue content
|
||||||
.route("/renderers/{renderer_id}/queue/play", post(play_content))
|
.route("/renderers/{renderer_id}/queue/play", post(play_content))
|
||||||
.route("/renderers/{renderer_id}/queue/add", post(add_to_queue))
|
.route("/renderers/{renderer_id}/queue/add", post(add_to_queue))
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
mod music_queue;
|
|
||||||
mod backend;
|
mod backend;
|
||||||
mod snapshot;
|
|
||||||
mod openhome;
|
|
||||||
mod interne;
|
mod interne;
|
||||||
|
mod music_queue;
|
||||||
|
mod openhome;
|
||||||
|
mod snapshot;
|
||||||
|
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
pub use backend::{EnqueueMode, QueueBackend};
|
||||||
pub use music_queue::MusicQueue;
|
pub use music_queue::MusicQueue;
|
||||||
pub use backend::{QueueBackend, EnqueueMode};
|
|
||||||
pub use snapshot::{PlaybackItem, QueueSnapshot};
|
pub use snapshot::{PlaybackItem, QueueSnapshot};
|
||||||
|
|
||||||
// Internal queue implementations - not part of the public API
|
// Internal queue implementations - not part of the public API
|
||||||
pub(crate) use openhome::OpenHomeQueue;
|
|
||||||
pub(crate) use interne::InternalQueue;
|
pub(crate) use interne::InternalQueue;
|
||||||
|
pub(crate) use openhome::OpenHomeQueue;
|
||||||
|
|
||||||
use crate::{RendererInfo, errors::ControlPointError};
|
use crate::{RendererInfo, errors::ControlPointError};
|
||||||
|
|
||||||
@@ -23,9 +23,7 @@ pub trait QueueFromRendererInfo {
|
|||||||
|
|
||||||
fn to_backend(self) -> MusicQueue;
|
fn to_backend(self) -> MusicQueue;
|
||||||
|
|
||||||
fn build_from_renderer_info(
|
fn build_from_renderer_info(renderer: &RendererInfo) -> Result<MusicQueue, ControlPointError>
|
||||||
renderer: &RendererInfo,
|
|
||||||
) -> Result<MusicQueue, ControlPointError>
|
|
||||||
where
|
where
|
||||||
Self: Sized,
|
Self: Sized,
|
||||||
{
|
{
|
||||||
@@ -42,4 +40,4 @@ pub trait QueueFromRendererInfo {
|
|||||||
let backend = Self::build_from_renderer_info(renderer)?;
|
let backend = Self::build_from_renderer_info(renderer)?;
|
||||||
Ok(Arc::new(Mutex::new(backend)))
|
Ok(Arc::new(Mutex::new(backend)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ use tracing::debug;
|
|||||||
|
|
||||||
use crate::errors::ControlPointError;
|
use crate::errors::ControlPointError;
|
||||||
use crate::upnp_clients::{
|
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::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
|
||||||
use crate::queue::{
|
use crate::queue::{
|
||||||
@@ -253,8 +254,8 @@ impl OpenHomeQueue {
|
|||||||
self.delete_marked_items(&old_ids_before, &keep_old_before, "BEFORE pivot")?;
|
self.delete_marked_items(&old_ids_before, &keep_old_before, "BEFORE pivot")?;
|
||||||
|
|
||||||
// Rebuild the playlist: [BEFORE, PIVOT, AFTER]
|
// Rebuild the playlist: [BEFORE, PIVOT, AFTER]
|
||||||
// Rebuild BEFORE part
|
// Rebuild BEFORE part (we don't need the returned previous_id)
|
||||||
let previous_id = self.rebuild_playlist_section(
|
self.rebuild_playlist_section(
|
||||||
new_before,
|
new_before,
|
||||||
&keep_new_before,
|
&keep_new_before,
|
||||||
&old_ids_before,
|
&old_ids_before,
|
||||||
@@ -263,7 +264,7 @@ impl OpenHomeQueue {
|
|||||||
"BEFORE pivot",
|
"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;
|
let previous_id = pivot_id as u32;
|
||||||
debug!(
|
debug!(
|
||||||
renderer = self.renderer_id.0.as_str(),
|
renderer = self.renderer_id.0.as_str(),
|
||||||
@@ -513,7 +514,9 @@ impl QueueBackend for OpenHomeQueue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
|
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> {
|
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
|
||||||
|
|||||||
@@ -106,12 +106,12 @@ impl DeviceOnline for DeviceItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DeviceRegistry {
|
impl DeviceRegistry {
|
||||||
pub fn new(renderer_bus: RendererEventBus, server_bus: MediaServerEventBus) -> Self {
|
pub fn new(renderer_bus: &RendererEventBus, server_bus: &MediaServerEventBus) -> Self {
|
||||||
Self {
|
Self {
|
||||||
devices: HashMap::new(),
|
devices: HashMap::new(),
|
||||||
udn_index: HashMap::new(),
|
udn_index: HashMap::new(),
|
||||||
renderer_bus,
|
renderer_bus: renderer_bus.clone(),
|
||||||
server_bus,
|
server_bus: server_bus.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,8 +168,8 @@ impl DeviceRegistry {
|
|||||||
|
|
||||||
if !was_online {
|
if !was_online {
|
||||||
self.renderer_bus.broadcast(RendererEvent::Online {
|
self.renderer_bus.broadcast(RendererEvent::Online {
|
||||||
id: device_id,
|
id: device_id.clone(),
|
||||||
info: info.clone(),
|
info: info.basic_info(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -182,8 +182,8 @@ impl DeviceRegistry {
|
|||||||
|
|
||||||
// Broadcast sur le bon bus
|
// Broadcast sur le bon bus
|
||||||
self.renderer_bus.broadcast(RendererEvent::Online {
|
self.renderer_bus.broadcast(RendererEvent::Online {
|
||||||
id: device_id,
|
id: device_id.clone(),
|
||||||
info: info.clone(),
|
info: info.basic_info(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -201,7 +201,7 @@ impl DeviceRegistry {
|
|||||||
// Broadcast sur le bon bus
|
// Broadcast sur le bon bus
|
||||||
self.renderer_bus.broadcast(RendererEvent::Online {
|
self.renderer_bus.broadcast(RendererEvent::Online {
|
||||||
id: device_id,
|
id: device_id,
|
||||||
info: info.clone(),
|
info: info.basic_info(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,8 +217,8 @@ impl DeviceRegistry {
|
|||||||
|
|
||||||
if !was_online {
|
if !was_online {
|
||||||
self.server_bus.broadcast(MediaServerEvent::Online {
|
self.server_bus.broadcast(MediaServerEvent::Online {
|
||||||
server_id: device_id,
|
server_id: device_id.clone(),
|
||||||
info: info.clone(),
|
info: info.basic_info(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,8 +232,8 @@ impl DeviceRegistry {
|
|||||||
|
|
||||||
// Broadcast sur le bon bus
|
// Broadcast sur le bon bus
|
||||||
self.server_bus.broadcast(MediaServerEvent::Online {
|
self.server_bus.broadcast(MediaServerEvent::Online {
|
||||||
server_id: device_id,
|
server_id: device_id.clone(),
|
||||||
info: info.clone(),
|
info: info.basic_info(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -251,7 +251,7 @@ impl DeviceRegistry {
|
|||||||
// Broadcast sur le bon bus
|
// Broadcast sur le bon bus
|
||||||
self.server_bus.broadcast(MediaServerEvent::Online {
|
self.server_bus.broadcast(MediaServerEvent::Online {
|
||||||
server_id: device_id,
|
server_id: device_id,
|
||||||
info: info.clone(),
|
info: info.basic_info(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ use crate::control_point::ControlPoint;
|
|||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use crate::model::{MediaServerEvent, RendererEvent};
|
use crate::model::{MediaServerEvent, RendererEvent};
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use crate::registry::DeviceRegistryRead;
|
|
||||||
#[cfg(feature = "pmoserver")]
|
|
||||||
use async_stream::stream;
|
use async_stream::stream;
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -34,6 +32,9 @@ use serde::Serialize;
|
|||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::{DeviceIdentity, DeviceOnline};
|
||||||
|
use tracing::error;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// PAYLOADS SSE
|
// PAYLOADS SSE
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -175,17 +176,23 @@ pub async fn renderer_events_sse(
|
|||||||
let initial_renderers = {
|
let initial_renderers = {
|
||||||
let registry = control_point.registry();
|
let registry = control_point.registry();
|
||||||
let reg = registry.read().unwrap();
|
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 {
|
for renderer in initial_renderers {
|
||||||
if info.is_online() {
|
if renderer.is_online() {
|
||||||
let timestamp = chrono::Utc::now();
|
let timestamp = chrono::Utc::now();
|
||||||
let payload = RendererEventPayload::Online {
|
let payload = RendererEventPayload::Online {
|
||||||
renderer_id: info.id().0,
|
renderer_id: renderer.id().0,
|
||||||
friendly_name: info.friendly_name().to_string(),
|
friendly_name: renderer.friendly_name().to_string(),
|
||||||
model_name: info. ().to_string(),
|
model_name: renderer.model_name().to_string(),
|
||||||
manufacturer: info.manufacturer().to_string(),
|
manufacturer: renderer.manufacturer().to_string(),
|
||||||
timestamp,
|
timestamp,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -258,9 +265,9 @@ pub async fn renderer_events_sse(
|
|||||||
RendererEvent::Online { id, info } => {
|
RendererEvent::Online { id, info } => {
|
||||||
RendererEventPayload::Online {
|
RendererEventPayload::Online {
|
||||||
renderer_id: id.0,
|
renderer_id: id.0,
|
||||||
friendly_name: info.friendly_name,
|
friendly_name: info.friendly_name.clone(),
|
||||||
model_name: info.model_name,
|
model_name: info.model_name.clone(),
|
||||||
manufacturer: info.manufacturer,
|
manufacturer: info.manufacturer.clone(),
|
||||||
timestamp,
|
timestamp,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -318,17 +325,23 @@ pub async fn media_server_events_sse(
|
|||||||
let initial_servers = {
|
let initial_servers = {
|
||||||
let registry = control_point.registry();
|
let registry = control_point.registry();
|
||||||
let reg = registry.read().unwrap();
|
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 {
|
for server in initial_servers {
|
||||||
if info.online {
|
if server.is_online() {
|
||||||
let timestamp = chrono::Utc::now();
|
let timestamp = chrono::Utc::now();
|
||||||
let payload = MediaServerEventPayload::Online {
|
let payload = MediaServerEventPayload::Online {
|
||||||
server_id: info.id.0.clone(),
|
server_id: server.id().0,
|
||||||
friendly_name: info.friendly_name.clone(),
|
friendly_name: server.friendly_name().to_string(),
|
||||||
model_name: info.model_name.clone(),
|
model_name: server.model_name().to_string(),
|
||||||
manufacturer: info.manufacturer.clone(),
|
manufacturer: server.manufacturer().to_string(),
|
||||||
timestamp,
|
timestamp,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -360,9 +373,9 @@ pub async fn media_server_events_sse(
|
|||||||
MediaServerEvent::Online { server_id, info } => {
|
MediaServerEvent::Online { server_id, info } => {
|
||||||
MediaServerEventPayload::Online {
|
MediaServerEventPayload::Online {
|
||||||
server_id: server_id.0,
|
server_id: server_id.0,
|
||||||
friendly_name: info.friendly_name,
|
friendly_name: info.friendly_name.clone(),
|
||||||
model_name: info.model_name,
|
model_name: info.model_name.clone(),
|
||||||
manufacturer: info.manufacturer,
|
manufacturer: info.manufacturer.clone(),
|
||||||
timestamp,
|
timestamp,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -429,18 +442,32 @@ pub async fn all_events_sse(State(control_point): State<Arc<ControlPoint>>) -> i
|
|||||||
let (initial_renderers, initial_servers) = {
|
let (initial_renderers, initial_servers) = {
|
||||||
let registry = control_point.registry();
|
let registry = control_point.registry();
|
||||||
let reg = registry.read().unwrap();
|
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
|
// Send renderer Online events
|
||||||
for info in initial_renderers {
|
for renderer in initial_renderers {
|
||||||
if info.online {
|
if renderer.is_online() {
|
||||||
let timestamp = chrono::Utc::now();
|
let timestamp = chrono::Utc::now();
|
||||||
let renderer_payload = RendererEventPayload::Online {
|
let renderer_payload = RendererEventPayload::Online {
|
||||||
renderer_id: info.id.0.clone(),
|
renderer_id: renderer.id().0,
|
||||||
friendly_name: info.friendly_name.clone(),
|
friendly_name: renderer.friendly_name().to_string(),
|
||||||
model_name: info.model_name.clone(),
|
model_name: renderer.model_name().to_string(),
|
||||||
manufacturer: info.manufacturer.clone(),
|
manufacturer: renderer.manufacturer().to_string(),
|
||||||
timestamp,
|
timestamp,
|
||||||
};
|
};
|
||||||
let payload = UnifiedEventPayload::Renderer(renderer_payload);
|
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
|
// Send server Online events
|
||||||
for info in initial_servers {
|
for server in initial_servers {
|
||||||
if info.online {
|
if server.is_online() {
|
||||||
let timestamp = chrono::Utc::now();
|
let timestamp = chrono::Utc::now();
|
||||||
let server_payload = MediaServerEventPayload::Online {
|
let server_payload = MediaServerEventPayload::Online {
|
||||||
server_id: info.id.0.clone(),
|
server_id: server.id().0,
|
||||||
friendly_name: info.friendly_name.clone(),
|
friendly_name: server.friendly_name().to_string(),
|
||||||
model_name: info.model_name.clone(),
|
model_name: server.model_name().to_string(),
|
||||||
manufacturer: info.manufacturer.clone(),
|
manufacturer: server.manufacturer().to_string(),
|
||||||
timestamp,
|
timestamp,
|
||||||
};
|
};
|
||||||
let payload = UnifiedEventPayload::MediaServer(server_payload);
|
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 } => {
|
RendererEvent::Online { id, info } => {
|
||||||
RendererEventPayload::Online {
|
RendererEventPayload::Online {
|
||||||
renderer_id: id.0,
|
renderer_id: id.0,
|
||||||
friendly_name: info.friendly_name,
|
friendly_name: info.friendly_name.clone(),
|
||||||
model_name: info.model_name,
|
model_name: info.model_name.clone(),
|
||||||
manufacturer: info.manufacturer,
|
manufacturer: info.manufacturer.clone(),
|
||||||
timestamp,
|
timestamp,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -576,9 +603,9 @@ pub async fn all_events_sse(State(control_point): State<Arc<ControlPoint>>) -> i
|
|||||||
MediaServerEvent::Online { server_id, info } => {
|
MediaServerEvent::Online { server_id, info } => {
|
||||||
MediaServerEventPayload::Online {
|
MediaServerEventPayload::Online {
|
||||||
server_id: server_id.0,
|
server_id: server_id.0,
|
||||||
friendly_name: info.friendly_name,
|
friendly_name: info.friendly_name.clone(),
|
||||||
model_name: info.model_name,
|
model_name: info.model_name.clone(),
|
||||||
manufacturer: info.manufacturer,
|
manufacturer: info.manufacturer.clone(),
|
||||||
timestamp,
|
timestamp,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ use std::time::Duration;
|
|||||||
use crate::{
|
use crate::{
|
||||||
errors::ControlPointError,
|
errors::ControlPointError,
|
||||||
soap_client::{
|
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 pmoupnp::soap::SoapEnvelope;
|
||||||
use xmltree::{Element, XMLNode};
|
use xmltree::{Element, XMLNode};
|
||||||
|
|
||||||
@@ -54,10 +54,11 @@ impl AvTransportClient {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let envelope = call_result
|
let envelope = call_result.envelope.as_ref().ok_or_else(|| {
|
||||||
.envelope
|
ControlPointError::UpnpError(format!(
|
||||||
.as_ref()
|
"Missing SOAP envelope in GetTransportInfo response"
|
||||||
.ok_or_else(|| ControlPointError::UpnpError(format!("Missing SOAP envelope in GetTransportInfo response")))?;
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
parse_transport_info(envelope)
|
parse_transport_info(envelope)
|
||||||
}
|
}
|
||||||
@@ -181,7 +182,11 @@ impl AvTransportClient {
|
|||||||
|
|
||||||
fn parse_transport_info(envelope: &SoapEnvelope) -> Result<TransportInfo, ControlPointError> {
|
fn parse_transport_info(envelope: &SoapEnvelope) -> Result<TransportInfo, ControlPointError> {
|
||||||
let response = find_child_with_suffix(&envelope.body.content, "GetTransportInfoResponse")
|
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_state = extract_child_text(response, "CurrentTransportState")?;
|
||||||
let current_transport_status = extract_child_text(response, "CurrentTransportStatus")?;
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -382,10 +386,11 @@ impl AvTransportClient {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let envelope = call_result
|
let envelope = call_result.envelope.as_ref().ok_or_else(|| {
|
||||||
.envelope
|
ControlPointError::UpnpError(format!(
|
||||||
.as_ref()
|
"Missing SOAP envelope in GetPositionInfo response"
|
||||||
.ok_or_else(|| ControlPointError::UpnpError(format!("Missing SOAP envelope in GetPositionInfo response")))?;
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
parse_position_info(envelope)
|
parse_position_info(envelope)
|
||||||
}
|
}
|
||||||
@@ -393,7 +398,9 @@ impl AvTransportClient {
|
|||||||
|
|
||||||
fn parse_position_info(envelope: &SoapEnvelope) -> Result<PositionInfo, ControlPointError> {
|
fn parse_position_info(envelope: &SoapEnvelope) -> Result<PositionInfo, ControlPointError> {
|
||||||
let response = find_child_with_suffix(&envelope.body.content, "GetPositionInfoResponse")
|
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)
|
// Helpers allow missing text (AVTransport allows empty durations)
|
||||||
fn opt(parent: &Element, name: &str) -> Option<String> {
|
fn opt(parent: &Element, name: &str) -> Option<String> {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
use anyhow::{Result, anyhow};
|
use crate::{
|
||||||
|
errors::ControlPointError,
|
||||||
use crate::{errors::ControlPointError, soap_client::{
|
soap_client::{
|
||||||
SoapCallResult, ensure_success, extract_child_text, extract_child_text_allow_empty, find_child_with_suffix, invoke_upnp_action, parse_upnp_error
|
ensure_success, extract_child_text, extract_child_text_allow_empty, find_child_with_suffix,
|
||||||
}};
|
invoke_upnp_action, parse_upnp_error,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ConnectionManagerClient {
|
pub struct ConnectionManagerClient {
|
||||||
@@ -48,22 +50,25 @@ impl ConnectionManagerClient {
|
|||||||
|
|
||||||
ensure_success("GetProtocolInfo", &call_result)?;
|
ensure_success("GetProtocolInfo", &call_result)?;
|
||||||
|
|
||||||
let envelope = call_result
|
let envelope = call_result.envelope.as_ref().ok_or_else(|| {
|
||||||
.envelope
|
ControlPointError::UpnpError(format!(
|
||||||
.as_ref()
|
"Missing SOAP envelope in GetProtocolInfo response"
|
||||||
.ok_or_else(|| ControlPointError::UpnpError(format!("Missing SOAP envelope in GetProtocolInfo response")))?;
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
if let Some(err) = parse_upnp_error(envelope) {
|
if let Some(err) = parse_upnp_error(envelope) {
|
||||||
return Err(ControlPointError::UpnpError(format!(
|
return Err(ControlPointError::UpnpError(format!(
|
||||||
"GetProtocolInfo returned UPnP error {}: {} (HTTP status {})",
|
"GetProtocolInfo returned UPnP error {}: {} (HTTP status {})",
|
||||||
err.error_code,
|
err.error_code, err.error_description, call_result.status
|
||||||
err.error_description,
|
|
||||||
call_result.status
|
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = find_child_with_suffix(&envelope.body.content, "GetProtocolInfoResponse")
|
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 source_text = extract_child_text_allow_empty(response, "Source")?;
|
||||||
let sink_text = extract_child_text_allow_empty(response, "Sink")?;
|
let sink_text = extract_child_text_allow_empty(response, "Sink")?;
|
||||||
@@ -85,24 +90,25 @@ impl ConnectionManagerClient {
|
|||||||
|
|
||||||
ensure_success("GetCurrentConnectionIDs", &call_result)?;
|
ensure_success("GetCurrentConnectionIDs", &call_result)?;
|
||||||
|
|
||||||
let envelope = call_result
|
let envelope = call_result.envelope.as_ref().ok_or_else(|| {
|
||||||
.envelope
|
ControlPointError::UpnpError(format!(
|
||||||
.as_ref()
|
"Missing SOAP envelope in GetCurrentConnectionIDs response"
|
||||||
.ok_or_else(|| ControlPointError::UpnpError(format!("Missing SOAP envelope in GetCurrentConnectionIDs response")))?;
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
if let Some(err) = parse_upnp_error(envelope) {
|
if let Some(err) = parse_upnp_error(envelope) {
|
||||||
return Err(ControlPointError::UpnpError(format!(
|
return Err(ControlPointError::UpnpError(format!(
|
||||||
"GetCurrentConnectionIDs returned UPnP error {}: {} (HTTP status {})",
|
"GetCurrentConnectionIDs returned UPnP error {}: {} (HTTP status {})",
|
||||||
err.error_code,
|
err.error_code, err.error_description, call_result.status
|
||||||
err.error_description,
|
|
||||||
call_result.status
|
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let response =
|
let response =
|
||||||
find_child_with_suffix(&envelope.body.content, "GetCurrentConnectionIDsResponse")
|
find_child_with_suffix(&envelope.body.content, "GetCurrentConnectionIDsResponse")
|
||||||
.ok_or_else(|| {
|
.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")?;
|
let ids_text = extract_child_text_allow_empty(response, "ConnectionIDs")?;
|
||||||
@@ -118,9 +124,9 @@ impl ConnectionManagerClient {
|
|||||||
if value.is_empty() {
|
if value.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let parsed = value
|
let parsed = value.parse::<i32>().map_err(|_| {
|
||||||
.parse::<i32>()
|
ControlPointError::UpnpError(format!("Invalid ConnectionID value: {}", value))
|
||||||
.map_err(|_| ControlPointError::UpnpError(format!("Invalid ConnectionID value: {}", value)))?;
|
})?;
|
||||||
ids.push(parsed);
|
ids.push(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +134,10 @@ impl ConnectionManagerClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// GetCurrentConnectionInfo
|
/// 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 connection_id_str = connection_id.to_string();
|
||||||
let args = [("ConnectionID", connection_id_str.as_str())];
|
let args = [("ConnectionID", connection_id_str.as_str())];
|
||||||
|
|
||||||
@@ -141,33 +150,38 @@ impl ConnectionManagerClient {
|
|||||||
|
|
||||||
ensure_success("GetCurrentConnectionInfo", &call_result)?;
|
ensure_success("GetCurrentConnectionInfo", &call_result)?;
|
||||||
|
|
||||||
let envelope = call_result
|
let envelope = call_result.envelope.as_ref().ok_or_else(|| {
|
||||||
.envelope
|
ControlPointError::UpnpError(format!(
|
||||||
.as_ref()
|
"Missing SOAP envelope in GetCurrentConnectionInfo response"
|
||||||
.ok_or_else(|| ControlPointError::UpnpError(format!("Missing SOAP envelope in GetCurrentConnectionInfo response")))?;
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
if let Some(err) = parse_upnp_error(envelope) {
|
if let Some(err) = parse_upnp_error(envelope) {
|
||||||
return Err(ControlPointError::UpnpError(format!(
|
return Err(ControlPointError::UpnpError(format!(
|
||||||
"GetCurrentConnectionInfo returned UPnP error {}: {} (HTTP status {})",
|
"GetCurrentConnectionInfo returned UPnP error {}: {} (HTTP status {})",
|
||||||
err.error_code,
|
err.error_code, err.error_description, call_result.status
|
||||||
err.error_description,
|
|
||||||
call_result.status
|
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let response =
|
let response =
|
||||||
find_child_with_suffix(&envelope.body.content, "GetCurrentConnectionInfoResponse")
|
find_child_with_suffix(&envelope.body.content, "GetCurrentConnectionInfoResponse")
|
||||||
.ok_or_else(|| {
|
.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")?
|
let rcs_id = extract_child_text(response, "RcsID")?
|
||||||
.parse::<i32>()
|
.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")?
|
let av_transport_id = extract_child_text(response, "AVTransportID")?
|
||||||
.parse::<i32>()
|
.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 protocol_info = extract_child_text_allow_empty(response, "ProtocolInfo")?;
|
||||||
let peer_connection_manager =
|
let peer_connection_manager =
|
||||||
@@ -175,7 +189,9 @@ impl ConnectionManagerClient {
|
|||||||
|
|
||||||
let peer_connection_id = extract_child_text(response, "PeerConnectionID")?
|
let peer_connection_id = extract_child_text(response, "PeerConnectionID")?
|
||||||
.parse::<i32>()
|
.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 direction = extract_child_text(response, "Direction")?;
|
||||||
let status = extract_child_text(response, "Status")?;
|
let status = extract_child_text(response, "Status")?;
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
errors::ControlPointError,
|
errors::ControlPointError,
|
||||||
soap_client::{
|
soap_client::{
|
||||||
SoapCallResult, ensure_success, extract_child_text, find_child_with_suffix,
|
ensure_success, extract_child_text, find_child_with_suffix, handle_action_response,
|
||||||
handle_action_response, invoke_upnp_action, parse_upnp_error,
|
invoke_upnp_action, parse_upnp_error,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use anyhow::{Result, anyhow};
|
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -96,22 +95,23 @@ impl RenderingControlClient {
|
|||||||
|
|
||||||
ensure_success("GetMute", &call_result)?;
|
ensure_success("GetMute", &call_result)?;
|
||||||
|
|
||||||
let envelope = call_result
|
let envelope = call_result.envelope.as_ref().ok_or_else(|| {
|
||||||
.envelope
|
ControlPointError::UpnpError(format!("Missing SOAP envelope in GetMute response"))
|
||||||
.as_ref()
|
})?;
|
||||||
.ok_or_else(|| ControlPointError::UpnpError(format!("Missing SOAP envelope in GetMute response")))?;
|
|
||||||
|
|
||||||
if let Some(err) = parse_upnp_error(envelope) {
|
if let Some(err) = parse_upnp_error(envelope) {
|
||||||
return Err(ControlPointError::UpnpError(format!(
|
return Err(ControlPointError::UpnpError(format!(
|
||||||
"GetMute returned UPnP error {}: {} (HTTP status {})",
|
"GetMute returned UPnP error {}: {} (HTTP status {})",
|
||||||
err.error_code,
|
err.error_code, err.error_description, call_result.status
|
||||||
err.error_description,
|
|
||||||
call_result.status
|
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = find_child_with_suffix(&envelope.body.content, "GetMuteResponse")
|
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 text = extract_child_text(response, "CurrentMute")?;
|
||||||
let mute = match text.as_str() {
|
let mute = match text.as_str() {
|
||||||
|
|||||||
@@ -5,11 +5,11 @@
|
|||||||
use bevy_reflect::Reflect;
|
use bevy_reflect::Reflect;
|
||||||
use pmoutils::ToXmlElement;
|
use pmoutils::ToXmlElement;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::time::SystemTime;
|
use std::borrow::Cow;
|
||||||
use std::{borrow::Cow, time::Instant};
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
|
use std::time::SystemTime;
|
||||||
use xmltree::{Element, XMLNode};
|
use xmltree::{Element, XMLNode};
|
||||||
|
|
||||||
// ============= Couche d'abstraction générique =============
|
// ============= 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