debugagen openhome
This commit is contained in:
@@ -2,7 +2,11 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { usePlaybackStore } from '@/stores/playback'
|
import { usePlaybackStore } from '@/stores/playback'
|
||||||
import type { RendererSummary, RendererState } from '@/services/pmocontrol/types'
|
import type {
|
||||||
|
RendererCapabilitiesSummary,
|
||||||
|
RendererSummary,
|
||||||
|
RendererState,
|
||||||
|
} from '@/services/pmocontrol/types'
|
||||||
import StatusBadge from './StatusBadge.vue'
|
import StatusBadge from './StatusBadge.vue'
|
||||||
import { Music, Volume2, VolumeX } from 'lucide-vue-next'
|
import { Music, Volume2, VolumeX } from 'lucide-vue-next'
|
||||||
|
|
||||||
@@ -18,30 +22,44 @@ const metadata = computed(() => playbackStore.getTrackMetadata(props.renderer.id
|
|||||||
|
|
||||||
const protocolLabel = computed(() => {
|
const protocolLabel = computed(() => {
|
||||||
switch (props.renderer.protocol) {
|
switch (props.renderer.protocol) {
|
||||||
case 'UpnpAvOnly':
|
case 'upnp':
|
||||||
return 'UPnP AV'
|
return 'UPnP AV'
|
||||||
case 'OpenHomeOnly':
|
case 'openhome':
|
||||||
return 'OpenHome'
|
return 'OpenHome'
|
||||||
case 'Hybrid':
|
case 'hybrid':
|
||||||
return 'Hybrid'
|
return 'Hybrid (UPnP + OpenHome)'
|
||||||
default:
|
default:
|
||||||
return 'Unknown'
|
return 'Inconnu'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const protocolClass = computed(() => {
|
const protocolClass = computed(() => {
|
||||||
switch (props.renderer.protocol) {
|
switch (props.renderer.protocol) {
|
||||||
case 'UpnpAvOnly':
|
case 'upnp':
|
||||||
return 'protocol-upnp'
|
return 'protocol-upnp'
|
||||||
case 'OpenHomeOnly':
|
case 'openhome':
|
||||||
return 'protocol-openhome'
|
return 'protocol-openhome'
|
||||||
case 'Hybrid':
|
case 'hybrid':
|
||||||
return 'protocol-hybrid'
|
return 'protocol-hybrid'
|
||||||
default:
|
default:
|
||||||
return 'protocol-unknown'
|
return 'protocol-unknown'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const capabilityBadges = computed(() => {
|
||||||
|
const caps = props.renderer.capabilities
|
||||||
|
if (!caps) return []
|
||||||
|
const mapping: Array<{ key: keyof RendererCapabilitiesSummary; label: string }> = [
|
||||||
|
{ key: 'has_avtransport', label: 'AVTransport' },
|
||||||
|
{ key: 'has_oh_playlist', label: 'OpenHome' },
|
||||||
|
{ key: 'has_linkplay_http', label: 'Hybrid' },
|
||||||
|
{ key: 'has_oh_volume', label: 'Vol' },
|
||||||
|
{ key: 'has_oh_time', label: 'Time' },
|
||||||
|
{ key: 'has_oh_info', label: 'Info' },
|
||||||
|
]
|
||||||
|
return mapping.filter(({ key }) => caps[key]).map(({ key, label }) => ({ key, label }))
|
||||||
|
})
|
||||||
|
|
||||||
const hasCover = computed(() => !!metadata.value?.album_art_uri)
|
const hasCover = computed(() => !!metadata.value?.album_art_uri)
|
||||||
|
|
||||||
function goToRenderer() {
|
function goToRenderer() {
|
||||||
@@ -66,6 +84,12 @@ function goToRenderer() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Cover Art -->
|
<!-- Cover Art -->
|
||||||
|
<div v-if="capabilityBadges.length" class="capabilities">
|
||||||
|
<span v-for="badge in capabilityBadges" :key="badge.key" class="capability-badge">
|
||||||
|
{{ badge.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card-cover">
|
<div class="card-cover">
|
||||||
<img
|
<img
|
||||||
v-if="hasCover"
|
v-if="hasCover"
|
||||||
@@ -166,6 +190,21 @@ function goToRenderer() {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.capabilities {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.capability-badge {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
background-color: var(--color-bg-tertiary);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
.protocol-badge {
|
.protocol-badge {
|
||||||
padding: var(--spacing-xs) var(--spacing-sm);
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
|
|||||||
55
pmoapp/webapp/src/services/openhomePlaylist.ts
Normal file
55
pmoapp/webapp/src/services/openhomePlaylist.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
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,11 +5,28 @@
|
|||||||
// RENDERERS
|
// RENDERERS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
export type RendererProtocolSummary = 'upnp' | 'openhome' | 'hybrid'
|
||||||
|
|
||||||
|
export interface RendererCapabilitiesSummary {
|
||||||
|
has_avtransport: boolean
|
||||||
|
has_avtransport_set_next: boolean
|
||||||
|
has_rendering_control: boolean
|
||||||
|
has_connection_manager: boolean
|
||||||
|
has_linkplay_http: boolean
|
||||||
|
has_arylic_tcp: boolean
|
||||||
|
has_oh_playlist: boolean
|
||||||
|
has_oh_volume: boolean
|
||||||
|
has_oh_info: boolean
|
||||||
|
has_oh_time: boolean
|
||||||
|
has_oh_radio: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface RendererSummary {
|
export interface RendererSummary {
|
||||||
id: string
|
id: string
|
||||||
friendly_name: string
|
friendly_name: string
|
||||||
model_name: string
|
model_name: string
|
||||||
protocol: 'UpnpAvOnly' | 'OpenHomeOnly' | 'Hybrid'
|
protocol: RendererProtocolSummary
|
||||||
|
capabilities: RendererCapabilitiesSummary
|
||||||
online: boolean
|
online: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +68,32 @@ export interface QueueSnapshot {
|
|||||||
current_index: number | null // Index de la piste en cours (null si rien en lecture)
|
current_index: number | null // Index de la piste en cours (null si rien en lecture)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 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
|
||||||
|
tracks: OpenHomePlaylistTrack[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenHomePlaylistAddRequest {
|
||||||
|
uri: string
|
||||||
|
metadata: string
|
||||||
|
after_id?: number | null
|
||||||
|
play?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// MEDIA SERVERS
|
// MEDIA SERVERS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useRenderersStore } from '@/stores/renderers'
|
import { useRenderersStore } from '@/stores/renderers'
|
||||||
import { useUIStore } from '@/stores/ui'
|
import { useUIStore } from '@/stores/ui'
|
||||||
@@ -10,6 +10,13 @@ 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()
|
||||||
@@ -19,6 +26,22 @@ const uiStore = useUIStore()
|
|||||||
const rendererId = computed(() => route.params.id as string)
|
const rendererId = computed(() => route.params.id as string)
|
||||||
const renderer = computed(() => renderersStore.getRendererById(rendererId.value))
|
const renderer = computed(() => renderersStore.getRendererById(rendererId.value))
|
||||||
const state = computed(() => renderersStore.getStateById(rendererId.value))
|
const state = computed(() => renderersStore.getStateById(rendererId.value))
|
||||||
|
const openHomeSupported = computed(() => {
|
||||||
|
const current = renderer.value
|
||||||
|
if (!current) return false
|
||||||
|
const caps = current.capabilities
|
||||||
|
return (
|
||||||
|
current.protocol === 'openhome' ||
|
||||||
|
current.protocol === 'hybrid' ||
|
||||||
|
caps?.has_oh_playlist === true
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const ohPlaylist = ref<OpenHomePlaylistSnapshot | null>(null)
|
||||||
|
const ohLoading = ref(false)
|
||||||
|
const ohError = ref<string | null>(null)
|
||||||
|
const newOhUri = ref('')
|
||||||
|
const newOhMeta = ref('')
|
||||||
|
const canAddOhTrack = computed(() => newOhUri.value.trim().length > 0)
|
||||||
|
|
||||||
// Charger les données au montage si nécessaire
|
// Charger les données au montage si nécessaire
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -34,6 +57,25 @@ onMounted(async () => {
|
|||||||
await renderersStore.fetchQueue(rendererId.value)
|
await renderersStore.fetchQueue(rendererId.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
openHomeSupported,
|
||||||
|
async supported => {
|
||||||
|
if (supported) {
|
||||||
|
await refreshOhPlaylist()
|
||||||
|
} else {
|
||||||
|
ohPlaylist.value = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ 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)
|
||||||
@@ -46,16 +88,69 @@ function goBack() {
|
|||||||
const protocolLabel = computed(() => {
|
const protocolLabel = computed(() => {
|
||||||
if (!renderer.value) return ''
|
if (!renderer.value) return ''
|
||||||
switch (renderer.value.protocol) {
|
switch (renderer.value.protocol) {
|
||||||
case 'UpnpAvOnly':
|
case 'upnp':
|
||||||
return 'UPnP AV'
|
return 'UPnP AV'
|
||||||
case 'OpenHomeOnly':
|
case 'openhome':
|
||||||
return 'OpenHome'
|
return 'OpenHome'
|
||||||
case 'Hybrid':
|
case 'hybrid':
|
||||||
return 'Hybrid'
|
return 'Hybrid (UPnP + OpenHome)'
|
||||||
default:
|
default:
|
||||||
return 'Unknown'
|
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>
|
||||||
@@ -106,6 +201,65 @@ const protocolLabel = computed(() => {
|
|||||||
<section class="content-section">
|
<section class="content-section">
|
||||||
<PlaylistBindingPanel :rendererId="rendererId" />
|
<PlaylistBindingPanel :rendererId="rendererId" />
|
||||||
</section>
|
</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 class="oh-add-form">
|
||||||
|
<input v-model="newOhUri" placeholder="URI à ajouter" />
|
||||||
|
<textarea v-model="newOhMeta" placeholder="DIDL-Lite (optionnel)" rows="2"></textarea>
|
||||||
|
<button class="btn btn-primary" @click="handleOhAdd" :disabled="!canAddOhTrack">
|
||||||
|
➕ Ajouter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right column (Desktop) / Bottom (Mobile) -->
|
<!-- Right column (Desktop) / Bottom (Mobile) -->
|
||||||
@@ -244,6 +398,77 @@ const protocolLabel = computed(() => {
|
|||||||
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 {
|
||||||
|
|||||||
@@ -19,11 +19,11 @@ use std::thread;
|
|||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use pmocontrol::model::TrackMetadata;
|
use pmocontrol::model::TrackMetadata;
|
||||||
|
use pmocontrol::openhome_renderer::{format_seconds, map_openhome_state};
|
||||||
use pmocontrol::{
|
use pmocontrol::{
|
||||||
ControlPoint, DeviceRegistryRead, PlaybackPositionInfo, PlaybackState, RendererEvent,
|
ControlPoint, DeviceRegistryRead, PlaybackPositionInfo, PlaybackState, RendererEvent,
|
||||||
RendererId, RendererInfo,
|
RendererId, RendererInfo,
|
||||||
};
|
};
|
||||||
use pmocontrol::openhome_renderer::{format_seconds, map_openhome_state};
|
|
||||||
|
|
||||||
fn main() -> io::Result<()> {
|
fn main() -> io::Result<()> {
|
||||||
// Logging simple (tracing_subscriber est déjà utilisé dans les autres exemples)
|
// Logging simple (tracing_subscriber est déjà utilisé dans les autres exemples)
|
||||||
@@ -200,10 +200,7 @@ fn print_openhome_summary(prefix: &str, info: &RendererInfo, cp: &ControlPoint)
|
|||||||
match client.transport_state() {
|
match client.transport_state() {
|
||||||
Ok(state) => {
|
Ok(state) => {
|
||||||
let logical = map_openhome_state(&state);
|
let logical = map_openhome_state(&state);
|
||||||
println!(
|
println!("{prefix} Transport state : {} ({:?})", state, logical);
|
||||||
"{prefix} Transport state : {} ({:?})",
|
|
||||||
state, logical
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
Err(err) => println!("{prefix} Transport state : <error {err}>"),
|
Err(err) => println!("{prefix} Transport state : <error {err}>"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,28 +8,28 @@ use std::fs::{File, OpenOptions};
|
|||||||
use std::io::{self, Stdout, Write};
|
use std::io::{self, Stdout, Write};
|
||||||
use std::process;
|
use std::process;
|
||||||
use std::sync::mpsc::TryRecvError;
|
use std::sync::mpsc::TryRecvError;
|
||||||
use std::sync::{mpsc, Arc, Mutex};
|
use std::sync::{Arc, Mutex, mpsc};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use anyhow::{anyhow, bail, Context, Result};
|
use anyhow::{Context, Result, anyhow, bail};
|
||||||
use crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent};
|
use crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent};
|
||||||
use crossterm::execute;
|
use crossterm::execute;
|
||||||
use crossterm::terminal::{
|
use crossterm::terminal::{
|
||||||
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
|
||||||
};
|
};
|
||||||
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
|
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
|
||||||
|
use ratatui::Terminal;
|
||||||
use ratatui::backend::CrosstermBackend;
|
use ratatui::backend::CrosstermBackend;
|
||||||
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
|
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
|
||||||
use ratatui::style::{Color, Modifier, Style};
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Borders, Clear, Gauge, List, ListItem, ListState, Paragraph};
|
use ratatui::widgets::{Block, Borders, Clear, Gauge, List, ListItem, ListState, Paragraph};
|
||||||
use ratatui::Terminal;
|
|
||||||
use serde::de::{DeserializeOwned, Deserializer};
|
use serde::de::{DeserializeOwned, Deserializer};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
use tracing_subscriber::fmt::writer::BoxMakeWriter;
|
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
use tracing_subscriber::fmt::writer::BoxMakeWriter;
|
||||||
use ureq::http;
|
use ureq::http;
|
||||||
use ureq::{Agent, Body};
|
use ureq::{Agent, Body};
|
||||||
|
|
||||||
|
|||||||
@@ -14,12 +14,12 @@
|
|||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use pmocontrol::PlaybackPosition;
|
use pmocontrol::PlaybackPosition;
|
||||||
|
use pmocontrol::model::RendererInfo;
|
||||||
|
use pmocontrol::openhome_client::{OhInfoClient, OhPlaylistClient, OhTimeClient};
|
||||||
use pmocontrol::{
|
use pmocontrol::{
|
||||||
ControlPoint, MusicRenderer, PlaybackState, PlaybackStatus, RendererCapabilities,
|
ControlPoint, MusicRenderer, PlaybackState, PlaybackStatus, RendererCapabilities,
|
||||||
RendererProtocol, TransportControl, VolumeControl,
|
RendererProtocol, TransportControl, VolumeControl,
|
||||||
};
|
};
|
||||||
use pmocontrol::model::RendererInfo;
|
|
||||||
use pmocontrol::openhome_renderer::{format_seconds, map_openhome_state};
|
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -85,7 +85,7 @@ fn main() -> Result<()> {
|
|||||||
);
|
);
|
||||||
print_backend(" ", r);
|
print_backend(" ", r);
|
||||||
print_capabilities(" ", &info.capabilities, &info.protocol);
|
print_capabilities(" ", &info.capabilities, &info.protocol);
|
||||||
print_openhome_details(" ", info, &cp);
|
print_openhome_details(" ", info);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Select renderer
|
// 5. Select renderer
|
||||||
@@ -101,7 +101,7 @@ fn main() -> Result<()> {
|
|||||||
println!(" Protocol : {:?}", info.protocol);
|
println!(" Protocol : {:?}", info.protocol);
|
||||||
print_backend(" ", renderer);
|
print_backend(" ", renderer);
|
||||||
print_capabilities(" ", &info.capabilities, &info.protocol);
|
print_capabilities(" ", &info.capabilities, &info.protocol);
|
||||||
print_openhome_details(" ", info, &cp);
|
print_openhome_details(" ", info);
|
||||||
|
|
||||||
if let Some(upnp) = renderer.as_upnp() {
|
if let Some(upnp) = renderer.as_upnp() {
|
||||||
println!(
|
println!(
|
||||||
@@ -228,7 +228,7 @@ fn print_capabilities(prefix: &str, caps: &RendererCapabilities, proto: &Rendere
|
|||||||
println!("{prefix} OH Radio : {}", caps.has_oh_radio);
|
println!("{prefix} OH Radio : {}", caps.has_oh_radio);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_openhome_details(prefix: &str, info: &RendererInfo, cp: &ControlPoint) {
|
fn print_openhome_details(prefix: &str, info: &RendererInfo) {
|
||||||
if !info.capabilities.has_oh_playlist
|
if !info.capabilities.has_oh_playlist
|
||||||
&& !info.capabilities.has_oh_info
|
&& !info.capabilities.has_oh_info
|
||||||
&& !info.capabilities.has_oh_time
|
&& !info.capabilities.has_oh_time
|
||||||
@@ -236,12 +236,9 @@ fn print_openhome_details(prefix: &str, info: &RendererInfo, cp: &ControlPoint)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let registry = cp.registry();
|
let playlist_client = build_playlist_client(info);
|
||||||
let reg = registry.read().unwrap();
|
let info_client = build_info_client(info);
|
||||||
let playlist_client = reg.oh_playlist_client_for_renderer(&info.id);
|
let time_client = build_time_client(info);
|
||||||
let info_client = reg.oh_info_client_for_renderer(&info.id);
|
|
||||||
let time_client = reg.oh_time_client_for_renderer(&info.id);
|
|
||||||
drop(reg);
|
|
||||||
|
|
||||||
if playlist_client.is_none() && info_client.is_none() && time_client.is_none() {
|
if playlist_client.is_none() && info_client.is_none() && time_client.is_none() {
|
||||||
return;
|
return;
|
||||||
@@ -260,10 +257,7 @@ fn print_openhome_details(prefix: &str, info: &RendererInfo, cp: &ControlPoint)
|
|||||||
match client.transport_state() {
|
match client.transport_state() {
|
||||||
Ok(state) => {
|
Ok(state) => {
|
||||||
let logical = map_openhome_state(&state);
|
let logical = map_openhome_state(&state);
|
||||||
println!(
|
println!("{prefix} Transport state : {} ({:?})", state, logical);
|
||||||
"{prefix} Transport state : {} ({:?})",
|
|
||||||
state, logical
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
Err(err) => println!("{prefix} Transport state : <error {err}>"),
|
Err(err) => println!("{prefix} Transport state : <error {err}>"),
|
||||||
}
|
}
|
||||||
@@ -290,6 +284,7 @@ fn print_backend(prefix: &str, renderer: &MusicRenderer) {
|
|||||||
MusicRenderer::HybridUpnpArylic { .. } => {
|
MusicRenderer::HybridUpnpArylic { .. } => {
|
||||||
"Hybrid UpnpArylicRenderer (UPnP AV / DLNA + ARylic TCP Protocol)"
|
"Hybrid UpnpArylicRenderer (UPnP AV / DLNA + ARylic TCP Protocol)"
|
||||||
}
|
}
|
||||||
|
MusicRenderer::OpenHome(_) => "OpenHomeRenderer (native OpenHome stack)",
|
||||||
};
|
};
|
||||||
println!("{prefix}Backend : {backend}");
|
println!("{prefix}Backend : {backend}");
|
||||||
}
|
}
|
||||||
@@ -407,3 +402,41 @@ fn volume_demo(renderer: &MusicRenderer) -> Result<()> {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_playlist_client(info: &RendererInfo) -> Option<OhPlaylistClient> {
|
||||||
|
let control_url = info.oh_playlist_control_url.as_ref()?;
|
||||||
|
let service_type = info.oh_playlist_service_type.as_ref()?;
|
||||||
|
Some(OhPlaylistClient::new(
|
||||||
|
control_url.clone(),
|
||||||
|
service_type.clone(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_info_client(info: &RendererInfo) -> Option<OhInfoClient> {
|
||||||
|
let control_url = info.oh_info_control_url.as_ref()?;
|
||||||
|
let service_type = info.oh_info_service_type.as_ref()?;
|
||||||
|
Some(OhInfoClient::new(control_url.clone(), service_type.clone()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_time_client(info: &RendererInfo) -> Option<OhTimeClient> {
|
||||||
|
let control_url = info.oh_time_control_url.as_ref()?;
|
||||||
|
let service_type = info.oh_time_service_type.as_ref()?;
|
||||||
|
Some(OhTimeClient::new(control_url.clone(), service_type.clone()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_openhome_state(raw: &str) -> PlaybackState {
|
||||||
|
match raw.trim().to_ascii_uppercase().as_str() {
|
||||||
|
"PLAYING" => PlaybackState::Playing,
|
||||||
|
"PAUSED" | "PAUSED_PLAYBACK" => PlaybackState::Paused,
|
||||||
|
"STOPPED" => PlaybackState::Stopped,
|
||||||
|
"BUFFERING" | "TRANSITIONING" => PlaybackState::Transitioning,
|
||||||
|
other => PlaybackState::Unknown(other.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_seconds(seconds: u32) -> String {
|
||||||
|
let hours = seconds / 3600;
|
||||||
|
let minutes = (seconds % 3600) / 60;
|
||||||
|
let secs = seconds % 60;
|
||||||
|
format!("{hours:02}:{minutes:02}:{secs:02}")
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ use std::sync::{Arc, Mutex, RwLock};
|
|||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use anyhow::{anyhow, Context};
|
use anyhow::{Context, anyhow};
|
||||||
use crossbeam_channel::{Receiver, Sender, unbounded};
|
use crossbeam_channel::{Receiver, Sender, unbounded};
|
||||||
use pmoupnp::ssdp::SsdpClient;
|
use pmoupnp::ssdp::SsdpClient;
|
||||||
|
use thiserror::Error;
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
use ureq::{Agent, http};
|
use ureq::{Agent, http};
|
||||||
use xmltree::{Element, XMLNode};
|
use xmltree::{Element, XMLNode};
|
||||||
@@ -26,7 +27,8 @@ use crate::media_server::{
|
|||||||
use crate::media_server_events::spawn_media_server_event_runtime;
|
use crate::media_server_events::spawn_media_server_event_runtime;
|
||||||
use crate::model::TrackMetadata;
|
use crate::model::TrackMetadata;
|
||||||
use crate::model::{MediaServerEvent, RendererEvent, RendererId, RendererInfo};
|
use crate::model::{MediaServerEvent, RendererEvent, RendererId, RendererInfo};
|
||||||
use crate::openhome_client::{OhPlaylistClient, OhTrackEntry, parse_track_metadata_from_didl};
|
use crate::openhome_client::parse_track_metadata_from_didl;
|
||||||
|
use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
|
||||||
use crate::openhome_renderer::{format_seconds, map_openhome_state};
|
use crate::openhome_renderer::{format_seconds, map_openhome_state};
|
||||||
use crate::playback_queue::{PlaybackItem, PlaybackQueue};
|
use crate::playback_queue::{PlaybackItem, PlaybackQueue};
|
||||||
use crate::provider::HttpXmlDescriptionProvider;
|
use crate::provider::HttpXmlDescriptionProvider;
|
||||||
@@ -54,6 +56,14 @@ pub struct PlaylistBinding {
|
|||||||
pub(crate) auto_play_on_refresh: bool,
|
pub(crate) auto_play_on_refresh: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum OpenHomeAccessError {
|
||||||
|
#[error("Renderer {0} not found")]
|
||||||
|
RendererNotFound(String),
|
||||||
|
#[error("Renderer {0} has no OpenHome playlist service")]
|
||||||
|
PlaylistNotSupported(String),
|
||||||
|
}
|
||||||
|
|
||||||
/// Control point minimal :
|
/// Control point minimal :
|
||||||
/// - lance un SsdpClient dans un thread,
|
/// - lance un SsdpClient dans un thread,
|
||||||
/// - passe les SsdpEvent au DiscoveryManager,
|
/// - passe les SsdpEvent au DiscoveryManager,
|
||||||
@@ -164,9 +174,7 @@ impl ControlPoint {
|
|||||||
};
|
};
|
||||||
let previous_backend = runtime_cp.runtime.playlist_backend(&info.id);
|
let previous_backend = runtime_cp.runtime.playlist_backend(&info.id);
|
||||||
if previous_backend != backend {
|
if previous_backend != backend {
|
||||||
runtime_cp
|
runtime_cp.runtime.set_playlist_backend(&info.id, backend);
|
||||||
.runtime
|
|
||||||
.set_playlist_backend(&info.id, backend);
|
|
||||||
if matches!(backend, PlaylistBackend::OpenHome) {
|
if matches!(backend, PlaylistBackend::OpenHome) {
|
||||||
if let Err(err) = sync_openhome_playlist(
|
if let Err(err) = sync_openhome_playlist(
|
||||||
&runtime_cp.registry,
|
&runtime_cp.registry,
|
||||||
@@ -217,10 +225,12 @@ impl ControlPoint {
|
|||||||
artist = metadata.artist.as_deref(),
|
artist = metadata.artist.as_deref(),
|
||||||
"Emitting metadata changed event"
|
"Emitting metadata changed event"
|
||||||
);
|
);
|
||||||
runtime_cp.emit_renderer_event(RendererEvent::MetadataChanged {
|
runtime_cp.emit_renderer_event(
|
||||||
id: renderer_id.clone(),
|
RendererEvent::MetadataChanged {
|
||||||
metadata: metadata.clone(),
|
id: renderer_id.clone(),
|
||||||
});
|
metadata: metadata.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
new_snapshot.last_metadata = Some(metadata);
|
new_snapshot.last_metadata = Some(metadata);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -568,8 +578,8 @@ impl ControlPoint {
|
|||||||
self.detach_binding_on_user_mutation(renderer_id, "clear_queue");
|
self.detach_binding_on_user_mutation(renderer_id, "clear_queue");
|
||||||
|
|
||||||
if self.runtime.uses_openhome_playlist(renderer_id) {
|
if self.runtime.uses_openhome_playlist(renderer_id) {
|
||||||
let client = self.openhome_playlist_client(renderer_id)?;
|
let renderer = self.openhome_renderer(renderer_id)?;
|
||||||
client.delete_all()?;
|
renderer.openhome_playlist_clear()?;
|
||||||
self.sync_openhome_playlist_for(renderer_id)?;
|
self.sync_openhome_playlist_for(renderer_id)?;
|
||||||
debug!(
|
debug!(
|
||||||
renderer = renderer_id.0.as_str(),
|
renderer = renderer_id.0.as_str(),
|
||||||
@@ -695,6 +705,45 @@ impl ControlPoint {
|
|||||||
self.sync_openhome_playlist_for(renderer_id)
|
self.sync_openhome_playlist_for(renderer_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_openhome_playlist_snapshot(
|
||||||
|
&self,
|
||||||
|
renderer_id: &RendererId,
|
||||||
|
) -> anyhow::Result<OpenHomePlaylistSnapshot> {
|
||||||
|
let renderer = self.openhome_renderer(renderer_id)?;
|
||||||
|
renderer.openhome_playlist_snapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_openhome_playlist(&self, renderer_id: &RendererId) -> anyhow::Result<()> {
|
||||||
|
let renderer = self.openhome_renderer(renderer_id)?;
|
||||||
|
renderer.openhome_playlist_clear()?;
|
||||||
|
self.sync_openhome_playlist_for(renderer_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_openhome_track(
|
||||||
|
&self,
|
||||||
|
renderer_id: &RendererId,
|
||||||
|
uri: &str,
|
||||||
|
metadata: &str,
|
||||||
|
after_id: Option<u32>,
|
||||||
|
play: bool,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let renderer = self.openhome_renderer(renderer_id)?;
|
||||||
|
renderer.openhome_playlist_add_track(uri, metadata, after_id, play)?;
|
||||||
|
self.sync_openhome_playlist_for(renderer_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn play_openhome_track_id(
|
||||||
|
&self,
|
||||||
|
renderer_id: &RendererId,
|
||||||
|
track_id: u32,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let renderer = self.openhome_renderer(renderer_id)?;
|
||||||
|
renderer.openhome_playlist_play_id(track_id)?;
|
||||||
|
self.runtime
|
||||||
|
.set_playback_source(renderer_id, PlaybackSource::FromQueue);
|
||||||
|
self.sync_openhome_playlist_for(renderer_id)
|
||||||
|
}
|
||||||
|
|
||||||
/// Play the current item from the queue without advancing the index.
|
/// Play the current item from the queue without advancing the index.
|
||||||
///
|
///
|
||||||
/// This is useful after a Stop operation to resume playback from the current
|
/// This is useful after a Stop operation to resume playback from the current
|
||||||
@@ -917,10 +966,7 @@ impl ControlPoint {
|
|||||||
anyhow!("Renderer {} not found", renderer_id.0)
|
anyhow!("Renderer {} not found", renderer_id.0)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
debug!(
|
debug!(renderer = renderer_id.0.as_str(), "User-requested stop");
|
||||||
renderer = renderer_id.0.as_str(),
|
|
||||||
"User-requested stop"
|
|
||||||
);
|
|
||||||
|
|
||||||
renderer.stop()
|
renderer.stop()
|
||||||
}
|
}
|
||||||
@@ -1141,17 +1187,18 @@ impl ControlPoint {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sync_openhome_playlist_for(&self, renderer_id: &RendererId) -> anyhow::Result<()> {
|
fn openhome_renderer(&self, renderer_id: &RendererId) -> anyhow::Result<MusicRenderer> {
|
||||||
sync_openhome_playlist(&self.registry, &self.runtime, &self.event_bus, renderer_id)
|
let renderer = self
|
||||||
|
.music_renderer_by_id(renderer_id)
|
||||||
|
.ok_or_else(|| OpenHomeAccessError::RendererNotFound(renderer_id.0.clone()))?;
|
||||||
|
if !renderer.info().capabilities.has_oh_playlist {
|
||||||
|
return Err(OpenHomeAccessError::PlaylistNotSupported(renderer_id.0.clone()).into());
|
||||||
|
}
|
||||||
|
Ok(renderer)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn openhome_playlist_client(
|
fn sync_openhome_playlist_for(&self, renderer_id: &RendererId) -> anyhow::Result<()> {
|
||||||
&self,
|
sync_openhome_playlist(&self.registry, &self.runtime, &self.event_bus, renderer_id)
|
||||||
renderer_id: &RendererId,
|
|
||||||
) -> anyhow::Result<OhPlaylistClient> {
|
|
||||||
let reg = self.registry.read().unwrap();
|
|
||||||
reg.oh_playlist_client_for_renderer(renderer_id)
|
|
||||||
.ok_or_else(|| anyhow!("Renderer {} has no OpenHome playlist", renderer_id.0))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn enqueue_items_openhome(
|
fn enqueue_items_openhome(
|
||||||
@@ -1163,16 +1210,16 @@ impl ControlPoint {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let client = self.openhome_playlist_client(renderer_id)?;
|
let renderer = self.openhome_renderer(renderer_id)?;
|
||||||
let mut after_id = self
|
let mut after_id = self
|
||||||
.runtime
|
.runtime
|
||||||
.queue_full_snapshot(renderer_id)
|
.queue_full_snapshot(renderer_id)
|
||||||
.and_then(|(queue, _)| queue.last().and_then(openhome_track_id_from_item))
|
.and_then(|(queue, _)| queue.last().and_then(openhome_track_id_from_item));
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
for item in items.iter() {
|
for item in items.iter() {
|
||||||
let metadata = item.to_didl_metadata();
|
let metadata = item.to_didl_metadata();
|
||||||
after_id = client.insert(after_id, &item.uri, &metadata)?;
|
after_id =
|
||||||
|
Some(renderer.openhome_playlist_add_track(&item.uri, &metadata, after_id, false)?);
|
||||||
}
|
}
|
||||||
|
|
||||||
self.sync_openhome_playlist_for(renderer_id)?;
|
self.sync_openhome_playlist_for(renderer_id)?;
|
||||||
@@ -1192,28 +1239,49 @@ impl ControlPoint {
|
|||||||
|
|
||||||
let track_id = openhome_track_id_from_item(&item)
|
let track_id = openhome_track_id_from_item(&item)
|
||||||
.ok_or_else(|| anyhow!("Current OpenHome item has no track id"))?;
|
.ok_or_else(|| anyhow!("Current OpenHome item has no track id"))?;
|
||||||
let client = self.openhome_playlist_client(renderer_id)?;
|
let renderer = self.openhome_renderer(renderer_id)?;
|
||||||
client.play_id(track_id)?;
|
renderer.openhome_playlist_play_id(track_id)?;
|
||||||
self.runtime
|
self.runtime
|
||||||
.set_playback_source(renderer_id, PlaybackSource::FromQueue);
|
.set_playback_source(renderer_id, PlaybackSource::FromQueue);
|
||||||
self.sync_openhome_playlist_for(renderer_id)?;
|
self.sync_openhome_playlist_for(renderer_id)?;
|
||||||
info!(
|
info!(
|
||||||
renderer = renderer_id.0.as_str(),
|
renderer = renderer_id.0.as_str(),
|
||||||
track_id,
|
track_id, "Started OpenHome playlist playback (current item)"
|
||||||
"Started OpenHome playlist playback (current item)"
|
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn play_next_openhome(&self, renderer_id: &RendererId) -> anyhow::Result<()> {
|
fn play_next_openhome(&self, renderer_id: &RendererId) -> anyhow::Result<()> {
|
||||||
let client = self.openhome_playlist_client(renderer_id)?;
|
let renderer = self.openhome_renderer(renderer_id)?;
|
||||||
client.next()?;
|
let (queue, current_index) = self
|
||||||
|
.runtime
|
||||||
|
.queue_full_snapshot(renderer_id)
|
||||||
|
.ok_or_else(|| Self::runtime_entry_missing(renderer_id))?;
|
||||||
|
|
||||||
|
let next_item = match current_index {
|
||||||
|
Some(idx) => queue.get(idx + 1),
|
||||||
|
None => queue.first(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(item) = next_item else {
|
||||||
|
debug!(
|
||||||
|
renderer = renderer_id.0.as_str(),
|
||||||
|
"No OpenHome track available to advance to"
|
||||||
|
);
|
||||||
|
self.runtime
|
||||||
|
.set_playback_source(renderer_id, PlaybackSource::None);
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let track_id = openhome_track_id_from_item(item)
|
||||||
|
.ok_or_else(|| anyhow!("Next OpenHome item has no track id"))?;
|
||||||
|
renderer.openhome_playlist_play_id(track_id)?;
|
||||||
self.runtime
|
self.runtime
|
||||||
.set_playback_source(renderer_id, PlaybackSource::FromQueue);
|
.set_playback_source(renderer_id, PlaybackSource::FromQueue);
|
||||||
self.sync_openhome_playlist_for(renderer_id)?;
|
self.sync_openhome_playlist_for(renderer_id)?;
|
||||||
info!(
|
info!(
|
||||||
renderer = renderer_id.0.as_str(),
|
renderer = renderer_id.0.as_str(),
|
||||||
"Advanced OpenHome playlist to next track"
|
track_id, "Advanced OpenHome playlist to next track"
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1389,10 +1457,7 @@ impl RuntimeState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn uses_openhome_playlist(&self, id: &RendererId) -> bool {
|
fn uses_openhome_playlist(&self, id: &RendererId) -> bool {
|
||||||
matches!(
|
matches!(self.playlist_backend(id), PlaylistBackend::OpenHome)
|
||||||
self.playlist_backend(id),
|
|
||||||
PlaylistBackend::OpenHome
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mark_user_stop_requested(&self, id: &RendererId) {
|
fn mark_user_stop_requested(&self, id: &RendererId) {
|
||||||
@@ -1688,21 +1753,13 @@ fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option<
|
|||||||
|
|
||||||
const OPENHOME_TRACK_PREFIX: &str = "openhome:";
|
const OPENHOME_TRACK_PREFIX: &str = "openhome:";
|
||||||
|
|
||||||
fn playback_item_from_openhome_entry(entry: &OhTrackEntry) -> PlaybackItem {
|
fn playback_item_from_openhome_track(track: &OpenHomePlaylistTrack) -> PlaybackItem {
|
||||||
let mut item = PlaybackItem::new(entry.uri.clone());
|
let mut item = PlaybackItem::new(track.uri.clone());
|
||||||
item.object_id = Some(format!("{}{}", OPENHOME_TRACK_PREFIX, entry.id));
|
item.object_id = Some(format!("{}{}", OPENHOME_TRACK_PREFIX, track.id));
|
||||||
|
item.title = track.title.clone();
|
||||||
if let Some(metadata) = parse_track_metadata_from_didl(&entry.metadata_xml) {
|
item.artist = track.artist.clone();
|
||||||
item.title = metadata.title;
|
item.album = track.album.clone();
|
||||||
item.artist = metadata.artist;
|
item.album_art_uri = track.album_art_uri.clone();
|
||||||
item.album = metadata.album;
|
|
||||||
item.genre = metadata.genre;
|
|
||||||
item.album_art_uri = metadata.album_art_uri;
|
|
||||||
item.date = metadata.date;
|
|
||||||
item.track_number = metadata.track_number;
|
|
||||||
item.creator = metadata.creator;
|
|
||||||
}
|
|
||||||
|
|
||||||
item
|
item
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1718,23 +1775,28 @@ fn sync_openhome_playlist(
|
|||||||
event_bus: &RendererEventBus,
|
event_bus: &RendererEventBus,
|
||||||
renderer_id: &RendererId,
|
renderer_id: &RendererId,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let (playlist_client, info_client) = {
|
let renderer = {
|
||||||
let reg = registry.read().unwrap();
|
let info = {
|
||||||
let playlist = reg
|
let reg = registry.read().unwrap();
|
||||||
.oh_playlist_client_for_renderer(renderer_id)
|
reg.get_renderer(renderer_id)
|
||||||
.ok_or_else(|| anyhow!("Renderer has no OpenHome playlist service"))?;
|
.ok_or_else(|| OpenHomeAccessError::RendererNotFound(renderer_id.0.clone()))?
|
||||||
let info = reg.oh_info_client_for_renderer(renderer_id);
|
};
|
||||||
(playlist, info)
|
MusicRenderer::from_registry_info(info, registry)
|
||||||
|
.and_then(|r| match r {
|
||||||
|
MusicRenderer::OpenHome(_) => Some(r),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.ok_or_else(|| OpenHomeAccessError::PlaylistNotSupported(renderer_id.0.clone()))?
|
||||||
};
|
};
|
||||||
|
|
||||||
let track_entries = playlist_client.read_all_tracks()?;
|
let snapshot = renderer.openhome_playlist_snapshot()?;
|
||||||
let playback_items: Vec<PlaybackItem> = track_entries
|
let playback_items: Vec<PlaybackItem> = snapshot
|
||||||
|
.tracks
|
||||||
.iter()
|
.iter()
|
||||||
.map(playback_item_from_openhome_entry)
|
.map(playback_item_from_openhome_track)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let current_id = info_client
|
let current_id = snapshot.current_id;
|
||||||
.and_then(|client| client.id().ok());
|
|
||||||
|
|
||||||
let current_index = current_id.and_then(|id| {
|
let current_index = current_id.and_then(|id| {
|
||||||
playback_items
|
playback_items
|
||||||
@@ -1904,19 +1966,16 @@ impl OpenHomeEventRuntime {
|
|||||||
info: RendererInfo,
|
info: RendererInfo,
|
||||||
event_url: String,
|
event_url: String,
|
||||||
) {
|
) {
|
||||||
let entry = self
|
let entry = self.subscriptions.entry(key.clone()).or_insert_with(|| {
|
||||||
.subscriptions
|
OpenHomeSubscriptionState::new(info.clone(), key.service, event_url.clone())
|
||||||
.entry(key.clone())
|
});
|
||||||
.or_insert_with(|| OpenHomeSubscriptionState::new(info.clone(), key.service, event_url.clone()));
|
|
||||||
|
|
||||||
entry.update(info, event_url);
|
entry.update(info, event_url);
|
||||||
self.path_index
|
self.path_index
|
||||||
.insert(entry.callback_path.clone(), key.clone());
|
.insert(entry.callback_path.clone(), key.clone());
|
||||||
|
|
||||||
if entry.sid.is_none() && entry.should_retry() {
|
if entry.sid.is_none() && entry.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!(
|
||||||
renderer = entry.renderer.friendly_name.as_str(),
|
renderer = entry.renderer.friendly_name.as_str(),
|
||||||
service = entry.service.as_str(),
|
service = entry.service.as_str(),
|
||||||
@@ -1977,18 +2036,19 @@ impl OpenHomeEventRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let properties = parse_openhome_propertyset(&entry.renderer.id, &entry.service, ¬ify.body);
|
let properties =
|
||||||
|
parse_openhome_propertyset(&entry.renderer.id, &entry.service, ¬ify.body);
|
||||||
if properties.is_empty() {
|
if properties.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
match entry.service {
|
match entry.service {
|
||||||
OpenHomeServiceKind::Playlist => {
|
OpenHomeServiceKind::Playlist => {
|
||||||
if properties.iter().any(|(name, _)| is_id_array_property(name)) {
|
if properties
|
||||||
if self
|
.iter()
|
||||||
.runtime
|
.any(|(name, _)| is_id_array_property(name))
|
||||||
.uses_openhome_playlist(&entry.renderer.id)
|
{
|
||||||
{
|
if self.runtime.uses_openhome_playlist(&entry.renderer.id) {
|
||||||
if let Err(err) = sync_openhome_playlist(
|
if let Err(err) = sync_openhome_playlist(
|
||||||
&self.registry,
|
&self.registry,
|
||||||
&self.runtime,
|
&self.runtime,
|
||||||
@@ -2013,11 +2073,7 @@ impl OpenHomeEventRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_info_properties(
|
fn handle_info_properties(&self, renderer_id: &RendererId, properties: Vec<(String, String)>) {
|
||||||
&self,
|
|
||||||
renderer_id: &RendererId,
|
|
||||||
properties: Vec<(String, String)>,
|
|
||||||
) {
|
|
||||||
let mut metadata_xml: Option<String> = None;
|
let mut metadata_xml: Option<String> = None;
|
||||||
let mut transport_state: Option<String> = None;
|
let mut transport_state: Option<String> = None;
|
||||||
let mut track_id: Option<u32> = None;
|
let mut track_id: Option<u32> = None;
|
||||||
@@ -2090,11 +2146,7 @@ impl OpenHomeEventRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_time_properties(
|
fn handle_time_properties(&self, renderer_id: &RendererId, properties: Vec<(String, String)>) {
|
||||||
&self,
|
|
||||||
renderer_id: &RendererId,
|
|
||||||
properties: Vec<(String, String)>,
|
|
||||||
) {
|
|
||||||
let mut duration: Option<u32> = None;
|
let mut duration: Option<u32> = None;
|
||||||
let mut seconds: Option<u32> = None;
|
let mut seconds: Option<u32> = None;
|
||||||
|
|
||||||
@@ -2123,10 +2175,9 @@ impl OpenHomeEventRuntime {
|
|||||||
new_position.rel_time = Some(format_seconds(s));
|
new_position.rel_time = Some(format_seconds(s));
|
||||||
}
|
}
|
||||||
|
|
||||||
self.runtime
|
self.runtime.update_snapshot_with(renderer_id, |snapshot| {
|
||||||
.update_snapshot_with(renderer_id, |snapshot| {
|
snapshot.position = Some(new_position.clone());
|
||||||
snapshot.position = Some(new_position.clone());
|
});
|
||||||
});
|
|
||||||
|
|
||||||
let _ = self.event_tx.send(RendererEvent::PositionChanged {
|
let _ = self.event_tx.send(RendererEvent::PositionChanged {
|
||||||
id: renderer_id.clone(),
|
id: renderer_id.clone(),
|
||||||
@@ -2175,10 +2226,7 @@ impl OpenHomeEventRuntime {
|
|||||||
|
|
||||||
let response = build_agent(http_timeout).run(request)?;
|
let response = build_agent(http_timeout).run(request)?;
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
anyhow::bail!(
|
anyhow::bail!("SUBSCRIBE returned HTTP {}", response.status());
|
||||||
"SUBSCRIBE returned HTTP {}",
|
|
||||||
response.status()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let sid = response
|
let sid = response
|
||||||
@@ -2226,10 +2274,7 @@ impl OpenHomeEventRuntime {
|
|||||||
.map_err(anyhow::Error::new)?;
|
.map_err(anyhow::Error::new)?;
|
||||||
let response = build_agent(http_timeout).run(request)?;
|
let response = build_agent(http_timeout).run(request)?;
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
anyhow::bail!(
|
anyhow::bail!("SUBSCRIBE renewal failed with {}", response.status());
|
||||||
"SUBSCRIBE renewal failed with {}",
|
|
||||||
response.status()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
let timeout = parse_timeout(
|
let timeout = parse_timeout(
|
||||||
response
|
response
|
||||||
@@ -2332,9 +2377,7 @@ impl OpenHomeSubscriptionState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, renderer: RendererInfo, event_url: String) {
|
fn update(&mut self, renderer: RendererInfo, event_url: String) {
|
||||||
if self.renderer.location != renderer.location
|
if self.renderer.location != renderer.location || self.event_sub_url != event_url {
|
||||||
|| self.event_sub_url != event_url
|
|
||||||
{
|
|
||||||
self.event_sub_url = event_url;
|
self.event_sub_url = event_url;
|
||||||
self.sid = None;
|
self.sid = None;
|
||||||
self.expires_at = None;
|
self.expires_at = None;
|
||||||
@@ -2364,15 +2407,15 @@ struct OpenHomeIncomingNotify {
|
|||||||
body: Vec<u8>,
|
body: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_openhome_http_listener(
|
fn run_openhome_http_listener(listener: TcpListener, notify_tx: Sender<OpenHomeIncomingNotify>) {
|
||||||
listener: TcpListener,
|
|
||||||
notify_tx: Sender<OpenHomeIncomingNotify>,
|
|
||||||
) {
|
|
||||||
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(5))) {
|
||||||
warn!("Failed to set read timeout on OpenHome notify connection: {}", err);
|
warn!(
|
||||||
|
"Failed to set read timeout on OpenHome notify connection: {}",
|
||||||
|
err
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
match read_openhome_http_request(&mut stream) {
|
match read_openhome_http_request(&mut stream) {
|
||||||
@@ -2495,11 +2538,7 @@ fn build_openhome_callback_path(id: &RendererId, service: OpenHomeServiceKind) -
|
|||||||
id.hash(&mut hasher);
|
id.hash(&mut hasher);
|
||||||
service.hash(&mut hasher);
|
service.hash(&mut hasher);
|
||||||
let suffix = hasher.finish();
|
let suffix = hasher.finish();
|
||||||
format!(
|
format!("/openhome-events/{}/{:x}", service.as_str(), suffix)
|
||||||
"/openhome-events/{}/{:x}",
|
|
||||||
service.as_str(),
|
|
||||||
suffix
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_openhome_propertyset(
|
fn parse_openhome_propertyset(
|
||||||
@@ -2536,9 +2575,7 @@ fn parse_openhome_propertyset(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn is_id_array_property(name: &str) -> bool {
|
fn is_id_array_property(name: &str) -> bool {
|
||||||
name.trim()
|
name.trim().to_ascii_lowercase().ends_with("idarray")
|
||||||
.to_ascii_lowercase()
|
|
||||||
.ends_with("idarray")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn empty_playback_position() -> PlaybackPositionInfo {
|
fn empty_playback_position() -> PlaybackPositionInfo {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pub mod media_server;
|
|||||||
pub mod model;
|
pub mod model;
|
||||||
pub mod music_renderer;
|
pub mod music_renderer;
|
||||||
pub mod openhome_client;
|
pub mod openhome_client;
|
||||||
|
pub mod openhome_playlist;
|
||||||
pub mod openhome_renderer;
|
pub mod openhome_renderer;
|
||||||
pub mod playback_queue;
|
pub mod playback_queue;
|
||||||
pub mod provider;
|
pub mod provider;
|
||||||
@@ -45,6 +46,7 @@ pub use media_server::{
|
|||||||
UpnpMediaServer,
|
UpnpMediaServer,
|
||||||
};
|
};
|
||||||
pub use music_renderer::MusicRenderer;
|
pub use music_renderer::MusicRenderer;
|
||||||
|
pub use openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
|
||||||
pub use openhome_renderer::OpenHomeRenderer;
|
pub use openhome_renderer::OpenHomeRenderer;
|
||||||
pub use playback_queue::{PlaybackItem, PlaybackQueue};
|
pub use playback_queue::{PlaybackItem, PlaybackQueue};
|
||||||
pub use rendering_control_client::RenderingControlClient;
|
pub use rendering_control_client::RenderingControlClient;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use std::sync::{Arc, RwLock};
|
|||||||
|
|
||||||
use crate::capabilities::{PlaybackPositionInfo, PlaybackStatus};
|
use crate::capabilities::{PlaybackPositionInfo, PlaybackStatus};
|
||||||
use crate::model::{RendererId, RendererInfo, RendererProtocol};
|
use crate::model::{RendererId, RendererInfo, RendererProtocol};
|
||||||
|
use crate::openhome_playlist::OpenHomePlaylistSnapshot;
|
||||||
use crate::{
|
use crate::{
|
||||||
ArylicTcpRenderer, DeviceRegistry, LinkPlayRenderer, OpenHomeRenderer, PlaybackPosition,
|
ArylicTcpRenderer, DeviceRegistry, LinkPlayRenderer, OpenHomeRenderer, PlaybackPosition,
|
||||||
PlaybackState, TransportControl, UpnpRenderer, VolumeControl,
|
PlaybackState, TransportControl, UpnpRenderer, VolumeControl,
|
||||||
@@ -101,10 +102,12 @@ impl MusicRenderer {
|
|||||||
info: RendererInfo,
|
info: RendererInfo,
|
||||||
registry: &Arc<RwLock<DeviceRegistry>>,
|
registry: &Arc<RwLock<DeviceRegistry>>,
|
||||||
) -> Option<Self> {
|
) -> Option<Self> {
|
||||||
if matches!(info.protocol, RendererProtocol::OpenHomeOnly | RendererProtocol::Hybrid) {
|
if matches!(
|
||||||
|
info.protocol,
|
||||||
|
RendererProtocol::OpenHomeOnly | RendererProtocol::Hybrid
|
||||||
|
) {
|
||||||
if let Some(renderer) = {
|
if let Some(renderer) = {
|
||||||
let reg = registry.read().unwrap();
|
let renderer = OpenHomeRenderer::new(info.clone());
|
||||||
let renderer = OpenHomeRenderer::new(info.clone(), &*reg);
|
|
||||||
renderer.has_any_openhome_service().then_some(renderer)
|
renderer.has_any_openhome_service().then_some(renderer)
|
||||||
} {
|
} {
|
||||||
return Some(MusicRenderer::OpenHome(renderer));
|
return Some(MusicRenderer::OpenHome(renderer));
|
||||||
@@ -157,6 +160,64 @@ impl MusicRenderer {
|
|||||||
RendererProtocol::OpenHomeOnly => None,
|
RendererProtocol::OpenHomeOnly => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn openhome_playlist_snapshot(&self) -> Result<OpenHomePlaylistSnapshot> {
|
||||||
|
match self {
|
||||||
|
MusicRenderer::OpenHome(renderer) => renderer.snapshot_openhome_playlist(),
|
||||||
|
_ => Err(op_not_supported(
|
||||||
|
"openhome_playlist_snapshot",
|
||||||
|
self.unsupported_backend_name(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn openhome_playlist_clear(&self) -> Result<()> {
|
||||||
|
match self {
|
||||||
|
MusicRenderer::OpenHome(renderer) => renderer.clear_openhome_playlist(),
|
||||||
|
_ => Err(op_not_supported(
|
||||||
|
"openhome_playlist_clear",
|
||||||
|
self.unsupported_backend_name(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn openhome_playlist_add_track(
|
||||||
|
&self,
|
||||||
|
uri: &str,
|
||||||
|
metadata: &str,
|
||||||
|
after_id: Option<u32>,
|
||||||
|
play: bool,
|
||||||
|
) -> Result<u32> {
|
||||||
|
match self {
|
||||||
|
MusicRenderer::OpenHome(renderer) => {
|
||||||
|
renderer.add_track_openhome(uri, metadata, after_id, play)
|
||||||
|
}
|
||||||
|
_ => Err(op_not_supported(
|
||||||
|
"openhome_playlist_add_track",
|
||||||
|
self.unsupported_backend_name(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn openhome_playlist_play_id(&self, id: u32) -> Result<()> {
|
||||||
|
match self {
|
||||||
|
MusicRenderer::OpenHome(renderer) => renderer.play_openhome_track_id(id),
|
||||||
|
_ => Err(op_not_supported(
|
||||||
|
"openhome_playlist_play_id",
|
||||||
|
self.unsupported_backend_name(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unsupported_backend_name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
MusicRenderer::Upnp(_) => "UPnP",
|
||||||
|
MusicRenderer::OpenHome(_) => "OpenHome",
|
||||||
|
MusicRenderer::LinkPlay(_) => "LinkPlay",
|
||||||
|
MusicRenderer::ArylicTcp(_) => "ArylicTcp",
|
||||||
|
MusicRenderer::HybridUpnpArylic { .. } => "HybridUpnpArylic",
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transport control façade that dispatches to whichever backend can fulfill
|
/// Transport control façade that dispatches to whichever backend can fulfill
|
||||||
|
|||||||
@@ -133,35 +133,8 @@ pub struct QueueSnapshot {
|
|||||||
// OPENHOME PLAYLIST
|
// OPENHOME PLAYLIST
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/// Snapshot de la playlist native OpenHome
|
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
pub use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
|
||||||
pub struct OpenHomePlaylistSnapshot {
|
|
||||||
/// ID du renderer concerné
|
|
||||||
pub renderer_id: String,
|
|
||||||
/// ID courant dans la playlist (si connu)
|
|
||||||
pub current_id: Option<u32>,
|
|
||||||
/// Tracks présents dans la playlist native
|
|
||||||
pub tracks: Vec<OpenHomePlaylistTrack>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Track issue de la playlist native OpenHome
|
|
||||||
#[cfg(feature = "pmoserver")]
|
|
||||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
|
||||||
pub struct OpenHomePlaylistTrack {
|
|
||||||
/// ID interne OpenHome
|
|
||||||
pub id: u32,
|
|
||||||
/// URI du flux
|
|
||||||
pub uri: String,
|
|
||||||
/// Titre
|
|
||||||
pub title: Option<String>,
|
|
||||||
/// Artiste
|
|
||||||
pub artist: Option<String>,
|
|
||||||
/// Album
|
|
||||||
pub album: Option<String>,
|
|
||||||
/// Pochette (si disponible)
|
|
||||||
pub album_art_uri: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Requête pour ajouter un track à la playlist OpenHome
|
/// Requête pour ajouter un track à la playlist OpenHome
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use anyhow::{Result, anyhow};
|
|
||||||
use crate::model::TrackMetadata;
|
use crate::model::TrackMetadata;
|
||||||
use crate::soap_client::{SoapCallResult, invoke_upnp_action};
|
use crate::soap_client::{SoapCallResult, invoke_upnp_action};
|
||||||
|
use anyhow::{Result, anyhow};
|
||||||
use pmoupnp::soap::SoapEnvelope;
|
use pmoupnp::soap::SoapEnvelope;
|
||||||
use xmltree::{Element, XMLNode};
|
use xmltree::{Element, XMLNode};
|
||||||
|
|
||||||
@@ -64,12 +64,8 @@ impl OhPlaylistClient {
|
|||||||
.join(",");
|
.join(",");
|
||||||
let args = [("aIdList", id_list_csv.as_str())];
|
let args = [("aIdList", id_list_csv.as_str())];
|
||||||
|
|
||||||
let call_result = invoke_upnp_action(
|
let call_result =
|
||||||
&self.control_url,
|
invoke_upnp_action(&self.control_url, &self.service_type, "ReadList", &args)?;
|
||||||
&self.service_type,
|
|
||||||
"ReadList",
|
|
||||||
&args,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let envelope = ensure_success("ReadList", &call_result)?;
|
let envelope = ensure_success("ReadList", &call_result)?;
|
||||||
let response = find_child_with_suffix(&envelope.body.content, "ReadListResponse")
|
let response = find_child_with_suffix(&envelope.body.content, "ReadListResponse")
|
||||||
@@ -87,12 +83,8 @@ impl OhPlaylistClient {
|
|||||||
("aMetadata", metadata),
|
("aMetadata", metadata),
|
||||||
];
|
];
|
||||||
|
|
||||||
let call_result = invoke_upnp_action(
|
let call_result =
|
||||||
&self.control_url,
|
invoke_upnp_action(&self.control_url, &self.service_type, "Insert", &args)?;
|
||||||
&self.service_type,
|
|
||||||
"Insert",
|
|
||||||
&args,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let envelope = ensure_success("Insert", &call_result)?;
|
let envelope = ensure_success("Insert", &call_result)?;
|
||||||
let response = find_child_with_suffix(&envelope.body.content, "InsertResponse")
|
let response = find_child_with_suffix(&envelope.body.content, "InsertResponse")
|
||||||
@@ -109,37 +101,29 @@ impl OhPlaylistClient {
|
|||||||
let id_str = id.to_string();
|
let id_str = id.to_string();
|
||||||
let args = [("aId", id_str.as_str())];
|
let args = [("aId", id_str.as_str())];
|
||||||
|
|
||||||
let call_result = invoke_upnp_action(
|
let call_result =
|
||||||
&self.control_url,
|
invoke_upnp_action(&self.control_url, &self.service_type, "PlayId", &args)?;
|
||||||
&self.service_type,
|
|
||||||
"PlayId",
|
|
||||||
&args,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
handle_action_response("PlayId", &call_result)
|
handle_action_response("PlayId", &call_result)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn play(&self) -> Result<()> {
|
pub fn play(&self) -> Result<()> {
|
||||||
let call_result =
|
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Play", &[])?;
|
||||||
invoke_upnp_action(&self.control_url, &self.service_type, "Play", &[])?;
|
|
||||||
handle_action_response("Play", &call_result)
|
handle_action_response("Play", &call_result)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn pause(&self) -> Result<()> {
|
pub fn pause(&self) -> Result<()> {
|
||||||
let call_result =
|
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Pause", &[])?;
|
||||||
invoke_upnp_action(&self.control_url, &self.service_type, "Pause", &[])?;
|
|
||||||
handle_action_response("Pause", &call_result)
|
handle_action_response("Pause", &call_result)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn stop(&self) -> Result<()> {
|
pub fn stop(&self) -> Result<()> {
|
||||||
let call_result =
|
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Stop", &[])?;
|
||||||
invoke_upnp_action(&self.control_url, &self.service_type, "Stop", &[])?;
|
|
||||||
handle_action_response("Stop", &call_result)
|
handle_action_response("Stop", &call_result)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn next(&self) -> Result<()> {
|
pub fn next(&self) -> Result<()> {
|
||||||
let call_result =
|
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Next", &[])?;
|
||||||
invoke_upnp_action(&self.control_url, &self.service_type, "Next", &[])?;
|
|
||||||
handle_action_response("Next", &call_result)
|
handle_action_response("Next", &call_result)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,12 +150,8 @@ impl OhPlaylistClient {
|
|||||||
let id_str = id.to_string();
|
let id_str = id.to_string();
|
||||||
let args = [("aId", id_str.as_str())];
|
let args = [("aId", id_str.as_str())];
|
||||||
|
|
||||||
let call_result = invoke_upnp_action(
|
let call_result =
|
||||||
&self.control_url,
|
invoke_upnp_action(&self.control_url, &self.service_type, "DeleteId", &args)?;
|
||||||
&self.service_type,
|
|
||||||
"DeleteId",
|
|
||||||
&args,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
handle_action_response("DeleteId", &call_result)
|
handle_action_response("DeleteId", &call_result)
|
||||||
}
|
}
|
||||||
@@ -183,12 +163,8 @@ impl OhPlaylistClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn tracks_max(&self) -> Result<u32> {
|
pub fn tracks_max(&self) -> Result<u32> {
|
||||||
let call_result = invoke_upnp_action(
|
let call_result =
|
||||||
&self.control_url,
|
invoke_upnp_action(&self.control_url, &self.service_type, "TracksMax", &[])?;
|
||||||
&self.service_type,
|
|
||||||
"TracksMax",
|
|
||||||
&[],
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let envelope = ensure_success("TracksMax", &call_result)?;
|
let envelope = ensure_success("TracksMax", &call_result)?;
|
||||||
let response = find_child_with_suffix(&envelope.body.content, "TracksMaxResponse")
|
let response = find_child_with_suffix(&envelope.body.content, "TracksMaxResponse")
|
||||||
@@ -202,12 +178,8 @@ impl OhPlaylistClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn id_array(&self) -> Result<Vec<u32>> {
|
pub fn id_array(&self) -> Result<Vec<u32>> {
|
||||||
let call_result = invoke_upnp_action(
|
let call_result =
|
||||||
&self.control_url,
|
invoke_upnp_action(&self.control_url, &self.service_type, "IdArray", &[])?;
|
||||||
&self.service_type,
|
|
||||||
"IdArray",
|
|
||||||
&[],
|
|
||||||
)?;
|
|
||||||
let envelope = ensure_success("IdArray", &call_result)?;
|
let envelope = ensure_success("IdArray", &call_result)?;
|
||||||
let response = find_child_with_suffix(&envelope.body.content, "IdArrayResponse")
|
let response = find_child_with_suffix(&envelope.body.content, "IdArrayResponse")
|
||||||
.ok_or_else(|| anyhow!("Missing IdArrayResponse element in SOAP body"))?;
|
.ok_or_else(|| anyhow!("Missing IdArrayResponse element in SOAP body"))?;
|
||||||
@@ -259,8 +231,7 @@ impl OhInfoClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn track(&self) -> Result<OhInfoTrack> {
|
pub fn track(&self) -> Result<OhInfoTrack> {
|
||||||
let call_result =
|
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Track", &[])?;
|
||||||
invoke_upnp_action(&self.control_url, &self.service_type, "Track", &[])?;
|
|
||||||
|
|
||||||
let envelope = ensure_success("Track", &call_result)?;
|
let envelope = ensure_success("Track", &call_result)?;
|
||||||
let response = find_child_with_suffix(&envelope.body.content, "TrackResponse")
|
let response = find_child_with_suffix(&envelope.body.content, "TrackResponse")
|
||||||
@@ -275,8 +246,7 @@ impl OhInfoClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn next(&self) -> Result<OhInfoTrack> {
|
pub fn next(&self) -> Result<OhInfoTrack> {
|
||||||
let call_result =
|
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Next", &[])?;
|
||||||
invoke_upnp_action(&self.control_url, &self.service_type, "Next", &[])?;
|
|
||||||
|
|
||||||
let envelope = ensure_success("Next", &call_result)?;
|
let envelope = ensure_success("Next", &call_result)?;
|
||||||
let response = find_child_with_suffix(&envelope.body.content, "NextResponse")
|
let response = find_child_with_suffix(&envelope.body.content, "NextResponse")
|
||||||
@@ -291,8 +261,7 @@ impl OhInfoClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn id(&self) -> Result<u32> {
|
pub fn id(&self) -> Result<u32> {
|
||||||
let call_result =
|
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Id", &[])?;
|
||||||
invoke_upnp_action(&self.control_url, &self.service_type, "Id", &[])?;
|
|
||||||
|
|
||||||
let envelope = ensure_success("Id", &call_result)?;
|
let envelope = ensure_success("Id", &call_result)?;
|
||||||
let response = find_child_with_suffix(&envelope.body.content, "IdResponse")
|
let response = find_child_with_suffix(&envelope.body.content, "IdResponse")
|
||||||
@@ -305,17 +274,12 @@ impl OhInfoClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn transport_state(&self) -> Result<String> {
|
pub fn transport_state(&self) -> Result<String> {
|
||||||
let call_result = invoke_upnp_action(
|
let call_result =
|
||||||
&self.control_url,
|
invoke_upnp_action(&self.control_url, &self.service_type, "TransportState", &[])?;
|
||||||
&self.service_type,
|
|
||||||
"TransportState",
|
|
||||||
&[],
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let envelope = ensure_success("TransportState", &call_result)?;
|
let envelope = ensure_success("TransportState", &call_result)?;
|
||||||
let response =
|
let response = find_child_with_suffix(&envelope.body.content, "TransportStateResponse")
|
||||||
find_child_with_suffix(&envelope.body.content, "TransportStateResponse")
|
.ok_or_else(|| anyhow!("Missing TransportStateResponse element in SOAP body"))?;
|
||||||
.ok_or_else(|| anyhow!("Missing TransportStateResponse element in SOAP body"))?;
|
|
||||||
let state = extract_child_text(response, "aState")?;
|
let state = extract_child_text(response, "aState")?;
|
||||||
Ok(state)
|
Ok(state)
|
||||||
}
|
}
|
||||||
@@ -341,8 +305,7 @@ impl OhTimeClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn position(&self) -> Result<OhTimePosition> {
|
pub fn position(&self) -> Result<OhTimePosition> {
|
||||||
let call_result =
|
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Time", &[])?;
|
||||||
invoke_upnp_action(&self.control_url, &self.service_type, "Time", &[])?;
|
|
||||||
|
|
||||||
let envelope = ensure_success("Time", &call_result)?;
|
let envelope = ensure_success("Time", &call_result)?;
|
||||||
let response = find_child_with_suffix(&envelope.body.content, "TimeResponse")
|
let response = find_child_with_suffix(&envelope.body.content, "TimeResponse")
|
||||||
@@ -381,8 +344,7 @@ impl OhVolumeClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn volume(&self) -> Result<u16> {
|
pub fn volume(&self) -> Result<u16> {
|
||||||
let call_result =
|
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Volume", &[])?;
|
||||||
invoke_upnp_action(&self.control_url, &self.service_type, "Volume", &[])?;
|
|
||||||
let envelope = ensure_success("Volume", &call_result)?;
|
let envelope = ensure_success("Volume", &call_result)?;
|
||||||
let response = find_child_with_suffix(&envelope.body.content, "VolumeResponse")
|
let response = find_child_with_suffix(&envelope.body.content, "VolumeResponse")
|
||||||
.ok_or_else(|| anyhow!("Missing VolumeResponse element in SOAP body"))?;
|
.ok_or_else(|| anyhow!("Missing VolumeResponse element in SOAP body"))?;
|
||||||
@@ -396,18 +358,13 @@ impl OhVolumeClient {
|
|||||||
pub fn set_volume(&self, vol: u16) -> Result<()> {
|
pub fn set_volume(&self, vol: u16) -> Result<()> {
|
||||||
let vol_str = vol.to_string();
|
let vol_str = vol.to_string();
|
||||||
let args = [("aVolume", vol_str.as_str())];
|
let args = [("aVolume", vol_str.as_str())];
|
||||||
let call_result = invoke_upnp_action(
|
let call_result =
|
||||||
&self.control_url,
|
invoke_upnp_action(&self.control_url, &self.service_type, "SetVolume", &args)?;
|
||||||
&self.service_type,
|
|
||||||
"SetVolume",
|
|
||||||
&args,
|
|
||||||
)?;
|
|
||||||
handle_action_response("SetVolume", &call_result)
|
handle_action_response("SetVolume", &call_result)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn mute(&self) -> Result<bool> {
|
pub fn mute(&self) -> Result<bool> {
|
||||||
let call_result =
|
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Mute", &[])?;
|
||||||
invoke_upnp_action(&self.control_url, &self.service_type, "Mute", &[])?;
|
|
||||||
let envelope = ensure_success("Mute", &call_result)?;
|
let envelope = ensure_success("Mute", &call_result)?;
|
||||||
let response = find_child_with_suffix(&envelope.body.content, "MuteResponse")
|
let response = find_child_with_suffix(&envelope.body.content, "MuteResponse")
|
||||||
.ok_or_else(|| anyhow!("Missing MuteResponse element in SOAP body"))?;
|
.ok_or_else(|| anyhow!("Missing MuteResponse element in SOAP body"))?;
|
||||||
@@ -418,12 +375,8 @@ impl OhVolumeClient {
|
|||||||
pub fn set_mute(&self, mute: bool) -> Result<()> {
|
pub fn set_mute(&self, mute: bool) -> Result<()> {
|
||||||
let mute_str = if mute { "1" } else { "0" };
|
let mute_str = if mute { "1" } else { "0" };
|
||||||
let args = [("aMute", mute_str)];
|
let args = [("aMute", mute_str)];
|
||||||
let call_result = invoke_upnp_action(
|
let call_result =
|
||||||
&self.control_url,
|
invoke_upnp_action(&self.control_url, &self.service_type, "SetMute", &args)?;
|
||||||
&self.service_type,
|
|
||||||
"SetMute",
|
|
||||||
&args,
|
|
||||||
)?;
|
|
||||||
handle_action_response("SetMute", &call_result)
|
handle_action_response("SetMute", &call_result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -445,12 +398,8 @@ impl OhRadioClient {
|
|||||||
pub fn play_channel(&self, id: u32) -> Result<()> {
|
pub fn play_channel(&self, id: u32) -> Result<()> {
|
||||||
let id_str = id.to_string();
|
let id_str = id.to_string();
|
||||||
let args = [("aId", id_str.as_str())];
|
let args = [("aId", id_str.as_str())];
|
||||||
let call_result = invoke_upnp_action(
|
let call_result =
|
||||||
&self.control_url,
|
invoke_upnp_action(&self.control_url, &self.service_type, "PlayChannel", &args)?;
|
||||||
&self.service_type,
|
|
||||||
"PlayChannel",
|
|
||||||
&args,
|
|
||||||
)?;
|
|
||||||
handle_action_response("PlayChannel", &call_result)
|
handle_action_response("PlayChannel", &call_result)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -529,10 +478,7 @@ fn parse_track_entry(elem: &Element) -> Result<OhTrackEntry> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_success<'a>(
|
fn ensure_success<'a>(action: &str, call_result: &'a SoapCallResult) -> Result<&'a SoapEnvelope> {
|
||||||
action: &str,
|
|
||||||
call_result: &'a SoapCallResult,
|
|
||||||
) -> Result<&'a SoapEnvelope> {
|
|
||||||
if !call_result.status.is_success() {
|
if !call_result.status.is_success() {
|
||||||
if let Some(env) = &call_result.envelope {
|
if let Some(env) = &call_result.envelope {
|
||||||
if let Some(err) = parse_upnp_error(env) {
|
if let Some(err) = parse_upnp_error(env) {
|
||||||
@@ -631,7 +577,10 @@ fn extract_child_text(parent: &Element, suffix: &str) -> Result<String> {
|
|||||||
|
|
||||||
fn extract_child_text_optional(parent: &Element, suffix: &str) -> Result<Option<String>> {
|
fn extract_child_text_optional(parent: &Element, suffix: &str) -> Result<Option<String>> {
|
||||||
if let Some(child) = find_child_with_suffix(parent, suffix) {
|
if let Some(child) = find_child_with_suffix(parent, suffix) {
|
||||||
let text = child.get_text().map(|t| t.trim().to_string()).unwrap_or_default();
|
let text = child
|
||||||
|
.get_text()
|
||||||
|
.map(|t| t.trim().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
Ok(Some(text))
|
Ok(Some(text))
|
||||||
} else {
|
} else {
|
||||||
Ok(None)
|
Ok(None)
|
||||||
@@ -644,7 +593,10 @@ fn extract_child_text_any(parent: &Element, suffixes: &[&str]) -> Result<String>
|
|||||||
return Ok(text);
|
return Ok(text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(anyhow!("Missing {} element in response", suffixes.join(" or ")))
|
Err(anyhow!(
|
||||||
|
"Missing {} element in response",
|
||||||
|
suffixes.join(" or ")
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_bool(value: &str) -> Result<bool> {
|
fn parse_bool(value: &str) -> Result<bool> {
|
||||||
@@ -678,8 +630,8 @@ pub(crate) fn decode_base64(input: &str) -> Result<Vec<u8>> {
|
|||||||
if byte == b'\r' || byte == b'\n' || byte == b' ' || byte == b'\t' {
|
if byte == b'\r' || byte == b'\n' || byte == b' ' || byte == b'\t' {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let val = value(byte)
|
let val =
|
||||||
.ok_or_else(|| anyhow!("Invalid base64 character '{}'", byte as char))?;
|
value(byte).ok_or_else(|| anyhow!("Invalid base64 character '{}'", byte as char))?;
|
||||||
buffer = (buffer << 6) | (val as u32);
|
buffer = (buffer << 6) | (val as u32);
|
||||||
bits_collected += 6;
|
bits_collected += 6;
|
||||||
if bits_collected >= 8 {
|
if bits_collected >= 8 {
|
||||||
|
|||||||
29
pmocontrol/src/openhome_playlist.rs
Normal file
29
pmocontrol/src/openhome_playlist.rs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/// Snapshot de la playlist native OpenHome pour un renderer donné.
|
||||||
|
#[cfg_attr(feature = "pmoserver", derive(serde::Serialize, utoipa::ToSchema))]
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct OpenHomePlaylistSnapshot {
|
||||||
|
/// ID du renderer concerné.
|
||||||
|
pub renderer_id: String,
|
||||||
|
/// ID courant dans la playlist (si connu).
|
||||||
|
pub current_id: Option<u32>,
|
||||||
|
/// Tracks présents dans la playlist native.
|
||||||
|
pub tracks: Vec<OpenHomePlaylistTrack>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Représentation d'un track OpenHome tel qu'exposé par la playlist native.
|
||||||
|
#[cfg_attr(feature = "pmoserver", derive(serde::Serialize, utoipa::ToSchema))]
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct OpenHomePlaylistTrack {
|
||||||
|
/// ID interne OpenHome du track.
|
||||||
|
pub id: u32,
|
||||||
|
/// URI de lecture.
|
||||||
|
pub uri: String,
|
||||||
|
/// Titre (optionnel si non fourni par le renderer).
|
||||||
|
pub title: Option<String>,
|
||||||
|
/// Artiste (optionnel).
|
||||||
|
pub artist: Option<String>,
|
||||||
|
/// Album (optionnel).
|
||||||
|
pub album: Option<String>,
|
||||||
|
/// URI de pochette (optionnelle).
|
||||||
|
pub album_art_uri: Option<String>,
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
use anyhow::{Result, anyhow};
|
|
||||||
use crate::capabilities::{
|
use crate::capabilities::{
|
||||||
PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl,
|
PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl,
|
||||||
VolumeControl,
|
VolumeControl,
|
||||||
@@ -6,9 +5,11 @@ use crate::capabilities::{
|
|||||||
use crate::model::{RendererId, RendererInfo, RendererProtocol};
|
use crate::model::{RendererId, RendererInfo, RendererProtocol};
|
||||||
use crate::music_renderer::op_not_supported;
|
use crate::music_renderer::op_not_supported;
|
||||||
use crate::openhome_client::{
|
use crate::openhome_client::{
|
||||||
OhInfoClient, OhPlaylistClient, OhRadioClient, OhTimeClient, OhVolumeClient,
|
OhInfoClient, OhPlaylistClient, OhRadioClient, OhTimeClient, OhTrackEntry, OhVolumeClient,
|
||||||
|
parse_track_metadata_from_didl,
|
||||||
};
|
};
|
||||||
use crate::registry::DeviceRegistry;
|
use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
|
||||||
|
use anyhow::{Result, anyhow};
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -23,14 +24,13 @@ pub struct OpenHomeRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl OpenHomeRenderer {
|
impl OpenHomeRenderer {
|
||||||
pub fn new(info: RendererInfo, registry: &DeviceRegistry) -> Self {
|
pub fn new(info: RendererInfo) -> Self {
|
||||||
let id = info.id.clone();
|
|
||||||
Self {
|
Self {
|
||||||
playlist: registry.oh_playlist_client_for_renderer(&id),
|
playlist: build_playlist_client(&info),
|
||||||
info_client: registry.oh_info_client_for_renderer(&id),
|
info_client: build_info_client(&info),
|
||||||
time_client: registry.oh_time_client_for_renderer(&id),
|
time_client: build_time_client(&info),
|
||||||
volume_client: registry.oh_volume_client_for_renderer(&id),
|
volume_client: build_volume_client(&info),
|
||||||
radio_client: registry.oh_radio_client_for_renderer(&id),
|
radio_client: build_radio_client(&info),
|
||||||
info,
|
info,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,6 +90,53 @@ impl OpenHomeRenderer {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| op_not_supported(op, "OpenHome Volume"))
|
.ok_or_else(|| op_not_supported(op, "OpenHome Volume"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn snapshot_openhome_playlist(&self) -> Result<OpenHomePlaylistSnapshot> {
|
||||||
|
let playlist = self.playlist_client_for("snapshot_openhome_playlist")?;
|
||||||
|
let entries = playlist.read_all_tracks()?;
|
||||||
|
let current_id = self
|
||||||
|
.info_client
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|client| client.id().ok());
|
||||||
|
|
||||||
|
let tracks = entries.iter().map(convert_oh_track_entry).collect();
|
||||||
|
|
||||||
|
Ok(OpenHomePlaylistSnapshot {
|
||||||
|
renderer_id: self.info.id.0.clone(),
|
||||||
|
current_id,
|
||||||
|
tracks,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn clear_openhome_playlist(&self) -> Result<()> {
|
||||||
|
let playlist = self.playlist_client_for("clear_openhome_playlist")?;
|
||||||
|
playlist.delete_all()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn add_track_openhome(
|
||||||
|
&self,
|
||||||
|
uri: &str,
|
||||||
|
metadata: &str,
|
||||||
|
after_id: Option<u32>,
|
||||||
|
play: bool,
|
||||||
|
) -> Result<u32> {
|
||||||
|
let playlist = self.playlist_client_for("add_track_openhome")?;
|
||||||
|
let insert_after = match after_id {
|
||||||
|
Some(id) => id,
|
||||||
|
None => playlist.id_array()?.last().copied().unwrap_or(0),
|
||||||
|
};
|
||||||
|
|
||||||
|
let new_id = playlist.insert(insert_after, uri, metadata)?;
|
||||||
|
if play {
|
||||||
|
playlist.play_id(new_id)?;
|
||||||
|
}
|
||||||
|
Ok(new_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn play_openhome_track_id(&self, id: u32) -> Result<()> {
|
||||||
|
let playlist = self.playlist_client_for("play_openhome_track_id")?;
|
||||||
|
playlist.play_id(id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TransportControl for OpenHomeRenderer {
|
impl TransportControl for OpenHomeRenderer {
|
||||||
@@ -237,3 +284,54 @@ pub(crate) fn format_seconds(seconds: u32) -> String {
|
|||||||
let secs = seconds % 60;
|
let secs = seconds % 60;
|
||||||
format!("{hours:02}:{minutes:02}:{secs:02}")
|
format!("{hours:02}:{minutes:02}:{secs:02}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn convert_oh_track_entry(entry: &OhTrackEntry) -> OpenHomePlaylistTrack {
|
||||||
|
let metadata = parse_track_metadata_from_didl(&entry.metadata_xml);
|
||||||
|
OpenHomePlaylistTrack {
|
||||||
|
id: entry.id,
|
||||||
|
uri: entry.uri.clone(),
|
||||||
|
title: metadata.as_ref().and_then(|m| m.title.clone()),
|
||||||
|
artist: metadata.as_ref().and_then(|m| m.artist.clone()),
|
||||||
|
album: metadata.as_ref().and_then(|m| m.album.clone()),
|
||||||
|
album_art_uri: metadata.and_then(|m| m.album_art_uri),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_playlist_client(info: &RendererInfo) -> Option<OhPlaylistClient> {
|
||||||
|
let control_url = info.oh_playlist_control_url.as_ref()?;
|
||||||
|
let service_type = info.oh_playlist_service_type.as_ref()?;
|
||||||
|
Some(OhPlaylistClient::new(
|
||||||
|
control_url.clone(),
|
||||||
|
service_type.clone(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_info_client(info: &RendererInfo) -> Option<OhInfoClient> {
|
||||||
|
let control_url = info.oh_info_control_url.as_ref()?;
|
||||||
|
let service_type = info.oh_info_service_type.as_ref()?;
|
||||||
|
Some(OhInfoClient::new(control_url.clone(), service_type.clone()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_time_client(info: &RendererInfo) -> Option<OhTimeClient> {
|
||||||
|
let control_url = info.oh_time_control_url.as_ref()?;
|
||||||
|
let service_type = info.oh_time_service_type.as_ref()?;
|
||||||
|
Some(OhTimeClient::new(control_url.clone(), service_type.clone()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_volume_client(info: &RendererInfo) -> Option<OhVolumeClient> {
|
||||||
|
let control_url = info.oh_volume_control_url.as_ref()?;
|
||||||
|
let service_type = info.oh_volume_service_type.as_ref()?;
|
||||||
|
Some(OhVolumeClient::new(
|
||||||
|
control_url.clone(),
|
||||||
|
service_type.clone(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_radio_client(info: &RendererInfo) -> Option<OhRadioClient> {
|
||||||
|
let control_url = info.oh_radio_control_url.as_ref()?;
|
||||||
|
let service_type = info.oh_radio_service_type.as_ref()?;
|
||||||
|
Some(OhRadioClient::new(
|
||||||
|
control_url.clone(),
|
||||||
|
service_type.clone(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|||||||
@@ -64,7 +64,10 @@ impl PlaybackItem {
|
|||||||
|
|
||||||
if let Some(album_art) = &self.album_art_uri {
|
if let Some(album_art) = &self.album_art_uri {
|
||||||
let escaped_art = escape(album_art);
|
let escaped_art = escape(album_art);
|
||||||
didl.push_str(&format!("<upnp:albumArtURI>{}</upnp:albumArtURI>", escaped_art));
|
didl.push_str(&format!(
|
||||||
|
"<upnp:albumArtURI>{}</upnp:albumArtURI>",
|
||||||
|
escaped_art
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(date) = &self.date {
|
if let Some(date) = &self.date {
|
||||||
|
|||||||
@@ -4,25 +4,20 @@
|
|||||||
//! 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::ControlPoint;
|
use crate::control_point::{ControlPoint, OpenHomeAccessError};
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use crate::media_server::{MediaBrowser, MediaEntry, MediaResource, MusicServer, ServerId};
|
use crate::media_server::{MediaBrowser, MediaEntry, MediaResource, MusicServer, ServerId};
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use crate::model::{RendererCapabilities, RendererId, RendererProtocol};
|
use crate::model::{RendererCapabilities, RendererId, RendererProtocol};
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use crate::playback_queue::PlaybackItem;
|
|
||||||
#[cfg(feature = "pmoserver")]
|
|
||||||
use crate::openapi::{
|
use crate::openapi::{
|
||||||
AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry, ErrorResponse,
|
AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry, ErrorResponse,
|
||||||
MediaServerSummary, OpenHomePlaylistAddRequest, OpenHomePlaylistSnapshot,
|
MediaServerSummary, OpenHomePlaylistAddRequest, OpenHomePlaylistSnapshot, PlayContentRequest,
|
||||||
OpenHomePlaylistTrack, PlayContentRequest, QueueItem, QueueSnapshot,
|
QueueItem, QueueSnapshot, RendererCapabilitiesSummary, RendererProtocolSummary, RendererState,
|
||||||
RendererCapabilitiesSummary, RendererProtocolSummary, RendererState, RendererSummary,
|
RendererSummary, SuccessResponse, VolumeSetRequest,
|
||||||
SuccessResponse, VolumeSetRequest,
|
|
||||||
};
|
};
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use crate::openhome_client::{
|
use crate::playback_queue::PlaybackItem;
|
||||||
OhInfoClient, OhPlaylistClient, OhTrackEntry, parse_track_metadata_from_didl,
|
|
||||||
};
|
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use crate::{PlaybackPosition, PlaybackStatus, TransportControl, VolumeControl};
|
use crate::{PlaybackPosition, PlaybackStatus, TransportControl, VolumeControl};
|
||||||
|
|
||||||
@@ -178,7 +173,10 @@ async fn get_renderer_state(
|
|||||||
.unwrap_or((None, None));
|
.unwrap_or((None, None));
|
||||||
|
|
||||||
// Volume et mute
|
// Volume et mute
|
||||||
let volume = renderer_clone.volume().ok().and_then(|v| u8::try_from(v).ok());
|
let volume = renderer_clone
|
||||||
|
.volume()
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| u8::try_from(v).ok());
|
||||||
let mute = renderer_clone.mute().ok();
|
let mute = renderer_clone.mute().ok();
|
||||||
|
|
||||||
(transport_state, position_ms, duration_ms, volume, mute)
|
(transport_state, position_ms, duration_ms, volume, mute)
|
||||||
@@ -610,9 +608,8 @@ async fn resume_renderer(
|
|||||||
let control_point = state.control_point.clone();
|
let control_point = state.control_point.clone();
|
||||||
let rid_clone = rid.clone();
|
let rid_clone = rid.clone();
|
||||||
|
|
||||||
let resume_task = tokio::task::spawn_blocking(move || {
|
let resume_task =
|
||||||
control_point.play_current_from_queue(&rid_clone)
|
tokio::task::spawn_blocking(move || control_point.play_current_from_queue(&rid_clone));
|
||||||
});
|
|
||||||
|
|
||||||
time::timeout(QUEUE_COMMAND_TIMEOUT, resume_task)
|
time::timeout(QUEUE_COMMAND_TIMEOUT, resume_task)
|
||||||
.await
|
.await
|
||||||
@@ -641,10 +638,7 @@ async fn resume_renderer(
|
|||||||
)
|
)
|
||||||
})?
|
})?
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
warn!(
|
warn!("Failed to resume renderer {}: {}", renderer_id, e);
|
||||||
"Failed to resume renderer {}: {}",
|
|
||||||
renderer_id, e
|
|
||||||
);
|
|
||||||
(
|
(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
Json(ErrorResponse {
|
Json(ErrorResponse {
|
||||||
@@ -681,9 +675,8 @@ async fn next_renderer(
|
|||||||
let control_point = state.control_point.clone();
|
let control_point = state.control_point.clone();
|
||||||
let rid_clone = rid.clone();
|
let rid_clone = rid.clone();
|
||||||
|
|
||||||
let next_task = tokio::task::spawn_blocking(move || {
|
let next_task =
|
||||||
control_point.play_next_from_queue(&rid_clone)
|
tokio::task::spawn_blocking(move || control_point.play_next_from_queue(&rid_clone));
|
||||||
});
|
|
||||||
|
|
||||||
time::timeout(QUEUE_COMMAND_TIMEOUT, next_task)
|
time::timeout(QUEUE_COMMAND_TIMEOUT, next_task)
|
||||||
.await
|
.await
|
||||||
@@ -771,9 +764,7 @@ async fn set_renderer_volume(
|
|||||||
|
|
||||||
let renderer_clone = renderer.clone();
|
let renderer_clone = renderer.clone();
|
||||||
let volume = req.volume;
|
let volume = req.volume;
|
||||||
let volume_task = tokio::task::spawn_blocking(move || {
|
let volume_task = tokio::task::spawn_blocking(move || renderer_clone.set_volume(volume as u16));
|
||||||
renderer_clone.set_volume(volume as u16)
|
|
||||||
});
|
|
||||||
|
|
||||||
time::timeout(VOLUME_COMMAND_TIMEOUT, volume_task)
|
time::timeout(VOLUME_COMMAND_TIMEOUT, volume_task)
|
||||||
.await
|
.await
|
||||||
@@ -1195,20 +1186,11 @@ async fn get_openhome_playlist(
|
|||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
) -> Result<Json<OpenHomePlaylistSnapshot>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<OpenHomePlaylistSnapshot>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = RendererId(renderer_id.clone());
|
let rid = RendererId(renderer_id.clone());
|
||||||
let (playlist_client, info_client) =
|
let control_point = Arc::clone(&state.control_point);
|
||||||
openhome_clients_for_renderer(&state.control_point, &rid)?;
|
let rid_for_task = rid.clone();
|
||||||
|
|
||||||
let fetch_task = tokio::task::spawn_blocking(move || -> anyhow::Result<OpenHomePlaylistSnapshot> {
|
let fetch_task = tokio::task::spawn_blocking(move || {
|
||||||
let entries = playlist_client.read_all_tracks()?;
|
control_point.get_openhome_playlist_snapshot(&rid_for_task)
|
||||||
let current_id = info_client
|
|
||||||
.and_then(|client| client.id().ok());
|
|
||||||
let tracks: Vec<OpenHomePlaylistTrack> =
|
|
||||||
entries.iter().map(convert_openhome_track).collect();
|
|
||||||
Ok(OpenHomePlaylistSnapshot {
|
|
||||||
renderer_id: rid.0,
|
|
||||||
current_id,
|
|
||||||
tracks,
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let snapshot = fetch_task
|
let snapshot = fetch_task
|
||||||
@@ -1232,12 +1214,7 @@ async fn get_openhome_playlist(
|
|||||||
error = %e,
|
error = %e,
|
||||||
"Failed to read OpenHome playlist"
|
"Failed to read OpenHome playlist"
|
||||||
);
|
);
|
||||||
(
|
map_openhome_error(&rid, e, "read OpenHome playlist")
|
||||||
StatusCode::BAD_GATEWAY,
|
|
||||||
Json(ErrorResponse {
|
|
||||||
error: format!("Failed to read OpenHome playlist: {}", e),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(Json(snapshot))
|
Ok(Json(snapshot))
|
||||||
@@ -1262,11 +1239,11 @@ async fn clear_openhome_playlist(
|
|||||||
Path(renderer_id): Path<String>,
|
Path(renderer_id): Path<String>,
|
||||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = RendererId(renderer_id.clone());
|
let rid = RendererId(renderer_id.clone());
|
||||||
let (playlist_client, _) =
|
let control_point = Arc::clone(&state.control_point);
|
||||||
openhome_clients_for_renderer(&state.control_point, &rid)?;
|
let rid_for_task = rid.clone();
|
||||||
|
|
||||||
let clear_task =
|
let clear_task =
|
||||||
tokio::task::spawn_blocking(move || playlist_client.delete_all());
|
tokio::task::spawn_blocking(move || control_point.clear_openhome_playlist(&rid_for_task));
|
||||||
|
|
||||||
time::timeout(QUEUE_COMMAND_TIMEOUT, clear_task)
|
time::timeout(QUEUE_COMMAND_TIMEOUT, clear_task)
|
||||||
.await
|
.await
|
||||||
@@ -1305,22 +1282,9 @@ async fn clear_openhome_playlist(
|
|||||||
error = %e,
|
error = %e,
|
||||||
"Failed to clear OpenHome playlist"
|
"Failed to clear OpenHome playlist"
|
||||||
);
|
);
|
||||||
(
|
map_openhome_error(&rid, e, "clear OpenHome playlist")
|
||||||
StatusCode::BAD_GATEWAY,
|
|
||||||
Json(ErrorResponse {
|
|
||||||
error: format!("Failed to clear OpenHome playlist: {}", e),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if let Err(err) = state.control_point.refresh_openhome_playlist(&rid) {
|
|
||||||
warn!(
|
|
||||||
renderer = renderer_id.as_str(),
|
|
||||||
error = %err,
|
|
||||||
"Failed to refresh queue after OpenHome clear"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Json(SuccessResponse {
|
Ok(Json(SuccessResponse {
|
||||||
message: "OpenHome playlist cleared".to_string(),
|
message: "OpenHome playlist cleared".to_string(),
|
||||||
}))
|
}))
|
||||||
@@ -1347,26 +1311,17 @@ async fn add_openhome_playlist_item(
|
|||||||
Json(req): Json<OpenHomePlaylistAddRequest>,
|
Json(req): Json<OpenHomePlaylistAddRequest>,
|
||||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = RendererId(renderer_id.clone());
|
let rid = RendererId(renderer_id.clone());
|
||||||
let (playlist_client, _) =
|
let control_point = Arc::clone(&state.control_point);
|
||||||
openhome_clients_for_renderer(&state.control_point, &rid)?;
|
let rid_for_task = rid.clone();
|
||||||
|
|
||||||
let add_task = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
|
let add_task = tokio::task::spawn_blocking(move || {
|
||||||
let mut after_id = if let Some(id) = req.after_id {
|
control_point.add_openhome_track(
|
||||||
id
|
&rid_for_task,
|
||||||
} else {
|
&req.uri,
|
||||||
playlist_client
|
&req.metadata,
|
||||||
.id_array()?
|
req.after_id,
|
||||||
.last()
|
req.play,
|
||||||
.copied()
|
)
|
||||||
.unwrap_or(0)
|
|
||||||
};
|
|
||||||
let new_id =
|
|
||||||
playlist_client.insert(after_id, &req.uri, &req.metadata)?;
|
|
||||||
after_id = new_id;
|
|
||||||
if req.play {
|
|
||||||
playlist_client.play_id(after_id)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
});
|
});
|
||||||
|
|
||||||
time::timeout(QUEUE_COMMAND_TIMEOUT, add_task)
|
time::timeout(QUEUE_COMMAND_TIMEOUT, add_task)
|
||||||
@@ -1406,22 +1361,9 @@ async fn add_openhome_playlist_item(
|
|||||||
error = %e,
|
error = %e,
|
||||||
"Failed to add OpenHome track"
|
"Failed to add OpenHome track"
|
||||||
);
|
);
|
||||||
(
|
map_openhome_error(&rid, e, "add OpenHome track")
|
||||||
StatusCode::BAD_GATEWAY,
|
|
||||||
Json(ErrorResponse {
|
|
||||||
error: format!("Failed to add OpenHome track: {}", e),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if let Err(err) = state.control_point.refresh_openhome_playlist(&rid) {
|
|
||||||
warn!(
|
|
||||||
renderer = renderer_id.as_str(),
|
|
||||||
error = %err,
|
|
||||||
"Failed to refresh queue after OpenHome add"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Json(SuccessResponse {
|
Ok(Json(SuccessResponse {
|
||||||
message: "Track added to OpenHome playlist".to_string(),
|
message: "Track added to OpenHome playlist".to_string(),
|
||||||
}))
|
}))
|
||||||
@@ -1447,9 +1389,6 @@ async fn play_openhome_track(
|
|||||||
Path((renderer_id, track_id)): Path<(String, String)>,
|
Path((renderer_id, track_id)): Path<(String, String)>,
|
||||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||||
let rid = RendererId(renderer_id.clone());
|
let rid = RendererId(renderer_id.clone());
|
||||||
let (playlist_client, _) =
|
|
||||||
openhome_clients_for_renderer(&state.control_point, &rid)?;
|
|
||||||
|
|
||||||
let parsed_id = track_id.parse::<u32>().map_err(|e| {
|
let parsed_id = track_id.parse::<u32>().map_err(|e| {
|
||||||
(
|
(
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
@@ -1459,8 +1398,12 @@ async fn play_openhome_track(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let play_task =
|
let control_point = Arc::clone(&state.control_point);
|
||||||
tokio::task::spawn_blocking(move || playlist_client.play_id(parsed_id));
|
let rid_for_task = rid.clone();
|
||||||
|
|
||||||
|
let play_task = tokio::task::spawn_blocking(move || {
|
||||||
|
control_point.play_openhome_track_id(&rid_for_task, parsed_id)
|
||||||
|
});
|
||||||
|
|
||||||
time::timeout(QUEUE_COMMAND_TIMEOUT, play_task)
|
time::timeout(QUEUE_COMMAND_TIMEOUT, play_task)
|
||||||
.await
|
.await
|
||||||
@@ -1500,12 +1443,7 @@ async fn play_openhome_track(
|
|||||||
track_id = parsed_id,
|
track_id = parsed_id,
|
||||||
"Failed to start OpenHome track"
|
"Failed to start OpenHome track"
|
||||||
);
|
);
|
||||||
(
|
map_openhome_error(&rid, e, "play OpenHome track")
|
||||||
StatusCode::BAD_GATEWAY,
|
|
||||||
Json(ErrorResponse {
|
|
||||||
error: format!("Failed to play OpenHome track: {}", e),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(Json(SuccessResponse {
|
Ok(Json(SuccessResponse {
|
||||||
@@ -1587,7 +1525,11 @@ async fn play_content(
|
|||||||
item_count = items.len(),
|
item_count = items.len(),
|
||||||
"Auto-binding playlist to renderer queue (without initial refresh)"
|
"Auto-binding playlist to renderer queue (without initial refresh)"
|
||||||
);
|
);
|
||||||
control_point.attach_queue_to_playlist_without_refresh(&rid, sid.clone(), object_id.clone());
|
control_point.attach_queue_to_playlist_without_refresh(
|
||||||
|
&rid,
|
||||||
|
sid.clone(),
|
||||||
|
object_id.clone(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok::<(), anyhow::Error>(())
|
Ok::<(), anyhow::Error>(())
|
||||||
@@ -1724,7 +1666,10 @@ async fn add_to_queue(
|
|||||||
)
|
)
|
||||||
})?
|
})?
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
warn!("Failed to add content to queue for renderer {}: {}", renderer_id, e);
|
warn!(
|
||||||
|
"Failed to add content to queue for renderer {}: {}",
|
||||||
|
renderer_id, e
|
||||||
|
);
|
||||||
(
|
(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
Json(ErrorResponse {
|
Json(ErrorResponse {
|
||||||
@@ -1904,6 +1849,32 @@ async fn browse_container(
|
|||||||
// HELPERS
|
// HELPERS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
|
fn map_openhome_error(
|
||||||
|
renderer_id: &RendererId,
|
||||||
|
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.
|
||||||
@@ -2017,42 +1988,6 @@ fn capability_summary(caps: &RendererCapabilities) -> RendererCapabilitiesSummar
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "pmoserver")]
|
|
||||||
fn openhome_clients_for_renderer(
|
|
||||||
control_point: &ControlPoint,
|
|
||||||
renderer_id: &RendererId,
|
|
||||||
) -> Result<(OhPlaylistClient, Option<OhInfoClient>), (StatusCode, Json<ErrorResponse>)> {
|
|
||||||
let registry = control_point.registry();
|
|
||||||
let reg = registry.read().unwrap();
|
|
||||||
let Some(client) = reg.oh_playlist_client_for_renderer(renderer_id) else {
|
|
||||||
return Err((
|
|
||||||
StatusCode::NOT_FOUND,
|
|
||||||
Json(ErrorResponse {
|
|
||||||
error: format!(
|
|
||||||
"Renderer {} has no OpenHome playlist service",
|
|
||||||
renderer_id.0
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
));
|
|
||||||
};
|
|
||||||
let info_client = reg.oh_info_client_for_renderer(renderer_id);
|
|
||||||
Ok((client, info_client))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "pmoserver")]
|
|
||||||
fn convert_openhome_track(entry: &OhTrackEntry) -> OpenHomePlaylistTrack {
|
|
||||||
let metadata = parse_track_metadata_from_didl(&entry.metadata_xml);
|
|
||||||
OpenHomePlaylistTrack {
|
|
||||||
id: entry.id,
|
|
||||||
uri: entry.uri.clone(),
|
|
||||||
title: metadata.as_ref().and_then(|m| m.title.clone()),
|
|
||||||
artist: metadata.as_ref().and_then(|m| m.artist.clone()),
|
|
||||||
album: metadata.as_ref().and_then(|m| m.album.clone()),
|
|
||||||
album_art_uri: metadata
|
|
||||||
.and_then(|m| m.album_art_uri),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
fn state_to_string(state: crate::PlaybackState) -> String {
|
fn state_to_string(state: crate::PlaybackState) -> String {
|
||||||
use crate::PlaybackState;
|
use crate::PlaybackState;
|
||||||
@@ -2147,14 +2082,8 @@ pub fn create_api_router(state: ControlPointState, control_point: Arc<ControlPoi
|
|||||||
post(play_openhome_track),
|
post(play_openhome_track),
|
||||||
)
|
)
|
||||||
// Queue content
|
// Queue content
|
||||||
.route(
|
.route("/renderers/{renderer_id}/queue/play", post(play_content))
|
||||||
"/renderers/{renderer_id}/queue/play",
|
.route("/renderers/{renderer_id}/queue/add", post(add_to_queue))
|
||||||
post(play_content),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/renderers/{renderer_id}/queue/add",
|
|
||||||
post(add_to_queue),
|
|
||||||
)
|
|
||||||
// Servers
|
// Servers
|
||||||
.route("/servers", get(list_servers))
|
.route("/servers", get(list_servers))
|
||||||
.route(
|
.route(
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ use crate::avtransport_client::AvTransportClient;
|
|||||||
use crate::connection_manager_client::ConnectionManagerClient;
|
use crate::connection_manager_client::ConnectionManagerClient;
|
||||||
use crate::media_server::{MediaServerInfo, ServerId};
|
use crate::media_server::{MediaServerInfo, ServerId};
|
||||||
use crate::model::{RendererId, RendererInfo};
|
use crate::model::{RendererId, RendererInfo};
|
||||||
use crate::openhome_client::{
|
|
||||||
OhInfoClient, OhPlaylistClient, OhRadioClient, OhTimeClient, OhVolumeClient,
|
|
||||||
};
|
|
||||||
use crate::rendering_control_client::RenderingControlClient;
|
use crate::rendering_control_client::RenderingControlClient;
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
|
|
||||||
@@ -206,54 +203,4 @@ impl DeviceRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn oh_playlist_client_for_renderer(&self, id: &RendererId) -> Option<OhPlaylistClient> {
|
|
||||||
let info = self.renderers.get(id)?;
|
|
||||||
let service_type = info.oh_playlist_service_type.as_ref()?;
|
|
||||||
let control_url = info.oh_playlist_control_url.as_ref()?;
|
|
||||||
Some(OhPlaylistClient::new(
|
|
||||||
control_url.clone(),
|
|
||||||
service_type.clone(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn oh_info_client_for_renderer(&self, id: &RendererId) -> Option<OhInfoClient> {
|
|
||||||
let info = self.renderers.get(id)?;
|
|
||||||
let service_type = info.oh_info_service_type.as_ref()?;
|
|
||||||
let control_url = info.oh_info_control_url.as_ref()?;
|
|
||||||
Some(OhInfoClient::new(
|
|
||||||
control_url.clone(),
|
|
||||||
service_type.clone(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn oh_time_client_for_renderer(&self, id: &RendererId) -> Option<OhTimeClient> {
|
|
||||||
let info = self.renderers.get(id)?;
|
|
||||||
let service_type = info.oh_time_service_type.as_ref()?;
|
|
||||||
let control_url = info.oh_time_control_url.as_ref()?;
|
|
||||||
Some(OhTimeClient::new(
|
|
||||||
control_url.clone(),
|
|
||||||
service_type.clone(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn oh_volume_client_for_renderer(&self, id: &RendererId) -> Option<OhVolumeClient> {
|
|
||||||
let info = self.renderers.get(id)?;
|
|
||||||
let service_type = info.oh_volume_service_type.as_ref()?;
|
|
||||||
let control_url = info.oh_volume_control_url.as_ref()?;
|
|
||||||
Some(OhVolumeClient::new(
|
|
||||||
control_url.clone(),
|
|
||||||
service_type.clone(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn oh_radio_client_for_renderer(&self, id: &RendererId) -> Option<OhRadioClient> {
|
|
||||||
let info = self.renderers.get(id)?;
|
|
||||||
let service_type = info.oh_radio_service_type.as_ref()?;
|
|
||||||
let control_url = info.oh_radio_control_url.as_ref()?;
|
|
||||||
Some(OhRadioClient::new(
|
|
||||||
control_url.clone(),
|
|
||||||
service_type.clone(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user