Compare commits
1 Commits
version-0.
...
push-lxpmr
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f5fa69e1a |
@@ -1,3 +0,0 @@
|
||||
# Configuration PATH pour Claude Code
|
||||
# Ce fichier sera lu automatiquement pour configurer l'environnement
|
||||
export PATH="/Users/coissac/mamba/condabin:/opt/homebrew/lib/ruby/gems/3.4.0/bin:/opt/homebrew/opt/ruby/bin:/Users/coissac/go/bin:/Users/coissac/.cargo/bin:/Users/coissac/.modular/pkg/packages.modular.com_mojo/bin:/Applications/quarto/bin:/Users/coissac/.vscode-oss/extensions/vadimcn.vscode-lldb-1.12.0/bin:/Library/Frameworks/Python.framework/Versions/3.12/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/opt/pmk/env/global/bin:/opt/X11/bin:/usr/local/dorado/dorado-0.9.5-osx-arm64/bin:/usr/local/go/bin:/usr/local/src/last-main/bin:/Users/coissac/travail/__MOI__/GO/obitools4/build:/opt/podman/bin:/Applications/quarto/bin:/Users/coissac/.cargo/bin:/Users/coissac/.vscode-oss/extensions/vadimcn.vscode-lldb-1.12.0/bin:/Users/coissac/.vscode-oss/extensions/ms-python.debugpy-2025.14.1-darwin-arm64/bundled/scripts/noConfigScripts:/Users/coissac/.orbstack/bin"
|
||||
@@ -1,31 +0,0 @@
|
||||
# PMOMusic Project Configuration
|
||||
|
||||
## Version Control
|
||||
Ce projet utilise **Jujutsu (jj)** pour le contrôle de version, PAS git.
|
||||
- Utiliser les commandes `jj` au lieu des commandes `git`
|
||||
- Bookmark principal : `main`
|
||||
- Ne jamais suggérer de commandes git
|
||||
|
||||
## Environnement
|
||||
Le PATH et les variables d'environnement sont configurés dans `.claude-env` à la racine du projet.
|
||||
|
||||
## Configuration de l'application
|
||||
- Fichier de configuration principal : `.pmomusic/config.yaml`
|
||||
- Configuration UPNP personnalisable pour différencier les instances en développement
|
||||
|
||||
## Développement
|
||||
Pendant le développement, plusieurs serveurs PMOMusic peuvent tourner en parallèle. Utiliser la configuration UPNP dans `.pmomusic/config.yaml` pour différencier les instances :
|
||||
|
||||
```yaml
|
||||
host:
|
||||
upnp:
|
||||
manufacturer: "PMOMusic-Dev1"
|
||||
udn_prefix: "pmomusic-dev1"
|
||||
model_name_prefix: "PMOMusic-Dev1"
|
||||
friendly_name_prefix: "PMOMusic-Dev1"
|
||||
```
|
||||
|
||||
## Architecture
|
||||
- Projet Rust multi-crates avec workspaces
|
||||
- Crates principales : pmoupnp, pmomediaserver, pmomediarenderer, pmoconfig
|
||||
- Pattern d'extension de configuration via traits (voir pmocache/src/config_ext.rs)
|
||||
50
.vscode/settings.json
vendored
50
.vscode/settings.json
vendored
@@ -1,37 +1,15 @@
|
||||
{
|
||||
"makefile.configureOnOpen": false,
|
||||
"git.enabled": false,
|
||||
"claude-code.environmentVariables": [],
|
||||
|
||||
// Exclusions de fichiers/dossiers inutiles pour Rust Analyzer
|
||||
"files.exclude": {
|
||||
"target": true,
|
||||
"**/target": true,
|
||||
"node_modules": true,
|
||||
"tests/huge_benchmarks": true,
|
||||
"examples": true,
|
||||
"docs": true
|
||||
},
|
||||
// Rust Analyzer settings
|
||||
"rust-analyzer.rustupPath": "/Users/coissac/.cargo/bin/rustup",
|
||||
"rust-analyzer.cargoPath": "/Users/coissac/.cargo/bin/cargo",
|
||||
"rust-analyzer.rustcSource": "discover",
|
||||
"rust-analyzer.procMacro.enable": true,
|
||||
"rust-analyzer.numThreads": 4,
|
||||
"rust-analyzer.cargo.loadOutDirsFromCheck": false,
|
||||
"rust-analyzer.checkOnSave.enable": true,
|
||||
"rust-analyzer.checkOnSave.command": "check",
|
||||
"rust-analyzer.checkOnSave.extraArgs": ["--all-features"],
|
||||
"rust-analyzer.exclude": [
|
||||
"target",
|
||||
"tests/huge_benchmarks",
|
||||
"examples",
|
||||
"docs"
|
||||
],
|
||||
"rust-analyzer.server.extraEnv": {
|
||||
"RA_LARGE_PROJECT": "1"
|
||||
},
|
||||
"rust-analyzer.runnables.command": null,
|
||||
"rust-analyzer.server.path": null,
|
||||
"rust-analyzer.cargo.allTargets": true,
|
||||
}
|
||||
"makefile.configureOnOpen": false,
|
||||
"git.enabled": false,
|
||||
"claude-code.environmentVariables": [
|
||||
|
||||
],
|
||||
// Exclusions via VS Code
|
||||
"files.exclude": {
|
||||
"target": true,
|
||||
"**/target": true,
|
||||
"node_modules": true
|
||||
},
|
||||
"rust-analyzer.procMacro.enable": true,
|
||||
"rust-analyzer.numThreads": 4
|
||||
}
|
||||
48
Cargo.lock
generated
48
Cargo.lock
generated
@@ -306,17 +306,6 @@ dependencies = [
|
||||
"rustix 1.1.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-recursion"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-signal"
|
||||
version = "0.2.13"
|
||||
@@ -3642,7 +3631,7 @@ dependencies = [
|
||||
"pmoflac",
|
||||
"pmometadata",
|
||||
"pmoserver",
|
||||
"quick-xml",
|
||||
"quick-xml 0.37.5",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3721,7 +3710,7 @@ dependencies = [
|
||||
"pmodidl",
|
||||
"pmoserver",
|
||||
"pmoupnp",
|
||||
"quick-xml",
|
||||
"quick-xml 0.38.4",
|
||||
"ratatui",
|
||||
"rust_cast",
|
||||
"rustls",
|
||||
@@ -3769,7 +3758,7 @@ dependencies = [
|
||||
"bevy_reflect",
|
||||
"bevy_reflect_derive",
|
||||
"pmoutils",
|
||||
"quick-xml",
|
||||
"quick-xml 0.38.4",
|
||||
"serde",
|
||||
"utoipa",
|
||||
"utoipa-swagger-ui",
|
||||
@@ -3790,7 +3779,7 @@ dependencies = [
|
||||
"opus",
|
||||
"pmometadata",
|
||||
"tempfile",
|
||||
"thiserror 2.0.17",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
@@ -3804,7 +3793,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"pmodidl",
|
||||
"pmoupnp",
|
||||
"quick-xml",
|
||||
"quick-xml 0.38.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3827,10 +3816,10 @@ dependencies = [
|
||||
"pmosource",
|
||||
"pmoupnp",
|
||||
"pmoutils",
|
||||
"quick-xml",
|
||||
"quick-xml 0.38.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.17",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
@@ -3909,7 +3898,7 @@ dependencies = [
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.17",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
@@ -3946,7 +3935,7 @@ dependencies = [
|
||||
"serde_yaml",
|
||||
"sha1",
|
||||
"tempfile",
|
||||
"thiserror 2.0.17",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-test",
|
||||
"tracing",
|
||||
@@ -3998,7 +3987,7 @@ dependencies = [
|
||||
"pmoupnp",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.17",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
@@ -4010,8 +3999,6 @@ name = "pmoupnp"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
"async-trait",
|
||||
"axum 0.8.7",
|
||||
"base64 0.22.1",
|
||||
"bevy_reflect",
|
||||
@@ -4030,11 +4017,10 @@ dependencies = [
|
||||
"pmoplaylist",
|
||||
"pmoserver",
|
||||
"pmoutils",
|
||||
"quick-xml",
|
||||
"quick-xml 0.37.5",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"socket2 0.5.10",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
@@ -4053,7 +4039,7 @@ dependencies = [
|
||||
"get_if_addrs",
|
||||
"netstat2",
|
||||
"os_info",
|
||||
"quick-xml",
|
||||
"quick-xml 0.38.4",
|
||||
"sysinfo",
|
||||
"users",
|
||||
"xmltree 0.10.3",
|
||||
@@ -4337,6 +4323,16 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.37.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.4"
|
||||
|
||||
32
Cargo.toml
32
Cargo.toml
@@ -21,35 +21,3 @@ members = [
|
||||
"pmoflac",
|
||||
"pmometadata", "pmocontrol",
|
||||
]
|
||||
|
||||
[workspace.dependencies]
|
||||
# Core
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
serde_yaml = "0.9"
|
||||
|
||||
# Async runtime
|
||||
tokio = { version = "1.35", features = ["full"] }
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
async-trait = "0.1"
|
||||
|
||||
# Error handling
|
||||
anyhow = "1.0"
|
||||
thiserror = "2.0" # ⚠️ Unifier sur 2.0 (vous avez 1.0 et 2.0)
|
||||
|
||||
# Logging
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
|
||||
|
||||
# HTTP/XML
|
||||
reqwest = { version = "0.12", default-features = false }
|
||||
ureq = "3.1"
|
||||
quick-xml = { version = "0.38", features = ["serialize"] } # ⚠️ Unifier 0.37→0.38
|
||||
|
||||
# Utilities
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1.18", features = ["v4"] }
|
||||
crossbeam-channel = "0.5"
|
||||
|
||||
# Testing
|
||||
tokio-test = "0.4"
|
||||
@@ -78,7 +78,7 @@ LABEL org.opencontainers.image.title="PMOMusic UPNP Music Server"
|
||||
LABEL org.opencontainers.image.version="0.1"
|
||||
LABEL org.opencontainers.image.authors="eric@coissac.eu"
|
||||
LABEL org.opencontainers.image.licenses="CeCILL-2.0"
|
||||
LABEL org.opencontainers.image.url="https://gargoton.petite-maison-orange.fr/eric/pmomusic"
|
||||
LABEL org.opencontainers.image.source="https://gargoton.petite-maison-orange.fr/eric/pmomusic"
|
||||
LABEL org.opencontainers.image.description="PMOMusic est une application Rust proposant en un binaire unique une Serveur de Musique, et une point de controle UPNP controlable via une application web."
|
||||
|
||||
# Install only runtime dependencies
|
||||
|
||||
@@ -16,8 +16,8 @@ pmoaudio-ext = { path = "../pmoaudio-ext", features = ["all"] }
|
||||
pmoapp = { path = "../pmoapp", features = ["pmoserver"] }
|
||||
pmocontrol = { path = "../pmocontrol", features = ["pmoserver"] }
|
||||
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "time", "signal"] }
|
||||
tracing = { workspace = true }
|
||||
tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync", "time","signal"] }
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = "0.3.20"
|
||||
axum = "0.8.4"
|
||||
serde_json = "1.0.145"
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
[package]
|
||||
name = "PMOMusic"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
pmoconfig = { path = "../pmoconfig" }
|
||||
pmoupnp = { path = "../pmoupnp"}
|
||||
pmomediarenderer = { path = "../pmomediarenderer" }
|
||||
pmomediaserver = { path = "../pmomediaserver", features = ["qobuz", "paradise", "paradise-api", "api"] }
|
||||
pmosource = { path = "../pmosource", features = ["server"] }
|
||||
pmoserver = { path = "../pmoserver" }
|
||||
pmocovers = { path = "../pmocovers", features = ["pmoserver"] }
|
||||
pmoaudiocache = { path = "../pmoaudiocache", features = ["pmoserver"]}
|
||||
pmoaudio-ext = { path = "../pmoaudio-ext", features = ["all"] }
|
||||
pmoapp = { path = "../pmoapp", features = ["pmoserver"] }
|
||||
pmocontrol = { path = "../pmocontrol", features = ["pmoserver"] }
|
||||
|
||||
tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync", "time","signal"] }
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = "0.3.20"
|
||||
axum = "0.8.4"
|
||||
serde_json = "1.0.145"
|
||||
utoipa = "5.4"
|
||||
console-subscriber = "0.4.1"
|
||||
@@ -1,15 +0,0 @@
|
||||
[package]
|
||||
name = "pmoapp"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
rust-embed = "8.5.0"
|
||||
|
||||
[dependencies.pmoserver]
|
||||
path = "../pmoserver"
|
||||
optional = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
pmoserver = ["dep:pmoserver"]
|
||||
@@ -56,10 +56,6 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
try {
|
||||
await api.seekQueueIndex(props.rendererId, item.index)
|
||||
console.log('[RendererTabContent] Jumped to queue index:', item.index, item.title)
|
||||
|
||||
// Force un refetch immédiat pour synchroniser la cover affichée
|
||||
// sans attendre l'événement SSE qui peut avoir un délai
|
||||
await refresh(true)
|
||||
} catch (error) {
|
||||
console.error('[RendererTabContent] Error seeking to queue index:', error)
|
||||
uiStore.notifyError(`Erreur: ${error instanceof Error ? error.message : 'Impossible de sauter à cet item'}`)
|
||||
|
||||
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,65 +5,55 @@
|
||||
// RENDERERS
|
||||
// ============================================================================
|
||||
|
||||
export type RendererProtocolSummary =
|
||||
| "upnp"
|
||||
| "openhome"
|
||||
| "hybrid"
|
||||
| "chromecast";
|
||||
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;
|
||||
has_avtransport: boolean
|
||||
has_avtransport_set_next: boolean
|
||||
has_rendering_control: boolean
|
||||
has_connection_manager: boolean
|
||||
has_linkplay_http: boolean
|
||||
has_arylic_tcp: boolean
|
||||
has_oh_playlist: boolean
|
||||
has_oh_volume: boolean
|
||||
has_oh_info: boolean
|
||||
has_oh_time: boolean
|
||||
has_oh_radio: boolean
|
||||
}
|
||||
|
||||
export interface RendererSummary {
|
||||
id: string;
|
||||
friendly_name: string;
|
||||
model_name: string;
|
||||
protocol: RendererProtocolSummary;
|
||||
capabilities: RendererCapabilitiesSummary;
|
||||
online: boolean;
|
||||
id: string
|
||||
friendly_name: string
|
||||
model_name: string
|
||||
protocol: RendererProtocolSummary
|
||||
capabilities: RendererCapabilitiesSummary
|
||||
online: boolean
|
||||
}
|
||||
|
||||
export interface RendererState {
|
||||
id: string;
|
||||
friendly_name: string;
|
||||
transport_state:
|
||||
| "PLAYING"
|
||||
| "PAUSED"
|
||||
| "STOPPED"
|
||||
| "TRANSITIONING"
|
||||
| "NO_MEDIA"
|
||||
| "UNKNOWN";
|
||||
position_ms: number | null;
|
||||
duration_ms: number | null;
|
||||
volume: number | null; // 0-100
|
||||
mute: boolean | null;
|
||||
queue_len: number;
|
||||
attached_playlist: AttachedPlaylistInfo | null;
|
||||
current_track: CurrentTrackMetadata | null;
|
||||
id: string
|
||||
friendly_name: string
|
||||
transport_state: 'PLAYING' | 'PAUSED' | 'STOPPED' | 'TRANSITIONING' | 'NO_MEDIA' | 'UNKNOWN'
|
||||
position_ms: number | null
|
||||
duration_ms: number | null
|
||||
volume: number | null // 0-100
|
||||
mute: boolean | null
|
||||
queue_len: number
|
||||
attached_playlist: AttachedPlaylistInfo | null
|
||||
current_track: CurrentTrackMetadata | null
|
||||
}
|
||||
|
||||
export interface CurrentTrackMetadata {
|
||||
title: string | null;
|
||||
artist: string | null;
|
||||
album: string | null;
|
||||
album_art_uri: string | null;
|
||||
title: string | null
|
||||
artist: string | null
|
||||
album: string | null
|
||||
album_art_uri: string | null
|
||||
}
|
||||
|
||||
export interface AttachedPlaylistInfo {
|
||||
server_id: string;
|
||||
container_id: string;
|
||||
has_seen_update: boolean;
|
||||
server_id: string
|
||||
container_id: string
|
||||
has_seen_update: boolean
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -71,26 +61,53 @@ export interface AttachedPlaylistInfo {
|
||||
// ============================================================================
|
||||
|
||||
export interface QueueItem {
|
||||
index: number; // 0-based
|
||||
uri: string;
|
||||
title: string | null;
|
||||
artist: string | null;
|
||||
album: string | null;
|
||||
album_art_uri: string | null;
|
||||
server_id: string | null;
|
||||
object_id: string | null;
|
||||
index: number // 0-based
|
||||
uri: string
|
||||
title: string | null
|
||||
artist: string | null
|
||||
album: string | null
|
||||
album_art_uri: string | null
|
||||
server_id: string | null
|
||||
object_id: string | null
|
||||
}
|
||||
|
||||
export interface QueueSnapshot {
|
||||
renderer_id: string;
|
||||
items: QueueItem[];
|
||||
current_index: number | null; // Index de la piste en cours (null si rien en lecture)
|
||||
renderer_id: string
|
||||
items: QueueItem[]
|
||||
current_index: number | null // Index de la piste en cours (null si rien en lecture)
|
||||
}
|
||||
|
||||
export interface FullRendererSnapshot {
|
||||
state: RendererState;
|
||||
queue: QueueSnapshot;
|
||||
binding: AttachedPlaylistInfo | null;
|
||||
state: RendererState
|
||||
queue: QueueSnapshot
|
||||
binding: AttachedPlaylistInfo | null
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OPENHOME PLAYLIST
|
||||
// ============================================================================
|
||||
|
||||
export interface OpenHomePlaylistTrack {
|
||||
id: number
|
||||
uri: string
|
||||
title: string | null
|
||||
artist: string | null
|
||||
album: string | null
|
||||
album_art_uri: string | null
|
||||
}
|
||||
|
||||
export interface OpenHomePlaylistSnapshot {
|
||||
renderer_id: string
|
||||
current_id: number | null
|
||||
current_index: number | null
|
||||
tracks: OpenHomePlaylistTrack[]
|
||||
}
|
||||
|
||||
export interface OpenHomePlaylistAddRequest {
|
||||
uri: string
|
||||
metadata: string
|
||||
after_id?: number | null
|
||||
play?: boolean
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -98,26 +115,26 @@ export interface FullRendererSnapshot {
|
||||
// ============================================================================
|
||||
|
||||
export interface MediaServerSummary {
|
||||
id: string;
|
||||
friendly_name: string;
|
||||
model_name: string;
|
||||
online: boolean;
|
||||
id: string
|
||||
friendly_name: string
|
||||
model_name: string
|
||||
online: boolean
|
||||
}
|
||||
|
||||
export interface ContainerEntry {
|
||||
id: string;
|
||||
title: string;
|
||||
class: string; // UPnP class
|
||||
is_container: boolean;
|
||||
child_count: number | null;
|
||||
artist: string | null;
|
||||
album: string | null;
|
||||
album_art_uri: string | null; // ⚠️ Nom exact: album_art_uri
|
||||
id: string
|
||||
title: string
|
||||
class: string // UPnP class
|
||||
is_container: boolean
|
||||
child_count: number | null
|
||||
artist: string | null
|
||||
album: string | null
|
||||
album_art_uri: string | null // ⚠️ Nom exact: album_art_uri
|
||||
}
|
||||
|
||||
export interface BrowseResponse {
|
||||
container_id: string;
|
||||
entries: ContainerEntry[];
|
||||
container_id: string
|
||||
entries: ContainerEntry[]
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -125,26 +142,26 @@ export interface BrowseResponse {
|
||||
// ============================================================================
|
||||
|
||||
export interface VolumeSetRequest {
|
||||
volume: number; // 0-100
|
||||
volume: number // 0-100
|
||||
}
|
||||
|
||||
export interface AttachPlaylistRequest {
|
||||
server_id: string;
|
||||
container_id: string;
|
||||
auto_play?: boolean;
|
||||
server_id: string
|
||||
container_id: string
|
||||
auto_play?: boolean
|
||||
}
|
||||
|
||||
export interface PlayContentRequest {
|
||||
server_id: string;
|
||||
object_id: string;
|
||||
server_id: string
|
||||
object_id: string
|
||||
}
|
||||
|
||||
export interface SuccessResponse {
|
||||
message: string;
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
error: string;
|
||||
error: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -152,105 +169,40 @@ export interface ErrorResponse {
|
||||
// ============================================================================
|
||||
|
||||
export type RendererEventPayload =
|
||||
| {
|
||||
type: "state_changed";
|
||||
renderer_id: string;
|
||||
state: string;
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
type: "position_changed";
|
||||
renderer_id: string;
|
||||
track: number | null;
|
||||
rel_time: string | null;
|
||||
track_duration: string | null;
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
type: "volume_changed";
|
||||
renderer_id: string;
|
||||
volume: number;
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
type: "mute_changed";
|
||||
renderer_id: string;
|
||||
mute: boolean;
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
type: "metadata_changed";
|
||||
renderer_id: string;
|
||||
title: string | null;
|
||||
artist: string | null;
|
||||
album: string | null;
|
||||
album_art_uri: string | null;
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
type: "queue_updated";
|
||||
renderer_id: string;
|
||||
queue_length: number;
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
type: "binding_changed";
|
||||
renderer_id: string;
|
||||
server_id: string | null;
|
||||
container_id: string | null;
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
type: "online";
|
||||
renderer_id: string;
|
||||
friendly_name: string;
|
||||
model_name: string;
|
||||
manufacturer: string;
|
||||
timestamp: string;
|
||||
}
|
||||
| { type: "offline"; renderer_id: string; timestamp: string };
|
||||
| { type: 'state_changed'; renderer_id: string; state: string; timestamp: string }
|
||||
| { type: 'position_changed'; renderer_id: string; track: number | null; rel_time: string | null; track_duration: string | null; timestamp: string }
|
||||
| { type: 'volume_changed'; renderer_id: string; volume: number; timestamp: string }
|
||||
| { type: 'mute_changed'; renderer_id: string; mute: boolean; timestamp: string }
|
||||
| { type: 'metadata_changed'; renderer_id: string; title: string | null; artist: string | null; album: string | null; album_art_uri: string | null; timestamp: string }
|
||||
| { type: 'queue_updated'; renderer_id: string; queue_length: number; timestamp: string }
|
||||
| { type: 'binding_changed'; renderer_id: string; server_id: string | null; container_id: string | null; timestamp: string }
|
||||
| { type: 'online'; renderer_id: string; friendly_name: string; model_name: string; manufacturer: string; timestamp: string }
|
||||
| { type: 'offline'; renderer_id: string; timestamp: string }
|
||||
|
||||
export type MediaServerEventPayload =
|
||||
| {
|
||||
type: "global_updated";
|
||||
server_id: string;
|
||||
system_update_id: number | null;
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
type: "containers_updated";
|
||||
server_id: string;
|
||||
container_ids: string[];
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
type: "online";
|
||||
server_id: string;
|
||||
friendly_name: string;
|
||||
model_name: string;
|
||||
manufacturer: string;
|
||||
timestamp: string;
|
||||
}
|
||||
| { type: "offline"; server_id: string; timestamp: string };
|
||||
| { type: 'global_updated'; server_id: string; system_update_id: number | null; timestamp: string }
|
||||
| { type: 'containers_updated'; server_id: string; container_ids: string[]; timestamp: string }
|
||||
| { type: 'online'; server_id: string; friendly_name: string; model_name: string; manufacturer: string; timestamp: string }
|
||||
| { type: 'offline'; server_id: string; timestamp: string }
|
||||
|
||||
export type UnifiedEventPayload =
|
||||
| ({ category: "renderer" } & RendererEventPayload)
|
||||
| ({ category: "media_server" } & MediaServerEventPayload);
|
||||
| { category: 'renderer' } & RendererEventPayload
|
||||
| { category: 'media_server' } & MediaServerEventPayload
|
||||
|
||||
// ============================================================================
|
||||
// MÉTADONNÉES PISTE
|
||||
// ============================================================================
|
||||
|
||||
export interface TrackMetadata {
|
||||
title: string | null;
|
||||
artist: string | null;
|
||||
album: string | null;
|
||||
album_art_uri: string | null;
|
||||
duration_ms: number | null;
|
||||
title: string | null
|
||||
artist: string | null
|
||||
album: string | null
|
||||
album_art_uri: string | null
|
||||
duration_ms: number | null
|
||||
}
|
||||
|
||||
export interface PositionInfo {
|
||||
track: number | null;
|
||||
rel_time: string | null; // Format HH:MM:SS
|
||||
track_duration: string | null; // Format HH:MM:SS
|
||||
track: number | null
|
||||
rel_time: string | null // Format HH:MM:SS
|
||||
track_duration: string | null // Format HH:MM:SS
|
||||
}
|
||||
|
||||
@@ -1,302 +1,516 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, toRef } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useRenderer, useRenderers } from "@/composables/useRenderers";
|
||||
import { useUIStore } from "@/stores/ui";
|
||||
import CurrentTrack from "@/components/pmocontrol/CurrentTrack.vue";
|
||||
import TransportControls from "@/components/pmocontrol/TransportControls.vue";
|
||||
import VolumeControl from "@/components/pmocontrol/VolumeControl.vue";
|
||||
import QueueViewer from "@/components/pmocontrol/QueueViewer.vue";
|
||||
import PlaylistBindingPanel from "@/components/pmocontrol/PlaylistBindingPanel.vue";
|
||||
import StatusBadge from "@/components/pmocontrol/StatusBadge.vue";
|
||||
import { ArrowLeft, Radio } from "lucide-vue-next";
|
||||
import { computed, onMounted, onUnmounted, ref, watch, toRef } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useRenderer, useRenderers } from '@/composables/useRenderers'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
import CurrentTrack from '@/components/pmocontrol/CurrentTrack.vue'
|
||||
import TransportControls from '@/components/pmocontrol/TransportControls.vue'
|
||||
import VolumeControl from '@/components/pmocontrol/VolumeControl.vue'
|
||||
import QueueViewer from '@/components/pmocontrol/QueueViewer.vue'
|
||||
import PlaylistBindingPanel from '@/components/pmocontrol/PlaylistBindingPanel.vue'
|
||||
import StatusBadge from '@/components/pmocontrol/StatusBadge.vue'
|
||||
import { ArrowLeft, Radio } from 'lucide-vue-next'
|
||||
import {
|
||||
addOpenHomeTrack,
|
||||
clearOpenHomePlaylist,
|
||||
getOpenHomePlaylist,
|
||||
playOpenHomeTrack,
|
||||
} from '@/services/openhomePlaylist'
|
||||
import type { OpenHomePlaylistSnapshot } from '@/services/pmocontrol/types'
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const uiStore = useUIStore();
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const uiStore = useUIStore()
|
||||
|
||||
const rendererId = computed(() => route.params.id as string);
|
||||
const { renderer, state, refresh } = useRenderer(toRef(() => rendererId.value));
|
||||
const { fetchRenderers, selectRenderer: selectRendererSnapshot } =
|
||||
useRenderers();
|
||||
const rendererId = computed(() => route.params.id as string)
|
||||
const { renderer, state, refresh } = useRenderer(toRef(() => rendererId.value))
|
||||
const { fetchRenderers, selectRenderer: selectRendererSnapshot } = useRenderers()
|
||||
const openHomeSupported = computed(() => {
|
||||
const current = renderer.value
|
||||
if (!current) return false
|
||||
const caps = current.capabilities
|
||||
return (
|
||||
current.protocol === 'openhome' ||
|
||||
current.protocol === 'hybrid' ||
|
||||
caps?.has_oh_playlist === true
|
||||
)
|
||||
})
|
||||
const ohPlaylist = ref<OpenHomePlaylistSnapshot | null>(null)
|
||||
const ohLoading = ref(false)
|
||||
const ohError = ref<string | null>(null)
|
||||
const newOhUri = ref('')
|
||||
const newOhMeta = ref('')
|
||||
const canAddOhTrack = computed(() => newOhUri.value.trim().length > 0)
|
||||
|
||||
// Charger les données au montage si nécessaire
|
||||
onMounted(async () => {
|
||||
uiStore.selectRenderer(rendererId.value);
|
||||
selectRendererSnapshot(rendererId.value);
|
||||
uiStore.selectRenderer(rendererId.value)
|
||||
selectRendererSnapshot(rendererId.value)
|
||||
|
||||
// Charger toutes les données du renderer
|
||||
if (!renderer.value) {
|
||||
await fetchRenderers();
|
||||
// Charger toutes les données du renderer
|
||||
if (!renderer.value) {
|
||||
await fetchRenderers()
|
||||
}
|
||||
await refresh()
|
||||
})
|
||||
|
||||
watch(
|
||||
openHomeSupported,
|
||||
async supported => {
|
||||
if (supported) {
|
||||
await refreshOhPlaylist()
|
||||
} else {
|
||||
ohPlaylist.value = null
|
||||
}
|
||||
await refresh();
|
||||
});
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(rendererId, () => {
|
||||
ohPlaylist.value = null
|
||||
ohError.value = null
|
||||
newOhUri.value = ''
|
||||
newOhMeta.value = ''
|
||||
})
|
||||
|
||||
// Nettoyer la sélection au démontage
|
||||
onUnmounted(() => {
|
||||
uiStore.selectRenderer(null);
|
||||
selectRendererSnapshot(null);
|
||||
});
|
||||
uiStore.selectRenderer(null)
|
||||
selectRendererSnapshot(null)
|
||||
})
|
||||
|
||||
function goBack() {
|
||||
router.push("/");
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
const protocolLabel = computed(() => {
|
||||
if (!renderer.value) return "";
|
||||
switch (renderer.value.protocol) {
|
||||
case "upnp":
|
||||
return "UPnP AV";
|
||||
case "openhome":
|
||||
return "OpenHome";
|
||||
case "hybrid":
|
||||
return "Hybrid (UPnP + OpenHome)";
|
||||
case "chromecast":
|
||||
return "Chromecast";
|
||||
default:
|
||||
return "Inconnu";
|
||||
}
|
||||
});
|
||||
if (!renderer.value) return ''
|
||||
switch (renderer.value.protocol) {
|
||||
case 'upnp':
|
||||
return 'UPnP AV'
|
||||
case 'openhome':
|
||||
return 'OpenHome'
|
||||
case 'hybrid':
|
||||
return 'Hybrid (UPnP + OpenHome)'
|
||||
default:
|
||||
return 'Inconnu'
|
||||
}
|
||||
})
|
||||
|
||||
async function refreshOhPlaylist() {
|
||||
if (!renderer.value || !openHomeSupported.value) return
|
||||
ohLoading.value = true
|
||||
ohError.value = null
|
||||
try {
|
||||
ohPlaylist.value = await getOpenHomePlaylist(renderer.value.id)
|
||||
} catch (e) {
|
||||
ohError.value =
|
||||
e instanceof Error ? e.message : 'Failed to load OpenHome playlist'
|
||||
} finally {
|
||||
ohLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOhClear() {
|
||||
if (!renderer.value) return
|
||||
try {
|
||||
await clearOpenHomePlaylist(renderer.value.id)
|
||||
await refreshOhPlaylist()
|
||||
} catch (e) {
|
||||
ohError.value =
|
||||
e instanceof Error ? e.message : 'Failed to clear OpenHome playlist'
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOhPlay(trackId: number) {
|
||||
if (!renderer.value) return
|
||||
try {
|
||||
await playOpenHomeTrack(renderer.value.id, trackId)
|
||||
await refreshOhPlaylist()
|
||||
} catch (e) {
|
||||
ohError.value =
|
||||
e instanceof Error ? e.message : `Failed to play OpenHome track ${trackId}`
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOhAdd() {
|
||||
if (!renderer.value || !canAddOhTrack.value) return
|
||||
try {
|
||||
await addOpenHomeTrack(renderer.value.id, {
|
||||
uri: newOhUri.value.trim(),
|
||||
metadata: newOhMeta.value,
|
||||
play: false,
|
||||
})
|
||||
newOhUri.value = ''
|
||||
newOhMeta.value = ''
|
||||
await refreshOhPlaylist()
|
||||
} catch (e) {
|
||||
ohError.value =
|
||||
e instanceof Error ? e.message : 'Failed to add track to OpenHome playlist'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="renderer-view">
|
||||
<!-- Header -->
|
||||
<header class="renderer-header">
|
||||
<button
|
||||
class="btn-back"
|
||||
@click="goBack"
|
||||
title="Retour au dashboard"
|
||||
>
|
||||
<ArrowLeft :size="20" />
|
||||
</button>
|
||||
<div class="header-content">
|
||||
<div class="renderer-info">
|
||||
<Radio :size="24" class="renderer-icon" />
|
||||
<div class="renderer-details">
|
||||
<h1 class="renderer-name">
|
||||
{{ renderer?.friendly_name || "Chargement..." }}
|
||||
</h1>
|
||||
<p class="renderer-model">
|
||||
{{ renderer?.model_name }} • {{ protocolLabel }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge v-if="state" :status="state.transport_state" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="!renderer || !state" class="loading-state">
|
||||
<p>Chargement du renderer...</p>
|
||||
<div class="renderer-view">
|
||||
<!-- Header -->
|
||||
<header class="renderer-header">
|
||||
<button class="btn-back" @click="goBack" title="Retour au dashboard">
|
||||
<ArrowLeft :size="20" />
|
||||
</button>
|
||||
<div class="header-content">
|
||||
<div class="renderer-info">
|
||||
<Radio :size="24" class="renderer-icon" />
|
||||
<div class="renderer-details">
|
||||
<h1 class="renderer-name">{{ renderer?.friendly_name || 'Chargement...' }}</h1>
|
||||
<p class="renderer-model">{{ renderer?.model_name }} • {{ protocolLabel }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge v-if="state" :status="state.transport_state" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main content -->
|
||||
<div v-else class="renderer-content">
|
||||
<!-- Left column (Desktop) / Top (Mobile) -->
|
||||
<div class="left-column">
|
||||
<!-- Current Track -->
|
||||
<section class="content-section">
|
||||
<CurrentTrack :rendererId="rendererId" />
|
||||
</section>
|
||||
|
||||
<!-- Transport Controls -->
|
||||
<section class="content-section">
|
||||
<TransportControls :rendererId="rendererId" />
|
||||
</section>
|
||||
|
||||
<!-- Volume Control -->
|
||||
<section class="content-section">
|
||||
<h3 class="section-subtitle">Volume</h3>
|
||||
<VolumeControl :rendererId="rendererId" />
|
||||
</section>
|
||||
|
||||
<!-- Playlist Binding -->
|
||||
<section class="content-section">
|
||||
<PlaylistBindingPanel :rendererId="rendererId" />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Right column (Desktop) / Bottom (Mobile) -->
|
||||
<div class="right-column">
|
||||
<section class="content-section queue-section">
|
||||
<QueueViewer :rendererId="rendererId" />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Loading state -->
|
||||
<div v-if="!renderer || !state" class="loading-state">
|
||||
<p>Chargement du renderer...</p>
|
||||
</div>
|
||||
|
||||
<!-- Main content -->
|
||||
<div v-else class="renderer-content">
|
||||
<!-- Left column (Desktop) / Top (Mobile) -->
|
||||
<div class="left-column">
|
||||
<!-- Current Track -->
|
||||
<section class="content-section">
|
||||
<CurrentTrack :rendererId="rendererId" />
|
||||
</section>
|
||||
|
||||
<!-- Transport Controls -->
|
||||
<section class="content-section">
|
||||
<TransportControls :rendererId="rendererId" />
|
||||
</section>
|
||||
|
||||
<!-- Volume Control -->
|
||||
<section class="content-section">
|
||||
<h3 class="section-subtitle">Volume</h3>
|
||||
<VolumeControl :rendererId="rendererId" />
|
||||
</section>
|
||||
|
||||
<!-- Playlist Binding -->
|
||||
<section class="content-section">
|
||||
<PlaylistBindingPanel :rendererId="rendererId" />
|
||||
</section>
|
||||
|
||||
<section v-if="openHomeSupported" class="content-section openhome-playlist">
|
||||
<h2>OpenHome Playlist</h2>
|
||||
|
||||
<div v-if="ohLoading">Chargement de la playlist…</div>
|
||||
<div v-else-if="ohError" class="error">{{ ohError }}</div>
|
||||
|
||||
<div v-else-if="ohPlaylist && ohPlaylist.tracks.length === 0">
|
||||
Playlist vide.
|
||||
</div>
|
||||
|
||||
<div v-else-if="ohPlaylist">
|
||||
<table class="oh-playlist-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Titre</th>
|
||||
<th>Artiste</th>
|
||||
<th>Album</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="track in ohPlaylist.tracks"
|
||||
:key="track.id"
|
||||
:class="{ current: ohPlaylist.current_id === track.id }"
|
||||
>
|
||||
<td>{{ track.id }}</td>
|
||||
<td>{{ track.title || '—' }}</td>
|
||||
<td>{{ track.artist || '—' }}</td>
|
||||
<td>{{ track.album || '—' }}</td>
|
||||
<td class="actions-cell">
|
||||
<button class="btn btn-secondary btn-icon" @click="handleOhPlay(track.id)" title="Lire ce morceau">
|
||||
▶
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="oh-controls">
|
||||
<button class="btn btn-secondary" @click="refreshOhPlaylist">
|
||||
🔁 Rafraîchir
|
||||
</button>
|
||||
<button class="btn btn-danger" @click="handleOhClear">
|
||||
🗑 Effacer la playlist
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div 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>
|
||||
|
||||
<!-- Right column (Desktop) / Bottom (Mobile) -->
|
||||
<div class="right-column">
|
||||
<section class="content-section queue-section">
|
||||
<QueueViewer :rendererId="rendererId" />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.renderer-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
padding: var(--spacing-lg);
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
padding: var(--spacing-lg);
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.renderer-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-md);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-back:hover {
|
||||
background-color: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-md);
|
||||
flex-wrap: wrap;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-md);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.renderer-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.renderer-icon {
|
||||
color: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
color: var(--color-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.renderer-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.renderer-name {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.renderer-model {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-text-secondary);
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.renderer-content {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
gap: var(--spacing-xl);
|
||||
grid-template-columns: 1fr;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
display: grid;
|
||||
gap: var(--spacing-xl);
|
||||
grid-template-columns: 1fr;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.left-column,
|
||||
.right-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.content-section {
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.queue-section {
|
||||
flex: 1;
|
||||
min-height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0 0 var(--spacing-md);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0 0 var(--spacing-md);
|
||||
}
|
||||
|
||||
.openhome-playlist h2 {
|
||||
margin: 0 0 var(--spacing-md);
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.oh-playlist-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.oh-playlist-table th,
|
||||
.oh-playlist-table td {
|
||||
padding: var(--spacing-xs);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.oh-playlist-table tbody tr:hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.oh-playlist-table tr.current {
|
||||
background-color: rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.oh-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.oh-add-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.oh-add-form input,
|
||||
.oh-add-form textarea {
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.oh-add-form button {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--status-error, #dc2626);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Responsive - Desktop */
|
||||
@media (min-width: 1024px) {
|
||||
.renderer-content {
|
||||
grid-template-columns: 400px 1fr;
|
||||
}
|
||||
.renderer-content {
|
||||
grid-template-columns: 400px 1fr;
|
||||
}
|
||||
|
||||
.queue-section {
|
||||
min-height: 0;
|
||||
}
|
||||
.queue-section {
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive - Tablet */
|
||||
@media (min-width: 768px) and (max-width: 1023px) {
|
||||
.renderer-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.renderer-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.left-column {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
.left-column {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.queue-section {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.queue-section {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive - Mobile */
|
||||
@media (max-width: 767px) {
|
||||
.renderer-view {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
.renderer-view {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.renderer-name {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
.renderer-name {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
|
||||
.renderer-info {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.renderer-info {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.queue-section {
|
||||
min-height: 300px;
|
||||
}
|
||||
.queue-section {
|
||||
min-height: 300px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,7 +18,7 @@ pmoplaylist = { path = "../pmoplaylist", optional = true }
|
||||
pmocache = { path = "../pmocache", optional = true }
|
||||
|
||||
# Async runtime
|
||||
tokio = { workspace = true }
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
tokio-util = { version = "0.7" }
|
||||
async-trait = "0.1"
|
||||
|
||||
@@ -28,8 +28,8 @@ rand = "0.8"
|
||||
|
||||
# HTTP streaming dependencies
|
||||
bytes = { version = "1.0", optional = true }
|
||||
serde = { workspace = true, optional = true }
|
||||
serde_json = { workspace = true, optional = true }
|
||||
serde = { version = "1.0", features = ["derive"], optional = true }
|
||||
serde_json = { version = "1.0", optional = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
@@ -8,9 +8,9 @@ default = []
|
||||
simd = []
|
||||
|
||||
[dependencies]
|
||||
tokio = { workspace = true }
|
||||
tokio = { version = "1.42", features = ["full"] }
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
async-trait = { workspace = true }
|
||||
async-trait = "0.1"
|
||||
futures-util = "0.3"
|
||||
pmoflac = { path = "../pmoflac" }
|
||||
pmometadata = { path = "../pmometadata" }
|
||||
@@ -18,7 +18,7 @@ paste = "1"
|
||||
soxr = "0.6.0"
|
||||
bytemuck = "1.24.0"
|
||||
reqwest = { version = "0.12", features = ["stream"] }
|
||||
tracing = { workspace = true }
|
||||
tracing = "0.1"
|
||||
cpal = "0.15"
|
||||
once_cell = "1.20"
|
||||
|
||||
@@ -26,4 +26,4 @@ once_cell = "1.20"
|
||||
tokio-test = "0.4"
|
||||
tempfile = "3"
|
||||
wiremock = "0.6"
|
||||
tracing-subscriber = { workspace = true }
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
[package]
|
||||
name = "pmoaudio"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
simd = []
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1.42", features = ["full"] }
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
async-trait = "0.1"
|
||||
futures-util = "0.3"
|
||||
pmoflac = { path = "../pmoflac" }
|
||||
pmometadata = { path = "../pmometadata" }
|
||||
paste = "1"
|
||||
soxr = "0.6.0"
|
||||
bytemuck = "1.24.0"
|
||||
reqwest = { version = "0.12", features = ["stream"] }
|
||||
tracing = "0.1"
|
||||
cpal = "0.15"
|
||||
once_cell = "1.20"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
tempfile = "3"
|
||||
wiremock = "0.6"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
@@ -16,7 +16,7 @@ pmometadata = { path = "../pmometadata" }
|
||||
|
||||
# Base de données
|
||||
rusqlite = { version = "0.37", features = ["bundled"] }
|
||||
chrono = { workspace = true }
|
||||
chrono = "0.4"
|
||||
|
||||
# Métadonnées audio
|
||||
lofty = "0.22"
|
||||
@@ -26,15 +26,15 @@ futures-util = "0.3"
|
||||
bytes = "1.0"
|
||||
|
||||
# Utilitaires
|
||||
anyhow = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
quick-xml = { workspace = true }
|
||||
anyhow = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
quick-xml = { version = "0.37", features = ["serialize"] }
|
||||
paste = "1.0"
|
||||
async-trait = { workspace = true }
|
||||
async-trait = "0.1"
|
||||
|
||||
# Async
|
||||
tokio = { workspace = true }
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
|
||||
# Singleton
|
||||
once_cell = "1.20"
|
||||
@@ -45,7 +45,7 @@ pmoconfig = { path = "../pmoconfig", optional = true }
|
||||
axum = { version = "0.8", optional = true }
|
||||
utoipa = { version = "5.3", features = ["axum_extras"], optional = true }
|
||||
|
||||
tracing = { workspace = true }
|
||||
tracing = "0.1.41"
|
||||
|
||||
[dev-dependencies]
|
||||
tracing-subscriber = "0.3"
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
[package]
|
||||
name = "pmoaudiocache"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
# Cache générique
|
||||
pmocache = { path = "../pmocache" }
|
||||
|
||||
# DIDL-Lite pour UPnP
|
||||
pmodidl = { path = "../pmodidl" }
|
||||
|
||||
# Streaming FLAC asynchrone
|
||||
pmoflac = { path = "../pmoflac" }
|
||||
pmometadata = { path = "../pmometadata" }
|
||||
|
||||
# Base de données
|
||||
rusqlite = { version = "0.37", features = ["bundled"] }
|
||||
chrono = "0.4"
|
||||
|
||||
# Métadonnées audio
|
||||
lofty = "0.22"
|
||||
|
||||
# Outils de streaming
|
||||
futures-util = "0.3"
|
||||
bytes = "1.0"
|
||||
|
||||
# Utilitaires
|
||||
anyhow = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
quick-xml = { version = "0.38", features = ["serialize"] }
|
||||
paste = "1.0"
|
||||
async-trait = "0.1"
|
||||
|
||||
# Async
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
|
||||
# Singleton
|
||||
once_cell = "1.20"
|
||||
|
||||
# Serveur HTTP (optionnel pour l'extension)
|
||||
pmoserver = { path = "../pmoserver", optional = true }
|
||||
pmoconfig = { path = "../pmoconfig", optional = true }
|
||||
axum = { version = "0.8", optional = true }
|
||||
utoipa = { version = "5.3", features = ["axum_extras"], optional = true }
|
||||
|
||||
tracing = "0.1.41"
|
||||
|
||||
[dev-dependencies]
|
||||
tracing-subscriber = "0.3"
|
||||
tempfile = "3"
|
||||
|
||||
[features]
|
||||
default = ["pmoserver"]
|
||||
pmoconfig = ["dep:pmoconfig", "pmocache/pmoconfig"]
|
||||
pmoserver = ["pmoconfig", "dep:pmoserver", "dep:axum", "dep:utoipa", "pmocache/pmoserver", "pmocache/openapi"]
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use pmometadata::{MetadataError, MetadataResult, TrackMetadata};
|
||||
use serde_json::{Number, Value};
|
||||
|
||||
@@ -17,21 +17,21 @@ sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
|
||||
# Utilitaires
|
||||
anyhow = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
anyhow = "1.0"
|
||||
async-trait = "0.1"
|
||||
chrono = "0.4"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
bytes = "1.6"
|
||||
paste = "1.0"
|
||||
pmoflac = { path = "../pmoflac" }
|
||||
|
||||
# Async
|
||||
tokio = { workspace = true }
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
|
||||
# Logging
|
||||
tracing = { workspace = true }
|
||||
tracing = "0.1"
|
||||
|
||||
# Feature pour OpenAPI
|
||||
utoipa = { version = "5.3", optional = true }
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
[package]
|
||||
name = "pmocache"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
# Base de données
|
||||
rusqlite = { version = "0.37.0", features = ["bundled"] }
|
||||
|
||||
# HTTP client
|
||||
reqwest = { version = "0.12", features = ["blocking", "stream"] }
|
||||
futures-util = "0.3"
|
||||
|
||||
# Cryptographie
|
||||
sha1 = "0.10"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
|
||||
# Utilitaires
|
||||
anyhow = "1.0"
|
||||
async-trait = "0.1"
|
||||
chrono = "0.4"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
bytes = "1.6"
|
||||
paste = "1.0"
|
||||
pmoflac = { path = "../pmoflac" }
|
||||
|
||||
# Async
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
|
||||
# Feature pour OpenAPI
|
||||
utoipa = { version = "5.3", optional = true }
|
||||
|
||||
# Feature pour pmoserver (extension HTTP)
|
||||
axum = { version = "0.8", optional = true }
|
||||
|
||||
# Feature pour pmoconfig (extension de configuration)
|
||||
pmoconfig = { path = "../pmoconfig", optional = true }
|
||||
serde_yaml = { version = "0.9", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
openapi = ["dep:utoipa"]
|
||||
pmoserver = ["dep:axum"]
|
||||
pmoconfig = ["dep:pmoconfig", "dep:serde_yaml"]
|
||||
@@ -124,24 +124,6 @@ async fn serve_finalized_pk<C: CacheConfig + 'static>(
|
||||
warn!("Error updating hit count for {}: {}", pk, e);
|
||||
}
|
||||
|
||||
// ROUTE 1 : Fichier en cours de téléchargement
|
||||
// Utilise le streaming progressif avec Content-Length pour permettre
|
||||
// la lecture pendant le téléchargement tout en préservant la durée/position
|
||||
if let Some(download) = cache.get_download(pk).await {
|
||||
if !download.finished().await {
|
||||
let response = stream_file_progressive(file_path, download, content_type).await;
|
||||
|
||||
if response.status().is_success() {
|
||||
cache.notify_broadcast(pk, &qualifier).await;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
// ROUTE 2 : Fichier complètement téléchargé
|
||||
// Utilise l'ancien système éprouvé qui garantit un passage correct
|
||||
// de toutes les informations (Content-Length automatique, etc.)
|
||||
let response = serve_complete_file(file_path, content_type).await;
|
||||
|
||||
if response.status().is_success() {
|
||||
@@ -274,8 +256,6 @@ async fn stream_file_progressive(
|
||||
download: Arc<crate::download::Download>,
|
||||
content_type: &'static str,
|
||||
) -> Response {
|
||||
use axum::http::header;
|
||||
|
||||
// Attendre qu'au moins 64 KB soient disponibles avant de commencer
|
||||
const MIN_SIZE_TO_START: u64 = 64 * 1024;
|
||||
|
||||
@@ -304,24 +284,15 @@ async fn stream_file_progressive(
|
||||
let stream = ReaderStream::new(file);
|
||||
let body = Body::from_stream(stream);
|
||||
|
||||
// Récupérer la taille attendue du fichier si disponible
|
||||
let expected_size = download.expected_size().await;
|
||||
|
||||
// Construire la réponse avec Content-Length si connu
|
||||
let mut response = axum::http::Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, content_type);
|
||||
|
||||
if let Some(size) = expected_size {
|
||||
// Si on connaît la taille finale, l'envoyer au renderer
|
||||
// pour qu'il puisse calculer la durée et afficher la position
|
||||
response = response.header(header::CONTENT_LENGTH, size);
|
||||
} else {
|
||||
// Sinon, utiliser chunked encoding
|
||||
response = response.header(header::TRANSFER_ENCODING, "chunked");
|
||||
}
|
||||
|
||||
response.body(body).unwrap()
|
||||
(
|
||||
StatusCode::OK,
|
||||
[
|
||||
("content-type", content_type),
|
||||
("transfer-encoding", "chunked"),
|
||||
],
|
||||
body,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Sert un fichier complet déjà téléchargé
|
||||
|
||||
@@ -7,21 +7,21 @@ edition = "2021"
|
||||
[dependencies]
|
||||
pmoutils ={ path = "../pmoutils" }
|
||||
|
||||
serde = { workspace = true }
|
||||
serde_yaml = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_yaml = "0.9.33"
|
||||
serde_json = "1.0"
|
||||
lazy_static = "1.4.0"
|
||||
dirs = "6.0.0"
|
||||
log = "0.4.20"
|
||||
anyhow = "1.0.75"
|
||||
uuid = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
uuid = { version = "1.18.1", features = ["v4"] }
|
||||
tracing = "0.1.41"
|
||||
aes-gcm = "0.10"
|
||||
sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
|
||||
# Async
|
||||
tokio = { workspace = true, optional = true }
|
||||
tokio = { version = "1.0", features = ["full"], optional = true }
|
||||
|
||||
# Serveur HTTP (pour l'API REST)
|
||||
axum = { version = "0.8", optional = true }
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
|
||||
[package]
|
||||
name = "pmoconfig"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
pmoutils ={ path = "../pmoutils" }
|
||||
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_yaml = "0.9.33"
|
||||
serde_json = "1.0"
|
||||
lazy_static = "1.4.0"
|
||||
dirs = "6.0.0"
|
||||
log = "0.4.20"
|
||||
anyhow = "1.0.75"
|
||||
uuid = { version = "1.18.1", features = ["v4"] }
|
||||
tracing = "0.1.41"
|
||||
aes-gcm = "0.10"
|
||||
sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
|
||||
# Async
|
||||
tokio = { version = "1.0", features = ["full"], optional = true }
|
||||
|
||||
# Serveur HTTP (pour l'API REST)
|
||||
axum = { version = "0.8", optional = true }
|
||||
utoipa = { version = "5.3", features = ["axum_extras"], optional = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
api = ["dep:tokio", "dep:axum", "dep:utoipa"]
|
||||
@@ -1,10 +1,5 @@
|
||||
host:
|
||||
http_port: "8080"
|
||||
upnp:
|
||||
manufacturer: "PMOMusic"
|
||||
udn_prefix: "pmomusic"
|
||||
model_name_prefix: "PMOMusic"
|
||||
friendly_name_prefix: "PMOMusic"
|
||||
cover_cache:
|
||||
directory: "cache_covers"
|
||||
size: 2000
|
||||
|
||||
@@ -6,13 +6,13 @@ edition = "2024"
|
||||
[dependencies]
|
||||
pmoupnp = { path = "../pmoupnp" }
|
||||
pmodidl = { path = "../pmodidl" }
|
||||
quick-xml = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
quick-xml = "0.38.4"
|
||||
thiserror = "2.0.17"
|
||||
ureq = "3.1.4"
|
||||
tracing = { workspace = true }
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = "0.3"
|
||||
tracing-log = "0.1"
|
||||
anyhow = { workspace = true }
|
||||
anyhow = "1.0"
|
||||
xmltree = "0.11.0"
|
||||
crossbeam-channel = "0.5"
|
||||
ratatui = { version = "0.26", default-features = false, features = ["crossterm"] }
|
||||
@@ -23,14 +23,14 @@ mdns = "3.0"
|
||||
async-std = "1.12"
|
||||
futures-util = "0.3"
|
||||
smol = "2.0"
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
# pmoserver extension support (optional)
|
||||
pmoserver = { path = "../pmoserver", optional = true }
|
||||
utoipa = { version = "5.4.0", optional = true }
|
||||
axum = { version = "0.8.4", optional = true }
|
||||
tokio = { workspace = true, features = ["sync", "rt"], optional = true }
|
||||
serde = { version = "1.0", features = ["derive"], optional = true }
|
||||
serde_json = { version = "1.0", optional = true }
|
||||
tokio = { version = "1", features = ["sync", "rt"], optional = true }
|
||||
tokio-util = { version = "0.7", optional = true }
|
||||
async-trait = { version = "0.1", optional = true }
|
||||
tokio-stream = { version = "0.1", features = ["sync"], optional = true }
|
||||
@@ -38,9 +38,11 @@ async-stream = { version = "0.3", optional = true }
|
||||
chrono = { version = "0.4", features = ["serde"], optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
percent-encoding = "2.3"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Active l'API REST pmoserver
|
||||
pmoserver = ["dep:pmoserver", "dep:utoipa", "dep:axum", "dep:tokio", "dep:tokio-util", "dep:async-trait", "dep:tokio-stream", "dep:async-stream", "dep:chrono"]
|
||||
pmoserver = ["dep:pmoserver", "dep:utoipa", "dep:axum", "dep:serde", "dep:serde_json", "dep:tokio", "dep:tokio-util", "dep:async-trait", "dep:tokio-stream", "dep:async-stream", "dep:chrono"]
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
[package]
|
||||
name = "pmocontrol"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
pmoupnp = { path = "../pmoupnp" }
|
||||
pmodidl = { path = "../pmodidl" }
|
||||
quick-xml = "0.38.4"
|
||||
thiserror = "2.0.17"
|
||||
ureq = "3.1.4"
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = "0.3"
|
||||
tracing-log = "0.1"
|
||||
anyhow = "1.0"
|
||||
xmltree = "0.11.0"
|
||||
crossbeam-channel = "0.5"
|
||||
ratatui = { version = "0.26", default-features = false, features = ["crossterm"] }
|
||||
crossterm = "0.27"
|
||||
rust_cast = "0.19"
|
||||
rustls = { version = "0.23", features = ["aws-lc-rs"] }
|
||||
mdns = "3.0"
|
||||
async-std = "1.12"
|
||||
futures-util = "0.3"
|
||||
smol = "2.0"
|
||||
|
||||
# pmoserver extension support (optional)
|
||||
pmoserver = { path = "../pmoserver", optional = true }
|
||||
utoipa = { version = "5.4.0", optional = true }
|
||||
axum = { version = "0.8.4", optional = true }
|
||||
serde = { version = "1.0", features = ["derive"], optional = true }
|
||||
serde_json = { version = "1.0", optional = true }
|
||||
tokio = { version = "1", features = ["sync", "rt"], optional = true }
|
||||
tokio-util = { version = "0.7", optional = true }
|
||||
async-trait = { version = "0.1", optional = true }
|
||||
tokio-stream = { version = "0.1", features = ["sync"], optional = true }
|
||||
async-stream = { version = "0.3", optional = true }
|
||||
chrono = { version = "0.4", features = ["serde"], optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
percent-encoding = "2.3"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Active l'API REST pmoserver
|
||||
pmoserver = ["dep:pmoserver", "dep:utoipa", "dep:axum", "dep:serde", "dep:serde_json", "dep:tokio", "dep:tokio-util", "dep:async-trait", "dep:tokio-stream", "dep:async-stream", "dep:chrono"]
|
||||
@@ -3,7 +3,7 @@ use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use pmocontrol::{
|
||||
ControlPoint, DeviceRegistryRead, MusicRendererBackend, PlaybackPosition, PlaybackPositionInfo,
|
||||
ControlPoint, DeviceRegistryRead, MusicRenderer, PlaybackPosition, PlaybackPositionInfo,
|
||||
PlaybackState, PlaybackStatus, TransportControl, VolumeControl,
|
||||
};
|
||||
|
||||
@@ -38,10 +38,10 @@ fn main() -> io::Result<()> {
|
||||
);
|
||||
}
|
||||
|
||||
let arylic_renderers: Vec<MusicRendererBackend> = renderers
|
||||
let arylic_renderers: Vec<MusicRenderer> = renderers
|
||||
.iter()
|
||||
.filter(|info| info.capabilities.has_arylic_tcp)
|
||||
.filter_map(|info| MusicRendererBackend::from_renderer_info(info.clone(), ®istry))
|
||||
.filter_map(|info| MusicRenderer::from_registry_info(info.clone(), ®istry))
|
||||
.collect();
|
||||
|
||||
if arylic_renderers.is_empty() {
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
//! Simple Chromecast info retrieval test
|
||||
//!
|
||||
//! This is the most minimal test - just connects and gets device status.
|
||||
//!
|
||||
//! Usage:
|
||||
//! cargo run --example chromecast_info -- <chromecast_ip>
|
||||
|
||||
use std::env;
|
||||
use std::sync::Once;
|
||||
|
||||
use rust_cast::CastDevice;
|
||||
|
||||
const DEFAULT_DESTINATION_ID: &str = "receiver-0";
|
||||
const DEFAULT_PORT: u16 = 8009;
|
||||
|
||||
/// Ensures the Rustls CryptoProvider is initialized exactly once.
|
||||
fn ensure_crypto_provider_initialized() {
|
||||
static INIT: Once = Once::new();
|
||||
|
||||
INIT.call_once(|| {
|
||||
let _ = rustls::crypto::CryptoProvider::install_default(
|
||||
rustls::crypto::aws_lc_rs::default_provider()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Parse command line arguments
|
||||
let args: Vec<String> = env::args().collect();
|
||||
|
||||
if args.len() < 2 {
|
||||
eprintln!("Usage: {} <chromecast_ip>", args[0]);
|
||||
eprintln!("\nExample:");
|
||||
eprintln!(" {} 192.168.1.100", args[0]);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let chromecast_ip = &args[1];
|
||||
|
||||
println!("═══════════════════════════════════════════════════════");
|
||||
println!(" Chromecast Info Test");
|
||||
println!("═══════════════════════════════════════════════════════");
|
||||
println!();
|
||||
|
||||
// Initialize crypto provider
|
||||
ensure_crypto_provider_initialized();
|
||||
|
||||
// Connect to the device
|
||||
println!("→ Connecting to {}:{}...", chromecast_ip, DEFAULT_PORT);
|
||||
let cast_device = match CastDevice::connect_without_host_verification(chromecast_ip, DEFAULT_PORT) {
|
||||
Ok(device) => {
|
||||
println!(" ✓ Connected");
|
||||
device
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(" ✗ Failed: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Connect to receiver channel
|
||||
println!();
|
||||
println!("→ Connecting to receiver channel...");
|
||||
if let Err(e) = cast_device.connection.connect(DEFAULT_DESTINATION_ID.to_string()) {
|
||||
eprintln!(" ✗ Failed: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
println!(" ✓ Channel connected");
|
||||
|
||||
// Send initial ping
|
||||
println!();
|
||||
println!("→ Sending initial ping...");
|
||||
if let Err(e) = cast_device.heartbeat.ping() {
|
||||
eprintln!(" ✗ Failed: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
println!(" ✓ Ping sent");
|
||||
|
||||
// Get receiver status
|
||||
println!();
|
||||
println!("→ Getting receiver status...");
|
||||
match cast_device.receiver.get_status() {
|
||||
Ok(status) => {
|
||||
println!(" ✓ Status retrieved\n");
|
||||
|
||||
// Volume
|
||||
if let Some(level) = status.volume.level {
|
||||
println!(" Volume: {:.0}%", level * 100.0);
|
||||
}
|
||||
if let Some(muted) = status.volume.muted {
|
||||
println!(" Muted: {}", muted);
|
||||
}
|
||||
|
||||
// Applications
|
||||
println!("\n Running applications: {}", status.applications.len());
|
||||
for (i, app) in status.applications.iter().enumerate() {
|
||||
println!("\n App #{}:", i + 1);
|
||||
println!(" Display Name: {}", app.display_name);
|
||||
println!(" App ID: {}", app.app_id);
|
||||
println!(" Session ID: {}", app.session_id);
|
||||
println!(" Transport ID: {}", app.transport_id);
|
||||
println!(" Status: {}", app.status_text);
|
||||
println!(" Namespaces: {}", app.namespaces.len());
|
||||
}
|
||||
|
||||
if status.applications.is_empty() {
|
||||
println!(" (no apps currently running)");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(" ✗ Failed: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("═══════════════════════════════════════════════════════");
|
||||
println!(" Test completed successfully!");
|
||||
println!("═══════════════════════════════════════════════════════");
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
//! Simple Chromecast playback test
|
||||
//!
|
||||
//! This example tests basic Chromecast functionality by:
|
||||
//! 1. Connecting to a Chromecast device
|
||||
//! 2. Launching the DefaultMediaReceiver app
|
||||
//! 3. Loading and playing a test media URL
|
||||
//! 4. Maintaining the heartbeat loop
|
||||
//!
|
||||
//! Usage:
|
||||
//! cargo run --example chromecast_playback_test -- <chromecast_ip> [media_url]
|
||||
//!
|
||||
//! Example:
|
||||
//! cargo run --example chromecast_playback_test -- 192.168.1.100
|
||||
|
||||
use std::env;
|
||||
use std::sync::Once;
|
||||
|
||||
use rust_cast::{
|
||||
CastDevice, ChannelMessage,
|
||||
channels::{
|
||||
heartbeat::HeartbeatResponse,
|
||||
media::{Media, StreamType},
|
||||
receiver::CastDeviceApp,
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_DESTINATION_ID: &str = "receiver-0";
|
||||
const DEFAULT_PORT: u16 = 8009;
|
||||
|
||||
// Test media URLs (public domain audio files)
|
||||
const TEST_MEDIA_URL: &str = "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3";
|
||||
|
||||
/// Ensures the Rustls CryptoProvider is initialized exactly once.
|
||||
fn ensure_crypto_provider_initialized() {
|
||||
static INIT: Once = Once::new();
|
||||
|
||||
INIT.call_once(|| {
|
||||
let _ = rustls::crypto::CryptoProvider::install_default(
|
||||
rustls::crypto::aws_lc_rs::default_provider()
|
||||
);
|
||||
println!("✓ Rustls CryptoProvider initialized");
|
||||
});
|
||||
}
|
||||
|
||||
/// Detects content type from URL path
|
||||
fn detect_content_type(url: &str) -> String {
|
||||
// Detect from URL path - check if path contains /flac/, /mp3/, etc.
|
||||
if url.contains("/flac/") || url.contains(".flac") {
|
||||
println!(" ✓ Detected FLAC from URL path");
|
||||
return "audio/flac".to_string();
|
||||
}
|
||||
if url.contains("/mp3/") || url.contains(".mp3") {
|
||||
println!(" ✓ Detected MP3 from URL path");
|
||||
return "audio/mpeg".to_string();
|
||||
}
|
||||
if url.contains(".m4a") || url.contains(".mp4") || url.contains(".aac") {
|
||||
println!(" ✓ Detected AAC/M4A from URL path");
|
||||
return "audio/mp4".to_string();
|
||||
}
|
||||
if url.contains("/ogg/") || url.contains(".ogg") {
|
||||
println!(" ✓ Detected OGG from URL path");
|
||||
return "audio/ogg".to_string();
|
||||
}
|
||||
if url.contains(".opus") {
|
||||
println!(" ✓ Detected Opus from URL path");
|
||||
return "audio/opus".to_string();
|
||||
}
|
||||
if url.contains(".wav") {
|
||||
println!(" ✓ Detected WAV from URL path");
|
||||
return "audio/wav".to_string();
|
||||
}
|
||||
|
||||
// Fallback
|
||||
println!(" ⚠ Could not detect type, using audio/mpeg as fallback");
|
||||
"audio/mpeg".to_string()
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Setup logging
|
||||
tracing_subscriber::fmt()
|
||||
.with_max_level(tracing::Level::DEBUG)
|
||||
.init();
|
||||
|
||||
// Parse command line arguments
|
||||
let args: Vec<String> = env::args().collect();
|
||||
|
||||
if args.len() < 2 {
|
||||
eprintln!("Usage: {} <chromecast_ip> [media_url]", args[0]);
|
||||
eprintln!("\nExample:");
|
||||
eprintln!(" {} 192.168.1.100", args[0]);
|
||||
eprintln!("\nIf no media URL is provided, will use: {}", TEST_MEDIA_URL);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let chromecast_ip = &args[1];
|
||||
let media_url = if args.len() > 2 {
|
||||
&args[2]
|
||||
} else {
|
||||
TEST_MEDIA_URL
|
||||
};
|
||||
|
||||
println!("╔════════════════════════════════════════════════════════════╗");
|
||||
println!("║ Chromecast Playback Test (rust_cast) ║");
|
||||
println!("╚════════════════════════════════════════════════════════════╝");
|
||||
println!();
|
||||
println!("Target: {}:{}", chromecast_ip, DEFAULT_PORT);
|
||||
println!("Media URL: {}", media_url);
|
||||
println!();
|
||||
println!("Detecting media content type...");
|
||||
let media_type = detect_content_type(media_url);
|
||||
println!();
|
||||
|
||||
// Initialize crypto provider
|
||||
ensure_crypto_provider_initialized();
|
||||
|
||||
// Step 1: Connect to the device
|
||||
println!("──────────────────────────────────────────────────────────");
|
||||
println!("STEP 1: Connecting to Chromecast...");
|
||||
let cast_device = match CastDevice::connect_without_host_verification(chromecast_ip, DEFAULT_PORT) {
|
||||
Ok(device) => {
|
||||
println!("✓ Connected to Chromecast");
|
||||
device
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("✗ Failed to connect: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Step 2: Connect to the default receiver channel
|
||||
println!();
|
||||
println!("STEP 2: Connecting to receiver channel...");
|
||||
if let Err(e) = cast_device.connection.connect(DEFAULT_DESTINATION_ID.to_string()) {
|
||||
eprintln!("✗ Failed to connect channel: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
println!("✓ Channel connected");
|
||||
|
||||
// Step 3: Send initial ping (CRITICAL per rust_caster.rs)
|
||||
println!();
|
||||
println!("STEP 3: Sending initial heartbeat ping...");
|
||||
if let Err(e) = cast_device.heartbeat.ping() {
|
||||
eprintln!("✗ Failed to send initial ping: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
println!("✓ Initial ping sent");
|
||||
|
||||
// Step 4: Get receiver status
|
||||
println!();
|
||||
println!("STEP 4: Getting receiver status...");
|
||||
let status = match cast_device.receiver.get_status() {
|
||||
Ok(status) => {
|
||||
println!("✓ Receiver status obtained");
|
||||
println!(" - Volume: {:.0}%", status.volume.level.unwrap_or(0.5) * 100.0);
|
||||
println!(" - Muted: {}", status.volume.muted.unwrap_or(false));
|
||||
println!(" - Running apps: {}", status.applications.len());
|
||||
status
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("✗ Failed to get status: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Step 5: Launch DefaultMediaReceiver
|
||||
println!();
|
||||
println!("STEP 5: Launching DefaultMediaReceiver app...");
|
||||
let app = match cast_device.receiver.launch_app(&CastDeviceApp::DefaultMediaReceiver) {
|
||||
Ok(app) => {
|
||||
println!("✓ App launched successfully");
|
||||
println!(" - App ID: {}", app.app_id);
|
||||
println!(" - Display Name: {}", app.display_name);
|
||||
println!(" - Session ID: {}", app.session_id);
|
||||
println!(" - Transport ID: {}", app.transport_id);
|
||||
app
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("✗ Failed to launch app: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Step 6: Connect to the app's transport
|
||||
println!();
|
||||
println!("STEP 6: Connecting to app transport...");
|
||||
if let Err(e) = cast_device.connection.connect(app.transport_id.as_str()) {
|
||||
eprintln!("✗ Failed to connect to app transport: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
println!("✓ Connected to app transport: {}", app.transport_id);
|
||||
|
||||
// Step 7: Load the media
|
||||
println!();
|
||||
println!("STEP 7: Loading media...");
|
||||
println!(" Content-Type: {}", media_type);
|
||||
let media = Media {
|
||||
content_id: media_url.to_string(),
|
||||
content_type: media_type,
|
||||
stream_type: StreamType::Buffered,
|
||||
duration: None,
|
||||
metadata: None,
|
||||
};
|
||||
|
||||
match cast_device.media.load(
|
||||
app.transport_id.as_str(),
|
||||
app.session_id.as_str(),
|
||||
&media,
|
||||
) {
|
||||
Ok(status) => {
|
||||
println!("✓ Media loaded successfully!");
|
||||
println!(" - Media status entries: {}", status.entries.len());
|
||||
|
||||
if let Some(entry) = status.entries.first() {
|
||||
println!(" - Player state: {:?}", entry.player_state);
|
||||
println!(" - Media session ID: {}", entry.media_session_id);
|
||||
|
||||
if let Some(ref media) = entry.media {
|
||||
println!(" - Content ID: {}", media.content_id);
|
||||
println!(" - Stream type: {:?}", media.stream_type);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("✗ Failed to load media: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 8: Enter heartbeat loop
|
||||
println!();
|
||||
println!("──────────────────────────────────────────────────────────");
|
||||
println!("STEP 8: Entering heartbeat loop (Ctrl+C to exit)...");
|
||||
println!("──────────────────────────────────────────────────────────");
|
||||
println!();
|
||||
|
||||
let mut heartbeat_count = 0;
|
||||
let mut media_message_count = 0;
|
||||
|
||||
loop {
|
||||
match cast_device.receive() {
|
||||
Ok(ChannelMessage::Heartbeat(response)) => {
|
||||
if let HeartbeatResponse::Ping = response {
|
||||
heartbeat_count += 1;
|
||||
println!("[Heartbeat #{:3}] Received Ping, sending Pong...", heartbeat_count);
|
||||
|
||||
if let Err(e) = cast_device.heartbeat.pong() {
|
||||
eprintln!("✗ Failed to send pong: {}", e);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
println!("[Heartbeat] {:?}", response);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ChannelMessage::Media(response)) => {
|
||||
media_message_count += 1;
|
||||
println!("[Media #{:3}] {:?}", media_message_count, response);
|
||||
}
|
||||
|
||||
Ok(ChannelMessage::Receiver(response)) => {
|
||||
println!("[Receiver] {:?}", response);
|
||||
}
|
||||
|
||||
Ok(ChannelMessage::Connection(response)) => {
|
||||
println!("[Connection] {:?}", response);
|
||||
}
|
||||
|
||||
Ok(ChannelMessage::Raw(response)) => {
|
||||
println!("[Raw] Unsupported message type: {:?}", response);
|
||||
}
|
||||
|
||||
Err(e) => {
|
||||
eprintln!("✗ Error receiving message: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("──────────────────────────────────────────────────────────");
|
||||
println!("Test completed.");
|
||||
println!("Total heartbeats: {}", heartbeat_count);
|
||||
println!("Total media messages: {}", media_message_count);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use pmocontrol::RendererProtocol;
|
||||
use pmocontrol::{ControlPoint, DeviceRegistryRead, UpnpMediaServer, RendererInfo};
|
||||
use pmocontrol::{ControlPoint, DeviceRegistryRead, MediaServerInfo, RendererInfo};
|
||||
|
||||
fn main() -> std::io::Result<()> {
|
||||
// Un tout petit logging optionnel
|
||||
@@ -21,7 +21,7 @@ fn main() -> std::io::Result<()> {
|
||||
let reg = reg.read().expect("registry poisoned");
|
||||
|
||||
let renderers: Vec<RendererInfo> = reg.list_renderers();
|
||||
let servers: Vec<UpnpMediaServer> = reg.list_servers();
|
||||
let servers: Vec<MediaServerInfo> = reg.list_servers();
|
||||
|
||||
println!("=====================");
|
||||
println!("Renderers detected : {}", renderers.len());
|
||||
@@ -29,7 +29,7 @@ fn main() -> std::io::Result<()> {
|
||||
let proto = match r.protocol {
|
||||
RendererProtocol::UpnpAvOnly => "UPnP AV",
|
||||
RendererProtocol::OpenHomeOnly => "OpenHome",
|
||||
RendererProtocol::OpenHomeHybrid => "Hybrid",
|
||||
RendererProtocol::Hybrid => "Hybrid",
|
||||
};
|
||||
|
||||
println!(
|
||||
|
||||
@@ -19,8 +19,8 @@ use crossterm::terminal::{
|
||||
};
|
||||
use pmocontrol::model::TrackMetadata;
|
||||
use pmocontrol::{
|
||||
ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, UpnpMediaServer,
|
||||
UpnpMediaServer, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
|
||||
ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, MediaServerInfo,
|
||||
MusicServer, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
|
||||
RendererEvent, RendererInfo, TransportControl, VolumeControl,
|
||||
};
|
||||
use ratatui::Terminal;
|
||||
@@ -102,7 +102,7 @@ fn main() -> Result<()> {
|
||||
|
||||
let servers = {
|
||||
let reg = registry.read().expect("registry poisoned");
|
||||
let list: Vec<UpnpMediaServer> = reg
|
||||
let list: Vec<MediaServerInfo> = reg
|
||||
.list_servers()
|
||||
.into_iter()
|
||||
.filter(|s| s.has_content_directory && s.content_directory_control_url.is_some())
|
||||
@@ -129,10 +129,10 @@ struct App {
|
||||
renderers: Vec<RendererInfo>,
|
||||
renderer_index: usize,
|
||||
renderer_info: Option<RendererInfo>,
|
||||
servers: Vec<UpnpMediaServer>,
|
||||
servers: Vec<MediaServerInfo>,
|
||||
server_index: usize,
|
||||
server_info: Option<UpnpMediaServer>,
|
||||
music_server: Option<UpnpMediaServer>,
|
||||
server_info: Option<MediaServerInfo>,
|
||||
music_server: Option<MusicServer>,
|
||||
browser: Option<BrowserState>,
|
||||
mode: Mode,
|
||||
ui_state: UiState,
|
||||
@@ -168,7 +168,7 @@ impl App {
|
||||
fn new(
|
||||
control_point: Arc<ControlPoint>,
|
||||
renderers: Vec<RendererInfo>,
|
||||
servers: Vec<UpnpMediaServer>,
|
||||
servers: Vec<MediaServerInfo>,
|
||||
) -> Self {
|
||||
Self {
|
||||
control_point,
|
||||
@@ -192,7 +192,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn renderer_id(&self) -> Option<pmocontrol::model::ServiceId> {
|
||||
fn renderer_id(&self) -> Option<pmocontrol::model::RendererId> {
|
||||
self.renderer_info.as_ref().map(|info| info.id.clone())
|
||||
}
|
||||
|
||||
@@ -552,7 +552,7 @@ impl App {
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let info = self.servers[self.server_index].clone();
|
||||
match UpnpMediaServer::from_info(&info, Duration::from_secs(DEFAULT_TIMEOUT_SECS)) {
|
||||
match MusicServer::from_info(&info, Duration::from_secs(DEFAULT_TIMEOUT_SECS)) {
|
||||
Ok(server) => {
|
||||
let entries = server.browse_root()?;
|
||||
let browser = BrowserState::new(entries);
|
||||
@@ -947,7 +947,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_queue_snapshot(&mut self, renderer_id: &pmocontrol::model::ServiceId) {
|
||||
fn refresh_queue_snapshot(&mut self, renderer_id: &pmocontrol::model::RendererId) {
|
||||
if let Ok((queue, current_index)) = self.control_point.get_full_queue_snapshot(renderer_id)
|
||||
{
|
||||
self.queue_snapshot = queue;
|
||||
@@ -967,7 +967,7 @@ impl App {
|
||||
|
||||
fn peek_next_queue_item(
|
||||
&self,
|
||||
renderer_id: &pmocontrol::model::ServiceId,
|
||||
renderer_id: &pmocontrol::model::RendererId,
|
||||
) -> Option<PlaybackItem> {
|
||||
self.control_point
|
||||
.get_queue_snapshot(renderer_id)
|
||||
@@ -975,7 +975,7 @@ impl App {
|
||||
.and_then(|queue| queue.into_iter().next())
|
||||
}
|
||||
|
||||
fn get_renderer(&self) -> Result<pmocontrol::MusicRendererBackend> {
|
||||
fn get_renderer(&self) -> Result<pmocontrol::MusicRenderer> {
|
||||
let renderer_id = self
|
||||
.renderer_id()
|
||||
.ok_or_else(|| anyhow!("Renderer not selected"))?;
|
||||
@@ -1230,7 +1230,7 @@ impl NavigationState {
|
||||
|
||||
/// Collect playable items from MediaEntry list (including nested containers).
|
||||
fn collect_playable_items(
|
||||
server: &UpnpMediaServer,
|
||||
server: &MusicServer,
|
||||
entries: &[MediaEntry],
|
||||
) -> Result<Vec<PlaybackItem>> {
|
||||
let mut items = Vec::new();
|
||||
@@ -1255,7 +1255,7 @@ fn collect_playable_items(
|
||||
}
|
||||
|
||||
/// Convert MediaEntry to PlaybackItem.
|
||||
fn playback_item_from_entry(server: &UpnpMediaServer, entry: &MediaEntry) -> Option<PlaybackItem> {
|
||||
fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option<PlaybackItem> {
|
||||
let resource = entry.resources.iter().find(|res| res.is_audio())?;
|
||||
let metadata = TrackMetadata {
|
||||
title: Some(entry.title.clone()),
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use pmocontrol::{
|
||||
ControlPoint, DeviceRegistryRead, MusicRendererBackend, PlaybackPosition, PlaybackPositionInfo,
|
||||
ControlPoint, DeviceRegistryRead, MusicRenderer, PlaybackPosition, PlaybackPositionInfo,
|
||||
PlaybackState, PlaybackStatus, VolumeControl,
|
||||
};
|
||||
|
||||
@@ -34,10 +34,10 @@ fn main() -> io::Result<()> {
|
||||
);
|
||||
}
|
||||
|
||||
let linkplay_renderers: Vec<MusicRendererBackend> = renderers
|
||||
let linkplay_renderers: Vec<MusicRenderer> = renderers
|
||||
.iter()
|
||||
.filter(|info| info.capabilities.has_linkplay_http)
|
||||
.filter_map(|info| MusicRendererBackend::from_renderer_info(info.clone(), ®istry))
|
||||
.filter_map(|info| MusicRenderer::from_registry_info(info.clone(), ®istry))
|
||||
.collect();
|
||||
|
||||
if linkplay_renderers.is_empty() {
|
||||
|
||||
@@ -10,8 +10,8 @@ use std::time::Duration;
|
||||
use anyhow::{Context, Result};
|
||||
use pmocontrol::model::TrackMetadata;
|
||||
use pmocontrol::{
|
||||
ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, UpnpMediaServer,
|
||||
MusicRendererBackend, UpnpMediaServer, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, RendererInfo,
|
||||
ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, MediaServerInfo,
|
||||
MusicRenderer, MusicServer, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, RendererInfo,
|
||||
};
|
||||
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 5;
|
||||
@@ -69,7 +69,7 @@ fn main() -> Result<()> {
|
||||
server_info.friendly_name, server_info.location, server_info.id.0
|
||||
);
|
||||
|
||||
let renderer_instance = MusicRendererBackend::from_renderer_info(renderer.clone(), ®istry)
|
||||
let renderer_instance = MusicRenderer::from_registry_info(renderer.clone(), ®istry)
|
||||
.expect("Selected renderer is not usable by MusicRenderer façade");
|
||||
let supports_set_next = renderer_instance
|
||||
.as_upnp()
|
||||
@@ -82,7 +82,7 @@ fn main() -> Result<()> {
|
||||
|
||||
let timeout = Duration::from_secs(config.timeout_secs);
|
||||
let server =
|
||||
UpnpMediaServer::from_info(&server_info, timeout).context("Failed to init MusicServer")?;
|
||||
MusicServer::from_info(&server_info, timeout).context("Failed to init MusicServer")?;
|
||||
|
||||
println!("Searching for a Live Playlist container in ContentDirectory...");
|
||||
let live_playlist_container = find_live_playlist_container(&server)
|
||||
@@ -355,8 +355,8 @@ fn pick_renderer(renderers: Vec<RendererInfo>) -> Option<RendererInfo> {
|
||||
Some(selected)
|
||||
}
|
||||
|
||||
fn pick_pmomusic_server(servers: Vec<UpnpMediaServer>) -> Option<UpnpMediaServer> {
|
||||
let mut candidates: Vec<UpnpMediaServer> = servers
|
||||
fn pick_pmomusic_server(servers: Vec<MediaServerInfo>) -> Option<MediaServerInfo> {
|
||||
let mut candidates: Vec<MediaServerInfo> = servers
|
||||
.into_iter()
|
||||
.filter(|info| info.has_content_directory)
|
||||
.filter(|info| info.content_directory_control_url.is_some())
|
||||
@@ -381,7 +381,7 @@ fn pick_pmomusic_server(servers: Vec<UpnpMediaServer>) -> Option<UpnpMediaServer
|
||||
Some(candidates.remove(0))
|
||||
}
|
||||
|
||||
fn is_pmomusic_server(info: &UpnpMediaServer) -> bool {
|
||||
fn is_pmomusic_server(info: &MediaServerInfo) -> bool {
|
||||
let name = info.friendly_name.to_ascii_lowercase();
|
||||
let model = info.model_name.to_ascii_lowercase();
|
||||
let manufacturer = info.manufacturer.to_ascii_lowercase();
|
||||
@@ -395,7 +395,7 @@ fn is_pmomusic_renderer(info: &RendererInfo) -> bool {
|
||||
}
|
||||
|
||||
/// Search for a container whose title contains "Live Playlist" using BFS.
|
||||
fn find_live_playlist_container(server: &UpnpMediaServer) -> Result<Option<MediaEntry>> {
|
||||
fn find_live_playlist_container(server: &MusicServer) -> Result<Option<MediaEntry>> {
|
||||
let root_entries = server
|
||||
.browse_root()
|
||||
.context("Failed to browse ContentDirectory root")?;
|
||||
@@ -477,7 +477,7 @@ fn find_live_playlist_container(server: &UpnpMediaServer) -> Result<Option<Media
|
||||
/// Collect playable items from a specific container.
|
||||
/// Retries with fewer items if the initial browse times out.
|
||||
fn collect_playable_items_from_container(
|
||||
server: &UpnpMediaServer,
|
||||
server: &MusicServer,
|
||||
container_id: &str,
|
||||
max_tracks: usize,
|
||||
) -> Result<Vec<PlaybackItem>> {
|
||||
@@ -532,7 +532,7 @@ fn collect_playable_items_from_container(
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
fn playback_item_from_entry(server: &UpnpMediaServer, entry: &MediaEntry) -> Option<PlaybackItem> {
|
||||
fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option<PlaybackItem> {
|
||||
// Skip live streams (we're looking for regular tracks in a live playlist)
|
||||
if entry.title.to_ascii_lowercase().contains("live stream") {
|
||||
return None;
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Result;
|
||||
use crossbeam_channel::RecvTimeoutError;
|
||||
use pmocontrol::{ControlPoint, MediaServerEvent, UpnpMediaServer, ServerId};
|
||||
use pmocontrol::{ControlPoint, MediaServerEvent, MediaServerInfo, ServerId};
|
||||
|
||||
const DISCOVERY_WAIT_SECS: u64 = 5;
|
||||
const MONITOR_DURATION_SECS: u64 = 90;
|
||||
@@ -33,7 +33,7 @@ fn main() -> Result<()> {
|
||||
);
|
||||
}
|
||||
|
||||
let mut cache: HashMap<ServerId, UpnpMediaServer> = servers
|
||||
let mut cache: HashMap<ServerId, MediaServerInfo> = servers
|
||||
.into_iter()
|
||||
.map(|info| (info.id.clone(), info))
|
||||
.collect();
|
||||
@@ -64,7 +64,7 @@ fn main() -> Result<()> {
|
||||
|
||||
fn print_event(
|
||||
cp: &ControlPoint,
|
||||
cache: &mut HashMap<ServerId, UpnpMediaServer>,
|
||||
cache: &mut HashMap<ServerId, MediaServerInfo>,
|
||||
event: &MediaServerEvent,
|
||||
) {
|
||||
match event {
|
||||
@@ -101,7 +101,7 @@ fn print_event(
|
||||
|
||||
fn describe_server(
|
||||
cp: &ControlPoint,
|
||||
cache: &mut HashMap<ServerId, UpnpMediaServer>,
|
||||
cache: &mut HashMap<ServerId, MediaServerInfo>,
|
||||
id: &ServerId,
|
||||
) -> String {
|
||||
if let Some(info) = cache.get(id) {
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use pmocontrol::{
|
||||
DeviceDescriptionProvider, DiscoveredEndpoint, HttpXmlDescriptionProvider, MusicRendererBackend,
|
||||
DeviceDescriptionProvider, DiscoveredEndpoint, HttpXmlDescriptionProvider, MusicRenderer,
|
||||
RendererInfo,
|
||||
control_point::ControlPoint,
|
||||
openhome_client::{
|
||||
@@ -86,7 +86,7 @@ fn auto_discover_renderer() -> Result<RendererInfo> {
|
||||
let mut openhome_infos = Vec::new();
|
||||
|
||||
for renderer in renderers {
|
||||
if let MusicRendererBackend::OpenHome(oh) = renderer {
|
||||
if let MusicRenderer::OpenHome(oh) = renderer {
|
||||
if seen.insert(oh.id().0.clone()) {
|
||||
openhome_infos.push(oh.info.clone());
|
||||
}
|
||||
|
||||
539
pmocontrol/examples/queue_pmomusic_demo.rs
Normal file
539
pmocontrol/examples/queue_pmomusic_demo.rs
Normal file
@@ -0,0 +1,539 @@
|
||||
//! End-to-end queue demo that prefers the PMOMusic media server and exercises
|
||||
//! the ControlPoint playback queue API.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::env;
|
||||
use std::process;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use pmocontrol::model::TrackMetadata;
|
||||
use pmocontrol::{
|
||||
ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, MediaServerInfo,
|
||||
MusicRenderer, MusicServer, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, RendererInfo,
|
||||
};
|
||||
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 5;
|
||||
const DEFAULT_DISCOVERY_SECS: u64 = 5;
|
||||
const DEFAULT_MAX_TRACKS: usize = 3;
|
||||
const MONITOR_DURATION_SECS: u64 = 600;
|
||||
const MONITOR_POLL_SECS: u64 = 5;
|
||||
const MAX_BROWSE_DEPTH: usize = 2;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let _ = tracing_subscriber::fmt::try_init();
|
||||
let config = CliConfig::parse_from_env().unwrap_or_else(|err| {
|
||||
eprintln!("Error parsing arguments: {err}");
|
||||
print_usage_and_exit();
|
||||
});
|
||||
|
||||
if config.max_tracks == 0 {
|
||||
eprintln!("--max-tracks must be >= 1");
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
println!(
|
||||
"Starting queue_pmomusic_demo with timeout={}s discovery={}s max_tracks={}",
|
||||
config.timeout_secs, config.discovery_secs, config.max_tracks
|
||||
);
|
||||
|
||||
// ControlPoint::spawn starts the HttpXmlDescriptionProvider + DiscoveryManager combo.
|
||||
let control_point =
|
||||
ControlPoint::spawn(config.timeout_secs).context("Failed to start control point")?;
|
||||
|
||||
println!(
|
||||
"Discovery running for {} seconds before selecting devices...",
|
||||
config.discovery_secs
|
||||
);
|
||||
thread::sleep(Duration::from_secs(config.discovery_secs));
|
||||
|
||||
let registry = control_point.registry();
|
||||
let (renderer, server_info) = {
|
||||
let reg = registry.read().expect("registry poisoned");
|
||||
let renderer_candidates: Vec<RendererInfo> = reg
|
||||
.list_renderers()
|
||||
.into_iter()
|
||||
.filter(|info| !is_pmomusic_renderer(info))
|
||||
.collect();
|
||||
let renderer = pick_renderer(renderer_candidates)
|
||||
.unwrap_or_else(|| no_renderer_and_exit("No suitable renderer found after discovery."));
|
||||
let server = pick_media_server(reg.list_servers())
|
||||
.unwrap_or_else(|| no_server_and_exit("No media server with ContentDirectory."));
|
||||
(renderer, server)
|
||||
};
|
||||
|
||||
println!(
|
||||
"Selected renderer \"{}\" (protocol={:?}, id={})",
|
||||
renderer.friendly_name, renderer.protocol, renderer.id.0
|
||||
);
|
||||
println!(
|
||||
"Selected media server \"{}\" at {} (id={})",
|
||||
server_info.friendly_name, server_info.location, server_info.id.0
|
||||
);
|
||||
|
||||
let renderer_instance = MusicRenderer::from_registry_info(renderer.clone(), ®istry)
|
||||
.expect("Selected renderer is not usable by MusicRenderer façade");
|
||||
let supports_set_next = renderer_instance
|
||||
.as_upnp()
|
||||
.map(|upnp| upnp.supports_set_next())
|
||||
.unwrap_or(false);
|
||||
println!(
|
||||
"Renderer \"{}\": AVTransport present = {}, SetNextAVTransportURI supported = {}",
|
||||
renderer.friendly_name, renderer.capabilities.has_avtransport, supports_set_next
|
||||
);
|
||||
|
||||
let timeout = Duration::from_secs(config.timeout_secs);
|
||||
let server =
|
||||
MusicServer::from_info(&server_info, timeout).context("Failed to init MusicServer")?;
|
||||
|
||||
let root_entries = server
|
||||
.browse_root()
|
||||
.context("Failed to browse ContentDirectory root")?;
|
||||
println!("Root returned {} entries", root_entries.len());
|
||||
|
||||
// Try to find a playlist container first
|
||||
let (playback_items, bound_container_id) =
|
||||
collect_playable_items_with_binding(&server, &root_entries, config.max_tracks)
|
||||
.context("Failed to derive playable items from ContentDirectory root/children")?;
|
||||
|
||||
if playback_items.is_empty() {
|
||||
println!("No playable tracks were found on the selected server.");
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
println!(
|
||||
"Discovered {} playable items; enqueuing…",
|
||||
playback_items.len()
|
||||
);
|
||||
|
||||
if let Some(ref container_id) = bound_container_id {
|
||||
println!(
|
||||
"Found playlist container '{}' to bind queue to",
|
||||
container_id
|
||||
);
|
||||
} else {
|
||||
println!("No playlist container found; queue will not be bound to server");
|
||||
}
|
||||
|
||||
let mut planned_queue: VecDeque<PlaybackItem>;
|
||||
let renderer_id = renderer.id.clone();
|
||||
control_point
|
||||
.clear_queue(&renderer_id)
|
||||
.context("Failed to clear playback queue")?;
|
||||
control_point
|
||||
.enqueue_items(&renderer_id, playback_items)
|
||||
.context("Failed to enqueue playback items")?;
|
||||
|
||||
// Attach queue to playlist container if we found one
|
||||
if let Some(container_id) = bound_container_id {
|
||||
control_point
|
||||
.attach_queue_to_playlist(&renderer_id, server_info.id.clone(), container_id.clone())
|
||||
.context("Failed to attach queue to playlist container")?;
|
||||
println!(
|
||||
"✓ Queue attached to playlist container {} on server {}",
|
||||
container_id, server_info.friendly_name
|
||||
);
|
||||
}
|
||||
|
||||
let snapshot = control_point
|
||||
.get_queue_snapshot(&renderer_id)
|
||||
.context("Failed to snapshot queue after enqueue")?;
|
||||
print_queue_snapshot(&snapshot);
|
||||
planned_queue = snapshot.clone().into();
|
||||
|
||||
control_point
|
||||
.play_next_from_queue(&renderer_id)
|
||||
.context("Failed to start playback from queue")?;
|
||||
let mut current_track = planned_queue.pop_front();
|
||||
let remaining = control_point
|
||||
.get_queue_snapshot(&renderer_id)
|
||||
.context("Failed to snapshot queue after play_next_from_queue")?;
|
||||
planned_queue = remaining.clone().into();
|
||||
println!(
|
||||
"Playback started on \"{}\"; {} tracks remaining in queue.",
|
||||
renderer.friendly_name,
|
||||
remaining.len()
|
||||
);
|
||||
|
||||
println!(
|
||||
"Monitoring queue auto-advance for {} seconds (poll every {}s)…",
|
||||
MONITOR_DURATION_SECS, MONITOR_POLL_SECS
|
||||
);
|
||||
|
||||
// Subscribe to media server events to observe playlist updates
|
||||
let media_event_rx = control_point.subscribe_media_server_events();
|
||||
|
||||
let poll_count = MONITOR_DURATION_SECS / MONITOR_POLL_SECS;
|
||||
for tick in 0..poll_count {
|
||||
thread::sleep(Duration::from_secs(MONITOR_POLL_SECS));
|
||||
|
||||
// Drain any MediaServerEvent that arrived since last poll
|
||||
loop {
|
||||
match media_event_rx.try_recv() {
|
||||
Ok(MediaServerEvent::GlobalUpdated {
|
||||
server_id,
|
||||
system_update_id,
|
||||
}) => {
|
||||
println!(
|
||||
" 📢 MediaServer {} global update (SystemUpdateID={:?})",
|
||||
server_id.0, system_update_id
|
||||
);
|
||||
}
|
||||
Ok(MediaServerEvent::ContainersUpdated {
|
||||
server_id,
|
||||
container_ids,
|
||||
}) => {
|
||||
println!(
|
||||
" 📢 MediaServer {} containers updated: {:?}",
|
||||
server_id.0, container_ids
|
||||
);
|
||||
|
||||
// Check if our bound container was updated
|
||||
if let Some((bound_server, bound_container, _)) =
|
||||
control_point.current_queue_playlist_binding(&renderer_id)
|
||||
{
|
||||
if bound_server == server_id && container_ids.contains(&bound_container) {
|
||||
println!(
|
||||
" 🔄 Bound playlist container '{}' was updated, queue will refresh automatically",
|
||||
bound_container
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => break, // No more events, continue with normal monitoring
|
||||
}
|
||||
}
|
||||
|
||||
let snapshot = control_point
|
||||
.get_queue_snapshot(&renderer_id)
|
||||
.context("Queue snapshot failed during monitoring loop")?;
|
||||
let new_plan: VecDeque<PlaybackItem> = snapshot.clone().into();
|
||||
if planned_queue.len() > new_plan.len() {
|
||||
let removed = planned_queue.len() - new_plan.len();
|
||||
for _ in 0..removed {
|
||||
current_track = planned_queue.pop_front();
|
||||
}
|
||||
}
|
||||
planned_queue = new_plan;
|
||||
|
||||
let playback_info = control_point
|
||||
.music_renderer_by_id(&renderer_id)
|
||||
.and_then(|renderer| renderer.playback_position().ok());
|
||||
|
||||
let title = current_track_title(current_track.as_ref());
|
||||
if let Some(info) = playback_info {
|
||||
println!(
|
||||
"[tick {tick}] Queue length = {} | now playing: {} [{}]",
|
||||
snapshot.len(),
|
||||
title,
|
||||
format_playback_position(&info)
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"[tick {tick}] Queue length = {} | now playing: {} [position unavailable]",
|
||||
snapshot.len(),
|
||||
title
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
println!("Monitoring finished, exiting.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CliConfig {
|
||||
timeout_secs: u64,
|
||||
discovery_secs: u64,
|
||||
max_tracks: usize,
|
||||
}
|
||||
|
||||
impl CliConfig {
|
||||
fn parse_from_env() -> Result<Self, String> {
|
||||
let mut timeout_secs = DEFAULT_TIMEOUT_SECS;
|
||||
let mut discovery_secs = DEFAULT_DISCOVERY_SECS;
|
||||
let mut max_tracks = DEFAULT_MAX_TRACKS;
|
||||
|
||||
let mut args = env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--timeout-secs" => {
|
||||
let value = args
|
||||
.next()
|
||||
.ok_or_else(|| "--timeout-secs requires a value".to_string())?;
|
||||
timeout_secs = value.parse().map_err(|err| {
|
||||
format!("Invalid value for --timeout-secs ({value}): {err}")
|
||||
})?;
|
||||
}
|
||||
"--discovery-secs" => {
|
||||
let value = args
|
||||
.next()
|
||||
.ok_or_else(|| "--discovery-secs requires a value".to_string())?;
|
||||
discovery_secs = value.parse().map_err(|err| {
|
||||
format!("Invalid value for --discovery-secs ({value}): {err}")
|
||||
})?;
|
||||
}
|
||||
"--max-tracks" => {
|
||||
let value = args
|
||||
.next()
|
||||
.ok_or_else(|| "--max-tracks requires a value".to_string())?;
|
||||
max_tracks = value.parse().map_err(|err| {
|
||||
format!("Invalid value for --max-tracks ({value}): {err}")
|
||||
})?;
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
print_usage_and_exit();
|
||||
}
|
||||
unknown => {
|
||||
return Err(format!("Unknown argument: {unknown}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
timeout_secs,
|
||||
discovery_secs,
|
||||
max_tracks,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn pick_renderer(renderers: Vec<RendererInfo>) -> Option<RendererInfo> {
|
||||
let mut candidates: Vec<RendererInfo> = renderers;
|
||||
|
||||
if candidates.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
println!("Renderer candidates:");
|
||||
for (idx, info) in candidates.iter().enumerate() {
|
||||
println!(
|
||||
" [{}] {} | model={} | location={} | online={}",
|
||||
idx, info.friendly_name, info.model_name, info.location, info.online
|
||||
);
|
||||
}
|
||||
|
||||
let selected = candidates.remove(0);
|
||||
println!(
|
||||
"Automatically selecting renderer index 0: {}",
|
||||
selected.friendly_name
|
||||
);
|
||||
Some(selected)
|
||||
}
|
||||
|
||||
fn pick_media_server(servers: Vec<MediaServerInfo>) -> Option<MediaServerInfo> {
|
||||
let mut candidates: Vec<MediaServerInfo> = servers
|
||||
.into_iter()
|
||||
.filter(|info| info.has_content_directory)
|
||||
.filter(|info| info.content_directory_control_url.is_some())
|
||||
.collect();
|
||||
|
||||
if candidates.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(idx) = candidates.iter().position(is_pmomusic_server) {
|
||||
let server = candidates.remove(idx);
|
||||
println!(
|
||||
"Preferring PMOMusic server \"{}\" (server header: {}).",
|
||||
server.friendly_name, server.server_header
|
||||
);
|
||||
Some(server)
|
||||
} else {
|
||||
println!("No PMOMusic server discovered; falling back to first ContentDirectory server.");
|
||||
Some(candidates.remove(0))
|
||||
}
|
||||
}
|
||||
|
||||
fn is_pmomusic_server(info: &MediaServerInfo) -> bool {
|
||||
let name = info.friendly_name.to_ascii_lowercase();
|
||||
let header = info.server_header.to_ascii_lowercase();
|
||||
name.contains("pmomusic") || header.contains("pmomusic")
|
||||
}
|
||||
|
||||
fn is_pmomusic_renderer(info: &RendererInfo) -> bool {
|
||||
info.friendly_name
|
||||
.to_ascii_lowercase()
|
||||
.contains("pmomusic audio renderer")
|
||||
}
|
||||
|
||||
fn collect_playable_items_with_binding(
|
||||
server: &MusicServer,
|
||||
entries: &[MediaEntry],
|
||||
max_tracks: usize,
|
||||
) -> Result<(Vec<PlaybackItem>, Option<String>)> {
|
||||
// First, try to find a playlist container
|
||||
let playlist_container = entries.iter().find(|entry| {
|
||||
entry.is_container
|
||||
&& entry
|
||||
.class
|
||||
.to_ascii_lowercase()
|
||||
.contains("object.container.playlistcontainer")
|
||||
});
|
||||
|
||||
if let Some(playlist) = playlist_container {
|
||||
println!(
|
||||
"Found playlist container: '{}' (id: {}, class: {})",
|
||||
playlist.title, playlist.id, playlist.class
|
||||
);
|
||||
|
||||
// Browse the playlist container
|
||||
match server.browse_children(&playlist.id, 0, max_tracks as u32) {
|
||||
Ok(children) => {
|
||||
let mut items = Vec::new();
|
||||
for entry in &children {
|
||||
if let Some(item) = playback_item_from_entry(server, entry) {
|
||||
items.push(item);
|
||||
if items.len() >= max_tracks {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !items.is_empty() {
|
||||
return Ok((items, Some(playlist.id.clone())));
|
||||
}
|
||||
|
||||
println!(
|
||||
"Playlist container '{}' is empty, falling back to general browse",
|
||||
playlist.title
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
println!(
|
||||
"Failed to browse playlist container '{}': {}, falling back",
|
||||
playlist.title, err
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("No playlist container found in root entries, using fallback");
|
||||
}
|
||||
|
||||
// Fallback: collect from any container/item
|
||||
let mut items = Vec::new();
|
||||
for entry in entries {
|
||||
gather_items_from_entry(server, entry, max_tracks, 0, &mut items)?;
|
||||
if items.len() >= max_tracks {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok((items, None))
|
||||
}
|
||||
|
||||
fn gather_items_from_entry(
|
||||
server: &MusicServer,
|
||||
entry: &MediaEntry,
|
||||
max_tracks: usize,
|
||||
depth: usize,
|
||||
out: &mut Vec<PlaybackItem>,
|
||||
) -> Result<()> {
|
||||
if out.len() >= max_tracks {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if entry.is_container {
|
||||
if depth >= MAX_BROWSE_DEPTH {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match server.browse_children(&entry.id, 0, 50) {
|
||||
Ok(children) => {
|
||||
for child in children {
|
||||
gather_items_from_entry(server, &child, max_tracks, depth + 1, out)?;
|
||||
if out.len() >= max_tracks {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
container_id = entry.id.as_str(),
|
||||
error = %err,
|
||||
"Failed to browse child container"
|
||||
);
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(item) = playback_item_from_entry(server, entry) {
|
||||
out.push(item);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option<PlaybackItem> {
|
||||
if entry.title.to_ascii_lowercase().contains("live stream") {
|
||||
return None;
|
||||
}
|
||||
let resource = entry.resources.iter().find(|res| res.is_audio())?;
|
||||
let metadata = TrackMetadata {
|
||||
title: Some(entry.title.clone()),
|
||||
artist: entry.artist.clone(),
|
||||
album: entry.album.clone(),
|
||||
genre: entry.genre.clone(),
|
||||
album_art_uri: entry.album_art_uri.clone(),
|
||||
date: entry.date.clone(),
|
||||
track_number: entry.track_number.clone(),
|
||||
creator: entry.creator.clone(),
|
||||
};
|
||||
Some(PlaybackItem {
|
||||
media_server_id: server.id().clone(),
|
||||
didl_id: entry.id.clone(),
|
||||
uri: resource.uri.clone(),
|
||||
protocol_info: resource.protocol_info.clone(),
|
||||
metadata: Some(metadata),
|
||||
})
|
||||
}
|
||||
|
||||
fn print_queue_snapshot(items: &[PlaybackItem]) {
|
||||
println!("Current queue snapshot ({} items):", items.len());
|
||||
for (idx, item) in items.iter().enumerate() {
|
||||
let label = item
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.title.as_deref())
|
||||
.unwrap_or_else(|| item.uri.as_str());
|
||||
println!(" [{}] {} -> {}", idx, label, item.uri);
|
||||
}
|
||||
if items.is_empty() {
|
||||
println!(" <queue is empty>");
|
||||
}
|
||||
}
|
||||
|
||||
fn current_track_title(item: Option<&PlaybackItem>) -> String {
|
||||
match item {
|
||||
Some(track) => track
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.title.as_deref())
|
||||
.unwrap_or_else(|| track.uri.as_str())
|
||||
.to_string(),
|
||||
None => "<unknown>".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_playback_position(info: &PlaybackPositionInfo) -> String {
|
||||
let rel = info.rel_time.as_deref().unwrap_or("-");
|
||||
let dur = info.track_duration.as_deref().unwrap_or("-");
|
||||
format!("{rel} / {dur}")
|
||||
}
|
||||
|
||||
fn no_renderer_and_exit(message: &str) -> ! {
|
||||
println!("{message}");
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
fn no_server_and_exit(message: &str) -> ! {
|
||||
println!("{message}");
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
fn print_usage_and_exit() -> ! {
|
||||
println!(
|
||||
"Usage: cargo run -p pmocontrol --example queue_pmomusic_demo -- [--timeout-secs N] [--discovery-secs N] [--max-tracks N]"
|
||||
);
|
||||
process::exit(1);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ use pmocontrol::PlaybackPosition;
|
||||
use pmocontrol::model::RendererInfo;
|
||||
use pmocontrol::openhome_client::{OhInfoClient, OhPlaylistClient, OhTimeClient};
|
||||
use pmocontrol::{
|
||||
ControlPoint, MusicRendererBackend, PlaybackState, PlaybackStatus, RendererCapabilities,
|
||||
ControlPoint, MusicRenderer, PlaybackState, PlaybackStatus, RendererCapabilities,
|
||||
RendererProtocol, TransportControl, VolumeControl,
|
||||
};
|
||||
use std::env;
|
||||
@@ -37,7 +37,7 @@ fn main() -> Result<()> {
|
||||
thread::sleep(Duration::from_secs(5));
|
||||
|
||||
// 2. Snapshot of logical music renderers
|
||||
let mut renderers: Vec<MusicRendererBackend> = cp.list_music_renderers();
|
||||
let mut renderers: Vec<MusicRenderer> = cp.list_music_renderers();
|
||||
|
||||
// Filter out the in-dev PMOMusic renderer (if present)
|
||||
renderers.retain(|r| {
|
||||
@@ -276,20 +276,20 @@ fn print_openhome_details(prefix: &str, info: &RendererInfo) {
|
||||
}
|
||||
}
|
||||
|
||||
fn print_backend(prefix: &str, renderer: &MusicRendererBackend) {
|
||||
fn print_backend(prefix: &str, renderer: &MusicRenderer) {
|
||||
let backend = match renderer {
|
||||
MusicRendererBackend::Upnp(_) => "UpnpRenderer (UPnP AV / DLNA)",
|
||||
MusicRendererBackend::LinkPlay(_) => "LinkPlayRenderer (LinkPlay HTTP)",
|
||||
MusicRendererBackend::ArylicTcp(_) => "ArylicTcpRenderer (ARylic TCP Protocol)",
|
||||
MusicRendererBackend::HybridUpnpArylic { .. } => {
|
||||
MusicRenderer::Upnp(_) => "UpnpRenderer (UPnP AV / DLNA)",
|
||||
MusicRenderer::LinkPlay(_) => "LinkPlayRenderer (LinkPlay HTTP)",
|
||||
MusicRenderer::ArylicTcp(_) => "ArylicTcpRenderer (ARylic TCP Protocol)",
|
||||
MusicRenderer::HybridUpnpArylic { .. } => {
|
||||
"Hybrid UpnpArylicRenderer (UPnP AV / DLNA + ARylic TCP Protocol)"
|
||||
}
|
||||
MusicRendererBackend::OpenHome(_) => "OpenHomeRenderer (native OpenHome stack)",
|
||||
MusicRenderer::OpenHome(_) => "OpenHomeRenderer (native OpenHome stack)",
|
||||
};
|
||||
println!("{prefix}Backend : {backend}");
|
||||
}
|
||||
|
||||
fn dump_renderer_state(renderer: &MusicRendererBackend, label: &str) -> Result<()> {
|
||||
fn dump_renderer_state(renderer: &MusicRenderer, label: &str) -> Result<()> {
|
||||
println!("\n[{label}]");
|
||||
|
||||
if let Ok(state) = renderer.playback_state() {
|
||||
@@ -344,7 +344,7 @@ fn dump_renderer_state(renderer: &MusicRendererBackend, label: &str) -> Result<(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn progress_monitor(renderer: &MusicRendererBackend, label: &str, iterations: usize, interval_secs: u64) {
|
||||
fn progress_monitor(renderer: &MusicRenderer, label: &str, iterations: usize, interval_secs: u64) {
|
||||
println!(
|
||||
"\n[{label}] polling playback state/position {} times (every {} s)...",
|
||||
iterations, interval_secs
|
||||
@@ -370,7 +370,7 @@ fn progress_monitor(renderer: &MusicRendererBackend, label: &str, iterations: us
|
||||
}
|
||||
}
|
||||
|
||||
fn volume_demo(renderer: &MusicRendererBackend) -> Result<()> {
|
||||
fn volume_demo(renderer: &MusicRenderer) -> Result<()> {
|
||||
// Try to get current volume
|
||||
let original = renderer.volume()?;
|
||||
println!(" Current music volume : {}", original);
|
||||
|
||||
@@ -1,409 +0,0 @@
|
||||
# Analyse : Rendre rust-cast asynchrone vs autres options
|
||||
|
||||
**Date :** 2025-12-27
|
||||
**Question :** Est-il plus simple d'intégrer rust-cast et le modifier pour le rendre asynchrone ?
|
||||
|
||||
---
|
||||
|
||||
## 1. Analyse de la codebase rust-cast
|
||||
|
||||
### Taille et complexité
|
||||
|
||||
```bash
|
||||
Total : ~5800 lignes de code Rust
|
||||
Structure modulaire :
|
||||
├── src/lib.rs (~570 lignes)
|
||||
├── src/message_manager.rs (~300 lignes)
|
||||
├── src/channels/
|
||||
│ ├── media.rs (~800 lignes)
|
||||
│ ├── receiver.rs (~400 lignes)
|
||||
│ ├── heartbeat.rs (~100 lignes)
|
||||
│ └── connection.rs (~100 lignes)
|
||||
├── src/cast/
|
||||
│ ├── cast_channel.rs (généré par protobuf)
|
||||
│ └── proxies.rs (~500 lignes)
|
||||
└── src/errors.rs, utils.rs (~200 lignes)
|
||||
```
|
||||
|
||||
**Conclusion :** Codebase de taille **modeste et bien structurée**.
|
||||
|
||||
---
|
||||
|
||||
## 2. Points bloquants identifiés
|
||||
|
||||
### 2.1 I/O synchrone bloquant
|
||||
|
||||
Tous les I/O passent par `MessageManager<S>` où `S: Read + Write` :
|
||||
|
||||
```rust
|
||||
// message_manager.rs:246-253
|
||||
fn read(&self) -> Result<CastMessage, Error> {
|
||||
let mut buffer: [u8; 4] = [0; 4];
|
||||
let reader = &mut *self.stream.borrow_mut();
|
||||
|
||||
reader.read_exact(&mut buffer)?; // ❌ BLOQUANT
|
||||
let length = utils::read_u32_from_buffer(&buffer)?;
|
||||
|
||||
let mut buffer: Vec<u8> = Vec::with_capacity(length as usize);
|
||||
let mut limited_reader = reader.take(u64::from(length));
|
||||
limited_reader.read_to_end(&mut buffer)?; // ❌ BLOQUANT
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
```rust
|
||||
// message_manager.rs:138-141
|
||||
pub fn send(&self, message: CastMessage) -> Result<(), Error> {
|
||||
...
|
||||
let writer = &mut *self.stream.borrow_mut();
|
||||
writer.write_all(&message_length_buffer)?; // ❌ BLOQUANT
|
||||
writer.write_all(&message_content_buffer)?; // ❌ BLOQUANT
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Connexion TLS
|
||||
|
||||
```rust
|
||||
// lib.rs:125
|
||||
let stream = StreamOwned::new(
|
||||
conn,
|
||||
TcpStream::connect((host.as_ref(), port))? // ❌ BLOQUANT
|
||||
);
|
||||
```
|
||||
|
||||
**Total : 5 points bloquants critiques** (connect, read_exact, read_to_end, 2x write_all)
|
||||
|
||||
---
|
||||
|
||||
## 3. Effort pour rendre rust-cast asynchrone
|
||||
|
||||
### 3.1 Modifications requises
|
||||
|
||||
#### A. Remplacer la stack réseau
|
||||
|
||||
**Avant (sync) :**
|
||||
```rust
|
||||
use std::net::TcpStream;
|
||||
use rustls::{ClientConnection, StreamOwned};
|
||||
|
||||
type TlsStream = StreamOwned<ClientConnection, TcpStream>;
|
||||
```
|
||||
|
||||
**Après (async) :**
|
||||
```rust
|
||||
use async_io::Async;
|
||||
use std::net::TcpStream;
|
||||
use async_rustls::{TlsConnector, client::TlsStream};
|
||||
|
||||
// OU avec tokio :
|
||||
use tokio::net::TcpStream;
|
||||
use tokio_rustls::{TlsConnector, client::TlsStream};
|
||||
```
|
||||
|
||||
⚠️ **PROBLÈME :** `rustls::StreamOwned` n'existe pas en version async native. Il faut utiliser :
|
||||
- `async-rustls` (pour async-std/smol)
|
||||
- `tokio-rustls` (pour tokio)
|
||||
|
||||
Ces crates ont une **API différente** de `rustls::StreamOwned`.
|
||||
|
||||
#### B. Modifier `MessageManager`
|
||||
|
||||
```diff
|
||||
- pub struct MessageManager<S> where S: Write + Read {
|
||||
+ pub struct MessageManager<S> where S: AsyncWrite + AsyncRead + Unpin {
|
||||
|
||||
- pub fn send(&self, message: CastMessage) -> Result<(), Error> {
|
||||
+ pub async fn send(&self, message: CastMessage) -> Result<(), Error> {
|
||||
...
|
||||
- writer.write_all(&message_length_buffer)?;
|
||||
+ writer.write_all(&message_length_buffer).await?;
|
||||
}
|
||||
|
||||
- pub fn receive(&self) -> Result<CastMessage, Error> {
|
||||
+ pub async fn receive(&self) -> Result<CastMessage, Error> {
|
||||
...
|
||||
}
|
||||
|
||||
- fn read(&self) -> Result<CastMessage, Error> {
|
||||
+ async fn read(&self) -> Result<CastMessage, Error> {
|
||||
- reader.read_exact(&mut buffer)?;
|
||||
+ reader.read_exact(&mut buffer).await?;
|
||||
- limited_reader.read_to_end(&mut buffer)?;
|
||||
+ limited_reader.read_to_end(&mut buffer).await?;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### C. Propager `async` dans tous les channels
|
||||
|
||||
**Avant :**
|
||||
```rust
|
||||
// channels/media.rs
|
||||
impl<'a, S> MediaChannel<'a, S> where S: Write + Read {
|
||||
pub fn play(&self, ...) -> Result<(), Error> {
|
||||
self.message_manager.send(...)?;
|
||||
self.message_manager.receive_find_map(...)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Après :**
|
||||
```rust
|
||||
impl<'a, S> MediaChannel<'a, S> where S: AsyncWrite + AsyncRead + Unpin {
|
||||
pub async fn play(&self, ...) -> Result<(), Error> {
|
||||
self.message_manager.send(...).await?;
|
||||
self.message_manager.receive_find_map(...).await
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Impact :** TOUS les channels (media, receiver, heartbeat, connection) deviennent `async`.
|
||||
|
||||
#### D. Modifier `CastDevice`
|
||||
|
||||
```diff
|
||||
impl<'a> CastDevice<'a> {
|
||||
- pub fn connect<S>(host: S, port: u16) -> Result<CastDevice<'a>, Error>
|
||||
+ pub async fn connect<S>(host: S, port: u16) -> Result<CastDevice<'a>, Error>
|
||||
{
|
||||
...
|
||||
- let stream = TcpStream::connect((host.as_ref(), port))?;
|
||||
+ let stream = TcpStream::connect((host.as_ref(), port)).await?;
|
||||
...
|
||||
}
|
||||
|
||||
- pub fn receive(&self) -> Result<ChannelMessage, Error> {
|
||||
+ pub async fn receive(&self) -> Result<ChannelMessage, Error> {
|
||||
- let cast_message = self.message_manager.receive()?;
|
||||
+ let cast_message = self.message_manager.receive().await?;
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Estimation de l'effort
|
||||
|
||||
| Tâche | Fichiers touchés | Complexité | Temps estimé |
|
||||
|-------|------------------|------------|--------------|
|
||||
| Choisir stack async (smol vs tokio) | - | Faible | 1h |
|
||||
| Migrer vers async-rustls/tokio-rustls | lib.rs | **MOYENNE** | 4-6h |
|
||||
| Rendre MessageManager async | message_manager.rs | **MOYENNE** | 4-6h |
|
||||
| Rendre tous les channels async | 4 fichiers | **MOYENNE-ÉLEVÉE** | 8-12h |
|
||||
| Mettre à jour CastDevice | lib.rs | Moyenne | 2-4h |
|
||||
| Tests et debug | Tous | **ÉLEVÉE** | 8-16h |
|
||||
| **TOTAL** | **~10 fichiers** | **ÉLEVÉE** | **27-45 heures** |
|
||||
|
||||
⚠️ **RISQUES :**
|
||||
- API `async-rustls` différente de `rustls::StreamOwned` → peut nécessiter refactoring profond
|
||||
- Gestion des locks async (`Mutex` → `async_lock::Mutex` ou `tokio::sync::Mutex`)
|
||||
- Bugs subtils liés à la concurrence async
|
||||
- Tests nécessaires pour valider la stabilité
|
||||
|
||||
---
|
||||
|
||||
## 4. Comparaison des 4 options
|
||||
|
||||
### Option 1 : ✅ **Rester avec rust-cast sync et corriger le TLS**
|
||||
|
||||
**Effort :** FAIBLE (2-8 heures)
|
||||
|
||||
**Actions :**
|
||||
- Investiguer les erreurs TLS prématurées
|
||||
- Ajouter retry logic sur les reconnexions
|
||||
- Améliorer la gestion d'erreur dans [chromecast_renderer.rs](pmocontrol/src/chromecast_renderer.rs:86-104)
|
||||
- Peut-être ajuster les timeouts de lecture
|
||||
|
||||
**Avantages :**
|
||||
- ✅ Garde l'API sync compatible avec PMOMusic
|
||||
- ✅ Risque minimal
|
||||
- ✅ Solution rapide
|
||||
|
||||
**Inconvénients :**
|
||||
- ⚠️ Ne résout peut-être pas tous les problèmes TLS
|
||||
|
||||
---
|
||||
|
||||
### Option 2 : 🔧 **Forker rust-cast et moderniser le TLS (reste sync)**
|
||||
|
||||
**Effort :** MOYEN (8-16 heures)
|
||||
|
||||
**Actions :**
|
||||
- Forker rust-cast sur GitHub/GitLab
|
||||
- Améliorer la gestion TLS (retry, reconnexion automatique)
|
||||
- Ajouter logs détaillés
|
||||
- Corriger les bugs TLS identifiés
|
||||
- Maintenir un fork privé
|
||||
|
||||
**Avantages :**
|
||||
- ✅ Garde l'API sync
|
||||
- ✅ Contrôle total sur les correctifs
|
||||
- ✅ Peut merger les améliorations de upstream
|
||||
|
||||
**Inconvénients :**
|
||||
- ⚠️ Maintenance du fork à long terme
|
||||
- ⚠️ Doit suivre les mises à jour de rustls
|
||||
|
||||
---
|
||||
|
||||
### Option 3 : 🔄 **Rendre rust-cast asynchrone**
|
||||
|
||||
**Effort :** ÉLEVÉ (27-45 heures)
|
||||
|
||||
**Actions :**
|
||||
- Migrer vers async-rustls ou tokio-rustls
|
||||
- Rendre tout le code async (MessageManager, channels, CastDevice)
|
||||
- Adapter PMOMusic pour wrapper les appels async
|
||||
|
||||
**Avantages :**
|
||||
- ✅ Architecture moderne
|
||||
- ✅ Potentiellement meilleure performance pour gérer plusieurs devices
|
||||
- ✅ Résout probablement les problèmes TLS via stack moderne
|
||||
|
||||
**Inconvénients :**
|
||||
- ❌ Effort très élevé
|
||||
- ❌ Risque de bugs subtils
|
||||
- ❌ PMOMusic doit wrapper tous les appels avec `smol::block_on()`
|
||||
- ❌ Overhead de conversion sync→async→sync
|
||||
|
||||
**⚠️ PARADOXE :** Rendre rust-cast async pour ensuite le wrapper en sync dans PMOMusic = **surcharge inutile**
|
||||
|
||||
---
|
||||
|
||||
### Option 4 : ❌ **Migrer vers cast-sender (déjà async)**
|
||||
|
||||
**Effort :** TRÈS ÉLEVÉ (40-80 heures)
|
||||
|
||||
**Problèmes critiques :**
|
||||
- ❌ API incomplète (pas de get_status, pas de seek)
|
||||
- ❌ Nécessite architecture stateful complexe
|
||||
- ❌ Documentation insuffisante (23%)
|
||||
|
||||
**Voir :** [cast-sender-evaluation.md](cast-sender-evaluation.md)
|
||||
|
||||
---
|
||||
|
||||
## 5. Analyse détaillée : Async est-il vraiment utile ?
|
||||
|
||||
### 5.1 Cas d'usage PMOMusic
|
||||
|
||||
**Architecture actuelle :**
|
||||
- 1 thread par Chromecast actif (pour le heartbeat)
|
||||
- Opérations de contrôle (play, pause, volume) : sporadiques
|
||||
- Pas de gestion massive de connexions simultanées
|
||||
|
||||
**Bénéfice de async :**
|
||||
- ❌ **FAIBLE** : PMOMusic n'a pas besoin de gérer 100+ connexions simultanées
|
||||
- ❌ **OVERHEAD** : Wrapping sync→async→sync ajoute de la complexité
|
||||
|
||||
### 5.2 Vraie cause des problèmes TLS ?
|
||||
|
||||
Les problèmes de "fermeture TLS prématurée" sont probablement dus à :
|
||||
- Timeout réseau trop court
|
||||
- Gestion d'erreur insuffisante lors des reconnexions
|
||||
- Bugs spécifiques de certaines versions de rustls
|
||||
|
||||
**Async ne résout PAS directement ces problèmes !**
|
||||
|
||||
---
|
||||
|
||||
## 6. Recommandation finale
|
||||
|
||||
### 🏆 **Option recommandée : Option 1 (Corriger rust-cast sync)**
|
||||
|
||||
**Raisons :**
|
||||
|
||||
1. **Effort minimal** : 2-8 heures vs 27-45h pour async
|
||||
2. **Risque minimal** : Garde l'architecture validée
|
||||
3. **Compatibilité** : Pas de changement dans PMOMusic
|
||||
4. **Pragmatique** : Résout le problème réel (TLS) sans over-engineering
|
||||
|
||||
**Plan d'action concret :**
|
||||
|
||||
```rust
|
||||
// Améliorer la fonction connect_to_device
|
||||
fn connect_to_device(host: &str, port: u16) -> Result<CastDevice> {
|
||||
const MAX_RETRIES: u32 = 3;
|
||||
const RETRY_DELAY_MS: u64 = 1000;
|
||||
|
||||
for attempt in 1..=MAX_RETRIES {
|
||||
match try_connect(host, port) {
|
||||
Ok(device) => return Ok(device),
|
||||
Err(e) if attempt < MAX_RETRIES => {
|
||||
tracing::warn!(
|
||||
"Connection attempt {} failed: {}. Retrying in {}ms...",
|
||||
attempt, e, RETRY_DELAY_MS
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(RETRY_DELAY_MS));
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
// Ajouter timeout configurable pour les read operations
|
||||
// Ajouter meilleure gestion d'erreur dans le heartbeat loop
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🥈 **Alternative : Option 2 (Fork rust-cast)**
|
||||
|
||||
Si l'Option 1 ne suffit pas après investigation, forker permet :
|
||||
- Corrections TLS plus profondes
|
||||
- Ajout de fonctionnalités manquantes
|
||||
- Contrôle total
|
||||
|
||||
**Pas besoin de rendre async !**
|
||||
|
||||
---
|
||||
|
||||
### 🚫 **Options déconseillées :**
|
||||
|
||||
- ❌ **Option 3** (Async rust-cast) : Effort 5-10x supérieur pour bénéfice marginal
|
||||
- ❌ **Option 4** (cast-sender) : API incomplète, effort encore plus élevé
|
||||
|
||||
---
|
||||
|
||||
## 7. Conclusion
|
||||
|
||||
**NON, rendre rust-cast asynchrone n'est PAS plus simple.**
|
||||
|
||||
**Comparaison des efforts :**
|
||||
|
||||
| Option | Effort (heures) | Complexité | Risque |
|
||||
|--------|----------------|------------|--------|
|
||||
| 1. Corriger rust-cast sync | 2-8 | Faible | Minimal |
|
||||
| 2. Forker rust-cast | 8-16 | Moyenne | Faible |
|
||||
| 3. **Async rust-cast** | **27-45** | **Élevée** | **Élevé** |
|
||||
| 4. Migrer cast-sender | 40-80 | Très élevée | Très élevé |
|
||||
|
||||
**Le ratio effort/bénéfice de l'option async est défavorable :**
|
||||
- **5-10x plus d'effort** que corriger le code sync
|
||||
- **Bénéfice minimal** pour l'architecture actuelle de PMOMusic
|
||||
- **Risques élevés** de bugs de concurrence async
|
||||
|
||||
**Recommandation :** Commencer par l'**Option 1**, investiguer les vrais problèmes TLS, et envisager l'**Option 2** (fork) uniquement si nécessaire. Éviter absolument l'**Option 3** (async) sauf changement radical d'architecture de PMOMusic.
|
||||
|
||||
---
|
||||
|
||||
## Annexe : Si vous vouliez quand même faire async...
|
||||
|
||||
### Stack recommandée
|
||||
|
||||
**Pour PMOMusic (déjà avec smol) :**
|
||||
```toml
|
||||
[dependencies]
|
||||
async-io = "2.3"
|
||||
async-rustls = "0.4"
|
||||
futures-lite = "2.1"
|
||||
```
|
||||
|
||||
**Points d'attention :**
|
||||
- Remplacer tous les `Mutex` par `async_lock::Mutex`
|
||||
- Gérer correctement le `Unpin` trait pour les streams
|
||||
- Tester intensivement la gestion des erreurs async
|
||||
- Prévoir 2-3 semaines de développement + tests
|
||||
|
||||
**Mais encore une fois : le jeu n'en vaut pas la chandelle !**
|
||||
@@ -1,296 +0,0 @@
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
net::{Shutdown, TcpStream, ToSocketAddrs},
|
||||
sync::{Mutex, OnceLock},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::errors::ControlPointError;
|
||||
|
||||
pub const ARYLIC_TCP_PORT: u16 = 8899;
|
||||
pub const DEFAULT_TIMEOUT_SECS: u64 = 3;
|
||||
|
||||
const PACKET_HEADER: [u8; 4] = [0x18, 0x96, 0x18, 0x20];
|
||||
const RESERVED_BYTES: [u8; 8] = [0; 8];
|
||||
const MAX_RESPONSE_ATTEMPTS: usize = 8;
|
||||
|
||||
// Garde global pour respecter le délai de 200ms entre commandes
|
||||
static LAST_COMMAND_TIME: OnceLock<Mutex<Instant>> = OnceLock::new();
|
||||
|
||||
fn last_command_time() -> &'static Mutex<Instant> {
|
||||
LAST_COMMAND_TIME.get_or_init(|| Mutex::new(Instant::now()))
|
||||
}
|
||||
|
||||
/// Mode d’attente de réponse pour une commande TCP Arylic.
|
||||
enum ResponseMode<'a> {
|
||||
/// On n’attend aucune réponse (fire-and-forget).
|
||||
None,
|
||||
/// On attend une réponse, mais si la lecture échoue immédiatement, on traite comme succès.
|
||||
Optional(&'a [&'a str]),
|
||||
/// On attend une réponse, et l’absence de réponse est une erreur.
|
||||
Required(&'a [&'a str]),
|
||||
}
|
||||
|
||||
fn send_command_with_mode(
|
||||
host: &str,
|
||||
port: u16,
|
||||
timeout: Duration,
|
||||
payload: &str,
|
||||
mode: ResponseMode<'_>,
|
||||
) -> Result<Option<String>, ControlPointError> {
|
||||
let mut stream = connect(host, port, timeout)?;
|
||||
let packet = encode_packet(payload);
|
||||
|
||||
stream.write_all(&packet).map_err(|_| {
|
||||
ControlPointError::ArilycTcpError(format!(
|
||||
"Failed to write Arylic TCP packet for {}: {}",
|
||||
host, payload
|
||||
))
|
||||
})?;
|
||||
|
||||
stream.flush().map_err(|_| {
|
||||
ControlPointError::ArilycTcpError(format!(
|
||||
"Failed to flush Arylic TCP stream for {} (command {})",
|
||||
host, payload
|
||||
))
|
||||
})?;
|
||||
|
||||
match mode {
|
||||
ResponseMode::None => {
|
||||
debug!(
|
||||
"Arylic TCP fire-and-forget command sent to {}: {}",
|
||||
host, payload
|
||||
);
|
||||
let _ = stream.shutdown(Shutdown::Write);
|
||||
Ok(None)
|
||||
}
|
||||
ResponseMode::Required(expected) => {
|
||||
read_expected_response(&mut stream, host, payload, expected).map(Some)
|
||||
}
|
||||
ResponseMode::Optional(expected) => {
|
||||
for _ in 0..MAX_RESPONSE_ATTEMPTS {
|
||||
match read_packet(&mut stream) {
|
||||
Ok(response) => {
|
||||
if expected.iter().any(|p| response.starts_with(p)) {
|
||||
return Ok(Some(response));
|
||||
}
|
||||
debug!(
|
||||
"Ignoring unsolicited Arylic payload from {}: {}",
|
||||
host, response
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
debug!(
|
||||
"No full response for Arylic TCP command {} on {}: {}. Treating as success and relying on PINFGET.",
|
||||
payload, host, err
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(ControlPointError::ArilycTcpError(format!(
|
||||
"No expected response for optional command {} on {}",
|
||||
payload, host
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_expected_response(
|
||||
stream: &mut TcpStream,
|
||||
host: &str,
|
||||
payload: &str,
|
||||
expected: &[&str],
|
||||
) -> Result<String, ControlPointError> {
|
||||
for _ in 0..MAX_RESPONSE_ATTEMPTS {
|
||||
let response = match read_packet(stream) {
|
||||
Ok(resp) => resp,
|
||||
Err(err) => {
|
||||
return Err(ControlPointError::ArilycTcpError(format!(
|
||||
"Failed to read Arylic TCP response for {} (command {}): {}",
|
||||
host, payload, err
|
||||
)));
|
||||
}
|
||||
};
|
||||
if expected.iter().any(|prefix| response.starts_with(prefix)) {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
debug!(
|
||||
"Ignoring unsolicited Arylic payload from {}: {}",
|
||||
host, response
|
||||
);
|
||||
}
|
||||
|
||||
Err(ControlPointError::ArilycTcpError(format!(
|
||||
"No expected response for command {} on {}",
|
||||
payload, host
|
||||
)))
|
||||
}
|
||||
|
||||
pub fn send_command_required(
|
||||
host: &str,
|
||||
port: u16,
|
||||
timeout: Duration,
|
||||
payload: &str,
|
||||
expected: &[&str],
|
||||
) -> Result<String, ControlPointError> {
|
||||
match send_command_with_mode(
|
||||
host,
|
||||
port,
|
||||
timeout,
|
||||
payload,
|
||||
ResponseMode::Required(expected),
|
||||
)? {
|
||||
Some(s) => Ok(s),
|
||||
None => Err(ControlPointError::ArilycTcpError(format!(
|
||||
"Arylic TCP: no response payload for required command {}",
|
||||
payload
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send_command_optional(
|
||||
host: &str,
|
||||
port: u16,
|
||||
timeout: Duration,
|
||||
payload: &str,
|
||||
expected: &[&str],
|
||||
) -> Result<Option<String>, ControlPointError> {
|
||||
send_command_with_mode(
|
||||
host,
|
||||
port,
|
||||
timeout,
|
||||
payload,
|
||||
ResponseMode::Optional(expected),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn send_command_no_response(
|
||||
host: &str,
|
||||
port: u16,
|
||||
timeout: Duration,
|
||||
payload: &str,
|
||||
) -> Result<(), ControlPointError> {
|
||||
send_command_with_mode(host, port, timeout, payload, ResponseMode::None).map(|_| ())
|
||||
}
|
||||
|
||||
fn connect(host: &str, port: u16, timeout: Duration) -> Result<TcpStream, ControlPointError> {
|
||||
if let Ok(mut last_time) = last_command_time().lock() {
|
||||
let elapsed = last_time.elapsed();
|
||||
if elapsed < Duration::from_millis(200) {
|
||||
let wait = Duration::from_millis(200) - elapsed;
|
||||
debug!(
|
||||
"Waiting {:?} before sending command to respect 200ms interval",
|
||||
wait
|
||||
);
|
||||
thread::sleep(wait);
|
||||
}
|
||||
*last_time = Instant::now();
|
||||
}
|
||||
|
||||
let address = if host.contains(':') {
|
||||
format!("[{}]:{}", host, port)
|
||||
} else {
|
||||
format!("{host}:{port}")
|
||||
};
|
||||
|
||||
let mut last_err = None;
|
||||
for addr in address.to_socket_addrs().map_err(|_| {
|
||||
ControlPointError::ArilycTcpError(format!("Failed to resolve {}:{}", host, port))
|
||||
})? {
|
||||
match TcpStream::connect_timeout(&addr, timeout) {
|
||||
Ok(stream) => {
|
||||
stream
|
||||
.set_read_timeout(Some(timeout))
|
||||
.and_then(|_| stream.set_write_timeout(Some(timeout)))
|
||||
.map_err(|_| {
|
||||
ControlPointError::ArilycTcpError(format!(
|
||||
"Failed to set socket timeouts for {}",
|
||||
address
|
||||
))
|
||||
})?;
|
||||
|
||||
return Ok(stream);
|
||||
}
|
||||
Err(err) => {
|
||||
last_err = Some((addr, err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match last_err {
|
||||
Some((addr, err)) => Err(ControlPointError::ArilycTcpError(format!(
|
||||
"Failed to connect to {} via {}: {}",
|
||||
host, addr, err
|
||||
))),
|
||||
None => Err(ControlPointError::ArilycTcpError(format!(
|
||||
"No socket addresses resolved for {}",
|
||||
address
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_packet(payload: &str) -> Vec<u8> {
|
||||
let bytes = payload.as_bytes();
|
||||
let len = bytes.len() as u32;
|
||||
let checksum = bytes.iter().fold(0u32, |acc, b| acc + (*b as u32));
|
||||
|
||||
let mut out = Vec::with_capacity(4 + 4 + 4 + 8 + bytes.len());
|
||||
out.extend_from_slice(&PACKET_HEADER);
|
||||
out.extend_from_slice(&len.to_le_bytes());
|
||||
out.extend_from_slice(&checksum.to_le_bytes());
|
||||
out.extend_from_slice(&RESERVED_BYTES);
|
||||
out.extend_from_slice(bytes);
|
||||
out
|
||||
}
|
||||
|
||||
fn read_packet(stream: &mut TcpStream) -> Result<String, ControlPointError> {
|
||||
let mut header = [0u8; 4];
|
||||
stream
|
||||
.read_exact(&mut header)
|
||||
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}", e)))?;
|
||||
if header != PACKET_HEADER {
|
||||
return Err(ControlPointError::ArilycTcpError(format!(
|
||||
"Invalid Arylic packet header: {:x?}",
|
||||
header
|
||||
)));
|
||||
}
|
||||
|
||||
let mut len_buf = [0u8; 4];
|
||||
stream
|
||||
.read_exact(&mut len_buf)
|
||||
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}", e)))?;
|
||||
let len = u32::from_le_bytes(len_buf) as usize;
|
||||
|
||||
let mut checksum_buf = [0u8; 4];
|
||||
stream
|
||||
.read_exact(&mut checksum_buf)
|
||||
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}", e)))?;
|
||||
let expected_checksum = u32::from_le_bytes(checksum_buf);
|
||||
|
||||
let mut reserved = [0u8; 8];
|
||||
stream
|
||||
.read_exact(&mut reserved)
|
||||
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}", e)))?;
|
||||
|
||||
let mut payload = vec![0u8; len];
|
||||
stream
|
||||
.read_exact(&mut payload)
|
||||
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}", e)))?;
|
||||
|
||||
let actual_checksum = payload.iter().fold(0u32, |acc, b| acc + (*b as u32));
|
||||
if actual_checksum != expected_checksum {
|
||||
warn!(
|
||||
"Arylic payload checksum mismatch: expected={} actual={}",
|
||||
expected_checksum, actual_checksum
|
||||
);
|
||||
}
|
||||
|
||||
Ok(String::from_utf8(payload)
|
||||
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}", e)))?)
|
||||
}
|
||||
659
pmocontrol/src/arylic_tcp.rs
Normal file
659
pmocontrol/src/arylic_tcp.rs
Normal file
@@ -0,0 +1,659 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{Shutdown, TcpStream, ToSocketAddrs};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::capabilities::{
|
||||
PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl,
|
||||
VolumeControl,
|
||||
};
|
||||
use crate::linkplay::{extract_linkplay_host, parse_flat_json};
|
||||
use crate::model::{RendererId, RendererInfo};
|
||||
use std::time::Instant;
|
||||
|
||||
// Garde global pour respecter le délai de 200ms entre commandes
|
||||
static LAST_COMMAND_TIME: OnceLock<Mutex<Instant>> = OnceLock::new();
|
||||
|
||||
fn last_command_time() -> &'static Mutex<Instant> {
|
||||
LAST_COMMAND_TIME.get_or_init(|| Mutex::new(Instant::now()))
|
||||
}
|
||||
|
||||
const ARYLIC_TCP_PORT: u16 = 8899;
|
||||
const PACKET_HEADER: [u8; 4] = [0x18, 0x96, 0x18, 0x20];
|
||||
const RESERVED_BYTES: [u8; 8] = [0; 8];
|
||||
const MAX_RESPONSE_ATTEMPTS: usize = 8;
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 3;
|
||||
|
||||
static DETECTION_CACHE: OnceLock<Mutex<HashMap<String, bool>>> = OnceLock::new();
|
||||
|
||||
/// Mode d’attente de réponse pour une commande TCP Arylic.
|
||||
enum ResponseMode<'a> {
|
||||
/// On n’attend aucune réponse (fire-and-forget).
|
||||
None,
|
||||
/// On attend une réponse, mais si la lecture échoue immédiatement, on traite comme succès.
|
||||
Optional(&'a [&'a str]),
|
||||
/// On attend une réponse, et l’absence de réponse est une erreur.
|
||||
Required(&'a [&'a str]),
|
||||
}
|
||||
|
||||
fn detection_cache() -> &'static Mutex<HashMap<String, bool>> {
|
||||
DETECTION_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// Probe whether the renderer at the given location exposes the Arylic TCP API.
|
||||
pub(crate) fn detect_arylic_tcp(location: &str, timeout: Duration) -> bool {
|
||||
let Some(host) = extract_linkplay_host(location) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if let Ok(cache) = detection_cache().lock() {
|
||||
if let Some(result) = cache.get(&host) {
|
||||
return *result;
|
||||
}
|
||||
}
|
||||
|
||||
let detected = match try_detect_tcp(&host, timeout) {
|
||||
Ok(_) => true,
|
||||
Err(err) => {
|
||||
debug!(
|
||||
"Arylic TCP detection failed for {} (host={}): {}",
|
||||
location, host, err
|
||||
);
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if let Ok(mut cache) = detection_cache().lock() {
|
||||
cache.insert(host, detected);
|
||||
}
|
||||
|
||||
detected
|
||||
}
|
||||
|
||||
fn try_detect_tcp(host: &str, timeout: Duration) -> Result<()> {
|
||||
let payload = send_command_required(
|
||||
host,
|
||||
ARYLIC_TCP_PORT,
|
||||
timeout,
|
||||
"MCU+INF+GET",
|
||||
&["AXX+INF+", "AXX+DEV+"],
|
||||
)?;
|
||||
|
||||
if payload.starts_with("AXX+INF+") || payload.starts_with("AXX+DEV+") {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!(
|
||||
"Unexpected INF response from {}: {}",
|
||||
host,
|
||||
payload
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Backend speaking the Arylic TCP control protocol (port 8899).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArylicTcpRenderer {
|
||||
pub info: RendererInfo,
|
||||
host: String,
|
||||
port: u16,
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
impl ArylicTcpRenderer {
|
||||
pub fn from_renderer_info(info: RendererInfo) -> Result<Self> {
|
||||
let host = extract_linkplay_host(&info.location)
|
||||
.ok_or_else(|| anyhow!("Renderer {} has no valid LOCATION host", info.udn))?;
|
||||
|
||||
Ok(Self {
|
||||
info,
|
||||
host,
|
||||
port: ARYLIC_TCP_PORT,
|
||||
timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &RendererId {
|
||||
&self.info.id
|
||||
}
|
||||
|
||||
pub fn friendly_name(&self) -> &str {
|
||||
&self.info.friendly_name
|
||||
}
|
||||
|
||||
fn send_required(&self, cmd: &str, expected: &[&str]) -> Result<String> {
|
||||
send_command_required(&self.host, self.port, self.timeout, cmd, expected)
|
||||
}
|
||||
|
||||
fn send_optional(&self, cmd: &str, expected: &[&str]) -> Result<Option<String>> {
|
||||
send_command_optional(&self.host, self.port, self.timeout, cmd, expected)
|
||||
}
|
||||
|
||||
fn send_no_response(&self, cmd: &str) -> Result<()> {
|
||||
send_command_no_response(&self.host, self.port, self.timeout, cmd)
|
||||
}
|
||||
|
||||
fn fetch_playback_info(&self) -> Result<ArylicPlaybackInfo> {
|
||||
let payload = self.send_required("MCU+PINFGET", &["AXX+PLY+INF"])?;
|
||||
match parse_playback_info(&payload) {
|
||||
Ok(info) => Ok(info),
|
||||
Err(err) => {
|
||||
debug!(
|
||||
"Failed to parse Arylic playback info for {}: {}",
|
||||
self.host, err
|
||||
);
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_volume_command(value: u16) -> String {
|
||||
format!("MCU+VOL+{:03}", value.min(100))
|
||||
}
|
||||
|
||||
fn parse_volume_payload(payload: &str) -> Result<u16> {
|
||||
let data = payload
|
||||
.strip_prefix("AXX+VOL+")
|
||||
.ok_or_else(|| anyhow!("Unexpected volume response: {}", payload))?;
|
||||
let value: u16 = data
|
||||
.trim()
|
||||
.parse()
|
||||
.with_context(|| format!("Invalid volume value: {}", data))?;
|
||||
Ok(value.min(100))
|
||||
}
|
||||
|
||||
fn parse_mute_payload(payload: &str) -> Result<bool> {
|
||||
let data = payload
|
||||
.strip_prefix("AXX+MUT+")
|
||||
.ok_or_else(|| anyhow!("Unexpected mute response: {}", payload))?;
|
||||
match data.trim() {
|
||||
"000" | "0" => Ok(false),
|
||||
"001" | "1" => Ok(true),
|
||||
other => Err(anyhow!("Invalid mute value: {}", other)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TransportControl for ArylicTcpRenderer {
|
||||
fn play_uri(&self, _uri: &str, _meta: &str) -> Result<()> {
|
||||
Err(anyhow!(
|
||||
"Arylic TCP backend does not support direct URL loading. Use UPnP AVTransport SetAVTransportURI instead."
|
||||
))
|
||||
}
|
||||
|
||||
fn play(&self) -> Result<()> {
|
||||
self.send_no_response("MCU+PLY-PLA")
|
||||
}
|
||||
|
||||
fn pause(&self) -> Result<()> {
|
||||
let _ = self.send_optional("MCU+PLY-PUS", &["AXX+PLY+"])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stop(&self) -> Result<()> {
|
||||
self.send_no_response("MCU+PLY-STP")
|
||||
}
|
||||
|
||||
fn seek_rel_time(&self, hhmmss: &str) -> Result<()> {
|
||||
let _ = parse_hhmmss(hhmmss)?;
|
||||
Err(anyhow!(
|
||||
"Arylic TCP seek_rel_time is not implemented yet for this device."
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl VolumeControl for ArylicTcpRenderer {
|
||||
fn volume(&self) -> Result<u16> {
|
||||
if let Ok(info) = self.fetch_playback_info() {
|
||||
if let Some(vol) = info.volume {
|
||||
return Ok(vol);
|
||||
}
|
||||
debug!(
|
||||
"Arylic playback info for {} missing volume, falling back to VOL GET",
|
||||
self.host
|
||||
);
|
||||
}
|
||||
let payload = self.send_required("MCU+VOL+GET", &["AXX+VOL+"])?;
|
||||
Self::parse_volume_payload(&payload)
|
||||
}
|
||||
|
||||
fn set_volume(&self, v: u16) -> Result<()> {
|
||||
let command = Self::format_volume_command(v);
|
||||
let _ = self.send_optional(&command, &["AXX+VOL+"])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mute(&self) -> Result<bool> {
|
||||
if let Ok(info) = self.fetch_playback_info() {
|
||||
if let Some(mute) = info.mute {
|
||||
return Ok(mute);
|
||||
}
|
||||
debug!(
|
||||
"Arylic playback info for {} missing mute, falling back to MUT GET",
|
||||
self.host
|
||||
);
|
||||
}
|
||||
let payload = self.send_required("MCU+MUT+GET", &["AXX+MUT+"])?;
|
||||
Self::parse_mute_payload(&payload)
|
||||
}
|
||||
|
||||
fn set_mute(&self, m: bool) -> Result<()> {
|
||||
let command = if m { "MCU+MUT+001" } else { "MCU+MUT+000" };
|
||||
let payload = self.send_required(command, &["AXX+MUT+"])?;
|
||||
let _ = Self::parse_mute_payload(&payload)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl PlaybackStatus for ArylicTcpRenderer {
|
||||
fn playback_state(&self) -> Result<PlaybackState> {
|
||||
let info = self.fetch_playback_info()?;
|
||||
Ok(info.playback_state())
|
||||
}
|
||||
}
|
||||
|
||||
impl PlaybackPosition for ArylicTcpRenderer {
|
||||
fn playback_position(&self) -> Result<PlaybackPositionInfo> {
|
||||
let info = self.fetch_playback_info()?;
|
||||
Ok(info.position_info())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ArylicPlaybackInfo {
|
||||
status_raw: String,
|
||||
curpos_ms: u64,
|
||||
totlen_ms: u64,
|
||||
volume: Option<u16>,
|
||||
mute: Option<bool>,
|
||||
playlist_size: Option<u32>,
|
||||
track_index: Option<u32>,
|
||||
}
|
||||
|
||||
impl ArylicPlaybackInfo {
|
||||
fn playback_state(&self) -> PlaybackState {
|
||||
match self.status_raw.as_str() {
|
||||
"play" => PlaybackState::Playing,
|
||||
"pause" => PlaybackState::Paused,
|
||||
"stop" => PlaybackState::Stopped,
|
||||
other => PlaybackState::Unknown(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn position_info(&self) -> PlaybackPositionInfo {
|
||||
let track = match (self.track_index, self.playlist_size) {
|
||||
(Some(idx), Some(count)) if count > 0 => Some(idx.min(count)),
|
||||
(Some(idx), _) => Some(idx),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
PlaybackPositionInfo {
|
||||
track,
|
||||
rel_time: Some(format_hms(self.curpos_ms / 1000)),
|
||||
abs_time: None,
|
||||
track_duration: if self.totlen_ms > 0 {
|
||||
Some(format_hms(self.totlen_ms / 1000))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
track_metadata: None,
|
||||
track_uri: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_playback_info(payload: &str) -> Result<ArylicPlaybackInfo> {
|
||||
let json_blob = payload
|
||||
.strip_prefix("AXX+PLY+INF")
|
||||
.ok_or_else(|| anyhow!("Unexpected playback info prefix: {}", payload))?;
|
||||
|
||||
let json_blob = json_blob.trim_end_matches('&').trim();
|
||||
let map = parse_flat_json(json_blob)?;
|
||||
|
||||
let status_raw = map
|
||||
.get("status")
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow!("Playback info missing `status` field"))?;
|
||||
|
||||
let curpos_ms = parse_u64_field(&map, "curpos")?;
|
||||
let totlen_ms = parse_u64_field(&map, "totlen")?;
|
||||
|
||||
let volume = match map.get("vol") {
|
||||
Some(raw) => match raw.parse::<u16>() {
|
||||
Ok(value) => Some(value.min(100)),
|
||||
Err(err) => {
|
||||
debug!("Invalid Arylic `vol` value {}: {}", raw, err);
|
||||
None
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
|
||||
let mute = match map.get("mute") {
|
||||
Some(value) if value == "1" => Some(true),
|
||||
Some(value) if value == "0" => Some(false),
|
||||
Some(other) => {
|
||||
debug!("Invalid Arylic `mute` value {}", other);
|
||||
None
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
let playlist_size = map
|
||||
.get("plicount")
|
||||
.and_then(|raw| match raw.parse::<u32>() {
|
||||
Ok(count) if count > 0 => Some(count),
|
||||
Ok(_) => None,
|
||||
Err(err) => {
|
||||
debug!("Invalid Arylic `plicount` value {}: {}", raw, err);
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let track_index = map.get("plicurr").and_then(|raw| match raw.parse::<u32>() {
|
||||
Ok(idx) if idx > 0 => Some(idx),
|
||||
Ok(_) => None,
|
||||
Err(err) => {
|
||||
debug!("Invalid Arylic `plicurr` value {}: {}", raw, err);
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
Ok(ArylicPlaybackInfo {
|
||||
status_raw,
|
||||
curpos_ms,
|
||||
totlen_ms,
|
||||
volume,
|
||||
mute,
|
||||
playlist_size,
|
||||
track_index,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_u64_field(map: &HashMap<String, String>, key: &str) -> Result<u64> {
|
||||
let raw = map
|
||||
.get(key)
|
||||
.ok_or_else(|| anyhow!("Playback info missing `{}` field", key))?;
|
||||
raw.parse::<u64>()
|
||||
.with_context(|| format!("Invalid `{}` value: {}", key, raw))
|
||||
}
|
||||
|
||||
fn connect(host: &str, port: u16, timeout: Duration) -> Result<TcpStream> {
|
||||
if let Ok(mut last_time) = last_command_time().lock() {
|
||||
let elapsed = last_time.elapsed();
|
||||
if elapsed < Duration::from_millis(200) {
|
||||
let wait = Duration::from_millis(200) - elapsed;
|
||||
debug!(
|
||||
"Waiting {:?} before sending command to respect 200ms interval",
|
||||
wait
|
||||
);
|
||||
thread::sleep(wait);
|
||||
}
|
||||
*last_time = Instant::now();
|
||||
}
|
||||
|
||||
let address = if host.contains(':') {
|
||||
format!("[{}]:{}", host, port)
|
||||
} else {
|
||||
format!("{host}:{port}")
|
||||
};
|
||||
|
||||
let mut last_err = None;
|
||||
for addr in address
|
||||
.to_socket_addrs()
|
||||
.with_context(|| format!("Failed to resolve {}:{}", host, port))?
|
||||
{
|
||||
match TcpStream::connect_timeout(&addr, timeout) {
|
||||
Ok(stream) => {
|
||||
stream
|
||||
.set_read_timeout(Some(timeout))
|
||||
.and_then(|_| stream.set_write_timeout(Some(timeout)))
|
||||
.with_context(|| format!("Failed to set socket timeouts for {}", address))?;
|
||||
return Ok(stream);
|
||||
}
|
||||
Err(err) => {
|
||||
last_err = Some((addr, err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match last_err {
|
||||
Some((addr, err)) => Err(anyhow!(
|
||||
"Failed to connect to {} via {}: {}",
|
||||
host,
|
||||
addr,
|
||||
err
|
||||
)),
|
||||
None => Err(anyhow!("No socket addresses resolved for {}", address)),
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_packet(payload: &str) -> Vec<u8> {
|
||||
let bytes = payload.as_bytes();
|
||||
let len = bytes.len() as u32;
|
||||
let checksum = bytes.iter().fold(0u32, |acc, b| acc + (*b as u32));
|
||||
|
||||
let mut out = Vec::with_capacity(4 + 4 + 4 + 8 + bytes.len());
|
||||
out.extend_from_slice(&PACKET_HEADER);
|
||||
out.extend_from_slice(&len.to_le_bytes());
|
||||
out.extend_from_slice(&checksum.to_le_bytes());
|
||||
out.extend_from_slice(&RESERVED_BYTES);
|
||||
out.extend_from_slice(bytes);
|
||||
out
|
||||
}
|
||||
|
||||
fn read_packet(stream: &mut TcpStream) -> Result<String> {
|
||||
let mut header = [0u8; 4];
|
||||
stream.read_exact(&mut header)?;
|
||||
if header != PACKET_HEADER {
|
||||
return Err(anyhow!("Invalid Arylic packet header: {:x?}", header));
|
||||
}
|
||||
|
||||
let mut len_buf = [0u8; 4];
|
||||
stream.read_exact(&mut len_buf)?;
|
||||
let len = u32::from_le_bytes(len_buf) as usize;
|
||||
|
||||
let mut checksum_buf = [0u8; 4];
|
||||
stream.read_exact(&mut checksum_buf)?;
|
||||
let expected_checksum = u32::from_le_bytes(checksum_buf);
|
||||
|
||||
let mut reserved = [0u8; 8];
|
||||
stream.read_exact(&mut reserved)?;
|
||||
|
||||
let mut payload = vec![0u8; len];
|
||||
stream.read_exact(&mut payload)?;
|
||||
|
||||
let actual_checksum = payload.iter().fold(0u32, |acc, b| acc + (*b as u32));
|
||||
if actual_checksum != expected_checksum {
|
||||
warn!(
|
||||
"Arylic payload checksum mismatch: expected={} actual={}",
|
||||
expected_checksum, actual_checksum
|
||||
);
|
||||
}
|
||||
|
||||
Ok(String::from_utf8(payload)?)
|
||||
}
|
||||
|
||||
fn format_hms(secs: u64) -> String {
|
||||
let h = secs / 3600;
|
||||
let m = (secs % 3600) / 60;
|
||||
let s = secs % 60;
|
||||
format!("{:02}:{:02}:{:02}", h, m, s)
|
||||
}
|
||||
|
||||
fn parse_hhmmss(value: &str) -> Result<u64> {
|
||||
let parts: Vec<_> = value.split(':').collect();
|
||||
if parts.len() != 3 {
|
||||
return Err(anyhow!(
|
||||
"Invalid time format `{}`. Expected HH:MM:SS.",
|
||||
value
|
||||
));
|
||||
}
|
||||
|
||||
let hours: u64 = parts[0]
|
||||
.parse()
|
||||
.with_context(|| format!("Invalid hour component in {}", value))?;
|
||||
let minutes: u64 = parts[1]
|
||||
.parse()
|
||||
.with_context(|| format!("Invalid minute component in {}", value))?;
|
||||
let seconds: u64 = parts[2]
|
||||
.parse()
|
||||
.with_context(|| format!("Invalid second component in {}", value))?;
|
||||
|
||||
if minutes > 59 || seconds > 59 {
|
||||
return Err(anyhow!(
|
||||
"Invalid HH:MM:SS value `{}`. Minutes and seconds must be < 60.",
|
||||
value
|
||||
));
|
||||
}
|
||||
|
||||
Ok(hours * 3600 + minutes * 60 + seconds)
|
||||
}
|
||||
|
||||
fn send_command_with_mode(
|
||||
host: &str,
|
||||
port: u16,
|
||||
timeout: Duration,
|
||||
payload: &str,
|
||||
mode: ResponseMode<'_>,
|
||||
) -> Result<Option<String>> {
|
||||
let mut stream = connect(host, port, timeout)?;
|
||||
let packet = encode_packet(payload);
|
||||
|
||||
stream.write_all(&packet).with_context(|| {
|
||||
format!(
|
||||
"Failed to write Arylic TCP packet for {}: {}",
|
||||
host, payload
|
||||
)
|
||||
})?;
|
||||
stream.flush().with_context(|| {
|
||||
format!(
|
||||
"Failed to flush Arylic TCP stream for {} (command {})",
|
||||
host, payload
|
||||
)
|
||||
})?;
|
||||
|
||||
match mode {
|
||||
ResponseMode::None => {
|
||||
debug!(
|
||||
"Arylic TCP fire-and-forget command sent to {}: {}",
|
||||
host, payload
|
||||
);
|
||||
let _ = stream.shutdown(Shutdown::Write);
|
||||
Ok(None)
|
||||
}
|
||||
ResponseMode::Required(expected) => {
|
||||
read_expected_response(&mut stream, host, payload, expected).map(Some)
|
||||
}
|
||||
ResponseMode::Optional(expected) => {
|
||||
for _ in 0..MAX_RESPONSE_ATTEMPTS {
|
||||
match read_packet(&mut stream) {
|
||||
Ok(response) => {
|
||||
if expected.iter().any(|p| response.starts_with(p)) {
|
||||
return Ok(Some(response));
|
||||
}
|
||||
debug!(
|
||||
"Ignoring unsolicited Arylic payload from {}: {}",
|
||||
host, response
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
debug!(
|
||||
"No full response for Arylic TCP command {} on {}: {}. Treating as success and relying on PINFGET.",
|
||||
payload, host, err
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!(
|
||||
"No expected response for optional command {} on {}",
|
||||
payload,
|
||||
host
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_expected_response(
|
||||
stream: &mut TcpStream,
|
||||
host: &str,
|
||||
payload: &str,
|
||||
expected: &[&str],
|
||||
) -> Result<String> {
|
||||
for _ in 0..MAX_RESPONSE_ATTEMPTS {
|
||||
let response = match read_packet(stream) {
|
||||
Ok(resp) => resp,
|
||||
Err(err) => {
|
||||
return Err(anyhow!(
|
||||
"Failed to read Arylic TCP response for {} (command {}): {}",
|
||||
host,
|
||||
payload,
|
||||
err
|
||||
));
|
||||
}
|
||||
};
|
||||
if expected.iter().any(|prefix| response.starts_with(prefix)) {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
debug!(
|
||||
"Ignoring unsolicited Arylic payload from {}: {}",
|
||||
host, response
|
||||
);
|
||||
}
|
||||
|
||||
Err(anyhow!(
|
||||
"No expected response for command {} on {}",
|
||||
payload,
|
||||
host
|
||||
))
|
||||
}
|
||||
|
||||
fn send_command_required(
|
||||
host: &str,
|
||||
port: u16,
|
||||
timeout: Duration,
|
||||
payload: &str,
|
||||
expected: &[&str],
|
||||
) -> Result<String> {
|
||||
match send_command_with_mode(
|
||||
host,
|
||||
port,
|
||||
timeout,
|
||||
payload,
|
||||
ResponseMode::Required(expected),
|
||||
)? {
|
||||
Some(s) => Ok(s),
|
||||
None => Err(anyhow!(
|
||||
"Arylic TCP: no response payload for required command {}",
|
||||
payload
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn send_command_optional(
|
||||
host: &str,
|
||||
port: u16,
|
||||
timeout: Duration,
|
||||
payload: &str,
|
||||
expected: &[&str],
|
||||
) -> Result<Option<String>> {
|
||||
send_command_with_mode(
|
||||
host,
|
||||
port,
|
||||
timeout,
|
||||
payload,
|
||||
ResponseMode::Optional(expected),
|
||||
)
|
||||
}
|
||||
|
||||
fn send_command_no_response(host: &str, port: u16, timeout: Duration, payload: &str) -> Result<()> {
|
||||
send_command_with_mode(host, port, timeout, payload, ResponseMode::None).map(|_| ())
|
||||
}
|
||||
@@ -2,13 +2,8 @@
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::{
|
||||
errors::ControlPointError,
|
||||
soap_client::{
|
||||
extract_child_text, handle_action_response, invoke_upnp_action,
|
||||
invoke_upnp_action_with_timeout,
|
||||
},
|
||||
};
|
||||
use crate::soap_client::{SoapCallResult, invoke_upnp_action, invoke_upnp_action_with_timeout};
|
||||
use anyhow::{Result, anyhow};
|
||||
use pmoupnp::soap::SoapEnvelope;
|
||||
use xmltree::{Element, XMLNode};
|
||||
|
||||
@@ -36,7 +31,7 @@ impl AvTransportClient {
|
||||
}
|
||||
|
||||
/// AVTransport:1 — GetTransportInfo
|
||||
pub fn get_transport_info(&self, instance_id: u32) -> Result<TransportInfo, ControlPointError> {
|
||||
pub fn get_transport_info(&self, instance_id: u32) -> Result<TransportInfo> {
|
||||
let instance_id_str = instance_id.to_string();
|
||||
let args = [("InstanceID", instance_id_str.as_str())];
|
||||
|
||||
@@ -48,17 +43,16 @@ impl AvTransportClient {
|
||||
)?;
|
||||
|
||||
if !call_result.status.is_success() {
|
||||
return Err(ControlPointError::UpnpError(format!(
|
||||
return Err(anyhow!(
|
||||
"GetTransportInfo failed with HTTP status {}",
|
||||
call_result.status
|
||||
)));
|
||||
));
|
||||
}
|
||||
|
||||
let envelope = call_result.envelope.as_ref().ok_or_else(|| {
|
||||
ControlPointError::UpnpError(format!(
|
||||
"Missing SOAP envelope in GetTransportInfo response"
|
||||
))
|
||||
})?;
|
||||
let envelope = call_result
|
||||
.envelope
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("Missing SOAP envelope in GetTransportInfo response"))?;
|
||||
|
||||
parse_transport_info(envelope)
|
||||
}
|
||||
@@ -70,7 +64,7 @@ impl AvTransportClient {
|
||||
///
|
||||
/// - `uri` : CurrentURI
|
||||
/// - `meta` : CurrentURIMetaData (DIDL-Lite ou chaîne vide)
|
||||
pub fn set_av_transport_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
|
||||
pub fn set_av_transport_uri(&self, uri: &str, meta: &str) -> Result<()> {
|
||||
let args = [
|
||||
("InstanceID", "0"),
|
||||
("CurrentURI", uri),
|
||||
@@ -88,7 +82,7 @@ impl AvTransportClient {
|
||||
}
|
||||
|
||||
/// AVTransport:1 — Play
|
||||
pub fn play(&self, instance_id: u32, speed: &str) -> Result<(), ControlPointError> {
|
||||
pub fn play(&self, instance_id: u32, speed: &str) -> Result<()> {
|
||||
let instance_id_str = instance_id.to_string();
|
||||
let args = [("InstanceID", instance_id_str.as_str()), ("Speed", speed)];
|
||||
|
||||
@@ -98,7 +92,7 @@ impl AvTransportClient {
|
||||
}
|
||||
|
||||
/// AVTransport:1 — Pause
|
||||
pub fn pause(&self, instance_id: u32) -> Result<(), ControlPointError> {
|
||||
pub fn pause(&self, instance_id: u32) -> Result<()> {
|
||||
let instance_id_str = instance_id.to_string();
|
||||
let args = [("InstanceID", instance_id_str.as_str())];
|
||||
|
||||
@@ -109,7 +103,7 @@ impl AvTransportClient {
|
||||
}
|
||||
|
||||
/// AVTransport:1 — Stop
|
||||
pub fn stop(&self, instance_id: u32) -> Result<(), ControlPointError> {
|
||||
pub fn stop(&self, instance_id: u32) -> Result<()> {
|
||||
let instance_id_str = instance_id.to_string();
|
||||
let args = [("InstanceID", instance_id_str.as_str())];
|
||||
|
||||
@@ -119,12 +113,7 @@ impl AvTransportClient {
|
||||
}
|
||||
|
||||
/// AVTransport:1 — Seek
|
||||
pub fn seek(
|
||||
&self,
|
||||
instance_id: u32,
|
||||
unit: &str,
|
||||
target: &str,
|
||||
) -> Result<(), ControlPointError> {
|
||||
pub fn seek(&self, instance_id: u32, unit: &str, target: &str) -> Result<()> {
|
||||
let instance_id_str = instance_id.to_string();
|
||||
let args = [
|
||||
("InstanceID", instance_id_str.as_str()),
|
||||
@@ -142,11 +131,7 @@ impl AvTransportClient {
|
||||
/// This should configure the *next* track to be played after the current one.
|
||||
/// Many renderers do NOT implement this action; in that case the method
|
||||
/// returns an error derived from the UPnP error code.
|
||||
pub fn set_next_av_transport_uri(
|
||||
&self,
|
||||
next_uri: &str,
|
||||
next_meta: &str,
|
||||
) -> Result<(), ControlPointError> {
|
||||
pub fn set_next_av_transport_uri(&self, next_uri: &str, next_meta: &str) -> Result<()> {
|
||||
let args = [
|
||||
("InstanceID", "0"),
|
||||
("NextURI", next_uri),
|
||||
@@ -165,9 +150,10 @@ impl AvTransportClient {
|
||||
if let Some(env) = &call_result.envelope {
|
||||
if let Some(upnp_error) = parse_upnp_error(env) {
|
||||
if is_set_next_not_supported_error(&upnp_error) {
|
||||
return Err(ControlPointError::upnp_operation_not_supported(
|
||||
"SetNextAVTransportURI",
|
||||
"AVTransport",
|
||||
return Err(anyhow!(
|
||||
"Renderer does not support AVTransport.SetNextAVTransportURI (UPnP error {}: {})",
|
||||
upnp_error.error_code,
|
||||
upnp_error.error_description
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -180,13 +166,43 @@ impl AvTransportClient {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_transport_info(envelope: &SoapEnvelope) -> Result<TransportInfo, ControlPointError> {
|
||||
fn handle_action_response(action: &str, call_result: &SoapCallResult) -> Result<()> {
|
||||
if !call_result.status.is_success() {
|
||||
if let Some(env) = &call_result.envelope {
|
||||
if let Some(upnp_error) = parse_upnp_error(env) {
|
||||
return Err(anyhow!(
|
||||
"{action} failed with UPnP error {}: {} (HTTP status {})",
|
||||
upnp_error.error_code,
|
||||
upnp_error.error_description,
|
||||
call_result.status
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return Err(anyhow!(
|
||||
"{action} failed with HTTP status {} and body: {}",
|
||||
call_result.status,
|
||||
call_result.raw_body
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(env) = &call_result.envelope {
|
||||
if let Some(upnp_error) = parse_upnp_error(env) {
|
||||
return Err(anyhow!(
|
||||
"{action} returned UPnP error {}: {} (HTTP status {})",
|
||||
upnp_error.error_code,
|
||||
upnp_error.error_description,
|
||||
call_result.status
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_transport_info(envelope: &SoapEnvelope) -> Result<TransportInfo> {
|
||||
let response = find_child_with_suffix(&envelope.body.content, "GetTransportInfoResponse")
|
||||
.ok_or_else(|| {
|
||||
ControlPointError::UpnpError(format!(
|
||||
"Missing GetTransportInfoResponse element in SOAP body"
|
||||
))
|
||||
})?;
|
||||
.ok_or_else(|| anyhow!("Missing GetTransportInfoResponse element in SOAP body"))?;
|
||||
|
||||
let current_transport_state = extract_child_text(response, "CurrentTransportState")?;
|
||||
let current_transport_status = extract_child_text(response, "CurrentTransportStatus")?;
|
||||
@@ -273,6 +289,19 @@ fn find_child_with_suffix<'a>(parent: &'a Element, suffix: &str) -> Option<&'a E
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_child_text(parent: &Element, suffix: &str) -> Result<String> {
|
||||
let child = find_child_with_suffix(parent, suffix)
|
||||
.ok_or_else(|| anyhow!("Missing {suffix} element in GetTransportInfoResponse"))?;
|
||||
|
||||
let text = child
|
||||
.get_text()
|
||||
.map(|t| t.trim().to_string())
|
||||
.filter(|t| !t.is_empty())
|
||||
.ok_or_else(|| anyhow!("{suffix} element missing text in GetTransportInfoResponse"))?;
|
||||
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -368,7 +397,7 @@ pub struct PositionInfo {
|
||||
|
||||
impl AvTransportClient {
|
||||
/// AVTransport:1 — GetPositionInfo
|
||||
pub fn get_position_info(&self, instance_id: u32) -> Result<PositionInfo, ControlPointError> {
|
||||
pub fn get_position_info(&self, instance_id: u32) -> Result<PositionInfo> {
|
||||
let instance_id_str = instance_id.to_string();
|
||||
let args = [("InstanceID", instance_id_str.as_str())];
|
||||
|
||||
@@ -380,27 +409,24 @@ impl AvTransportClient {
|
||||
)?;
|
||||
|
||||
if !call_result.status.is_success() {
|
||||
return Err(ControlPointError::UpnpError(format!(
|
||||
return Err(anyhow!(
|
||||
"GetPositionInfo failed with HTTP status {}",
|
||||
call_result.status
|
||||
)));
|
||||
));
|
||||
}
|
||||
|
||||
let envelope = call_result.envelope.as_ref().ok_or_else(|| {
|
||||
ControlPointError::UpnpError(format!(
|
||||
"Missing SOAP envelope in GetPositionInfo response"
|
||||
))
|
||||
})?;
|
||||
let envelope = call_result
|
||||
.envelope
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("Missing SOAP envelope in GetPositionInfo response"))?;
|
||||
|
||||
parse_position_info(envelope)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_position_info(envelope: &SoapEnvelope) -> Result<PositionInfo, ControlPointError> {
|
||||
fn parse_position_info(envelope: &SoapEnvelope) -> Result<PositionInfo> {
|
||||
let response = find_child_with_suffix(&envelope.body.content, "GetPositionInfoResponse")
|
||||
.ok_or_else(|| {
|
||||
ControlPointError::UpnpError(format!("Missing GetPositionInfoResponse element"))
|
||||
})?;
|
||||
.ok_or_else(|| anyhow!("Missing GetPositionInfoResponse element"))?;
|
||||
|
||||
// Helpers allow missing text (AVTransport allows empty durations)
|
||||
fn opt(parent: &Element, name: &str) -> Option<String> {
|
||||
111
pmocontrol/src/capabilities.rs
Normal file
111
pmocontrol/src/capabilities.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
// pmocontrol/src/capabilities.rs
|
||||
use anyhow::Result;
|
||||
|
||||
/// Logical playback position across backends.
|
||||
///
|
||||
/// Times peuvent être soit en secondes, soit en "HH:MM:SS" selon ce que
|
||||
/// tu préfères pour la façade; ici je reste en String pour garder la
|
||||
/// même granularité que UPnP sans parser.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PlaybackPositionInfo {
|
||||
pub track: Option<u32>,
|
||||
pub rel_time: Option<String>, // position courante
|
||||
pub abs_time: Option<String>, // si pertinent
|
||||
pub track_duration: Option<String>, // durée totale
|
||||
pub track_metadata: Option<String>, // DIDL-Lite XML from GetPositionInfo
|
||||
pub track_uri: Option<String>, // Current track URI
|
||||
}
|
||||
pub trait PlaybackPosition {
|
||||
fn playback_position(&self) -> Result<PlaybackPositionInfo>;
|
||||
}
|
||||
|
||||
/// High-level playback state across backends.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum PlaybackState {
|
||||
Stopped,
|
||||
Playing,
|
||||
Paused,
|
||||
Transitioning,
|
||||
NoMedia,
|
||||
/// Backend-specific or unknown state string.
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl PlaybackState {
|
||||
/// Map a raw UPnP AVTransport CurrentTransportState string
|
||||
/// to a logical PlaybackState.
|
||||
pub fn from_upnp_state(raw: &str) -> Self {
|
||||
let s = raw.trim().to_ascii_uppercase();
|
||||
match s.as_str() {
|
||||
"STOPPED" => PlaybackState::Stopped,
|
||||
"PLAYING" => PlaybackState::Playing,
|
||||
"PAUSED_PLAYBACK" => PlaybackState::Paused,
|
||||
// States from the AVTransport spec that we normalize:
|
||||
"PAUSED_RECORDING" => PlaybackState::Paused,
|
||||
"RECORDING" => PlaybackState::Playing,
|
||||
// Common vendor-specific states:
|
||||
"TRANSITIONING" => PlaybackState::Transitioning,
|
||||
"BUFFERING" | "PREPARING" => PlaybackState::Transitioning,
|
||||
"NO_MEDIA_PRESENT" => PlaybackState::NoMedia,
|
||||
_ => PlaybackState::Unknown(raw.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a human-readable label for the playback state.
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
PlaybackState::Stopped => "STOPPED",
|
||||
PlaybackState::Playing => "PLAYING",
|
||||
PlaybackState::Paused => "PAUSED",
|
||||
PlaybackState::Transitioning => "TRANSITIONING",
|
||||
PlaybackState::NoMedia => "NO_MEDIA",
|
||||
PlaybackState::Unknown(s) => s.as_str(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generic abstraction for playback status (transport state).
|
||||
///
|
||||
/// For UPnP AV, this is backed by AVTransport::GetTransportInfo.
|
||||
/// For OpenHome, a future implementation will adapt from OH Info/Time.
|
||||
pub trait PlaybackStatus {
|
||||
fn playback_state(&self) -> Result<PlaybackState>;
|
||||
}
|
||||
|
||||
/// Abstraction générique des capacités de transport (lecture / pause / stop / seek)
|
||||
/// indépendamment du protocole sous-jacent (UPnP AV, OpenHome, ...).
|
||||
pub trait TransportControl {
|
||||
/// Set la ressource à lire (URI + métadonnées) et/ou commence la lecture.
|
||||
///
|
||||
/// Selon l'implémentation, cette méthode peut soit :
|
||||
/// - faire un "Set...URI" + "Play" (cas UPnP AV),
|
||||
/// - ou configurer la file de lecture (cas OpenHome, etc.).
|
||||
fn play_uri(&self, uri: &str, meta: &str) -> Result<()>;
|
||||
|
||||
/// Démarre ou reprend la lecture.
|
||||
fn play(&self) -> Result<()>;
|
||||
|
||||
/// Met la lecture en pause.
|
||||
fn pause(&self) -> Result<()>;
|
||||
|
||||
/// Arrête la lecture.
|
||||
fn stop(&self) -> Result<()>;
|
||||
|
||||
/// Seek à un temps relatif (HH:MM:SS) si supporté.
|
||||
fn seek_rel_time(&self, hhmmss: &str) -> Result<()>;
|
||||
}
|
||||
|
||||
/// Abstraction générique des capacités de contrôle de volume / mute.
|
||||
pub trait VolumeControl {
|
||||
/// Retourne le volume logique courant (échelle dépendante du renderer).
|
||||
fn volume(&self) -> Result<u16>;
|
||||
|
||||
/// Définit le volume logique (échelle dépendante du renderer).
|
||||
fn set_volume(&self, v: u16) -> Result<()>;
|
||||
|
||||
/// Indique si le renderer est muet (mute activé).
|
||||
fn mute(&self) -> Result<bool>;
|
||||
|
||||
/// Active ou désactive le mute.
|
||||
fn set_mute(&self, m: bool) -> Result<()>;
|
||||
}
|
||||
285
pmocontrol/src/chromecast_discovery.rs
Normal file
285
pmocontrol/src/chromecast_discovery.rs
Normal file
@@ -0,0 +1,285 @@
|
||||
//! Chromecast device discovery via mDNS.
|
||||
//!
|
||||
//! Chromecast devices advertise themselves using mDNS (Multicast DNS) on the
|
||||
//! `_googlecast._tcp.local` service, unlike UPnP devices which use SSDP.
|
||||
//! This module handles the discovery of Chromecast devices and converts them
|
||||
//! into `DeviceUpdate` events that can be processed by the `DeviceRegistry`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use crate::registry::DeviceUpdate;
|
||||
use crate::model::{RendererCapabilities, RendererInfo, RendererProtocol, RendererId};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// Information about a discovered Chromecast device from mDNS.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DiscoveredChromecast {
|
||||
pub friendly_name: String,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub model: Option<String>,
|
||||
pub uuid: String,
|
||||
pub manufacturer: Option<String>,
|
||||
pub last_seen: SystemTime,
|
||||
}
|
||||
|
||||
/// Manages the discovery and tracking of Chromecast devices via mDNS.
|
||||
pub struct ChromecastDiscoveryManager {
|
||||
discovered_devices: HashMap<String, DiscoveredChromecast>,
|
||||
}
|
||||
|
||||
impl ChromecastDiscoveryManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
discovered_devices: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds or updates a discovered Chromecast device.
|
||||
pub fn update_device(&mut self, device: DiscoveredChromecast) {
|
||||
let uuid = device.uuid.clone();
|
||||
self.discovered_devices.insert(uuid, device);
|
||||
}
|
||||
|
||||
/// Retrieves a discovered device by UUID.
|
||||
pub fn get_device(&self, uuid: &str) -> Option<&DiscoveredChromecast> {
|
||||
self.discovered_devices.get(uuid)
|
||||
}
|
||||
|
||||
/// Lists all discovered devices.
|
||||
pub fn list_devices(&self) -> Vec<&DiscoveredChromecast> {
|
||||
self.discovered_devices.values().collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Processes an mDNS response and converts it into a `DeviceUpdate` event.
|
||||
///
|
||||
/// This function parses mDNS service discovery responses for Chromecast
|
||||
/// devices and creates the appropriate update event for the device registry.
|
||||
pub fn process_mdns_response(response: mdns::Response) -> Option<DeviceUpdate> {
|
||||
// Extract basic information from the mDNS response
|
||||
let service_name = response.records().filter_map(|r| {
|
||||
if let mdns::RecordKind::PTR(ref name) = r.kind {
|
||||
Some(name.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}).next()?;
|
||||
|
||||
debug!("Processing mDNS response for service: {}", service_name);
|
||||
|
||||
// Extract IP addresses
|
||||
let addresses: Vec<IpAddr> = response
|
||||
.records()
|
||||
.filter_map(|r| match r.kind {
|
||||
mdns::RecordKind::A(addr) => Some(IpAddr::V4(addr)),
|
||||
mdns::RecordKind::AAAA(addr) => Some(IpAddr::V6(addr)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
if addresses.is_empty() {
|
||||
warn!("No IP address found for Chromecast device: {}", service_name);
|
||||
return None;
|
||||
}
|
||||
|
||||
// Prefer IPv4 addresses
|
||||
let host = addresses
|
||||
.iter()
|
||||
.find(|addr| matches!(addr, IpAddr::V4(_)))
|
||||
.or_else(|| addresses.first())
|
||||
.map(|addr| addr.to_string())?;
|
||||
|
||||
// Extract port from SRV record
|
||||
let port = response
|
||||
.records()
|
||||
.filter_map(|r| {
|
||||
if let mdns::RecordKind::SRV { port, .. } = r.kind {
|
||||
Some(port)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.next()
|
||||
.unwrap_or(8009); // Default Chromecast port
|
||||
|
||||
// Extract TXT records for additional metadata
|
||||
let txt_records: HashMap<String, String> = response
|
||||
.records()
|
||||
.filter_map(|r| {
|
||||
if let mdns::RecordKind::TXT(ref data) = r.kind {
|
||||
Some(data.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.flat_map(|data| {
|
||||
// data is Vec<String>, each string is "key=value"
|
||||
data.into_iter().filter_map(|s| {
|
||||
let parts: Vec<&str> = s.splitn(2, '=').collect();
|
||||
if parts.len() == 2 {
|
||||
Some((parts[0].to_string(), parts[1].to_string()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Extract metadata from TXT records
|
||||
let model = txt_records.get("md").cloned();
|
||||
let uuid = txt_records
|
||||
.get("id")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("chromecast-{}-{}", host, port));
|
||||
let manufacturer = Some("Google Inc.".to_string());
|
||||
|
||||
// Extract friendly name from TXT record "fn" if available
|
||||
// Otherwise, extract from service instance name (PTR record)
|
||||
let friendly_name = txt_records
|
||||
.get("fn")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| {
|
||||
// Fallback: extract from service name, removing the UUID suffix if present
|
||||
service_name
|
||||
.split("._googlecast._tcp.local")
|
||||
.next()
|
||||
.unwrap_or("Unknown Chromecast")
|
||||
.split('-')
|
||||
.take_while(|part| part.len() != 32) // Skip 32-char hex UUID
|
||||
.collect::<Vec<_>>()
|
||||
.join("-")
|
||||
.trim()
|
||||
.to_string()
|
||||
});
|
||||
|
||||
debug!(
|
||||
"Discovered Chromecast: {} at {}:{} (UUID: {}, Model: {:?})",
|
||||
friendly_name, host, port, uuid, model
|
||||
);
|
||||
|
||||
// Create RendererInfo for the registry
|
||||
let renderer_info = build_renderer_info(
|
||||
&uuid,
|
||||
&friendly_name,
|
||||
&host,
|
||||
port,
|
||||
model.as_deref(),
|
||||
manufacturer.as_deref(),
|
||||
);
|
||||
|
||||
Some(DeviceUpdate::RendererOnline(renderer_info))
|
||||
}
|
||||
|
||||
/// Builds a `RendererInfo` structure for a Chromecast device.
|
||||
fn build_renderer_info(
|
||||
uuid: &str,
|
||||
friendly_name: &str,
|
||||
host: &str,
|
||||
port: u16,
|
||||
model: Option<&str>,
|
||||
manufacturer: Option<&str>,
|
||||
) -> RendererInfo {
|
||||
let udn = format!("uuid:{}", uuid);
|
||||
let id = RendererId(udn.clone());
|
||||
|
||||
// Build Chromecast capabilities
|
||||
let mut capabilities = RendererCapabilities::default();
|
||||
capabilities.has_chromecast = true;
|
||||
|
||||
// The location URL for Chromecast is just the host:port
|
||||
// (not a real HTTP endpoint like UPnP, but useful for identification)
|
||||
let location = format!("chromecast://{}:{}", host, port);
|
||||
|
||||
RendererInfo {
|
||||
id,
|
||||
udn,
|
||||
friendly_name: friendly_name.to_string(),
|
||||
model_name: model.unwrap_or("Chromecast").to_string(),
|
||||
manufacturer: manufacturer.unwrap_or("Google Inc.").to_string(),
|
||||
protocol: RendererProtocol::ChromecastOnly,
|
||||
capabilities,
|
||||
location,
|
||||
server_header: "Chromecast".to_string(),
|
||||
online: true,
|
||||
last_seen: SystemTime::now(),
|
||||
max_age: 1800, // 30 minutes
|
||||
// All UPnP/OpenHome fields are None for Chromecast
|
||||
avtransport_service_type: None,
|
||||
avtransport_control_url: None,
|
||||
rendering_control_service_type: None,
|
||||
rendering_control_control_url: None,
|
||||
connection_manager_service_type: None,
|
||||
connection_manager_control_url: None,
|
||||
oh_playlist_service_type: None,
|
||||
oh_playlist_control_url: None,
|
||||
oh_playlist_event_sub_url: None,
|
||||
oh_info_service_type: None,
|
||||
oh_info_control_url: None,
|
||||
oh_info_event_sub_url: None,
|
||||
oh_time_service_type: None,
|
||||
oh_time_control_url: None,
|
||||
oh_time_event_sub_url: None,
|
||||
oh_volume_service_type: None,
|
||||
oh_volume_control_url: None,
|
||||
oh_radio_service_type: None,
|
||||
oh_radio_control_url: None,
|
||||
oh_product_service_type: None,
|
||||
oh_product_control_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts the host (IP address) from a Chromecast location URL.
|
||||
///
|
||||
/// The location format is `chromecast://host:port`.
|
||||
pub fn extract_host_from_location(location: &str) -> Option<String> {
|
||||
if let Some(stripped) = location.strip_prefix("chromecast://") {
|
||||
let host = stripped.split(':').next()?;
|
||||
Some(host.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts the port from a Chromecast location URL.
|
||||
///
|
||||
/// The location format is `chromecast://host:port`.
|
||||
pub fn extract_port_from_location(location: &str) -> Option<u16> {
|
||||
if let Some(stripped) = location.strip_prefix("chromecast://") {
|
||||
let port_str = stripped.split(':').nth(1)?;
|
||||
port_str.parse().ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_host_from_location() {
|
||||
assert_eq!(
|
||||
extract_host_from_location("chromecast://192.168.1.100:8009"),
|
||||
Some("192.168.1.100".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
extract_host_from_location("http://192.168.1.100:8009"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_port_from_location() {
|
||||
assert_eq!(
|
||||
extract_port_from_location("chromecast://192.168.1.100:8009"),
|
||||
Some(8009)
|
||||
);
|
||||
assert_eq!(
|
||||
extract_port_from_location("chromecast://192.168.1.100"),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,19 +14,16 @@
|
||||
use std::sync::{Arc, Mutex, Once};
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
|
||||
use tracing::debug;
|
||||
|
||||
use crate::DeviceIdentity;
|
||||
use crate::music_renderer::capabilities::{
|
||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl,
|
||||
use crate::capabilities::{
|
||||
PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl,
|
||||
VolumeControl,
|
||||
};
|
||||
use crate::music_renderer::time_utils::{format_hhmmss_f64, parse_hhmmss_strict};
|
||||
use crate::discovery::chromecast_discovery::{extract_host_from_location, extract_port_from_location};
|
||||
use crate::errors::ControlPointError;
|
||||
use crate::model::{PlaybackState, RendererInfo};
|
||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||
use crate::music_renderer::musicrenderer::MusicRendererBackend;
|
||||
use crate::chromecast_discovery::{extract_host_from_location, extract_port_from_location};
|
||||
use crate::model::{RendererId, RendererInfo, RendererProtocol};
|
||||
|
||||
use rust_cast::{
|
||||
CastDevice, ChannelMessage,
|
||||
@@ -49,6 +46,7 @@ const DEFAULT_CHROMECAST_PORT: u16 = 8009;
|
||||
/// spawned to handle heartbeat responses from the device.
|
||||
#[derive(Clone)]
|
||||
pub struct ChromecastRenderer {
|
||||
pub info: RendererInfo,
|
||||
host: String,
|
||||
port: u16,
|
||||
stop_signal: Arc<Mutex<bool>>,
|
||||
@@ -60,6 +58,7 @@ pub struct ChromecastRenderer {
|
||||
impl std::fmt::Debug for ChromecastRenderer {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ChromecastRenderer")
|
||||
.field("info", &self.info)
|
||||
.field("host", &self.host)
|
||||
.field("port", &self.port)
|
||||
.finish()
|
||||
@@ -77,31 +76,23 @@ fn ensure_crypto_provider_initialized() {
|
||||
INIT.call_once(|| {
|
||||
// Install the default CryptoProvider (aws-lc-rs or ring, depending on features)
|
||||
let _ = rustls::crypto::CryptoProvider::install_default(
|
||||
rustls::crypto::aws_lc_rs::default_provider(),
|
||||
rustls::crypto::aws_lc_rs::default_provider()
|
||||
);
|
||||
tracing::debug!("Rustls CryptoProvider initialized for Chromecast connections");
|
||||
});
|
||||
}
|
||||
|
||||
/// Helper function to connect to a Chromecast device.
|
||||
fn connect_to_device<'a>(host: &'a str, port: u16) -> Result<CastDevice<'a>, ControlPointError> {
|
||||
fn connect_to_device<'a>(host: &'a str, port: u16) -> Result<CastDevice<'a>> {
|
||||
// Ensure rustls crypto provider is initialized before any TLS connection
|
||||
ensure_crypto_provider_initialized();
|
||||
|
||||
let device = CastDevice::connect_without_host_verification(host, port)
|
||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect to Chromecast: {}", e)))?;
|
||||
.map_err(|e| anyhow!("Failed to connect to Chromecast: {}", e))?;
|
||||
|
||||
device
|
||||
.connection
|
||||
device.connection
|
||||
.connect(DEFAULT_DESTINATION_ID.to_string())
|
||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect channel: {}", e)))?;
|
||||
|
||||
// Send initial gre to establish heartbeat communication
|
||||
// This is critical per rust_caster.rs example
|
||||
device
|
||||
.heartbeat
|
||||
.ping()
|
||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to send initial heartbeat ping: {}", e)))?;
|
||||
.map_err(|e| anyhow!("Failed to connect channel: {}", e))?;
|
||||
|
||||
Ok(device)
|
||||
}
|
||||
@@ -116,46 +107,62 @@ fn map_player_state(player_state: &CastPlayerState) -> PlaybackState {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl RendererFromMediaRendererInfo for ChromecastRenderer {
|
||||
fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||
impl ChromecastRenderer {
|
||||
/// Creates a new ChromecastRenderer from RendererInfo.
|
||||
pub fn from_renderer_info(info: RendererInfo) -> Result<Self> {
|
||||
tracing::info!(
|
||||
"ChromecastRenderer::from_renderer_info location={} for {}",
|
||||
info.location(),
|
||||
info.friendly_name()
|
||||
info.location,
|
||||
info.friendly_name
|
||||
);
|
||||
|
||||
let host = extract_host_from_location(info.location())
|
||||
.ok_or_else(|| ControlPointError::ChromecastError(format!("Invalid Chromecast location: {}", info.location())))?;
|
||||
let host = extract_host_from_location(&info.location)
|
||||
.ok_or_else(|| anyhow!("Invalid Chromecast location: {}", info.location))?;
|
||||
|
||||
let port = extract_port_from_location(info.location()).unwrap_or(DEFAULT_CHROMECAST_PORT);
|
||||
let port = extract_port_from_location(&info.location)
|
||||
.unwrap_or(DEFAULT_CHROMECAST_PORT);
|
||||
|
||||
let stop_signal = Arc::new(Mutex::new(false));
|
||||
let thread_handle = Arc::new(Mutex::new(None));
|
||||
|
||||
tracing::info!(
|
||||
"ChromecastRenderer created for {} with host={} port={}",
|
||||
info.friendly_name(),
|
||||
info.friendly_name,
|
||||
host,
|
||||
port
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
info,
|
||||
host,
|
||||
port,
|
||||
stop_signal,
|
||||
thread_handle,
|
||||
})
|
||||
}
|
||||
|
||||
fn to_backend(self) -> MusicRendererBackend {
|
||||
MusicRendererBackend::Chromecast(self)
|
||||
/// Returns the renderer ID.
|
||||
pub fn id(&self) -> &RendererId {
|
||||
&self.info.id
|
||||
}
|
||||
|
||||
/// Returns the friendly name.
|
||||
pub fn friendly_name(&self) -> &str {
|
||||
&self.info.friendly_name
|
||||
}
|
||||
|
||||
/// Returns the protocol.
|
||||
pub fn protocol(&self) -> &RendererProtocol {
|
||||
&self.info.protocol
|
||||
}
|
||||
|
||||
/// Returns the renderer info.
|
||||
pub fn info(&self) -> &RendererInfo {
|
||||
&self.info
|
||||
}
|
||||
}
|
||||
|
||||
impl TransportControl for ChromecastRenderer {
|
||||
fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
|
||||
fn play_uri(&self, uri: &str, meta: &str) -> Result<()> {
|
||||
debug!("ChromecastRenderer: play_uri({})", uri);
|
||||
|
||||
// Signal any existing play thread to stop
|
||||
@@ -171,7 +178,8 @@ impl TransportControl for ChromecastRenderer {
|
||||
|
||||
// Wait for thread to finish (it should see stop_signal and exit)
|
||||
// Note: device.receive() may block, so thread might take time to notice stop_signal
|
||||
let join_result = std::thread::spawn(move || handle.join()).join();
|
||||
let join_result = std::thread::spawn(move || handle.join())
|
||||
.join();
|
||||
|
||||
match join_result {
|
||||
Ok(Ok(())) => {
|
||||
@@ -211,10 +219,7 @@ impl TransportControl for ChromecastRenderer {
|
||||
};
|
||||
|
||||
// Launch DefaultMediaReceiver app
|
||||
let app = match device
|
||||
.receiver
|
||||
.launch_app(&CastDeviceApp::DefaultMediaReceiver)
|
||||
{
|
||||
let app = match device.receiver.launch_app(&CastDeviceApp::DefaultMediaReceiver) {
|
||||
Ok(app) => app,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to launch DefaultMediaReceiver: {}", e);
|
||||
@@ -238,10 +243,11 @@ impl TransportControl for ChromecastRenderer {
|
||||
metadata: None,
|
||||
};
|
||||
|
||||
match device
|
||||
.media
|
||||
.load(app.transport_id.as_str(), app.session_id.as_str(), &media)
|
||||
{
|
||||
match device.media.load(
|
||||
app.transport_id.as_str(),
|
||||
app.session_id.as_str(),
|
||||
&media,
|
||||
) {
|
||||
Ok(status) => {
|
||||
tracing::info!("Media loaded successfully: {:?}", status);
|
||||
}
|
||||
@@ -302,87 +308,63 @@ impl TransportControl for ChromecastRenderer {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn play(&self) -> Result<(), ControlPointError> {
|
||||
fn play(&self) -> Result<()> {
|
||||
debug!("ChromecastRenderer: play()");
|
||||
|
||||
let device = connect_to_device(&self.host, self.port)?;
|
||||
|
||||
// Get receiver status to find the active app
|
||||
let status = device
|
||||
.receiver
|
||||
.get_status()
|
||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?;
|
||||
let status = device.receiver.get_status()
|
||||
.map_err(|e| anyhow!("Failed to get receiver status: {}", e))?;
|
||||
|
||||
let app = status
|
||||
.applications
|
||||
.first()
|
||||
.ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?;
|
||||
let app = status.applications.first()
|
||||
.ok_or_else(|| anyhow!("No active app found"))?;
|
||||
|
||||
// Connect to the app
|
||||
device
|
||||
.connection
|
||||
.connect(app.transport_id.as_str())
|
||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)))?;
|
||||
device.connection.connect(app.transport_id.as_str())
|
||||
.map_err(|e| anyhow!("Failed to connect to app: {}", e))?;
|
||||
|
||||
// Get media status
|
||||
let media_status = device
|
||||
.media
|
||||
.get_status(app.transport_id.as_str(), None)
|
||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)))?;
|
||||
let media_status = device.media.get_status(app.transport_id.as_str(), None)
|
||||
.map_err(|e| anyhow!("Failed to get media status: {}", e))?;
|
||||
|
||||
let media_entry = media_status
|
||||
.entries
|
||||
.first()
|
||||
.ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?;
|
||||
let media_entry = media_status.entries.first()
|
||||
.ok_or_else(|| anyhow!("No media session found"))?;
|
||||
|
||||
// Send play command
|
||||
device
|
||||
.media
|
||||
.play(app.transport_id.as_str(), media_entry.media_session_id)
|
||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to play: {}", e)))?;
|
||||
device.media.play(app.transport_id.as_str(), media_entry.media_session_id)
|
||||
.map_err(|e| anyhow!("Failed to play: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pause(&self) -> Result<(), ControlPointError> {
|
||||
fn pause(&self) -> Result<()> {
|
||||
debug!("ChromecastRenderer: pause()");
|
||||
|
||||
let device = connect_to_device(&self.host, self.port)?;
|
||||
|
||||
let status = device
|
||||
.receiver
|
||||
.get_status()
|
||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?;
|
||||
let status = device.receiver.get_status()
|
||||
.map_err(|e| anyhow!("Failed to get receiver status: {}", e))?;
|
||||
|
||||
let app = status
|
||||
.applications
|
||||
.first()
|
||||
.ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?;
|
||||
let app = status.applications.first()
|
||||
.ok_or_else(|| anyhow!("No active app found"))?;
|
||||
|
||||
device
|
||||
.connection
|
||||
.connect(app.transport_id.as_str())
|
||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)))?;
|
||||
device.connection.connect(app.transport_id.as_str())
|
||||
.map_err(|e| anyhow!("Failed to connect to app: {}", e))?;
|
||||
|
||||
let media_status = device
|
||||
.media
|
||||
.get_status(app.transport_id.as_str(), None)
|
||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)))?;
|
||||
let media_status = device.media.get_status(app.transport_id.as_str(), None)
|
||||
.map_err(|e| anyhow!("Failed to get media status: {}", e))?;
|
||||
|
||||
let media_entry = media_status
|
||||
.entries
|
||||
.first()
|
||||
.ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?;
|
||||
let media_entry = media_status.entries.first()
|
||||
.ok_or_else(|| anyhow!("No media session found"))?;
|
||||
|
||||
device
|
||||
.media
|
||||
.pause(app.transport_id.as_str(), media_entry.media_session_id)
|
||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to pause: {}", e)))?;
|
||||
device.media.pause(app.transport_id.as_str(), media_entry.media_session_id)
|
||||
.map_err(|e| anyhow!("Failed to pause: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stop(&self) -> Result<(), ControlPointError> {
|
||||
fn stop(&self) -> Result<()> {
|
||||
debug!("ChromecastRenderer: stop()");
|
||||
|
||||
// Signal the play thread to stop
|
||||
@@ -397,94 +379,81 @@ impl TransportControl for ChromecastRenderer {
|
||||
// Also send stop command to the device
|
||||
let device = connect_to_device(&self.host, self.port)?;
|
||||
|
||||
let status = device
|
||||
.receiver
|
||||
.get_status()
|
||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?;
|
||||
let status = device.receiver.get_status()
|
||||
.map_err(|e| anyhow!("Failed to get receiver status: {}", e))?;
|
||||
|
||||
let app = status
|
||||
.applications
|
||||
.first()
|
||||
.ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?;
|
||||
let app = status.applications.first()
|
||||
.ok_or_else(|| anyhow!("No active app found"))?;
|
||||
|
||||
device
|
||||
.connection
|
||||
.connect(app.transport_id.as_str())
|
||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)))?;
|
||||
device.connection.connect(app.transport_id.as_str())
|
||||
.map_err(|e| anyhow!("Failed to connect to app: {}", e))?;
|
||||
|
||||
let media_status = device
|
||||
.media
|
||||
.get_status(app.transport_id.as_str(), None)
|
||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)))?;
|
||||
let media_status = device.media.get_status(app.transport_id.as_str(), None)
|
||||
.map_err(|e| anyhow!("Failed to get media status: {}", e))?;
|
||||
|
||||
let media_entry = media_status
|
||||
.entries
|
||||
.first()
|
||||
.ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?;
|
||||
let media_entry = media_status.entries.first()
|
||||
.ok_or_else(|| anyhow!("No media session found"))?;
|
||||
|
||||
device
|
||||
.media
|
||||
.stop(app.transport_id.as_str(), media_entry.media_session_id)
|
||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to stop: {}", e)))?;
|
||||
device.media.stop(app.transport_id.as_str(), media_entry.media_session_id)
|
||||
.map_err(|e| anyhow!("Failed to stop: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
||||
fn seek_rel_time(&self, hhmmss: &str) -> Result<()> {
|
||||
debug!("ChromecastRenderer: seek_rel_time({})", hhmmss);
|
||||
|
||||
let total_seconds = parse_hhmmss_strict(hhmmss)? as f32;
|
||||
// Parse HH:MM:SS to seconds
|
||||
let parts: Vec<&str> = hhmmss.split(':').collect();
|
||||
if parts.len() != 3 {
|
||||
return Err(anyhow!("Invalid time format, expected HH:MM:SS: {}", hhmmss));
|
||||
}
|
||||
|
||||
let hours: u32 = parts[0].parse()
|
||||
.map_err(|_| anyhow!("Invalid hours in time: {}", hhmmss))?;
|
||||
let minutes: u32 = parts[1].parse()
|
||||
.map_err(|_| anyhow!("Invalid minutes in time: {}", hhmmss))?;
|
||||
let seconds: u32 = parts[2].parse()
|
||||
.map_err(|_| anyhow!("Invalid seconds in time: {}", hhmmss))?;
|
||||
|
||||
let total_seconds = (hours * 3600 + minutes * 60 + seconds) as f32;
|
||||
|
||||
let device = connect_to_device(&self.host, self.port)?;
|
||||
|
||||
let status = device
|
||||
.receiver
|
||||
.get_status()
|
||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?;
|
||||
let status = device.receiver.get_status()
|
||||
.map_err(|e| anyhow!("Failed to get receiver status: {}", e))?;
|
||||
|
||||
let app = status
|
||||
.applications
|
||||
.first()
|
||||
.ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?;
|
||||
let app = status.applications.first()
|
||||
.ok_or_else(|| anyhow!("No active app found"))?;
|
||||
|
||||
device
|
||||
.connection
|
||||
.connect(app.transport_id.as_str())
|
||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)))?;
|
||||
device.connection.connect(app.transport_id.as_str())
|
||||
.map_err(|e| anyhow!("Failed to connect to app: {}", e))?;
|
||||
|
||||
let media_status = device
|
||||
.media
|
||||
.get_status(app.transport_id.as_str(), None)
|
||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)))?;
|
||||
let media_status = device.media.get_status(app.transport_id.as_str(), None)
|
||||
.map_err(|e| anyhow!("Failed to get media status: {}", e))?;
|
||||
|
||||
let media_entry = media_status
|
||||
.entries
|
||||
.first()
|
||||
.ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?;
|
||||
let media_entry = media_status.entries.first()
|
||||
.ok_or_else(|| anyhow!("No media session found"))?;
|
||||
|
||||
device
|
||||
.media
|
||||
.seek(
|
||||
app.transport_id.as_str(),
|
||||
media_entry.media_session_id,
|
||||
Some(total_seconds),
|
||||
None,
|
||||
)
|
||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to seek: {}", e)))?;
|
||||
device.media.seek(
|
||||
app.transport_id.as_str(),
|
||||
media_entry.media_session_id,
|
||||
Some(total_seconds),
|
||||
None,
|
||||
)
|
||||
.map_err(|e| anyhow!("Failed to seek: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl PlaybackStatus for ChromecastRenderer {
|
||||
fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
|
||||
fn playback_state(&self) -> Result<PlaybackState> {
|
||||
let device = connect_to_device(&self.host, self.port)?;
|
||||
|
||||
// Get receiver status to find the active app
|
||||
let status = device
|
||||
.receiver
|
||||
.get_status()
|
||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?;
|
||||
let status = device.receiver.get_status()
|
||||
.map_err(|e| anyhow!("Failed to get receiver status: {}", e))?;
|
||||
|
||||
// If no app is running, return NoMedia
|
||||
let app = match status.applications.first() {
|
||||
@@ -493,16 +462,12 @@ impl PlaybackStatus for ChromecastRenderer {
|
||||
};
|
||||
|
||||
// Connect to the app
|
||||
device
|
||||
.connection
|
||||
.connect(app.transport_id.as_str())
|
||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)))?;
|
||||
device.connection.connect(app.transport_id.as_str())
|
||||
.map_err(|e| anyhow!("Failed to connect to app: {}", e))?;
|
||||
|
||||
// Get media status
|
||||
let media_status = device
|
||||
.media
|
||||
.get_status(app.transport_id.as_str(), None)
|
||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)))?;
|
||||
let media_status = device.media.get_status(app.transport_id.as_str(), None)
|
||||
.map_err(|e| anyhow!("Failed to get media status: {}", e))?;
|
||||
|
||||
// If no media entry, return NoMedia
|
||||
let media_entry = match media_status.entries.first() {
|
||||
@@ -515,49 +480,37 @@ impl PlaybackStatus for ChromecastRenderer {
|
||||
}
|
||||
|
||||
impl PlaybackPosition for ChromecastRenderer {
|
||||
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
|
||||
fn playback_position(&self) -> Result<PlaybackPositionInfo> {
|
||||
let device = connect_to_device(&self.host, self.port)?;
|
||||
|
||||
// Get receiver status to find the active app
|
||||
let status = device
|
||||
.receiver
|
||||
.get_status()
|
||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?;
|
||||
let status = device.receiver.get_status()
|
||||
.map_err(|e| anyhow!("Failed to get receiver status: {}", e))?;
|
||||
|
||||
let app = status
|
||||
.applications
|
||||
.first()
|
||||
.ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?;
|
||||
let app = status.applications.first()
|
||||
.ok_or_else(|| anyhow!("No active app found"))?;
|
||||
|
||||
// Connect to the app
|
||||
device
|
||||
.connection
|
||||
.connect(app.transport_id.as_str())
|
||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e)))?;
|
||||
device.connection.connect(app.transport_id.as_str())
|
||||
.map_err(|e| anyhow!("Failed to connect to app: {}", e))?;
|
||||
|
||||
// Get media status
|
||||
let media_status = device
|
||||
.media
|
||||
.get_status(app.transport_id.as_str(), None)
|
||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get media status: {}", e)))?;
|
||||
let media_status = device.media.get_status(app.transport_id.as_str(), None)
|
||||
.map_err(|e| anyhow!("Failed to get media status: {}", e))?;
|
||||
|
||||
let media_entry = media_status
|
||||
.entries
|
||||
.first()
|
||||
.ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?;
|
||||
let media_entry = media_status.entries.first()
|
||||
.ok_or_else(|| anyhow!("No media session found"))?;
|
||||
|
||||
// Extract position information
|
||||
let rel_time = media_entry
|
||||
.current_time
|
||||
.map(|time| format_hhmmss_f64(time as f64));
|
||||
let rel_time = media_entry.current_time
|
||||
.map(|time| format_time_hhmmss(time as f64));
|
||||
|
||||
let track_duration = media_entry
|
||||
.media
|
||||
.as_ref()
|
||||
let track_duration = media_entry.media.as_ref()
|
||||
.and_then(|m| m.duration)
|
||||
.map(|dur| format_hhmmss_f64(dur as f64));
|
||||
.map(|dur| format_time_hhmmss(dur as f64));
|
||||
|
||||
let track_uri = media_entry.media.as_ref().map(|m| m.content_id.clone());
|
||||
let track_uri = media_entry.media.as_ref()
|
||||
.map(|m| m.content_id.clone());
|
||||
|
||||
Ok(PlaybackPositionInfo {
|
||||
track: Some(1),
|
||||
@@ -631,14 +584,21 @@ fn detect_content_type_from_meta(uri: &str, meta: &str) -> String {
|
||||
content_type.to_string()
|
||||
}
|
||||
|
||||
/// Converts seconds to HH:MM:SS format.
|
||||
fn format_time_hhmmss(seconds: f64) -> String {
|
||||
let total_secs = seconds as u64;
|
||||
let hours = total_secs / 3600;
|
||||
let minutes = (total_secs % 3600) / 60;
|
||||
let secs = total_secs % 60;
|
||||
format!("{:02}:{:02}:{:02}", hours, minutes, secs)
|
||||
}
|
||||
|
||||
impl VolumeControl for ChromecastRenderer {
|
||||
fn volume(&self) -> Result<u16, ControlPointError> {
|
||||
fn volume(&self) -> Result<u16> {
|
||||
let device = connect_to_device(&self.host, self.port)?;
|
||||
|
||||
let status = device
|
||||
.receiver
|
||||
.get_status()
|
||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?;
|
||||
let status = device.receiver.get_status()
|
||||
.map_err(|e| anyhow!("Failed to get receiver status: {}", e))?;
|
||||
|
||||
if let Some(level) = status.volume.level {
|
||||
Ok((level * 100.0) as u16)
|
||||
@@ -647,40 +607,34 @@ impl VolumeControl for ChromecastRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
fn set_volume(&self, volume: u16) -> Result<(), ControlPointError> {
|
||||
fn set_volume(&self, volume: u16) -> Result<()> {
|
||||
debug!("ChromecastRenderer: set_volume({})", volume);
|
||||
|
||||
let device = connect_to_device(&self.host, self.port)?;
|
||||
|
||||
let level = (volume as f32) / 100.0;
|
||||
device
|
||||
.receiver
|
||||
.set_volume(level)
|
||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to set volume: {}", e)))?;
|
||||
device.receiver.set_volume(level)
|
||||
.map_err(|e| anyhow!("Failed to set volume: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mute(&self) -> Result<bool, ControlPointError> {
|
||||
fn mute(&self) -> Result<bool> {
|
||||
let device = connect_to_device(&self.host, self.port)?;
|
||||
|
||||
let status = device
|
||||
.receiver
|
||||
.get_status()
|
||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?;
|
||||
let status = device.receiver.get_status()
|
||||
.map_err(|e| anyhow!("Failed to get receiver status: {}", e))?;
|
||||
|
||||
Ok(status.volume.muted.unwrap_or(false))
|
||||
}
|
||||
|
||||
fn set_mute(&self, mute: bool) -> Result<(), ControlPointError> {
|
||||
fn set_mute(&self, mute: bool) -> Result<()> {
|
||||
debug!("ChromecastRenderer: set_mute({})", mute);
|
||||
|
||||
let device = connect_to_device(&self.host, self.port)?;
|
||||
|
||||
device
|
||||
.receiver
|
||||
.set_volume(mute)
|
||||
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to set mute: {}", e)))?;
|
||||
device.receiver.set_volume(mute)
|
||||
.map_err(|e| anyhow!("Failed to set mute: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
294
pmocontrol/src/connection_manager_client.rs
Normal file
294
pmocontrol/src/connection_manager_client.rs
Normal file
@@ -0,0 +1,294 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
|
||||
use crate::soap_client::{SoapCallResult, invoke_upnp_action};
|
||||
use pmoupnp::soap::SoapEnvelope;
|
||||
use xmltree::{Element, XMLNode};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConnectionManagerClient {
|
||||
pub control_url: String,
|
||||
pub service_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProtocolInfo {
|
||||
/// Liste brute des protocolInfo "source" (séparés par virgule dans UPnP)
|
||||
pub source: Vec<String>,
|
||||
/// Liste brute des protocolInfo "sink"
|
||||
pub sink: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConnectionInfo {
|
||||
pub rcs_id: i32,
|
||||
pub av_transport_id: i32,
|
||||
pub protocol_info: String,
|
||||
pub peer_connection_manager: String,
|
||||
pub peer_connection_id: i32,
|
||||
pub direction: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
impl ConnectionManagerClient {
|
||||
pub fn new(control_url: String, service_type: String) -> Self {
|
||||
Self {
|
||||
control_url,
|
||||
service_type,
|
||||
}
|
||||
}
|
||||
|
||||
/// GetProtocolInfo
|
||||
pub fn get_protocol_info(&self) -> Result<ProtocolInfo> {
|
||||
let call_result = invoke_upnp_action(
|
||||
&self.control_url,
|
||||
&self.service_type,
|
||||
"GetProtocolInfo",
|
||||
&[],
|
||||
)?;
|
||||
|
||||
ensure_success("GetProtocolInfo", &call_result)?;
|
||||
|
||||
let envelope = call_result
|
||||
.envelope
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("Missing SOAP envelope in GetProtocolInfo response"))?;
|
||||
|
||||
if let Some(err) = parse_upnp_error(envelope) {
|
||||
return Err(anyhow!(
|
||||
"GetProtocolInfo returned UPnP error {}: {} (HTTP status {})",
|
||||
err.error_code,
|
||||
err.error_description,
|
||||
call_result.status
|
||||
));
|
||||
}
|
||||
|
||||
let response = find_child_with_suffix(&envelope.body.content, "GetProtocolInfoResponse")
|
||||
.ok_or_else(|| anyhow!("Missing GetProtocolInfoResponse element in SOAP body"))?;
|
||||
|
||||
let source_text = extract_child_text_allow_empty(response, "Source")?;
|
||||
let sink_text = extract_child_text_allow_empty(response, "Sink")?;
|
||||
|
||||
Ok(ProtocolInfo {
|
||||
source: split_list(&source_text),
|
||||
sink: split_list(&sink_text),
|
||||
})
|
||||
}
|
||||
|
||||
/// GetCurrentConnectionIDs
|
||||
pub fn get_current_connection_ids(&self) -> Result<Vec<i32>> {
|
||||
let call_result = invoke_upnp_action(
|
||||
&self.control_url,
|
||||
&self.service_type,
|
||||
"GetCurrentConnectionIDs",
|
||||
&[],
|
||||
)?;
|
||||
|
||||
ensure_success("GetCurrentConnectionIDs", &call_result)?;
|
||||
|
||||
let envelope = call_result
|
||||
.envelope
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("Missing SOAP envelope in GetCurrentConnectionIDs response"))?;
|
||||
|
||||
if let Some(err) = parse_upnp_error(envelope) {
|
||||
return Err(anyhow!(
|
||||
"GetCurrentConnectionIDs returned UPnP error {}: {} (HTTP status {})",
|
||||
err.error_code,
|
||||
err.error_description,
|
||||
call_result.status
|
||||
));
|
||||
}
|
||||
|
||||
let response =
|
||||
find_child_with_suffix(&envelope.body.content, "GetCurrentConnectionIDsResponse")
|
||||
.ok_or_else(|| {
|
||||
anyhow!("Missing GetCurrentConnectionIDsResponse element in SOAP body")
|
||||
})?;
|
||||
|
||||
let ids_text = extract_child_text_allow_empty(response, "ConnectionIDs")?;
|
||||
let trimmed = ids_text.trim();
|
||||
|
||||
if trimmed.is_empty() || trimmed == "0" {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut ids = Vec::new();
|
||||
for part in trimmed.split(',') {
|
||||
let value = part.trim();
|
||||
if value.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let parsed = value
|
||||
.parse::<i32>()
|
||||
.map_err(|_| anyhow!("Invalid ConnectionID value: {}", value))?;
|
||||
ids.push(parsed);
|
||||
}
|
||||
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// GetCurrentConnectionInfo
|
||||
pub fn get_current_connection_info(&self, connection_id: i32) -> Result<ConnectionInfo> {
|
||||
let connection_id_str = connection_id.to_string();
|
||||
let args = [("ConnectionID", connection_id_str.as_str())];
|
||||
|
||||
let call_result = invoke_upnp_action(
|
||||
&self.control_url,
|
||||
&self.service_type,
|
||||
"GetCurrentConnectionInfo",
|
||||
&args,
|
||||
)?;
|
||||
|
||||
ensure_success("GetCurrentConnectionInfo", &call_result)?;
|
||||
|
||||
let envelope = call_result
|
||||
.envelope
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("Missing SOAP envelope in GetCurrentConnectionInfo response"))?;
|
||||
|
||||
if let Some(err) = parse_upnp_error(envelope) {
|
||||
return Err(anyhow!(
|
||||
"GetCurrentConnectionInfo returned UPnP error {}: {} (HTTP status {})",
|
||||
err.error_code,
|
||||
err.error_description,
|
||||
call_result.status
|
||||
));
|
||||
}
|
||||
|
||||
let response =
|
||||
find_child_with_suffix(&envelope.body.content, "GetCurrentConnectionInfoResponse")
|
||||
.ok_or_else(|| {
|
||||
anyhow!("Missing GetCurrentConnectionInfoResponse element in SOAP body")
|
||||
})?;
|
||||
|
||||
let rcs_id = extract_child_text(response, "RcsID")?
|
||||
.parse::<i32>()
|
||||
.map_err(|_| anyhow!("Invalid RcsID value in response"))?;
|
||||
|
||||
let av_transport_id = extract_child_text(response, "AVTransportID")?
|
||||
.parse::<i32>()
|
||||
.map_err(|_| anyhow!("Invalid AVTransportID value in response"))?;
|
||||
|
||||
let protocol_info = extract_child_text_allow_empty(response, "ProtocolInfo")?;
|
||||
let peer_connection_manager =
|
||||
extract_child_text_allow_empty(response, "PeerConnectionManager")?;
|
||||
|
||||
let peer_connection_id = extract_child_text(response, "PeerConnectionID")?
|
||||
.parse::<i32>()
|
||||
.map_err(|_| anyhow!("Invalid PeerConnectionID value in response"))?;
|
||||
|
||||
let direction = extract_child_text(response, "Direction")?;
|
||||
let status = extract_child_text(response, "Status")?;
|
||||
|
||||
Ok(ConnectionInfo {
|
||||
rcs_id,
|
||||
av_transport_id,
|
||||
protocol_info,
|
||||
peer_connection_manager,
|
||||
peer_connection_id,
|
||||
direction,
|
||||
status,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn split_list(value: &str) -> Vec<String> {
|
||||
value
|
||||
.split(',')
|
||||
.filter_map(|part| {
|
||||
let trimmed = part.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn ensure_success(action: &str, call_result: &SoapCallResult) -> Result<()> {
|
||||
if call_result.status.is_success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(env) = &call_result.envelope {
|
||||
if let Some(err) = parse_upnp_error(env) {
|
||||
return Err(anyhow!(
|
||||
"{action} failed with UPnP error {}: {} (HTTP status {})",
|
||||
err.error_code,
|
||||
err.error_description,
|
||||
call_result.status
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!(
|
||||
"{action} failed with HTTP status {} and body: {}",
|
||||
call_result.status,
|
||||
call_result.raw_body
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct UpnpError {
|
||||
pub error_code: u32,
|
||||
pub error_description: String,
|
||||
}
|
||||
|
||||
fn parse_upnp_error(envelope: &SoapEnvelope) -> Option<UpnpError> {
|
||||
let fault = find_child_with_suffix(&envelope.body.content, "Fault")?;
|
||||
let detail = find_child_with_suffix(fault, "detail")?;
|
||||
let upnp_error = find_child_with_suffix(detail, "UPnPError")?;
|
||||
|
||||
let error_code_elem = upnp_error.children.iter().find_map(|node| match node {
|
||||
XMLNode::Element(elem) if elem.name.ends_with("errorCode") => Some(elem),
|
||||
_ => None,
|
||||
})?;
|
||||
|
||||
let binding = error_code_elem.get_text()?;
|
||||
let error_code_text = binding.trim();
|
||||
let error_code = error_code_text.parse::<u32>().ok()?;
|
||||
|
||||
let error_description = upnp_error
|
||||
.children
|
||||
.iter()
|
||||
.find_map(|node| match node {
|
||||
XMLNode::Element(elem) if elem.name.ends_with("errorDescription") => {
|
||||
elem.get_text().map(|t| t.trim().to_string())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| String::from(""));
|
||||
|
||||
Some(UpnpError {
|
||||
error_code,
|
||||
error_description,
|
||||
})
|
||||
}
|
||||
|
||||
fn find_child_with_suffix<'a>(parent: &'a Element, suffix: &str) -> Option<&'a Element> {
|
||||
parent.children.iter().find_map(|node| match node {
|
||||
XMLNode::Element(elem) if elem.name.ends_with(suffix) => Some(elem),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_child_text(parent: &Element, suffix: &str) -> Result<String> {
|
||||
let text = extract_child_text_allow_empty(parent, suffix)?;
|
||||
if text.is_empty() {
|
||||
return Err(anyhow!("{suffix} element missing text in response"));
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
fn extract_child_text_allow_empty(parent: &Element, suffix: &str) -> Result<String> {
|
||||
let child = find_child_with_suffix(parent, suffix)
|
||||
.ok_or_else(|| anyhow!("Missing {suffix} element in response"))?;
|
||||
|
||||
let text = child
|
||||
.get_text()
|
||||
.map(|t| t.trim().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(text)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
85
pmocontrol/src/control_point/music_queue.rs
Normal file
85
pmocontrol/src/control_point/music_queue.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
|
||||
use crate::control_point::openhome_queue::OpenHomeQueue;
|
||||
use crate::openhome_playlist::OpenHomePlaylistSnapshot;
|
||||
use crate::queue_backend::{PlaybackItem, QueueBackend, QueueSnapshot};
|
||||
use crate::queue_interne::InternalQueue;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum MusicQueue {
|
||||
Internal(InternalQueue),
|
||||
OpenHome(OpenHomeQueue),
|
||||
}
|
||||
|
||||
impl MusicQueue {
|
||||
pub fn new_internal() -> Self {
|
||||
MusicQueue::Internal(InternalQueue::default())
|
||||
}
|
||||
|
||||
pub fn openhome_playlist_snapshot(&self) -> Result<OpenHomePlaylistSnapshot> {
|
||||
match self {
|
||||
MusicQueue::OpenHome(queue) => queue.openhome_playlist_snapshot(),
|
||||
_ => Err(anyhow!(
|
||||
"OpenHome playlist snapshot is only available for OpenHome queues"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn replace_with_attached_playlist(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
current_index: Option<usize>,
|
||||
) -> Result<()> {
|
||||
match self {
|
||||
MusicQueue::OpenHome(queue) => queue.replace_entire_playlist(items, current_index),
|
||||
MusicQueue::Internal(queue) => queue.replace_queue(items, current_index),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MusicQueue {
|
||||
fn default() -> Self {
|
||||
MusicQueue::new_internal()
|
||||
}
|
||||
}
|
||||
|
||||
impl QueueBackend for MusicQueue {
|
||||
fn queue_snapshot(&self) -> Result<QueueSnapshot> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.queue_snapshot(),
|
||||
MusicQueue::OpenHome(q) => q.queue_snapshot(),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_index(&mut self, index: Option<usize>) -> Result<()> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.set_index(index),
|
||||
MusicQueue::OpenHome(q) => q.set_index(index),
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
current_index: Option<usize>,
|
||||
) -> Result<()> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.replace_queue(items, current_index),
|
||||
MusicQueue::OpenHome(q) => q.replace_queue(items, current_index),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.get_item(index),
|
||||
MusicQueue::OpenHome(q) => q.get_item(index),
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<()> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.replace_item(index, item),
|
||||
MusicQueue::OpenHome(q) => q.replace_item(index, item),
|
||||
}
|
||||
}
|
||||
}
|
||||
971
pmocontrol/src/control_point/openhome_queue.rs
Normal file
971
pmocontrol/src/control_point/openhome_queue.rs
Normal file
@@ -0,0 +1,971 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use pmodidl::DIDLLite;
|
||||
use quick_xml::escape::escape;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::media_server::ServerId;
|
||||
use crate::model::RendererId;
|
||||
use crate::openhome_client::{
|
||||
parse_track_metadata_from_didl, OhInfoClient, OhPlaylistClient, OhProductClient, OhTrackEntry,
|
||||
OPENHOME_PLAYLIST_HEAD_ID,
|
||||
};
|
||||
use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
|
||||
use crate::queue_backend::{PlaybackItem, QueueBackend, QueueSnapshot};
|
||||
|
||||
/// Local mirror of an OpenHome playlist for a single renderer.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OpenHomeQueue {
|
||||
pub renderer_id: RendererId,
|
||||
pub playlist: OhPlaylistClient,
|
||||
pub info_client: Option<OhInfoClient>,
|
||||
pub product_client: Option<OhProductClient>,
|
||||
pub items: Vec<PlaybackItem>,
|
||||
pub current_index: Option<usize>,
|
||||
track_ids: Vec<u32>,
|
||||
}
|
||||
|
||||
impl OpenHomeQueue {
|
||||
pub fn new(
|
||||
renderer_id: RendererId,
|
||||
playlist: OhPlaylistClient,
|
||||
info_client: Option<OhInfoClient>,
|
||||
product_client: Option<OhProductClient>,
|
||||
) -> Self {
|
||||
Self {
|
||||
renderer_id,
|
||||
playlist,
|
||||
info_client,
|
||||
product_client,
|
||||
items: Vec::new(),
|
||||
current_index: None,
|
||||
track_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reload the full OpenHome playlist snapshot into local playback items.
|
||||
///
|
||||
/// This mirrors the logic previously implemented by
|
||||
/// `OpenHomeRenderer::snapshot_openhome_playlist` but converts entries
|
||||
/// directly into `PlaybackItem`s.
|
||||
pub fn refresh_from_openhome(&mut self) -> Result<()> {
|
||||
self.ensure_playlist_source_selected()?;
|
||||
let entries = self.playlist.read_all_tracks()?;
|
||||
let mut items = Vec::with_capacity(entries.len());
|
||||
let mut track_ids = Vec::with_capacity(entries.len());
|
||||
|
||||
for entry in &entries {
|
||||
items.push(self.playback_item_from_entry(entry));
|
||||
track_ids.push(entry.id);
|
||||
}
|
||||
|
||||
// Try to get the currently playing track ID from the renderer
|
||||
// If this fails (renderer doesn't support it or no track playing), we'll just set current_index to None
|
||||
let current_id = self.playlist.id().ok();
|
||||
// {
|
||||
// // Try Info.Id() first
|
||||
// if let Ok(id) = client.id() {
|
||||
// debug!(
|
||||
// renderer = self.renderer_id.0.as_str(),
|
||||
// track_id = id,
|
||||
// "Detected current track via Info.Id()"
|
||||
// );
|
||||
// return Some(id);
|
||||
// }
|
||||
|
||||
// // If Id() fails, try Track() to get the URI and search for it
|
||||
// if let Ok(track_info) = client.track() {
|
||||
// debug!(
|
||||
// renderer = self.renderer_id.0.as_str(),
|
||||
// track_uri = track_info.uri.as_str(),
|
||||
// "Info.Id() failed, searching for current track by URI from Info.Track()"
|
||||
// );
|
||||
// return entries
|
||||
// .iter()
|
||||
// .find(|entry| entry.uri == track_info.uri)
|
||||
// .map(|entry| {
|
||||
// debug!(
|
||||
// renderer = self.renderer_id.0.as_str(),
|
||||
// found_id = entry.id,
|
||||
// found_uri = entry.uri.as_str(),
|
||||
// "Found current track ID by matching URI"
|
||||
// );
|
||||
// entry.id
|
||||
// });
|
||||
// }
|
||||
|
||||
// debug!(
|
||||
// renderer = self.renderer_id.0.as_str(),
|
||||
// "Both Info.Id() and Info.Track() failed, cannot determine current track"
|
||||
// );
|
||||
// None
|
||||
// });
|
||||
|
||||
// let current_index = current_id
|
||||
// .and_then(|id| track_ids.iter().position(|entry_id| *entry_id == id));
|
||||
|
||||
// Convert track ID to array index by searching in track_ids
|
||||
let current_index = current_id
|
||||
.and_then(|id| track_ids.iter().position(|&entry_id| entry_id == id));
|
||||
|
||||
self.items = items;
|
||||
self.track_ids = track_ids;
|
||||
self.current_index = current_index;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn openhome_playlist_snapshot(&self) -> Result<OpenHomePlaylistSnapshot> {
|
||||
let tracks = self
|
||||
.items
|
||||
.iter()
|
||||
.zip(self.track_ids.iter())
|
||||
.map(|(item, track_id)| OpenHomePlaylistTrack {
|
||||
id: *track_id,
|
||||
uri: item.uri.clone(),
|
||||
title: item.metadata.as_ref().and_then(|m| m.title.clone()),
|
||||
artist: item.metadata.as_ref().and_then(|m| m.artist.clone()),
|
||||
album: item.metadata.as_ref().and_then(|m| m.album.clone()),
|
||||
album_art_uri: item.metadata.as_ref().and_then(|m| m.album_art_uri.clone()),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(OpenHomePlaylistSnapshot {
|
||||
renderer_id: self.renderer_id.0.clone(),
|
||||
current_id: self
|
||||
.current_index
|
||||
.and_then(|idx| self.track_ids.get(idx).copied()),
|
||||
current_index: self.current_index,
|
||||
tracks,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.items.len()
|
||||
}
|
||||
|
||||
/// Return the list of OpenHome track IDs in order.
|
||||
pub fn openhome_track_ids(&self) -> Vec<u32> {
|
||||
self.track_ids.clone()
|
||||
}
|
||||
|
||||
pub fn select_track_id(&mut self, id: u32) -> Result<()> {
|
||||
let index = match self.track_ids.iter().position(|&tid| tid == id) {
|
||||
Some(pos) => pos,
|
||||
None => {
|
||||
self.refresh_from_openhome()?;
|
||||
self.track_ids
|
||||
.iter()
|
||||
.position(|&tid| tid == id)
|
||||
.ok_or_else(|| anyhow!("Unknown OpenHome track id {}", id))?
|
||||
}
|
||||
};
|
||||
|
||||
self.ensure_playlist_source_selected()?;
|
||||
self.playlist.play_id(id)?;
|
||||
self.current_index = Some(index);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Selects and plays a track by its queue index (0-based).
|
||||
pub fn select_track_index(&mut self, index: usize) -> Result<()> {
|
||||
let track_id = self
|
||||
.track_ids
|
||||
.get(index)
|
||||
.copied()
|
||||
.ok_or_else(|| anyhow!("Index {} out of bounds (queue length: {})", index, self.track_ids.len()))?;
|
||||
|
||||
self.ensure_playlist_source_selected()?;
|
||||
self.playlist.play_id(track_id)?;
|
||||
self.current_index = Some(index);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) -> Result<()> {
|
||||
self.ensure_playlist_source_selected()?;
|
||||
self.playlist.delete_all()?;
|
||||
self.items.clear();
|
||||
self.track_ids.clear();
|
||||
self.current_index = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replace the remote OpenHome playlist entirely with `items`.
|
||||
///
|
||||
/// This is used when attaching a media server playlist: we want to drop any
|
||||
/// stale entries (even if they were inserted by another control point) and
|
||||
/// rebuild the renderer playlist from scratch.
|
||||
pub fn replace_entire_playlist(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
current_index: Option<usize>,
|
||||
) -> Result<()> {
|
||||
self.ensure_playlist_source_selected()?;
|
||||
self.playlist.delete_all()?;
|
||||
self.items.clear();
|
||||
self.track_ids.clear();
|
||||
self.current_index = None;
|
||||
|
||||
if items.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut rebuilt_items = Vec::with_capacity(items.len());
|
||||
let mut rebuilt_ids = Vec::with_capacity(items.len());
|
||||
let mut previous_id = OPENHOME_PLAYLIST_HEAD_ID;
|
||||
|
||||
for item in items {
|
||||
let metadata = build_metadata_xml(&item);
|
||||
let new_id = self.playlist.insert(previous_id, &item.uri, &metadata)?;
|
||||
previous_id = new_id;
|
||||
rebuilt_ids.push(new_id);
|
||||
rebuilt_items.push(self.item_with_openhome_id(item, new_id));
|
||||
}
|
||||
|
||||
let normalized = current_index
|
||||
.filter(|&idx| idx < rebuilt_ids.len())
|
||||
.or_else(|| Some(0));
|
||||
|
||||
self.items = rebuilt_items;
|
||||
self.track_ids = rebuilt_ids;
|
||||
self.current_index = normalized;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_playback_item(
|
||||
&mut self,
|
||||
item: PlaybackItem,
|
||||
after_id: Option<u32>,
|
||||
play: bool,
|
||||
) -> Result<u32> {
|
||||
self.ensure_playlist_source_selected()?;
|
||||
let metadata_xml = build_metadata_xml(&item);
|
||||
let insert_after = match after_id {
|
||||
Some(id) => id,
|
||||
None => self.track_ids.last().copied().unwrap_or(0),
|
||||
};
|
||||
|
||||
let new_id = self
|
||||
.playlist
|
||||
.insert(insert_after, &item.uri, &metadata_xml)?;
|
||||
|
||||
if play {
|
||||
self.playlist.play_id(new_id)?;
|
||||
}
|
||||
|
||||
let mut insert_index = after_id
|
||||
.and_then(|id| {
|
||||
if id == 0 {
|
||||
Some(0)
|
||||
} else {
|
||||
self.track_ids
|
||||
.iter()
|
||||
.position(|tid| *tid == id)
|
||||
.map(|pos| pos + 1)
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| self.track_ids.len());
|
||||
|
||||
if insert_index > self.track_ids.len() {
|
||||
insert_index = self.track_ids.len();
|
||||
}
|
||||
|
||||
self.track_ids.insert(insert_index, new_id);
|
||||
let stored_item = self.item_with_openhome_id(item, new_id);
|
||||
self.items.insert(insert_index, stored_item);
|
||||
|
||||
self.current_index = if play {
|
||||
Some(insert_index)
|
||||
} else {
|
||||
self.current_index
|
||||
.map(|idx| if insert_index <= idx { idx + 1 } else { idx })
|
||||
};
|
||||
|
||||
Ok(new_id)
|
||||
}
|
||||
|
||||
fn ensure_playlist_source_selected(&self) -> Result<()> {
|
||||
if let Some(product) = &self.product_client {
|
||||
product.ensure_playlist_source_selected().map_err(|err| {
|
||||
anyhow!(
|
||||
"Failed to select OpenHome Playlist source for {}: {}",
|
||||
self.renderer_id.0,
|
||||
err
|
||||
)
|
||||
})
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn playback_item_from_entry(&self, entry: &OhTrackEntry) -> PlaybackItem {
|
||||
let metadata = parse_track_metadata_from_didl(&entry.metadata_xml);
|
||||
let didl_id = didl_id_from_metadata(&entry.metadata_xml)
|
||||
.unwrap_or_else(|| format!("openhome:{}", entry.id));
|
||||
PlaybackItem {
|
||||
media_server_id: ServerId(format!("openhome:{}", self.renderer_id.0)),
|
||||
didl_id,
|
||||
uri: entry.uri.clone(),
|
||||
// OpenHome tracks don't provide protocolInfo, use generic default
|
||||
protocol_info: "http-get:*:audio/*:*".to_string(),
|
||||
metadata,
|
||||
}
|
||||
}
|
||||
|
||||
fn item_with_openhome_id(&self, mut item: PlaybackItem, track_id: u32) -> PlaybackItem {
|
||||
item.didl_id = format!("openhome:{}", track_id);
|
||||
item.media_server_id = ServerId(format!("openhome:{}", self.renderer_id.0));
|
||||
item
|
||||
}
|
||||
|
||||
fn ensure_track_id(&mut self, index: usize) -> Result<u32> {
|
||||
if index >= self.items.len() {
|
||||
return Err(anyhow!("Index out of bounds in OpenHomeQueue: {}", index));
|
||||
}
|
||||
|
||||
if let Some(id) = self.track_ids.get(index).copied() {
|
||||
return Ok(id);
|
||||
}
|
||||
|
||||
self.refresh_from_openhome()?;
|
||||
self.track_ids
|
||||
.get(index)
|
||||
.copied()
|
||||
.ok_or_else(|| anyhow!("Failed to resolve OpenHome track id at index {}", index))
|
||||
}
|
||||
|
||||
/// CASE 1: Replace queue while preserving the currently playing item as first.
|
||||
/// The currently playing item is NOT in the new playlist, so we keep it as the first
|
||||
/// item and append the entire new playlist after it.
|
||||
fn replace_queue_preserve_current(
|
||||
&mut self,
|
||||
new_items: Vec<PlaybackItem>,
|
||||
playing_idx: usize,
|
||||
playing_id: u32,
|
||||
) -> Result<()> {
|
||||
// Re-read the current playlist state to get fresh IDs
|
||||
// This minimizes race conditions where IDs become invalid between our last refresh
|
||||
// and now (due to UPnP events from the server)
|
||||
let current_entries = self.playlist.read_all_tracks()?;
|
||||
let current_ids: Vec<u32> = current_entries.iter().map(|e| e.id).collect();
|
||||
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
fresh_id_count = current_ids.len(),
|
||||
cached_id_count = self.track_ids.len(),
|
||||
"Re-read playlist before deletions to avoid stale ID errors"
|
||||
);
|
||||
|
||||
// Find the playing track in the fresh list
|
||||
let fresh_playing_idx = current_ids.iter().position(|&id| id == playing_id);
|
||||
|
||||
if fresh_playing_idx.is_none() {
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
playing_id,
|
||||
"Playing track not found in fresh playlist - renderer state may have changed, aborting modification"
|
||||
);
|
||||
// The playing track is gone - don't try to manipulate the playlist
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Delete everything except the currently playing item (using fresh IDs)
|
||||
for &track_id in current_ids.iter().rev() {
|
||||
if track_id != playing_id {
|
||||
self.playlist.delete_id_if_exists(track_id)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild: [currently_playing, new_items...]
|
||||
let mut rebuilt_items = Vec::with_capacity(1 + new_items.len());
|
||||
let mut rebuilt_ids = Vec::with_capacity(1 + new_items.len());
|
||||
|
||||
rebuilt_items.push(self.items[playing_idx].clone());
|
||||
rebuilt_ids.push(playing_id);
|
||||
|
||||
let mut previous_id = playing_id;
|
||||
for item in new_items {
|
||||
let metadata = build_metadata_xml(&item);
|
||||
let new_id = self.playlist.insert(previous_id, &item.uri, &metadata)?;
|
||||
previous_id = new_id;
|
||||
rebuilt_ids.push(new_id);
|
||||
rebuilt_items.push(self.item_with_openhome_id(item, new_id));
|
||||
}
|
||||
|
||||
self.items = rebuilt_items;
|
||||
self.track_ids = rebuilt_ids;
|
||||
self.current_index = Some(0); // Currently playing is now at index 0
|
||||
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
"Gentle sync completed: preserved playing track as first item (not in new playlist)"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// CASE 2: Replace queue with double-LCS (before and after the pivot).
|
||||
/// The currently playing item IS in the new playlist, so we use it as a pivot
|
||||
/// and apply LCS separately to the portions before and after it.
|
||||
fn replace_queue_with_pivot(
|
||||
&mut self,
|
||||
new_items: Vec<PlaybackItem>,
|
||||
pivot_idx_new: usize,
|
||||
pivot_id: u32,
|
||||
) -> Result<()> {
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
pivot_id,
|
||||
pivot_idx_new,
|
||||
new_playlist_len = new_items.len(),
|
||||
"Starting replace_queue_with_pivot - will re-read from OpenHome"
|
||||
);
|
||||
|
||||
// Re-read the current playlist state from OpenHome (the ONLY source of truth)
|
||||
// This is CRITICAL to avoid deleting IDs that no longer exist, which can
|
||||
// put the renderer (upmpdcli) into a degraded state where Info.TransportState()
|
||||
// starts returning HTTP 500 errors.
|
||||
let current_entries = self.playlist.read_all_tracks()?;
|
||||
|
||||
// Convert entries to PlaybackItems - this is the REAL current state
|
||||
let mut fresh_items = Vec::with_capacity(current_entries.len());
|
||||
let mut fresh_ids = Vec::with_capacity(current_entries.len());
|
||||
for entry in ¤t_entries {
|
||||
fresh_items.push(self.playback_item_from_entry(entry));
|
||||
fresh_ids.push(entry.id);
|
||||
}
|
||||
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
fresh_count = fresh_items.len(),
|
||||
"Re-read playlist from OpenHome (source of truth)"
|
||||
);
|
||||
|
||||
// Find the pivot in the fresh list
|
||||
let fresh_pivot_idx = fresh_ids.iter().position(|&id| id == pivot_id);
|
||||
|
||||
if fresh_pivot_idx.is_none() {
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
pivot_id,
|
||||
"Pivot track not found in fresh playlist - renderer state changed, aborting"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let fresh_pivot_idx = fresh_pivot_idx.unwrap();
|
||||
|
||||
// Split fresh data at the pivot - use ONLY fresh data, ignore cache
|
||||
let old_before: Vec<PlaybackItem> = fresh_items[..fresh_pivot_idx].to_vec();
|
||||
let old_after: Vec<PlaybackItem> = fresh_items[fresh_pivot_idx + 1..].to_vec();
|
||||
let old_ids_before: Vec<u32> = fresh_ids[..fresh_pivot_idx].to_vec();
|
||||
let old_ids_after: Vec<u32> = fresh_ids[fresh_pivot_idx + 1..].to_vec();
|
||||
|
||||
let new_before = &new_items[..pivot_idx_new];
|
||||
let new_after = &new_items[pivot_idx_new + 1..];
|
||||
|
||||
// LCS on the AFTER part (using fresh data from OpenHome)
|
||||
let (keep_old_after, keep_new_after) = lcs_flags(&old_after, new_after);
|
||||
|
||||
// LCS on the BEFORE part (using fresh data from OpenHome)
|
||||
let (keep_old_before, keep_new_before) = lcs_flags(&old_before, new_before);
|
||||
|
||||
// Delete items marked for deletion in AFTER part (reverse order)
|
||||
for (idx, &track_id) in old_ids_after.iter().enumerate().rev() {
|
||||
if !keep_old_after[idx] {
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
track_id,
|
||||
position = "AFTER pivot",
|
||||
"RENDERER OP: DeleteId({})",
|
||||
track_id
|
||||
);
|
||||
self.playlist.delete_id_if_exists(track_id)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete items marked for deletion in BEFORE part (reverse order)
|
||||
for (idx, &track_id) in old_ids_before.iter().enumerate().rev() {
|
||||
if !keep_old_before[idx] {
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
track_id,
|
||||
position = "BEFORE pivot",
|
||||
"RENDERER OP: DeleteId({})",
|
||||
track_id
|
||||
);
|
||||
self.playlist.delete_id_if_exists(track_id)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild the playlist: [BEFORE, PIVOT, AFTER]
|
||||
let mut rebuilt_items = Vec::with_capacity(new_items.len());
|
||||
let mut rebuilt_ids = Vec::with_capacity(new_items.len());
|
||||
|
||||
// Collect IDs of kept items in BEFORE part (in order)
|
||||
let remaining_before: Vec<u32> = old_ids_before
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, &id)| if keep_old_before[idx] { Some(id) } else { None })
|
||||
.collect();
|
||||
|
||||
let mut remaining_before_idx = 0;
|
||||
let mut previous_id = OPENHOME_PLAYLIST_HEAD_ID;
|
||||
|
||||
// Rebuild BEFORE part
|
||||
for (idx, item) in new_before.iter().enumerate() {
|
||||
if keep_new_before[idx] {
|
||||
let existing_id = remaining_before[remaining_before_idx];
|
||||
remaining_before_idx += 1;
|
||||
previous_id = existing_id;
|
||||
rebuilt_ids.push(existing_id);
|
||||
rebuilt_items.push(self.item_with_openhome_id(item.clone(), existing_id));
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
track_id = existing_id,
|
||||
position = "BEFORE pivot",
|
||||
"KEPT existing track ID {}",
|
||||
existing_id
|
||||
);
|
||||
} else {
|
||||
let metadata = build_metadata_xml(item);
|
||||
let new_id = self.playlist.insert(previous_id, &item.uri, &metadata)?;
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
after_id = previous_id,
|
||||
new_id,
|
||||
position = "BEFORE pivot",
|
||||
"RENDERER OP: Insert(after={}) -> new_id={}",
|
||||
previous_id,
|
||||
new_id
|
||||
);
|
||||
previous_id = new_id;
|
||||
rebuilt_ids.push(new_id);
|
||||
rebuilt_items.push(self.item_with_openhome_id(item.clone(), new_id));
|
||||
}
|
||||
}
|
||||
|
||||
// Add PIVOT (keeps its ID!)
|
||||
rebuilt_ids.push(pivot_id);
|
||||
rebuilt_items.push(self.item_with_openhome_id(new_items[pivot_idx_new].clone(), pivot_id));
|
||||
previous_id = pivot_id;
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
pivot_id,
|
||||
pivot_idx_new,
|
||||
"PIVOT preserved with ID {} at index {}",
|
||||
pivot_id,
|
||||
pivot_idx_new
|
||||
);
|
||||
|
||||
// Collect IDs of kept items in AFTER part (in order)
|
||||
let remaining_after: Vec<u32> = old_ids_after
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, &id)| if keep_old_after[idx] { Some(id) } else { None })
|
||||
.collect();
|
||||
|
||||
let mut remaining_after_idx = 0;
|
||||
|
||||
// Rebuild AFTER part
|
||||
for (idx, item) in new_after.iter().enumerate() {
|
||||
if keep_new_after[idx] {
|
||||
let existing_id = remaining_after[remaining_after_idx];
|
||||
remaining_after_idx += 1;
|
||||
previous_id = existing_id;
|
||||
rebuilt_ids.push(existing_id);
|
||||
rebuilt_items.push(self.item_with_openhome_id(item.clone(), existing_id));
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
track_id = existing_id,
|
||||
position = "AFTER pivot",
|
||||
"KEPT existing track ID {}",
|
||||
existing_id
|
||||
);
|
||||
} else {
|
||||
let metadata = build_metadata_xml(item);
|
||||
let new_id = self.playlist.insert(previous_id, &item.uri, &metadata)?;
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
after_id = previous_id,
|
||||
new_id,
|
||||
position = "AFTER pivot",
|
||||
"RENDERER OP: Insert(after={}) -> new_id={}",
|
||||
previous_id,
|
||||
new_id
|
||||
);
|
||||
previous_id = new_id;
|
||||
rebuilt_ids.push(new_id);
|
||||
rebuilt_items.push(self.item_with_openhome_id(item.clone(), new_id));
|
||||
}
|
||||
}
|
||||
|
||||
self.items = rebuilt_items;
|
||||
self.track_ids = rebuilt_ids;
|
||||
self.current_index = Some(pivot_idx_new); // Pivot is at its new position
|
||||
|
||||
// VERIFICATION: Check that pivot ID is preserved
|
||||
let final_pivot_id = self.track_ids.get(pivot_idx_new).copied();
|
||||
if final_pivot_id != Some(pivot_id) {
|
||||
return Err(anyhow!(
|
||||
"CRITICAL BUG: Pivot ID changed from {} to {:?} during replace_queue_with_pivot!",
|
||||
pivot_id,
|
||||
final_pivot_id
|
||||
));
|
||||
}
|
||||
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
pivot_idx = pivot_idx_new,
|
||||
pivot_id,
|
||||
final_playlist_len = self.track_ids.len(),
|
||||
pivot_verified = true,
|
||||
"Gentle sync completed: double-LCS with pivot (playing track preserved)"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Standard LCS-based replacement (used when no currently playing item).
|
||||
fn replace_queue_standard_lcs(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
current_index: Option<usize>,
|
||||
) -> Result<()> {
|
||||
let (keep_current, keep_desired) = lcs_flags(&self.items, &items);
|
||||
|
||||
let items_to_keep = keep_current.iter().filter(|&&k| k).count();
|
||||
let items_to_delete = keep_current.iter().filter(|&&k| !k).count();
|
||||
let items_to_add = keep_desired.iter().filter(|&&k| !k).count();
|
||||
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
keep = items_to_keep,
|
||||
delete = items_to_delete,
|
||||
add = items_to_add,
|
||||
"LCS computed: minimizing OpenHome playlist operations"
|
||||
);
|
||||
|
||||
// If we're replacing everything (keep=0), use delete_all() instead of
|
||||
// individual delete_id() calls. This is much more robust for live playlists
|
||||
// where track IDs can become invalid between refresh and deletion.
|
||||
if items_to_keep == 0 && items_to_delete > 0 {
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
"Using delete_all() for complete replacement (more robust for live playlists)"
|
||||
);
|
||||
self.playlist.delete_all()?;
|
||||
self.track_ids.clear();
|
||||
self.items.clear();
|
||||
} else {
|
||||
// Selective deletion when keeping some items
|
||||
for idx in (0..self.track_ids.len()).rev() {
|
||||
if !keep_current[idx] {
|
||||
let track_id = self.track_ids[idx];
|
||||
// Use delete_id_if_exists() to handle cases where another control point
|
||||
// may have already modified the playlist
|
||||
self.playlist.delete_id_if_exists(track_id)?;
|
||||
self.track_ids.remove(idx);
|
||||
self.items.remove(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let remaining_ids = self.track_ids.clone();
|
||||
let mut remaining_idx = 0usize;
|
||||
let mut previous_id = OPENHOME_PLAYLIST_HEAD_ID;
|
||||
let mut rebuilt_items = Vec::with_capacity(items.len());
|
||||
let mut rebuilt_ids = Vec::with_capacity(items.len());
|
||||
|
||||
for (idx, item) in items.into_iter().enumerate() {
|
||||
if keep_desired[idx] {
|
||||
if remaining_idx >= remaining_ids.len() {
|
||||
return Err(anyhow!(
|
||||
"OpenHome playlist refresh bookkeeping mismatch (kept entries underflow)"
|
||||
));
|
||||
}
|
||||
let existing_id = remaining_ids[remaining_idx];
|
||||
remaining_idx += 1;
|
||||
previous_id = existing_id;
|
||||
rebuilt_ids.push(existing_id);
|
||||
rebuilt_items.push(self.item_with_openhome_id(item, existing_id));
|
||||
} else {
|
||||
let metadata = build_metadata_xml(&item);
|
||||
let new_id = self.playlist.insert(previous_id, &item.uri, &metadata)?;
|
||||
previous_id = new_id;
|
||||
rebuilt_ids.push(new_id);
|
||||
rebuilt_items.push(self.item_with_openhome_id(item, new_id));
|
||||
}
|
||||
}
|
||||
|
||||
if remaining_idx != remaining_ids.len() {
|
||||
return Err(anyhow!(
|
||||
"OpenHome playlist refresh bookkeeping mismatch (kept entries overflow)"
|
||||
));
|
||||
}
|
||||
|
||||
let previous_index = self
|
||||
.current_index
|
||||
.and_then(|idx| if idx < rebuilt_ids.len() { Some(idx) } else { None });
|
||||
let normalized = current_index
|
||||
.filter(|&i| i < rebuilt_ids.len())
|
||||
.or(previous_index)
|
||||
.or_else(|| if rebuilt_ids.is_empty() { None } else { Some(0) });
|
||||
self.items = rebuilt_items;
|
||||
self.track_ids = rebuilt_ids;
|
||||
self.current_index = normalized;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn didl_id_from_metadata(xml: &str) -> Option<String> {
|
||||
if xml.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let parsed = pmodidl::parse_metadata::<DIDLLite>(xml).ok()?;
|
||||
parsed.data.items.first().map(|item| item.id.clone())
|
||||
}
|
||||
|
||||
fn build_metadata_xml(item: &PlaybackItem) -> String {
|
||||
let title = item
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.title.as_deref())
|
||||
.unwrap_or("Unknown");
|
||||
let escaped_title = escape(title);
|
||||
let escaped_uri = escape(item.uri.as_str());
|
||||
let escaped_id = escape(item.didl_id.as_str());
|
||||
|
||||
let mut xml = String::from(
|
||||
r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">"#,
|
||||
);
|
||||
xml.push_str(&format!(
|
||||
r#"<item id="{}" parentID="-1" restricted="1">"#,
|
||||
escaped_id
|
||||
));
|
||||
xml.push_str(&format!("<dc:title>{}</dc:title>", escaped_title));
|
||||
|
||||
if let Some(meta) = &item.metadata {
|
||||
if let Some(artist) = meta.artist.as_deref() {
|
||||
let escaped = escape(artist);
|
||||
xml.push_str(&format!("<upnp:artist>{}</upnp:artist>", escaped));
|
||||
xml.push_str(&format!("<dc:creator>{}</dc:creator>", escaped));
|
||||
}
|
||||
if let Some(album) = meta.album.as_deref() {
|
||||
let escaped = escape(album);
|
||||
xml.push_str(&format!("<upnp:album>{}</upnp:album>", escaped));
|
||||
}
|
||||
if let Some(genre) = meta.genre.as_deref() {
|
||||
let escaped = escape(genre);
|
||||
xml.push_str(&format!("<upnp:genre>{}</upnp:genre>", escaped));
|
||||
}
|
||||
if let Some(uri) = meta.album_art_uri.as_deref() {
|
||||
let escaped = escape(uri);
|
||||
xml.push_str(&format!("<upnp:albumArtURI>{}</upnp:albumArtURI>", escaped));
|
||||
}
|
||||
if let Some(date) = meta.date.as_deref() {
|
||||
let escaped = escape(date);
|
||||
xml.push_str(&format!("<dc:date>{}</dc:date>", escaped));
|
||||
}
|
||||
if let Some(track_no) = meta.track_number.as_deref() {
|
||||
let escaped = escape(track_no);
|
||||
xml.push_str(&format!(
|
||||
"<upnp:originalTrackNumber>{}</upnp:originalTrackNumber>",
|
||||
escaped
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let escaped_protocol_info = escape(item.protocol_info.as_str());
|
||||
xml.push_str(&format!(
|
||||
r#"<res protocolInfo="{}">{}</res>"#,
|
||||
escaped_protocol_info, escaped_uri
|
||||
));
|
||||
xml.push_str(r#"<upnp:class>object.item.audioItem.musicTrack</upnp:class></item></DIDL-Lite>"#);
|
||||
xml
|
||||
}
|
||||
|
||||
fn lcs_flags(current: &[PlaybackItem], desired: &[PlaybackItem]) -> (Vec<bool>, Vec<bool>) {
|
||||
let m = current.len();
|
||||
let n = desired.len();
|
||||
let mut dp = vec![vec![0u32; n + 1]; m + 1];
|
||||
|
||||
for i in 0..m {
|
||||
for j in 0..n {
|
||||
if current[i].uri == desired[j].uri {
|
||||
dp[i + 1][j + 1] = dp[i][j] + 1;
|
||||
} else {
|
||||
dp[i + 1][j + 1] = dp[i + 1][j].max(dp[i][j + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut keep_current = vec![false; m];
|
||||
let mut keep_desired = vec![false; n];
|
||||
let (mut i, mut j) = (m, n);
|
||||
|
||||
while i > 0 && j > 0 {
|
||||
if current[i - 1].uri == desired[j - 1].uri {
|
||||
keep_current[i - 1] = true;
|
||||
keep_desired[j - 1] = true;
|
||||
i -= 1;
|
||||
j -= 1;
|
||||
} else if dp[i - 1][j] >= dp[i][j - 1] {
|
||||
i -= 1;
|
||||
} else {
|
||||
j -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
(keep_current, keep_desired)
|
||||
}
|
||||
|
||||
impl QueueBackend for OpenHomeQueue {
|
||||
fn queue_snapshot(&self) -> Result<QueueSnapshot> {
|
||||
Ok(QueueSnapshot {
|
||||
items: self.items.clone(),
|
||||
current_index: self.current_index,
|
||||
})
|
||||
}
|
||||
|
||||
fn set_index(&mut self, index: Option<usize>) -> Result<()> {
|
||||
let normalized = index.filter(|&i| i < self.items.len());
|
||||
if let Some(idx) = normalized {
|
||||
let track_id = self.ensure_track_id(idx)?;
|
||||
self.ensure_playlist_source_selected()?;
|
||||
self.playlist.play_id(track_id)?;
|
||||
}
|
||||
self.current_index = normalized;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
current_index: Option<usize>,
|
||||
) -> Result<()> {
|
||||
self.ensure_playlist_source_selected()?;
|
||||
if items.is_empty() {
|
||||
self.playlist.delete_all()?;
|
||||
self.items.clear();
|
||||
self.track_ids.clear();
|
||||
self.current_index = None;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Synchronize local state with the actual OpenHome playlist before computing
|
||||
// differences. Without this, any drift between our cache and the renderer
|
||||
// (e.g., manual edits from another control point) would keep the stale items.
|
||||
self.refresh_from_openhome()?;
|
||||
|
||||
// Try to get the currently playing track ID from the renderer.
|
||||
// Note: Some OpenHome renderers (like upmpdcli) don't reliably support Info.Id(),
|
||||
// so we fall back to using our internal current_index pointer.
|
||||
let currently_playing_id_from_renderer = self
|
||||
.playlist.id().ok();
|
||||
|
||||
// Find the currently playing item in our local state.
|
||||
// Priority: 1) Renderer-reported ID, 2) Our internal current_index
|
||||
let playing_info = if let Some(id) = currently_playing_id_from_renderer {
|
||||
// CASE: Renderer explicitly reported the playing track ID
|
||||
self.track_ids
|
||||
.iter()
|
||||
.position(|&tid| tid == id)
|
||||
.map(|idx| (idx, id, self.items[idx].uri.clone()))
|
||||
} else if let Some(idx) = self.current_index {
|
||||
// CASE: Use our internal pointer (fallback for renderers without Info.Id() support)
|
||||
if idx < self.track_ids.len() && idx < self.items.len() {
|
||||
let id = self.track_ids[idx];
|
||||
let uri = self.items[idx].uri.clone();
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
current_index = idx,
|
||||
track_id = id,
|
||||
"Using internal current_index as fallback (renderer didn't report playing ID)"
|
||||
);
|
||||
Some((idx, id, uri))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
actual_items = self.items.len(),
|
||||
currently_playing_id_from_renderer = ?currently_playing_id_from_renderer,
|
||||
playing_info_detected = playing_info.is_some(),
|
||||
"OpenHome playlist state refreshed before replace_queue"
|
||||
);
|
||||
|
||||
if let Some((playing_idx, playing_id, playing_uri)) = playing_info {
|
||||
// Find if the currently playing item is in the new playlist (by URI)
|
||||
let new_playing_idx = items.iter().position(|item| item.uri == playing_uri);
|
||||
|
||||
if let Some(pivot_idx) = new_playing_idx {
|
||||
// CASE 2: Currently playing item IS in the new playlist
|
||||
// Use gentle double-LCS strategy: preserve the pivot and sync before/after separately
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
playing_idx,
|
||||
pivot_idx,
|
||||
"Gentle sync: currently playing item found in new playlist at index {}",
|
||||
pivot_idx
|
||||
);
|
||||
|
||||
self.replace_queue_with_pivot(items, pivot_idx, playing_id)?;
|
||||
} else {
|
||||
// CASE 1: Currently playing item NOT in the new playlist
|
||||
// Keep it as first item and append the new playlist after it
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
playing_idx,
|
||||
"Gentle sync: currently playing item not in new playlist, preserving as first item"
|
||||
);
|
||||
|
||||
self.replace_queue_preserve_current(items, playing_idx, playing_id)?;
|
||||
}
|
||||
} else {
|
||||
// No currently playing item or can't determine it - use standard LCS
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
"No currently playing item, using standard LCS sync"
|
||||
);
|
||||
self.replace_queue_standard_lcs(items, current_index)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>> {
|
||||
Ok(self.items.get(index).cloned())
|
||||
}
|
||||
|
||||
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<()> {
|
||||
if index >= self.items.len() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.ensure_playlist_source_selected()?;
|
||||
let track_id = self.ensure_track_id(index)?;
|
||||
let before_id = if index == 0 {
|
||||
OPENHOME_PLAYLIST_HEAD_ID
|
||||
} else {
|
||||
self.ensure_track_id(index - 1)?
|
||||
};
|
||||
|
||||
// Use delete_id_if_exists() to handle cases where another control point
|
||||
// may have already modified the playlist
|
||||
self.playlist.delete_id_if_exists(track_id)?;
|
||||
let metadata = build_metadata_xml(&item);
|
||||
let new_id = self.playlist.insert(before_id, &item.uri, &metadata)?;
|
||||
|
||||
if self.current_index == Some(index) {
|
||||
self.playlist.play_id(new_id)?;
|
||||
}
|
||||
|
||||
self.items[index] = self.item_with_openhome_id(item, new_id);
|
||||
self.track_ids[index] = new_id;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
# Corrections à appliquer à control_point.rs
|
||||
|
||||
## Changements effectués avec succès :
|
||||
|
||||
1. ✅ Thread de découverte UPNP : utilise maintenant `UpnpDiscoveryManager`
|
||||
2. ✅ Boucle de polling : utilise `Arc<MusicRenderer>` directement avec traits
|
||||
3. ✅ Suppression du thread OpenHome event forwarder
|
||||
|
||||
## Corrections restantes à faire manuellement :
|
||||
|
||||
### 1. Thread de vérification des timeouts (ligne ~173)
|
||||
|
||||
**Remplacer :**
|
||||
```rust
|
||||
let registry_for_presence = Arc::clone(®istry);
|
||||
let event_bus_for_presence = event_bus.clone();
|
||||
let media_event_bus_for_presence = media_event_bus.clone();
|
||||
thread::spawn(move || {
|
||||
use ureq::Agent;
|
||||
// ... tout le code de vérification HTTP manuelle ...
|
||||
});
|
||||
```
|
||||
|
||||
**Par :**
|
||||
```rust
|
||||
let registry_for_timeout = Arc::clone(®istry);
|
||||
|
||||
thread::spawn(move || {
|
||||
loop {
|
||||
thread::sleep(Duration::from_secs(60));
|
||||
|
||||
// Le registry vérifie les timeouts et émet automatiquement les événements Offline
|
||||
if let Ok(mut reg) = registry_for_timeout.write() {
|
||||
reg.check_timeouts();
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Thread Chromecast mDNS (à ajouter après le thread de timeout)
|
||||
|
||||
**Ajouter :**
|
||||
```rust
|
||||
// Thread de découverte mDNS pour Chromecast
|
||||
let registry_for_mdns = Arc::clone(®istry);
|
||||
let udn_cache_for_mdns = Arc::clone(&udn_cache);
|
||||
thread::spawn(move || {
|
||||
use crate::discovery::ChromecastDiscoveryManager;
|
||||
use futures_util::StreamExt;
|
||||
|
||||
let mut discovery_manager =
|
||||
ChromecastDiscoveryManager::new(registry_for_mdns, udn_cache_for_mdns);
|
||||
|
||||
debug!("Starting mDNS discovery thread for Chromecast devices");
|
||||
|
||||
const SERVICE_NAME: &str = "_googlecast._tcp.local";
|
||||
|
||||
async_std::task::block_on(async {
|
||||
match mdns::discover::all(SERVICE_NAME, Duration::from_secs(15)) {
|
||||
Ok(discovery) => {
|
||||
let stream = discovery.listen();
|
||||
futures_util::pin_mut!(stream);
|
||||
|
||||
debug!("mDNS discovery stream started for Chromecast devices");
|
||||
|
||||
while let Some(result) = stream.next().await {
|
||||
match result {
|
||||
Ok(response) => {
|
||||
debug!(
|
||||
"Received mDNS response with {} records",
|
||||
response.records().count()
|
||||
);
|
||||
discovery_manager.handle_mdns_response(response);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("mDNS discovery error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
warn!("mDNS discovery stream ended unexpectedly");
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to start mDNS discovery: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Correction de la boucle de polling (ligne ~200)
|
||||
|
||||
**Le type doit être :**
|
||||
```rust
|
||||
let renderers: Vec<Arc<MusicRenderer>> = infos // Pas Vec<MusicRenderer>
|
||||
```
|
||||
|
||||
### 4. Retour du constructeur (fin de fonction)
|
||||
|
||||
**Remplacer :**
|
||||
```rust
|
||||
Ok(Self {
|
||||
registry,
|
||||
event_bus,
|
||||
media_event_bus,
|
||||
runtime,
|
||||
})
|
||||
```
|
||||
|
||||
**Par :**
|
||||
```rust
|
||||
Ok(Self {
|
||||
registry,
|
||||
udn_cache,
|
||||
event_bus,
|
||||
media_event_bus,
|
||||
runtime,
|
||||
})
|
||||
```
|
||||
|
||||
## Suppressions à faire :
|
||||
|
||||
- [ ] Supprimer toutes les fonctions OpenHome (à partir de `spawn_openhome_event_runtime`)
|
||||
- [ ] Supprimer `OpenHomeAccessError` enum
|
||||
- [ ] Supprimer `OPENHOME_SNAPSHOT_CACHE_TTL` constante
|
||||
- [ ] Nettoyer les imports obsolètes en haut du fichier
|
||||
|
||||
## Note importante :
|
||||
|
||||
Le code ne compilera pas tant que tous les imports obsolètes ne seront pas nettoyés, mais la structure sera correcte.
|
||||
228
pmocontrol/src/discovery.rs
Normal file
228
pmocontrol/src/discovery.rs
Normal file
@@ -0,0 +1,228 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use pmoupnp::ssdp::SsdpEvent;
|
||||
|
||||
use crate::media_server::MediaServerInfo;
|
||||
use crate::model::RendererInfo;
|
||||
use crate::registry::DeviceUpdate;
|
||||
|
||||
/// État connu pour un endpoint UPnP identifié par son UDN.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DiscoveredEndpoint {
|
||||
/// UDN normalisé (ex: "uuid:xxxx", en minuscules).
|
||||
pub udn: String,
|
||||
/// Dernière URL de description device (LOCATION SSDP).
|
||||
pub location: String,
|
||||
/// Dernier header SERVER vu sur cet endpoint.
|
||||
pub server_header: String,
|
||||
/// Dernier max-age indiqué (TTL SSDP).
|
||||
pub max_age: u32,
|
||||
/// Date de dernière vue (Now lors du dernier Alive ou SearchResponse).
|
||||
pub last_seen: SystemTime,
|
||||
/// Indique si on a vu ce endpoint comme MediaRenderer.
|
||||
pub seen_as_renderer: bool,
|
||||
/// Indique si on a vu ce endpoint comme MediaServer.
|
||||
pub seen_as_server: bool,
|
||||
/// ST/NT vus (pour debug/diagnostic si utile).
|
||||
pub types_seen: HashSet<String>,
|
||||
}
|
||||
|
||||
impl DiscoveredEndpoint {
|
||||
pub fn new(udn: String, location: String, server_header: String, max_age: u32) -> Self {
|
||||
Self {
|
||||
udn,
|
||||
location,
|
||||
server_header,
|
||||
max_age,
|
||||
last_seen: SystemTime::now(),
|
||||
seen_as_renderer: false,
|
||||
seen_as_server: false,
|
||||
types_seen: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn touch(&mut self, location: String, server_header: String, max_age: u32) {
|
||||
self.location = location;
|
||||
self.server_header = server_header;
|
||||
self.max_age = max_age;
|
||||
self.last_seen = SystemTime::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// Fournit les descriptions haut niveau à partir d’un endpoint découvert.
|
||||
/// L’implémentation pourra, plus tard, faire un HTTP GET sur `location`
|
||||
/// et parser la description pour remplir RendererInfo / MediaServerInfo.
|
||||
pub trait DeviceDescriptionProvider: Send + Sync {
|
||||
/// Construit un RendererInfo pour cet endpoint, ou None s’il
|
||||
/// ne correspond pas à un renderer audio intéressant.
|
||||
fn build_renderer_info(&self, endpoint: &DiscoveredEndpoint) -> Option<RendererInfo>;
|
||||
|
||||
/// Construit un MediaServerInfo pour cet endpoint, ou None s’il
|
||||
/// ne correspond pas à un media server (ou pas intéressant).
|
||||
fn build_server_info(&self, endpoint: &DiscoveredEndpoint) -> Option<MediaServerInfo>;
|
||||
}
|
||||
|
||||
/// Gestionnaire des événements SSDP -> DeviceUpdate.
|
||||
pub struct DiscoveryManager<P>
|
||||
where
|
||||
P: DeviceDescriptionProvider,
|
||||
{
|
||||
endpoints: HashMap<String, DiscoveredEndpoint>,
|
||||
provider: P,
|
||||
}
|
||||
|
||||
impl<P> DiscoveryManager<P>
|
||||
where
|
||||
P: DeviceDescriptionProvider,
|
||||
{
|
||||
pub fn new(provider: P) -> Self {
|
||||
Self {
|
||||
endpoints: HashMap::new(),
|
||||
provider,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_ssdp_event(&mut self, event: SsdpEvent) -> Vec<DeviceUpdate> {
|
||||
let mut updates = Vec::new();
|
||||
|
||||
match event {
|
||||
SsdpEvent::Alive {
|
||||
usn,
|
||||
nt,
|
||||
location,
|
||||
server,
|
||||
max_age,
|
||||
..
|
||||
} => {
|
||||
if let Some(udn) = extract_udn_from_usn(&usn) {
|
||||
self.handle_alive(udn, nt, location, server, max_age, &mut updates);
|
||||
}
|
||||
}
|
||||
SsdpEvent::SearchResponse {
|
||||
usn,
|
||||
st,
|
||||
location,
|
||||
server,
|
||||
max_age,
|
||||
..
|
||||
} => {
|
||||
if let Some(udn) = extract_udn_from_usn(&usn) {
|
||||
self.handle_search_response(udn, st, location, server, max_age, &mut updates);
|
||||
}
|
||||
}
|
||||
SsdpEvent::ByeBye { usn, nt, .. } => {
|
||||
if let Some(udn) = extract_udn_from_usn(&usn) {
|
||||
self.handle_byebye(udn, nt, &mut updates);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updates
|
||||
}
|
||||
|
||||
fn handle_alive(
|
||||
&mut self,
|
||||
udn: String,
|
||||
nt: String,
|
||||
location: String,
|
||||
server_header: String,
|
||||
max_age: u32,
|
||||
updates: &mut Vec<DeviceUpdate>,
|
||||
) {
|
||||
self.update_endpoint(udn, nt, location, server_header, max_age, updates);
|
||||
}
|
||||
|
||||
fn handle_search_response(
|
||||
&mut self,
|
||||
udn: String,
|
||||
st: String,
|
||||
location: String,
|
||||
server_header: String,
|
||||
max_age: u32,
|
||||
updates: &mut Vec<DeviceUpdate>,
|
||||
) {
|
||||
self.update_endpoint(udn, st, location, server_header, max_age, updates);
|
||||
}
|
||||
|
||||
fn handle_byebye(&mut self, udn: String, _nt: String, updates: &mut Vec<DeviceUpdate>) {
|
||||
if let Some(endpoint) = self.endpoints.get(&udn) {
|
||||
if endpoint.seen_as_renderer {
|
||||
updates.push(DeviceUpdate::RendererOfflineByUdn(udn.clone()));
|
||||
}
|
||||
if endpoint.seen_as_server {
|
||||
updates.push(DeviceUpdate::ServerOfflineByUdn(udn));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_endpoint(
|
||||
&mut self,
|
||||
udn: String,
|
||||
device_type: String,
|
||||
location: String,
|
||||
server_header: String,
|
||||
max_age: u32,
|
||||
updates: &mut Vec<DeviceUpdate>,
|
||||
) {
|
||||
tracing::debug!(
|
||||
"SSDP update: udn={} type={} location={} max_age={}",
|
||||
udn,
|
||||
device_type,
|
||||
location,
|
||||
max_age
|
||||
);
|
||||
|
||||
let endpoint = self.endpoints.entry(udn.clone()).or_insert_with({
|
||||
let udn = udn.clone();
|
||||
let location = location.clone();
|
||||
let server_header = server_header.clone();
|
||||
move || DiscoveredEndpoint::new(udn, location, server_header, max_age)
|
||||
});
|
||||
|
||||
endpoint.touch(location, server_header, max_age);
|
||||
endpoint.types_seen.insert(device_type);
|
||||
|
||||
// Toujours tenter de classifier et envoyer un événement Online pour maintenir
|
||||
// l'état à jour dans le registre (important pour les serveurs qui ré-apparaissent)
|
||||
if let Some(info) = self.provider.build_renderer_info(endpoint) {
|
||||
if !endpoint.seen_as_renderer {
|
||||
tracing::debug!(
|
||||
"Renderer classified: udn={} friendly_name={} model={}",
|
||||
info.udn,
|
||||
info.friendly_name,
|
||||
info.model_name
|
||||
);
|
||||
endpoint.seen_as_renderer = true;
|
||||
}
|
||||
updates.push(DeviceUpdate::RendererOnline(info));
|
||||
}
|
||||
|
||||
if let Some(info) = self.provider.build_server_info(endpoint) {
|
||||
if !endpoint.seen_as_server {
|
||||
tracing::debug!(
|
||||
"Server classified: udn={} friendly_name={} model={}",
|
||||
info.udn,
|
||||
info.friendly_name,
|
||||
info.model_name
|
||||
);
|
||||
endpoint.seen_as_server = true;
|
||||
}
|
||||
updates.push(DeviceUpdate::ServerOnline(info));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_udn_from_usn(usn: &str) -> Option<String> {
|
||||
let lower = usn.trim().to_ascii_lowercase();
|
||||
if let Some(idx) = lower.find("uuid:") {
|
||||
let sub = &lower[idx..];
|
||||
if let Some(end) = sub.find("::") {
|
||||
Some(sub[..end].to_string())
|
||||
} else {
|
||||
Some(sub.to_string())
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Mutex, OnceLock},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use tracing::debug;
|
||||
|
||||
use crate::{
|
||||
arylic_client::{ARYLIC_TCP_PORT, send_command_required}, errors::ControlPointError, linkplay_client::extract_linkplay_host
|
||||
};
|
||||
|
||||
static DETECTION_CACHE: OnceLock<Mutex<HashMap<String, bool>>> = OnceLock::new();
|
||||
|
||||
fn detection_cache() -> &'static Mutex<HashMap<String, bool>> {
|
||||
DETECTION_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// Probe whether the renderer at the given location exposes the Arylic TCP API.
|
||||
pub(crate) fn detect_arylic_tcp(location: &str, timeout: Duration) -> bool {
|
||||
let Some(host) = extract_linkplay_host(location) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if let Ok(cache) = detection_cache().lock() {
|
||||
if let Some(result) = cache.get(&host) {
|
||||
return *result;
|
||||
}
|
||||
}
|
||||
|
||||
let detected = match try_detect_tcp(&host, timeout) {
|
||||
Ok(_) => true,
|
||||
Err(err) => {
|
||||
debug!(
|
||||
"Arylic TCP detection failed for {} (host={}): {}",
|
||||
location, host, err
|
||||
);
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if let Ok(mut cache) = detection_cache().lock() {
|
||||
cache.insert(host.to_string(), detected);
|
||||
}
|
||||
|
||||
detected
|
||||
}
|
||||
|
||||
fn try_detect_tcp(host: &str, timeout: Duration) -> Result<(), ControlPointError> {
|
||||
let payload = send_command_required(
|
||||
host,
|
||||
ARYLIC_TCP_PORT,
|
||||
timeout,
|
||||
"MCU+INF+GET",
|
||||
&["AXX+INF+", "AXX+DEV+"],
|
||||
)?;
|
||||
|
||||
if payload.starts_with("AXX+INF+") || payload.starts_with("AXX+DEV+") {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ControlPointError::ArilycTcpError(format!(
|
||||
"Unexpected INF response from {}: {}",
|
||||
host, payload
|
||||
)))
|
||||
}
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
//! Chromecast device discovery via mDNS.
|
||||
//!
|
||||
//! Chromecast devices advertise themselves using mDNS (Multicast DNS) on the
|
||||
//! `_googlecast._tcp.local` service, unlike UPnP devices which use SSDP.
|
||||
//! This module handles the discovery of Chromecast devices and registers them
|
||||
//! directly into the `DeviceRegistry`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
use crate::DeviceId;
|
||||
use crate::DeviceRegistry;
|
||||
use crate::discovery::manager::UDNRegistry;
|
||||
use crate::model::{RendererCapabilities, RendererInfo, RendererProtocol};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
/// Gestionnaire des événements mDNS pour Chromecast.
|
||||
pub struct ChromecastDiscoveryManager {
|
||||
device_registry: Arc<RwLock<DeviceRegistry>>,
|
||||
udn_cache: Arc<Mutex<UDNRegistry>>,
|
||||
}
|
||||
|
||||
impl ChromecastDiscoveryManager {
|
||||
pub fn new(
|
||||
device_registry: Arc<RwLock<DeviceRegistry>>,
|
||||
udn_cache: Arc<Mutex<UDNRegistry>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
device_registry,
|
||||
udn_cache,
|
||||
}
|
||||
}
|
||||
|
||||
/// Traite une réponse mDNS pour un appareil Chromecast.
|
||||
///
|
||||
/// Cette fonction parse les réponses de service discovery mDNS pour les appareils
|
||||
/// Chromecast et les enregistre directement dans le registre.
|
||||
pub fn handle_mdns_response(&mut self, response: mdns::Response) {
|
||||
// Extract basic information from the mDNS response
|
||||
let service_name = match response.records().find_map(|r| {
|
||||
if let mdns::RecordKind::PTR(ref name) = r.kind {
|
||||
Some(name.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}) {
|
||||
Some(name) => name,
|
||||
None => {
|
||||
warn!("No PTR record found in mDNS response");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
debug!("Processing mDNS response for service: {}", service_name);
|
||||
|
||||
// Extract IP addresses
|
||||
let addresses: Vec<IpAddr> = response
|
||||
.records()
|
||||
.filter_map(|r| match r.kind {
|
||||
mdns::RecordKind::A(addr) => Some(IpAddr::V4(addr)),
|
||||
mdns::RecordKind::AAAA(addr) => Some(IpAddr::V6(addr)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
if addresses.is_empty() {
|
||||
warn!("No IP address found for Chromecast device: {}", service_name);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prefer IPv4 addresses
|
||||
let host = match addresses
|
||||
.iter()
|
||||
.find(|addr| matches!(addr, IpAddr::V4(_)))
|
||||
.or_else(|| addresses.first())
|
||||
{
|
||||
Some(addr) => addr.to_string(),
|
||||
None => {
|
||||
warn!("Could not extract host from addresses");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Extract port from SRV record
|
||||
let port = response
|
||||
.records()
|
||||
.find_map(|r| {
|
||||
if let mdns::RecordKind::SRV { port, .. } = r.kind {
|
||||
Some(port)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or(8009); // Default Chromecast port
|
||||
|
||||
// Extract TXT records for additional metadata
|
||||
let txt_records: HashMap<String, String> = response
|
||||
.records()
|
||||
.filter_map(|r| {
|
||||
if let mdns::RecordKind::TXT(ref data) = r.kind {
|
||||
Some(data.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.flat_map(|data| {
|
||||
// data is Vec<String>, each string is "key=value"
|
||||
data.into_iter().filter_map(|s| {
|
||||
let parts: Vec<&str> = s.splitn(2, '=').collect();
|
||||
if parts.len() == 2 {
|
||||
Some((parts[0].to_string(), parts[1].to_string()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Extract metadata from TXT records
|
||||
let model = txt_records.get("md").cloned();
|
||||
let uuid = txt_records
|
||||
.get("id")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("chromecast-{}-{}", host, port));
|
||||
let manufacturer = Some("Google Inc.".to_string());
|
||||
|
||||
// Extract friendly name from TXT record "fn" if available
|
||||
// Otherwise, extract from service instance name (PTR record)
|
||||
let friendly_name = txt_records
|
||||
.get("fn")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| {
|
||||
// Fallback: extract from service name, removing the UUID suffix if present
|
||||
service_name
|
||||
.split("._googlecast._tcp.local")
|
||||
.next()
|
||||
.unwrap_or("Unknown Chromecast")
|
||||
.split('-')
|
||||
.take_while(|part| part.len() != 32) // Skip 32-char hex UUID
|
||||
.collect::<Vec<_>>()
|
||||
.join("-")
|
||||
.trim()
|
||||
.to_string()
|
||||
});
|
||||
|
||||
debug!(
|
||||
"Discovered Chromecast: {} at {}:{} (UUID: {}, Model: {:?})",
|
||||
friendly_name, host, port, uuid, model
|
||||
);
|
||||
|
||||
// Build UDN and check cache
|
||||
let udn = format!("uuid:{}", uuid);
|
||||
|
||||
// Pour Chromecast, on utilise un max_age par défaut car mDNS n'a pas ce concept
|
||||
let default_max_age = 1800u64; // 30 minutes
|
||||
|
||||
// Check cache to avoid redundant updates
|
||||
if !UDNRegistry::should_fetch(self.udn_cache.clone(), &udn, default_max_age) {
|
||||
debug!("Chromecast {} recently seen, skipping", udn);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create RendererInfo for the registry
|
||||
let renderer_info = build_renderer_info(
|
||||
&uuid,
|
||||
&friendly_name,
|
||||
&host,
|
||||
port,
|
||||
model.as_deref(),
|
||||
manufacturer.as_deref(),
|
||||
);
|
||||
|
||||
// Register the renderer
|
||||
self.device_registry
|
||||
.write()
|
||||
.expect("DeviceRegistry mutex lock failed")
|
||||
.push_renderer(&renderer_info, default_max_age as u32);
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a `RendererInfo` structure for a Chromecast device.
|
||||
fn build_renderer_info(
|
||||
uuid: &str,
|
||||
friendly_name: &str,
|
||||
host: &str,
|
||||
port: u16,
|
||||
model: Option<&str>,
|
||||
manufacturer: Option<&str>,
|
||||
) -> RendererInfo {
|
||||
let udn = format!("uuid:{}", uuid);
|
||||
let id = DeviceId(udn.clone());
|
||||
|
||||
// Build Chromecast capabilities
|
||||
let mut capabilities = RendererCapabilities::default();
|
||||
capabilities.has_chromecast = true;
|
||||
|
||||
// The location URL for Chromecast is just the host:port
|
||||
// (not a real HTTP endpoint like UPnP, but useful for identification)
|
||||
let location = format!("chromecast://{}:{}", host, port);
|
||||
|
||||
RendererInfo::make(
|
||||
id,
|
||||
udn,
|
||||
friendly_name.to_string(),
|
||||
model.unwrap_or("Chromecast").to_string(),
|
||||
manufacturer.unwrap_or("Google Inc.").to_string(),
|
||||
RendererProtocol::ChromecastOnly,
|
||||
capabilities,
|
||||
location,
|
||||
"Chromecast".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// Extracts the host (IP address) from a Chromecast location URL.
|
||||
///
|
||||
/// The location format is `chromecast://host:port`.
|
||||
pub fn extract_host_from_location(location: &str) -> Option<String> {
|
||||
if let Some(stripped) = location.strip_prefix("chromecast://") {
|
||||
let host = stripped.split(':').next()?;
|
||||
Some(host.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts the port from a Chromecast location URL.
|
||||
///
|
||||
/// The location format is `chromecast://host:port`.
|
||||
pub fn extract_port_from_location(location: &str) -> Option<u16> {
|
||||
if let Some(stripped) = location.strip_prefix("chromecast://") {
|
||||
let port_str = stripped.split(':').nth(1)?;
|
||||
port_str.parse().ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_extract_host_from_location() {
|
||||
assert_eq!(
|
||||
extract_host_from_location("chromecast://192.168.1.100:8009"),
|
||||
Some("192.168.1.100".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
extract_host_from_location("http://192.168.1.100:8009"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_port_from_location() {
|
||||
assert_eq!(
|
||||
extract_port_from_location("chromecast://192.168.1.100:8009"),
|
||||
Some(8009)
|
||||
);
|
||||
assert_eq!(
|
||||
extract_port_from_location("chromecast://192.168.1.100"),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, Mutex},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
struct UDNSeen {
|
||||
max_age: u64,
|
||||
last_seen: Instant,
|
||||
}
|
||||
|
||||
pub struct UDNRegistry {
|
||||
seen: HashMap<String, UDNSeen>,
|
||||
}
|
||||
|
||||
impl UDNRegistry {
|
||||
pub fn new() -> Self {
|
||||
UDNRegistry {
|
||||
seen: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if we should fetch/process this UDN (either first time or more than half max_age elapsed)
|
||||
pub fn should_fetch(registry: Arc<Mutex<UDNRegistry>>, udn: &str, max_age: u64) -> bool {
|
||||
let now = Instant::now();
|
||||
let mut r = registry.lock().expect("UDNRegistry mutex lock failed");
|
||||
if let Some(seen) = r.seen.get_mut(udn) {
|
||||
// If more than half the max_age has elapsed, we should fetch/process again
|
||||
if now.duration_since(seen.last_seen).as_secs() > max_age / 2 {
|
||||
seen.last_seen = now;
|
||||
true
|
||||
} else {
|
||||
// Too soon, skip this SSDP event
|
||||
false
|
||||
}
|
||||
} else {
|
||||
// First time seeing this UDN, insert and fetch
|
||||
r.seen.insert(
|
||||
udn.to_string(),
|
||||
UDNSeen {
|
||||
max_age,
|
||||
last_seen: now,
|
||||
},
|
||||
);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
pub mod arylic;
|
||||
pub mod chromecast_discovery;
|
||||
pub mod manager;
|
||||
pub mod upnp_discovery;
|
||||
pub mod upnp_provider;
|
||||
|
||||
pub use chromecast_discovery::ChromecastDiscoveryManager;
|
||||
pub use upnp_discovery::UpnpDiscoveryManager;
|
||||
@@ -1,84 +0,0 @@
|
||||
use crate::{DeviceRegistry, discovery::upnp_provider::ParsedDeviceDescription};
|
||||
use pmoupnp::ssdp::SsdpEvent;
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
use crate::discovery::manager::UDNRegistry;
|
||||
|
||||
/// Gestionnaire des événements SSDP -> DeviceUpdate.
|
||||
|
||||
pub struct UpnpDiscoveryManager {
|
||||
device_registry: Arc<RwLock<DeviceRegistry>>,
|
||||
udn_cache: Arc<Mutex<UDNRegistry>>,
|
||||
}
|
||||
|
||||
impl UpnpDiscoveryManager {
|
||||
pub fn new(device_registry: Arc<RwLock<DeviceRegistry>>,
|
||||
udn_cache: Arc<Mutex<UDNRegistry>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
device_registry,
|
||||
udn_cache,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_ssdp_event(&mut self, event: SsdpEvent) {
|
||||
let (alive, usn, location, max_age, server_header) = match event {
|
||||
SsdpEvent::Alive {
|
||||
usn,
|
||||
location,
|
||||
max_age,
|
||||
server,
|
||||
..
|
||||
}
|
||||
| SsdpEvent::SearchResponse {
|
||||
usn,
|
||||
location,
|
||||
max_age,
|
||||
server,
|
||||
..
|
||||
} => (true, usn, location, max_age, server),
|
||||
SsdpEvent::ByeBye { usn, .. } => (false, usn, "".to_string(), 0, "".to_string()),
|
||||
};
|
||||
|
||||
if let Some(udn) = extract_udn_from_usn(&usn) {
|
||||
if alive {
|
||||
// ✅ Check cache
|
||||
if UDNRegistry::should_fetch(self.udn_cache.clone(), &udn, max_age as u64) {
|
||||
// ✅ Fetch + parse
|
||||
if let Ok(info) = ParsedDeviceDescription::new(&udn, &location, &server_header,5) {
|
||||
if let Some(renderer_info) = info.build_renderer() {
|
||||
if let Ok(mut reg) = self.device_registry.write() {
|
||||
reg.push_renderer(&renderer_info, max_age);
|
||||
}
|
||||
} else if let Some(server_info) = info.build_server() {
|
||||
if let Ok(mut reg) = self.device_registry.write() {
|
||||
reg.push_server(&server_info, max_age);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if let Ok(mut reg) = self.device_registry.write() {
|
||||
reg.device_says_byebye(&udn);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
fn extract_udn_from_usn(usn: &str) -> Option<String> {
|
||||
let lower = usn.trim().to_ascii_lowercase();
|
||||
if let Some(idx) = lower.find("uuid:") {
|
||||
let sub = &lower[idx..];
|
||||
if let Some(end) = sub.find("::") {
|
||||
Some(sub[..end].to_string())
|
||||
} else {
|
||||
Some(sub.to_string())
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum ControlPointError {
|
||||
// Utiliser par les DeviceItems dans leur conversion vers un media renderer
|
||||
#[error("{0} is not a MediaRenderer")]
|
||||
IsNotAMediaRender(String),
|
||||
#[error("{0} is not a MediaServer")]
|
||||
IsNotAMediaServer(String),
|
||||
#[error("Cannot build MusicRendererBackend for {0}")]
|
||||
MusicRendererBackendBuild(String),
|
||||
#[error("{0}")]
|
||||
ParsingError(String),
|
||||
#[error("OpenHome Error: {0}")]
|
||||
OpenHomeError(String),
|
||||
#[error("OpenHome Product source list does not expose a Playlist entry")]
|
||||
OpenHomeNoPlaylistEntry(),
|
||||
#[error("MusicRendererBackend {0} exposes no OpenHome services")]
|
||||
OpenHomeNotAValidDevice(String),
|
||||
#[error("UpnpError Error: {0}")]
|
||||
UpnpError(String),
|
||||
#[error("MusicRenderer operation '{0}' is not supported by backend '{1}'")]
|
||||
UpnpOperationNotSupported(String, String),
|
||||
#[error("Missing {0} element in SOAP body")]
|
||||
UpnpMissingReturnValue(String),
|
||||
#[error("Invalid {0} value: {1}")]
|
||||
UpnpBadReturnValue(String, String),
|
||||
#[error("Soap Error: Upnp action call {0}")]
|
||||
SoapAction(String),
|
||||
#[error("{0} returned UPnP error {1}: {2} (HTTP status {3})")]
|
||||
SoapUpnpParseError(String, u32, String, u32),
|
||||
#[error("{0} failed with HTTP status {1} and body: {2}")]
|
||||
SoapActionWrongBody(String, u32, String),
|
||||
#[error("Soap Error: No envelop for action {0}")]
|
||||
SoapNoEnvelop(String),
|
||||
#[error("ArilycTcp Error: {0}")]
|
||||
ArilycTcpError(String),
|
||||
#[error("LinkPlay Error: {0}")]
|
||||
LinkPlayError(String),
|
||||
#[error("Chromecast Error: {0}")]
|
||||
ChromecastError(String),
|
||||
#[error("MediaServer Error: {0}")]
|
||||
MediaServerError(String),
|
||||
#[error("Queue Error: {0}")]
|
||||
QueueError(String),
|
||||
#[error("Invalid time format: {0}")]
|
||||
InvalidTimeFormat(String),
|
||||
#[error("Error on snapshot: {0}")]
|
||||
SnapshotError(String),
|
||||
#[error("Error on ControlPoint: {0}")]
|
||||
ControlPoint(String),
|
||||
}
|
||||
|
||||
impl ControlPointError {
|
||||
pub fn upnp_operation_not_supported(operation: &str, service: &str) -> Self {
|
||||
ControlPointError::UpnpOperationNotSupported(operation.to_string(), service.to_string())
|
||||
}
|
||||
|
||||
pub fn upnp_missing_return_value(value: &str) -> Self {
|
||||
ControlPointError::UpnpMissingReturnValue(value.to_string())
|
||||
}
|
||||
|
||||
pub fn upnp_bad_return_value(name: &str, value: &str) -> Self {
|
||||
ControlPointError::UpnpBadReturnValue(name.to_string(), value.to_string())
|
||||
}
|
||||
|
||||
pub fn arilyc_tcp_error(message: &str) -> Self {
|
||||
ControlPointError::ArilycTcpError(message.to_string())
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
use crate::DeviceId;
|
||||
use crate::model::DeviceBasicInfo;
|
||||
|
||||
pub trait DeviceIdentity {
|
||||
fn id(&self) -> DeviceId;
|
||||
fn udn(&self) -> &str;
|
||||
fn friendly_name(&self) -> &str;
|
||||
fn model_name(&self) -> &str;
|
||||
fn manufacturer(&self) -> &str;
|
||||
fn location(&self) -> &str;
|
||||
fn server_header(&self) -> &str;
|
||||
|
||||
fn is_a_media_server(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn is_a_music_renderer(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Returns basic device information suitable for event notifications
|
||||
fn basic_info(&self) -> DeviceBasicInfo {
|
||||
DeviceBasicInfo {
|
||||
id: self.id(),
|
||||
friendly_name: self.friendly_name().to_string(),
|
||||
model_name: self.model_name().to_string(),
|
||||
manufacturer: self.manufacturer().to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,29 @@
|
||||
mod events;
|
||||
mod media_server_events;
|
||||
|
||||
pub mod arylic_client;
|
||||
pub mod arylic_tcp;
|
||||
pub mod avtransport_client;
|
||||
pub mod capabilities;
|
||||
pub mod chromecast_discovery;
|
||||
pub mod chromecast_renderer;
|
||||
pub mod connection_manager_client;
|
||||
pub mod control_point;
|
||||
pub mod discovery;
|
||||
pub mod errors;
|
||||
pub mod identity;
|
||||
pub mod linkplay_client;
|
||||
pub mod linkplay_utils;
|
||||
pub mod linkplay;
|
||||
pub mod media_server;
|
||||
pub mod model;
|
||||
pub mod music_renderer;
|
||||
pub mod online;
|
||||
pub mod queue;
|
||||
pub mod openhome;
|
||||
pub mod openhome_client;
|
||||
pub mod openhome_playlist;
|
||||
pub mod openhome_renderer;
|
||||
pub mod provider;
|
||||
pub mod queue_backend;
|
||||
pub mod queue_interne;
|
||||
pub mod registry;
|
||||
pub mod rendering_control_client;
|
||||
pub mod soap_client;
|
||||
pub mod upnp_clients;
|
||||
pub mod upnp_renderer;
|
||||
|
||||
// pmoserver extension (optional)
|
||||
#[cfg(feature = "pmoserver")]
|
||||
@@ -25,27 +33,36 @@ pub mod pmoserver_ext;
|
||||
#[cfg(feature = "pmoserver")]
|
||||
pub mod sse;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
pub use pmoserver_ext::ControlPointExt;
|
||||
|
||||
pub use control_point::ControlPoint;
|
||||
pub use media_server::{MediaBrowser, MediaEntry, MediaResource, UpnpMediaServer};
|
||||
pub use queue::{EnqueueMode, PlaybackItem, QueueSnapshot};
|
||||
pub use arylic_tcp::ArylicTcpRenderer;
|
||||
pub use avtransport_client::{AvTransportClient, PositionInfo, TransportInfo};
|
||||
pub use capabilities::{
|
||||
PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl,
|
||||
VolumeControl,
|
||||
};
|
||||
pub use chromecast_renderer::ChromecastRenderer;
|
||||
pub use connection_manager_client::{ConnectionInfo, ConnectionManagerClient, ProtocolInfo};
|
||||
pub use control_point::{ControlPoint, PlaylistBinding};
|
||||
pub use linkplay::LinkPlayRenderer;
|
||||
pub use media_server::{
|
||||
MediaBrowser, MediaEntry, MediaResource, MediaServerInfo, MusicServer, ServerId,
|
||||
UpnpMediaServer,
|
||||
};
|
||||
pub use music_renderer::MusicRenderer;
|
||||
pub use openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
|
||||
pub use openhome_renderer::OpenHomeRenderer;
|
||||
pub use queue_backend::{EnqueueMode, PlaybackItem, QueueSnapshot};
|
||||
pub use rendering_control_client::RenderingControlClient;
|
||||
pub use upnp_renderer::UpnpRenderer;
|
||||
|
||||
pub use discovery::{DeviceDescriptionProvider, DiscoveredEndpoint, DiscoveryManager};
|
||||
pub use model::{
|
||||
MediaServerEvent, PlaybackSource, RendererCapabilities, RendererEvent, RendererInfo,
|
||||
MediaServerEvent, RendererCapabilities, RendererEvent, RendererId, RendererInfo,
|
||||
RendererProtocol,
|
||||
};
|
||||
pub use registry::{DeviceRegistry, DeviceUpdate};
|
||||
pub use provider::HttpXmlDescriptionProvider;
|
||||
pub use registry::{DeviceRegistry, DeviceRegistryRead, DeviceUpdate};
|
||||
|
||||
pub use online::DeviceOnline;
|
||||
pub use soap_client::invoke_upnp_action;
|
||||
|
||||
pub use identity::DeviceIdentity;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct DeviceId(pub String);
|
||||
|
||||
const DEFAULT_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
476
pmocontrol/src/linkplay.rs
Normal file
476
pmocontrol/src/linkplay.rs
Normal file
@@ -0,0 +1,476 @@
|
||||
use std::char;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use tracing::debug;
|
||||
use ureq::Agent;
|
||||
|
||||
use crate::capabilities::{
|
||||
PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl,
|
||||
VolumeControl,
|
||||
};
|
||||
use crate::model::{RendererId, RendererInfo};
|
||||
|
||||
const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 3;
|
||||
const STATUS_COMMAND: &str = "getPlayerStatus";
|
||||
|
||||
/// Renderer backend for devices exposing the LinkPlay HTTP API.
|
||||
#[derive(Clone)]
|
||||
pub struct LinkPlayRenderer {
|
||||
pub info: RendererInfo,
|
||||
host: String,
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
impl fmt::Debug for LinkPlayRenderer {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("LinkPlayRenderer")
|
||||
.field("id", &self.info.id)
|
||||
.field("friendly_name", &self.info.friendly_name)
|
||||
.field("host", &self.host)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl LinkPlayRenderer {
|
||||
/// Build a LinkPlay backend from a registry snapshot.
|
||||
pub fn from_renderer_info(info: RendererInfo) -> Result<Self> {
|
||||
let host = extract_linkplay_host(&info.location)
|
||||
.ok_or_else(|| anyhow!("Renderer {} has no valid LOCATION host", info.udn))?;
|
||||
|
||||
Ok(Self {
|
||||
info,
|
||||
host,
|
||||
timeout: Duration::from_secs(DEFAULT_HTTP_TIMEOUT_SECS),
|
||||
})
|
||||
}
|
||||
|
||||
fn agent(&self) -> Agent {
|
||||
build_agent(self.timeout)
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &RendererId {
|
||||
&self.info.id
|
||||
}
|
||||
|
||||
pub fn friendly_name(&self) -> &str {
|
||||
&self.info.friendly_name
|
||||
}
|
||||
|
||||
fn send_player_command(&self, command: &str) -> Result<()> {
|
||||
let url = format!(
|
||||
"http://{}/httpapi.asp?command=setPlayerCmd:{}",
|
||||
self.host, command
|
||||
);
|
||||
self.agent()
|
||||
.get(&url)
|
||||
.call()
|
||||
.with_context(|| format!("LinkPlay command {} failed for {}", command, self.host))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fetch_status(&self) -> Result<LinkPlayStatus> {
|
||||
fetch_status_for_host(&self.host, self.timeout)
|
||||
}
|
||||
}
|
||||
|
||||
impl TransportControl for LinkPlayRenderer {
|
||||
fn play_uri(&self, uri: &str, _meta: &str) -> Result<()> {
|
||||
let encoded = percent_encode(uri);
|
||||
self.send_player_command(&format!("play:{}", encoded))
|
||||
}
|
||||
|
||||
fn play(&self) -> Result<()> {
|
||||
self.send_player_command("resume")
|
||||
}
|
||||
|
||||
fn pause(&self) -> Result<()> {
|
||||
self.send_player_command("pause")
|
||||
}
|
||||
|
||||
fn stop(&self) -> Result<()> {
|
||||
self.send_player_command("stop")
|
||||
}
|
||||
|
||||
fn seek_rel_time(&self, hhmmss: &str) -> Result<()> {
|
||||
let secs = parse_hhmmss_to_secs(hhmmss)
|
||||
.ok_or_else(|| anyhow!("Invalid seek position format: {}", hhmmss))?;
|
||||
self.send_player_command(&format!("seek:{}", secs))
|
||||
}
|
||||
}
|
||||
|
||||
impl VolumeControl for LinkPlayRenderer {
|
||||
fn volume(&self) -> Result<u16> {
|
||||
Ok(self.fetch_status()?.volume)
|
||||
}
|
||||
|
||||
fn set_volume(&self, v: u16) -> Result<()> {
|
||||
let value = v.min(100);
|
||||
self.send_player_command(&format!("vol:{}", value))
|
||||
}
|
||||
|
||||
fn mute(&self) -> Result<bool> {
|
||||
Ok(self.fetch_status()?.mute)
|
||||
}
|
||||
|
||||
fn set_mute(&self, m: bool) -> Result<()> {
|
||||
self.send_player_command(if m { "mute:1" } else { "mute:0" })
|
||||
}
|
||||
}
|
||||
|
||||
impl PlaybackStatus for LinkPlayRenderer {
|
||||
fn playback_state(&self) -> Result<PlaybackState> {
|
||||
Ok(self.fetch_status()?.playback_state())
|
||||
}
|
||||
}
|
||||
|
||||
impl PlaybackPosition for LinkPlayRenderer {
|
||||
fn playback_position(&self) -> Result<PlaybackPositionInfo> {
|
||||
Ok(self.fetch_status()?.position_info())
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect whether a renderer exposes the LinkPlay HTTP API.
|
||||
pub fn detect_linkplay_http(location: &str, timeout: Duration) -> bool {
|
||||
let Some(host) = extract_linkplay_host(location) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
match fetch_status_for_host(&host, timeout) {
|
||||
Ok(_) => true,
|
||||
Err(err) => {
|
||||
debug!(
|
||||
"LinkPlay detection failed for {} (host={}): {}",
|
||||
location, host, err
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the IP/host component from a LOCATION URL.
|
||||
pub fn extract_linkplay_host(location: &str) -> Option<String> {
|
||||
let (_, rest) = location.split_once("://")?;
|
||||
let authority = rest.split('/').next().unwrap_or(rest);
|
||||
let without_auth = authority.split('@').last().unwrap_or(authority);
|
||||
|
||||
if without_auth.starts_with('[') {
|
||||
let end = without_auth.find(']')?;
|
||||
let host = &without_auth[1..end];
|
||||
if host.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(host.to_string())
|
||||
}
|
||||
} else {
|
||||
let host = without_auth.split(':').next().unwrap_or("");
|
||||
if host.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(host.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_status_for_host(host: &str, timeout: Duration) -> Result<LinkPlayStatus> {
|
||||
let url = format!("http://{}/httpapi.asp?command={}", host, STATUS_COMMAND);
|
||||
let mut response = build_agent(timeout)
|
||||
.get(&url)
|
||||
.call()
|
||||
.with_context(|| format!("HTTP request failed for LinkPlay status on {}", host))?;
|
||||
|
||||
let body = response
|
||||
.body_mut()
|
||||
.read_to_string()
|
||||
.context("Failed to read LinkPlay status body")?;
|
||||
|
||||
parse_linkplay_status(&body)
|
||||
}
|
||||
|
||||
fn build_agent(timeout: Duration) -> Agent {
|
||||
Agent::config_builder()
|
||||
.timeout_global(Some(timeout))
|
||||
.build()
|
||||
.into()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct LinkPlayStatus {
|
||||
state_raw: String,
|
||||
curpos_ms: u64,
|
||||
totlen_ms: u64,
|
||||
track_index: Option<u32>,
|
||||
volume: u16,
|
||||
mute: bool,
|
||||
}
|
||||
|
||||
impl LinkPlayStatus {
|
||||
fn playback_state(&self) -> PlaybackState {
|
||||
match self.state_raw.as_str() {
|
||||
"play" => PlaybackState::Playing,
|
||||
"pause" => PlaybackState::Paused,
|
||||
"stop" => PlaybackState::Stopped,
|
||||
"load" => PlaybackState::Transitioning,
|
||||
other => PlaybackState::Unknown(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn position_info(&self) -> PlaybackPositionInfo {
|
||||
PlaybackPositionInfo {
|
||||
track: self.track_index,
|
||||
rel_time: Some(format_hms(self.curpos_ms / 1000)),
|
||||
abs_time: None,
|
||||
track_duration: if self.totlen_ms > 0 {
|
||||
Some(format_hms(self.totlen_ms / 1000))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
track_metadata: None,
|
||||
track_uri: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_linkplay_status(body: &str) -> Result<LinkPlayStatus> {
|
||||
let mut map = parse_flat_json(body)?;
|
||||
|
||||
let state_raw = map
|
||||
.remove("status")
|
||||
.ok_or_else(|| anyhow!("LinkPlay status missing `status` field"))?;
|
||||
|
||||
let curpos_ms = parse_u64_field(&map, "curpos")?;
|
||||
let totlen_ms = parse_u64_field(&map, "totlen")?;
|
||||
let volume = parse_u16_field(&map, "vol")?;
|
||||
let mute = match map.get("mute").map(|s| s.as_str()) {
|
||||
Some("1") => true,
|
||||
Some("0") => false,
|
||||
Some(other) => {
|
||||
return Err(anyhow!("Invalid LinkPlay mute value: {}", other));
|
||||
}
|
||||
None => return Err(anyhow!("LinkPlay status missing `mute` field")),
|
||||
};
|
||||
|
||||
let track_index = map
|
||||
.get("plicurr")
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
.filter(|idx| *idx > 0);
|
||||
|
||||
Ok(LinkPlayStatus {
|
||||
state_raw,
|
||||
curpos_ms,
|
||||
totlen_ms,
|
||||
track_index,
|
||||
volume,
|
||||
mute,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_u64_field(map: &HashMap<String, String>, key: &str) -> Result<u64> {
|
||||
let raw = map
|
||||
.get(key)
|
||||
.ok_or_else(|| anyhow!("LinkPlay status missing `{}` field", key))?;
|
||||
raw.parse::<u64>()
|
||||
.with_context(|| format!("Invalid `{}` value: {}", key, raw))
|
||||
}
|
||||
|
||||
fn parse_u16_field(map: &HashMap<String, String>, key: &str) -> Result<u16> {
|
||||
let raw = map
|
||||
.get(key)
|
||||
.ok_or_else(|| anyhow!("LinkPlay status missing `{}` field", key))?;
|
||||
let value = raw
|
||||
.parse::<u16>()
|
||||
.with_context(|| format!("Invalid `{}` value: {}", key, raw))?;
|
||||
Ok(value.min(100))
|
||||
}
|
||||
|
||||
pub(crate) fn parse_flat_json(input: &str) -> Result<HashMap<String, String>> {
|
||||
let mut chars = input.chars().peekable();
|
||||
skip_ws(&mut chars);
|
||||
if chars.next() != Some('{') {
|
||||
return Err(anyhow!("LinkPlay status is not a JSON object"));
|
||||
}
|
||||
|
||||
let mut map = HashMap::new();
|
||||
loop {
|
||||
skip_ws(&mut chars);
|
||||
match chars.peek() {
|
||||
Some('}') => {
|
||||
chars.next();
|
||||
break;
|
||||
}
|
||||
Some(_) => {}
|
||||
None => return Err(anyhow!("Unexpected end of JSON object")),
|
||||
}
|
||||
|
||||
let key = parse_json_string(&mut chars)?;
|
||||
skip_ws(&mut chars);
|
||||
expect_char(&mut chars, ':')?;
|
||||
skip_ws(&mut chars);
|
||||
let value = parse_json_value(&mut chars)?;
|
||||
map.insert(key, value);
|
||||
skip_ws(&mut chars);
|
||||
|
||||
match chars.peek() {
|
||||
Some(',') => {
|
||||
chars.next();
|
||||
continue;
|
||||
}
|
||||
Some('}') => {
|
||||
chars.next();
|
||||
break;
|
||||
}
|
||||
Some(other) => {
|
||||
return Err(anyhow!(
|
||||
"Unexpected character '{}' while parsing JSON",
|
||||
other
|
||||
));
|
||||
}
|
||||
None => return Err(anyhow!("Unexpected end of JSON while parsing fields")),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
fn parse_json_value(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Result<String> {
|
||||
match chars.peek() {
|
||||
Some('"') => parse_json_string(chars),
|
||||
Some(ch) if ch.is_ascii_digit() || *ch == '-' => parse_json_number(chars),
|
||||
Some('t') => {
|
||||
expect_literal(chars, "true")?;
|
||||
Ok("true".to_string())
|
||||
}
|
||||
Some('f') => {
|
||||
expect_literal(chars, "false")?;
|
||||
Ok("false".to_string())
|
||||
}
|
||||
_ => Err(anyhow!("Unsupported JSON value in LinkPlay status")),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_json_string(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Result<String> {
|
||||
if chars.next() != Some('"') {
|
||||
return Err(anyhow!("Expected string"));
|
||||
}
|
||||
|
||||
let mut out = String::new();
|
||||
while let Some(ch) = chars.next() {
|
||||
match ch {
|
||||
'"' => return Ok(out),
|
||||
'\\' => {
|
||||
let escaped = chars.next().ok_or_else(|| anyhow!("Invalid escape"))?;
|
||||
match escaped {
|
||||
'"' => out.push('"'),
|
||||
'\\' => out.push('\\'),
|
||||
'/' => out.push('/'),
|
||||
'b' => out.push('\u{0008}'),
|
||||
'f' => out.push('\u{000C}'),
|
||||
'n' => out.push('\n'),
|
||||
'r' => out.push('\r'),
|
||||
't' => out.push('\t'),
|
||||
'u' => {
|
||||
let mut hex = String::with_capacity(4);
|
||||
for _ in 0..4 {
|
||||
let h = chars.next().ok_or_else(|| anyhow!("Invalid \\u escape"))?;
|
||||
hex.push(h);
|
||||
}
|
||||
let code = u16::from_str_radix(&hex, 16)
|
||||
.with_context(|| format!("Invalid unicode escape: {}", hex))?;
|
||||
if let Some(c) = char::from_u32(code as u32) {
|
||||
out.push(c);
|
||||
} else {
|
||||
return Err(anyhow!("Invalid unicode code point: {}", code));
|
||||
}
|
||||
}
|
||||
other => return Err(anyhow!("Unsupported escape: {}", other)),
|
||||
}
|
||||
}
|
||||
other => out.push(other),
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!("Unterminated JSON string"))
|
||||
}
|
||||
|
||||
fn parse_json_number(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Result<String> {
|
||||
let mut out = String::new();
|
||||
|
||||
if matches!(chars.peek(), Some('-')) {
|
||||
out.push('-');
|
||||
chars.next();
|
||||
}
|
||||
|
||||
while let Some(ch) = chars.peek() {
|
||||
if ch.is_ascii_digit() || *ch == '.' {
|
||||
out.push(*ch);
|
||||
chars.next();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if out.is_empty() || out == "-" {
|
||||
return Err(anyhow!("Invalid number"));
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn expect_literal(
|
||||
chars: &mut std::iter::Peekable<std::str::Chars<'_>>,
|
||||
literal: &str,
|
||||
) -> Result<()> {
|
||||
for expected in literal.chars() {
|
||||
match chars.next() {
|
||||
Some(ch) if ch == expected => {}
|
||||
_ => return Err(anyhow!("Invalid literal while parsing JSON")),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn expect_char(chars: &mut std::iter::Peekable<std::str::Chars<'_>>, expected: char) -> Result<()> {
|
||||
match chars.next() {
|
||||
Some(ch) if ch == expected => Ok(()),
|
||||
_ => Err(anyhow!("Missing '{}' while parsing JSON", expected)),
|
||||
}
|
||||
}
|
||||
|
||||
fn skip_ws(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
|
||||
while matches!(chars.peek(), Some(ch) if ch.is_whitespace()) {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
|
||||
fn percent_encode(input: &str) -> String {
|
||||
let mut out = String::with_capacity(input.len());
|
||||
for b in input.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
|
||||
out.push(b as char);
|
||||
}
|
||||
_ => out.push_str(&format!("%{:02X}", b)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn parse_hhmmss_to_secs(s: &str) -> Option<u64> {
|
||||
let parts: Vec<_> = s.split(':').collect();
|
||||
if parts.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
let h: u64 = parts[0].parse().ok()?;
|
||||
let m: u64 = parts[1].parse().ok()?;
|
||||
let sec: u64 = parts[2].parse().ok()?;
|
||||
Some(h * 3600 + m * 60 + sec)
|
||||
}
|
||||
|
||||
fn format_hms(secs: u64) -> String {
|
||||
let h = secs / 3600;
|
||||
let m = (secs % 3600) / 60;
|
||||
let s = secs % 60;
|
||||
format!("{:02}:{:02}:{:02}", h, m, s)
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Deserialize;
|
||||
use ureq::Agent;
|
||||
|
||||
use crate::{
|
||||
errors::ControlPointError,
|
||||
model::PlaybackState,
|
||||
music_renderer::{
|
||||
PlaybackPositionInfo,
|
||||
time_utils::{format_hhmmss, ms_to_seconds},
|
||||
},
|
||||
};
|
||||
|
||||
const STATUS_COMMAND: &str = "getPlayerStatus";
|
||||
|
||||
/// Raw response from LinkPlay getPlayerStatus API
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LinkPlayStatusRaw {
|
||||
status: String,
|
||||
curpos: String,
|
||||
totlen: String,
|
||||
vol: String,
|
||||
mute: String,
|
||||
#[serde(default)]
|
||||
plicurr: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LinkPlayStatus {
|
||||
state_raw: String,
|
||||
pub curpos_ms: u64,
|
||||
pub totlen_ms: u64,
|
||||
pub track_index: Option<u32>,
|
||||
pub volume: u16,
|
||||
pub mute: bool,
|
||||
}
|
||||
|
||||
impl LinkPlayStatus {
|
||||
pub fn playback_state(&self) -> PlaybackState {
|
||||
match self.state_raw.as_str() {
|
||||
"play" => PlaybackState::Playing,
|
||||
"pause" => PlaybackState::Paused,
|
||||
"stop" => PlaybackState::Stopped,
|
||||
"load" => PlaybackState::Transitioning,
|
||||
other => PlaybackState::Unknown(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn position_info(&self) -> PlaybackPositionInfo {
|
||||
PlaybackPositionInfo {
|
||||
track: self.track_index,
|
||||
rel_time: Some(format_hhmmss(ms_to_seconds(self.curpos_ms))),
|
||||
abs_time: None,
|
||||
track_duration: if self.totlen_ms > 0 {
|
||||
Some(format_hhmmss(ms_to_seconds(self.totlen_ms)))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
track_metadata: None,
|
||||
track_uri: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the IP/host component from a LOCATION URL.
|
||||
pub fn extract_linkplay_host(location: &str) -> Option<String> {
|
||||
let (_, rest) = location.split_once("://")?;
|
||||
let authority = rest.split('/').next().unwrap_or(rest);
|
||||
let without_auth = authority.split('@').last().unwrap_or(authority);
|
||||
|
||||
if without_auth.starts_with('[') {
|
||||
let end = without_auth.find(']')?;
|
||||
let host = &without_auth[1..end];
|
||||
if host.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(host.to_string())
|
||||
}
|
||||
} else {
|
||||
let host = without_auth.split(':').next().unwrap_or("");
|
||||
if host.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(host.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_agent(timeout: Duration) -> Agent {
|
||||
Agent::config_builder()
|
||||
.timeout_global(Some(timeout))
|
||||
.build()
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn percent_encode(input: &str) -> String {
|
||||
let mut out = String::with_capacity(input.len());
|
||||
for b in input.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
|
||||
out.push(b as char);
|
||||
}
|
||||
_ => out.push_str(&format!("%{:02X}", b)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn fetch_status_for_host(
|
||||
host: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<LinkPlayStatus, ControlPointError> {
|
||||
let url = format!("http://{}/httpapi.asp?command={}", host, STATUS_COMMAND);
|
||||
let mut response = build_agent(timeout).get(&url).call().map_err(|_| {
|
||||
ControlPointError::ArilycTcpError(format!(
|
||||
"HTTP request failed for LinkPlay status on {}",
|
||||
host
|
||||
))
|
||||
})?;
|
||||
|
||||
let body = response.body_mut().read_to_string().map_err(|e| {
|
||||
ControlPointError::ArilycTcpError(format!("Failed to read LinkPlay status body : {}", e))
|
||||
})?;
|
||||
|
||||
parse_linkplay_status(&body)
|
||||
}
|
||||
|
||||
fn parse_linkplay_status(body: &str) -> Result<LinkPlayStatus, ControlPointError> {
|
||||
let raw: LinkPlayStatusRaw = serde_json::from_str(body).map_err(|e| {
|
||||
ControlPointError::LinkPlayError(format!("Failed to parse LinkPlay status JSON: {}", e))
|
||||
})?;
|
||||
|
||||
let curpos_ms = raw.curpos.parse::<u64>().map_err(|_| {
|
||||
ControlPointError::LinkPlayError(format!("Invalid curpos value: {}", raw.curpos))
|
||||
})?;
|
||||
|
||||
let totlen_ms = raw.totlen.parse::<u64>().map_err(|_| {
|
||||
ControlPointError::LinkPlayError(format!("Invalid totlen value: {}", raw.totlen))
|
||||
})?;
|
||||
|
||||
let volume = raw
|
||||
.vol
|
||||
.parse::<u16>()
|
||||
.map_err(|_| ControlPointError::LinkPlayError(format!("Invalid vol value: {}", raw.vol)))?
|
||||
.min(100);
|
||||
|
||||
let mute = match raw.mute.as_str() {
|
||||
"1" => true,
|
||||
"0" => false,
|
||||
other => {
|
||||
return Err(ControlPointError::LinkPlayError(format!(
|
||||
"Invalid mute value: {}",
|
||||
other
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let track_index = raw
|
||||
.plicurr
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
.filter(|idx| *idx > 0);
|
||||
|
||||
Ok(LinkPlayStatus {
|
||||
state_raw: raw.status,
|
||||
curpos_ms,
|
||||
totlen_ms,
|
||||
track_index,
|
||||
volume,
|
||||
mute,
|
||||
})
|
||||
}
|
||||
@@ -1,293 +1,33 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::SystemTime;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use pmodidl::DIDLLite;
|
||||
use anyhow::{Result, anyhow};
|
||||
use pmodidl::{self, DIDLLite};
|
||||
use pmoupnp::soap::SoapEnvelope;
|
||||
use pmoupnp::soap::error_codes;
|
||||
use tracing::{debug, warn};
|
||||
use xmltree::{Element, XMLNode};
|
||||
|
||||
use crate::errors::ControlPointError;
|
||||
use crate::model::TrackMetadata;
|
||||
use crate::online::{DeviceConnectionState, DeviceOnline};
|
||||
use crate::queue::PlaybackItem;
|
||||
use crate::soap_client::{SoapCallResult, invoke_upnp_action_with_timeout};
|
||||
use crate::{DEFAULT_HTTP_TIMEOUT, DeviceId, DeviceIdentity};
|
||||
|
||||
/// Unique identifier for a media server registered by the control point.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
|
||||
pub struct ServerId(pub String);
|
||||
|
||||
/// Snapshot of a media server discovered through UPnP SSDP.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct UpnpMediaServer {
|
||||
id: DeviceId,
|
||||
udn: String,
|
||||
friendly_name: String,
|
||||
model_name: String,
|
||||
manufacturer: String,
|
||||
location: String,
|
||||
server_header: String,
|
||||
has_content_directory: bool,
|
||||
content_directory_service_type: Option<String>,
|
||||
content_directory_control_url: Option<String>,
|
||||
connection: Arc<Mutex<DeviceConnectionState>>,
|
||||
}
|
||||
|
||||
impl UpnpMediaServer {
|
||||
pub fn new(
|
||||
id: DeviceId,
|
||||
udn: String,
|
||||
friendly_name: String,
|
||||
model_name: String,
|
||||
manufacturer: String,
|
||||
location: String,
|
||||
server_header: String,
|
||||
has_content_directory: bool,
|
||||
content_directory_service_type: Option<String>,
|
||||
content_directory_control_url: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
udn,
|
||||
friendly_name,
|
||||
model_name,
|
||||
manufacturer,
|
||||
location,
|
||||
server_header,
|
||||
has_content_directory,
|
||||
content_directory_service_type,
|
||||
content_directory_control_url,
|
||||
connection: DeviceConnectionState::make(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn make(
|
||||
id: DeviceId,
|
||||
udn: String,
|
||||
friendly_name: String,
|
||||
model_name: String,
|
||||
manufacturer: String,
|
||||
location: String,
|
||||
server_header: String,
|
||||
has_content_directory: bool,
|
||||
content_directory_service_type: Option<String>,
|
||||
content_directory_control_url: Option<String>,
|
||||
) -> Arc<Self> {
|
||||
Arc::new(UpnpMediaServer::new(
|
||||
id,
|
||||
udn,
|
||||
friendly_name,
|
||||
model_name,
|
||||
manufacturer,
|
||||
location,
|
||||
server_header,
|
||||
has_content_directory,
|
||||
content_directory_service_type,
|
||||
content_directory_control_url,
|
||||
))
|
||||
}
|
||||
|
||||
fn browse_with_flag(
|
||||
&self,
|
||||
object_id: &str,
|
||||
browse_flag: &str,
|
||||
start: u32,
|
||||
count: u32,
|
||||
) -> Result<Vec<MediaEntry>, ControlPointError> {
|
||||
let start_str = start.to_string();
|
||||
let count_str = count.to_string();
|
||||
let args = vec![
|
||||
("ObjectID", object_id.to_string()),
|
||||
("BrowseFlag", browse_flag.to_string()),
|
||||
("Filter", "*".to_string()),
|
||||
("StartingIndex", start_str),
|
||||
("RequestedCount", count_str),
|
||||
("SortCriteria", String::new()),
|
||||
];
|
||||
|
||||
let response = self.invoke_content_directory("Browse", None, args)?;
|
||||
let envelope = response.envelope.ok_or_else(|| {
|
||||
ControlPointError::MediaServerError(format!("Missing SOAP envelope in Browse response"))
|
||||
})?;
|
||||
let didl_xml = extract_result_payload(&envelope, "BrowseResponse")?;
|
||||
map_didl_entries(&didl_xml)
|
||||
}
|
||||
|
||||
fn has_content_directory(&self) -> bool {
|
||||
self.has_content_directory
|
||||
}
|
||||
|
||||
fn search_impl(
|
||||
&self,
|
||||
container_id: &str,
|
||||
query: &str,
|
||||
start: u32,
|
||||
count: u32,
|
||||
) -> Result<Vec<MediaEntry>, ControlPointError> {
|
||||
let start_str = start.to_string();
|
||||
let count_str = count.to_string();
|
||||
|
||||
let args = vec![
|
||||
("ContainerID", container_id.to_string()),
|
||||
("SearchCriteria", query.to_string()),
|
||||
("Filter", "*".to_string()),
|
||||
("StartingIndex", start_str),
|
||||
("RequestedCount", count_str),
|
||||
("SortCriteria", String::new()),
|
||||
];
|
||||
|
||||
let response = self.invoke_content_directory("Search", Some("search"), args)?;
|
||||
let envelope = response.envelope.ok_or_else(|| {
|
||||
ControlPointError::MediaServerError(format!("Missing SOAP envelope in Search response"))
|
||||
})?;
|
||||
let didl_xml = extract_result_payload(&envelope, "SearchResponse")?;
|
||||
map_didl_entries(&didl_xml)
|
||||
}
|
||||
|
||||
fn invoke_content_directory(
|
||||
&self,
|
||||
action: &str,
|
||||
op_name: Option<&str>,
|
||||
args: Vec<(&'static str, String)>,
|
||||
) -> Result<SoapCallResult, ControlPointError> {
|
||||
let op = op_name.unwrap_or(action);
|
||||
let (control_url, service_type) = self.content_directory_endpoints(op)?;
|
||||
let borrowed_args: Vec<(&str, &str)> = args.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||||
|
||||
let call_result = invoke_upnp_action_with_timeout(
|
||||
control_url,
|
||||
service_type,
|
||||
action,
|
||||
&borrowed_args,
|
||||
Some(DEFAULT_HTTP_TIMEOUT),
|
||||
)?;
|
||||
|
||||
if !call_result.status.is_success() {
|
||||
if let Some(env) = &call_result.envelope {
|
||||
if let Some(err) = parse_upnp_error(env) {
|
||||
if should_map_to_not_supported(action, op_name, err.error_code) {
|
||||
return Err(ControlPointError::upnp_operation_not_supported(
|
||||
op,
|
||||
"UpnpMediaServer",
|
||||
));
|
||||
}
|
||||
|
||||
return Err(ControlPointError::MediaServerError(format!(
|
||||
"{} failed with UPnP error {}: {}",
|
||||
action, err.error_code, err.error_description
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
return Err(ControlPointError::MediaServerError(format!(
|
||||
"{} failed with HTTP status {} and body: {}",
|
||||
action, call_result.status, call_result.raw_body
|
||||
)));
|
||||
}
|
||||
|
||||
if let Some(env) = &call_result.envelope {
|
||||
if let Some(err) = parse_upnp_error(env) {
|
||||
if should_map_to_not_supported(action, op_name, err.error_code) {
|
||||
return Err(ControlPointError::upnp_operation_not_supported(
|
||||
op,
|
||||
"UpnpMediaServer",
|
||||
));
|
||||
}
|
||||
|
||||
return Err(ControlPointError::MediaServerError(format!(
|
||||
"{} returned UPnP error {}: {}",
|
||||
action, err.error_code, err.error_description
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(call_result)
|
||||
}
|
||||
|
||||
fn content_directory_endpoints(
|
||||
&self,
|
||||
op_name: &str,
|
||||
) -> Result<(&str, &str), ControlPointError> {
|
||||
if !self.has_content_directory {
|
||||
return Err(ControlPointError::upnp_operation_not_supported(
|
||||
op_name,
|
||||
"UpnpMediaServer",
|
||||
));
|
||||
}
|
||||
|
||||
let control_url = self
|
||||
.content_directory_control_url
|
||||
.as_deref()
|
||||
.ok_or_else(|| {
|
||||
ControlPointError::upnp_operation_not_supported(op_name, "UpnpMediaServer")
|
||||
})?;
|
||||
let service_type = self
|
||||
.content_directory_service_type
|
||||
.as_deref()
|
||||
.ok_or_else(|| {
|
||||
ControlPointError::upnp_operation_not_supported(op_name, "UpnpMediaServer")
|
||||
})?;
|
||||
|
||||
Ok((control_url, service_type))
|
||||
}
|
||||
}
|
||||
|
||||
impl DeviceOnline for UpnpMediaServer {
|
||||
fn is_online(&self) -> bool {
|
||||
self.connection
|
||||
.lock()
|
||||
.expect("Connection Mutex Poisoned")
|
||||
.is_online()
|
||||
}
|
||||
fn last_seen(&self) -> SystemTime {
|
||||
self.connection
|
||||
.lock()
|
||||
.expect("Connection Mutex Poisoned")
|
||||
.last_seen()
|
||||
}
|
||||
fn has_been_seen_now(&self, max_age: u32) {
|
||||
self.connection
|
||||
.lock()
|
||||
.expect("Connection Mutex Poisoned")
|
||||
.has_been_seen_now(max_age)
|
||||
}
|
||||
fn mark_as_offline(&self) {
|
||||
self.connection
|
||||
.lock()
|
||||
.expect("Connection Mutex Poisoned")
|
||||
.mark_as_offline()
|
||||
}
|
||||
fn max_age(&self) -> u32 {
|
||||
self.connection
|
||||
.lock()
|
||||
.expect("Connection Mutex Poisoned")
|
||||
.max_age()
|
||||
}
|
||||
}
|
||||
|
||||
impl DeviceIdentity for UpnpMediaServer {
|
||||
fn id(&self) -> DeviceId {
|
||||
self.id.clone()
|
||||
}
|
||||
fn udn(&self) -> &str {
|
||||
&self.udn
|
||||
}
|
||||
fn friendly_name(&self) -> &str {
|
||||
&self.friendly_name
|
||||
}
|
||||
fn model_name(&self) -> &str {
|
||||
&self.model_name
|
||||
}
|
||||
fn manufacturer(&self) -> &str {
|
||||
&self.manufacturer
|
||||
}
|
||||
fn location(&self) -> &str {
|
||||
&self.location
|
||||
}
|
||||
fn server_header(&self) -> &str {
|
||||
&self.server_header
|
||||
}
|
||||
|
||||
fn is_a_media_server(&self) -> bool {
|
||||
true
|
||||
}
|
||||
pub struct MediaServerInfo {
|
||||
pub id: ServerId,
|
||||
pub udn: String,
|
||||
pub friendly_name: String,
|
||||
pub model_name: String,
|
||||
pub manufacturer: String,
|
||||
pub location: String,
|
||||
pub server_header: String,
|
||||
pub online: bool,
|
||||
pub last_seen: SystemTime,
|
||||
pub max_age: u32,
|
||||
pub has_content_directory: bool,
|
||||
pub content_directory_service_type: Option<String>,
|
||||
pub content_directory_control_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Simplified view over a DIDL-Lite resource entry.
|
||||
@@ -302,45 +42,15 @@ impl MediaResource {
|
||||
/// Returns true if this resource represents audio content.
|
||||
pub fn is_audio(&self) -> bool {
|
||||
let lower = self.protocol_info.to_ascii_lowercase();
|
||||
|
||||
// Standard case: audio/* MIME types
|
||||
if lower.contains("audio/") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// List of known audio format subtypes (the part after the /)
|
||||
// These are recognized regardless of the MIME type prefix
|
||||
const AUDIO_FORMATS: &[&str] = &[
|
||||
"flac", "ogg", "opus", "vorbis", "mp3", "mpeg", "mp4", "m4a", "aac", "wav", "wave",
|
||||
"pcm", "wma", "webm", "ape", "alac", "aiff", "dsd", "dsf", "dff",
|
||||
];
|
||||
|
||||
// Check if any known audio format appears in the protocol_info
|
||||
for format in AUDIO_FORMATS {
|
||||
if lower.contains(format) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// protocolInfo format: protocol:network:contentFormat:additionalInfo
|
||||
// Extract the MIME type (3rd field) for more precise checking
|
||||
if let Some(mime) = lower.split(':').nth(2) {
|
||||
// Check if it's audio/* or contains a known audio format
|
||||
if mime.starts_with("audio/") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check the subtype (part after /) for known audio formats
|
||||
if let Some(subtype) = mime.split('/').nth(1) {
|
||||
for format in AUDIO_FORMATS {
|
||||
if subtype.contains(format) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
lower
|
||||
.split(':')
|
||||
.nth(2)
|
||||
.map(|mime| mime.starts_with("audio/"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,21 +74,16 @@ pub struct MediaEntry {
|
||||
|
||||
/// Backend-agnostic media browsing contract.
|
||||
pub trait MediaBrowser {
|
||||
fn browse_root(&self) -> Result<Vec<MediaEntry>, ControlPointError>;
|
||||
fn browse_children(
|
||||
&self,
|
||||
object_id: &str,
|
||||
start: u32,
|
||||
count: u32,
|
||||
) -> Result<Vec<MediaEntry>, ControlPointError>;
|
||||
fn browse_object(&self, object_id: &str) -> Result<MediaEntry, ControlPointError>;
|
||||
fn browse_root(&self) -> Result<Vec<MediaEntry>>;
|
||||
fn browse_children(&self, object_id: &str, start: u32, count: u32) -> Result<Vec<MediaEntry>>;
|
||||
fn browse_object(&self, object_id: &str) -> Result<MediaEntry>;
|
||||
fn search(
|
||||
&self,
|
||||
container_id: &str,
|
||||
query: &str,
|
||||
start: u32,
|
||||
count: u32,
|
||||
) -> Result<Vec<MediaEntry>, ControlPointError>;
|
||||
) -> Result<Vec<MediaEntry>>;
|
||||
}
|
||||
|
||||
/// Façade over every supported media server backend.
|
||||
@@ -388,107 +93,40 @@ pub enum MusicServer {
|
||||
}
|
||||
|
||||
impl MusicServer {
|
||||
pub fn from_server_info(info: &UpnpMediaServer) -> Result<MusicServer, ControlPointError> {
|
||||
Ok(MusicServer::Upnp(info.clone()))
|
||||
pub fn from_info(info: &MediaServerInfo, timeout: Duration) -> Result<Self> {
|
||||
Ok(MusicServer::Upnp(UpnpMediaServer::new(
|
||||
info.clone(),
|
||||
timeout,
|
||||
)))
|
||||
}
|
||||
|
||||
pub fn has_content_directory(&self) -> bool {
|
||||
pub fn id(&self) -> &ServerId {
|
||||
match self {
|
||||
MusicServer::Upnp(u) => u.has_content_directory(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DeviceOnline for MusicServer {
|
||||
fn is_online(&self) -> bool {
|
||||
match self {
|
||||
MusicServer::Upnp(u) => u.is_online(),
|
||||
}
|
||||
}
|
||||
fn last_seen(&self) -> SystemTime {
|
||||
match self {
|
||||
MusicServer::Upnp(u) => u.last_seen(),
|
||||
}
|
||||
}
|
||||
fn has_been_seen_now(&self, max_age: u32) {
|
||||
match self {
|
||||
MusicServer::Upnp(u) => u.has_been_seen_now(max_age),
|
||||
}
|
||||
}
|
||||
fn mark_as_offline(&self) {
|
||||
match self {
|
||||
MusicServer::Upnp(u) => u.mark_as_offline(),
|
||||
}
|
||||
}
|
||||
fn max_age(&self) -> u32 {
|
||||
match self {
|
||||
MusicServer::Upnp(u) => u.max_age(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DeviceIdentity for MusicServer {
|
||||
fn id(&self) -> DeviceId {
|
||||
match self {
|
||||
MusicServer::Upnp(u) => u.id(),
|
||||
MusicServer::Upnp(upnp) => upnp.id(),
|
||||
}
|
||||
}
|
||||
|
||||
fn udn(&self) -> &str {
|
||||
pub fn info(&self) -> &MediaServerInfo {
|
||||
match self {
|
||||
MusicServer::Upnp(u) => u.udn(),
|
||||
MusicServer::Upnp(upnp) => upnp.info(),
|
||||
}
|
||||
}
|
||||
fn friendly_name(&self) -> &str {
|
||||
match self {
|
||||
MusicServer::Upnp(u) => u.friendly_name(),
|
||||
}
|
||||
}
|
||||
fn model_name(&self) -> &str {
|
||||
match self {
|
||||
MusicServer::Upnp(u) => u.model_name(),
|
||||
}
|
||||
}
|
||||
fn manufacturer(&self) -> &str {
|
||||
match self {
|
||||
MusicServer::Upnp(u) => u.manufacturer(),
|
||||
}
|
||||
}
|
||||
fn location(&self) -> &str {
|
||||
match self {
|
||||
MusicServer::Upnp(u) => u.location(),
|
||||
}
|
||||
}
|
||||
fn server_header(&self) -> &str {
|
||||
match self {
|
||||
MusicServer::Upnp(u) => u.server_header(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_a_media_server(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl MediaBrowser for MusicServer {
|
||||
fn browse_root(&self) -> Result<Vec<MediaEntry>, ControlPointError> {
|
||||
fn browse_root(&self) -> Result<Vec<MediaEntry>> {
|
||||
match self {
|
||||
MusicServer::Upnp(upnp) => upnp.browse_root(),
|
||||
}
|
||||
}
|
||||
|
||||
fn browse_children(
|
||||
&self,
|
||||
object_id: &str,
|
||||
start: u32,
|
||||
count: u32,
|
||||
) -> Result<Vec<MediaEntry>, ControlPointError> {
|
||||
fn browse_children(&self, object_id: &str, start: u32, count: u32) -> Result<Vec<MediaEntry>> {
|
||||
match self {
|
||||
MusicServer::Upnp(upnp) => upnp.browse_children(object_id, start, count),
|
||||
}
|
||||
}
|
||||
|
||||
fn browse_object(&self, object_id: &str) -> Result<MediaEntry, ControlPointError> {
|
||||
fn browse_object(&self, object_id: &str) -> Result<MediaEntry> {
|
||||
match self {
|
||||
MusicServer::Upnp(upnp) => upnp.browse_object(object_id),
|
||||
}
|
||||
@@ -500,113 +138,181 @@ impl MediaBrowser for MusicServer {
|
||||
query: &str,
|
||||
start: u32,
|
||||
count: u32,
|
||||
) -> Result<Vec<MediaEntry>, ControlPointError> {
|
||||
) -> Result<Vec<MediaEntry>> {
|
||||
match self {
|
||||
MusicServer::Upnp(upnp) => upnp.search(container_id, query, start, count),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to convert a MediaEntry to a PlaybackItem.
|
||||
///
|
||||
/// This function filters out containers and entries without audio resources,
|
||||
/// returning None for items that cannot be played.
|
||||
pub fn playback_item_from_entry(
|
||||
server: Arc<MusicServer>,
|
||||
entry: &MediaEntry,
|
||||
) -> Option<PlaybackItem> {
|
||||
// Ignore containers
|
||||
if entry.is_container {
|
||||
debug!(
|
||||
server_id = server.id().0.as_str(),
|
||||
entry_id = entry.id.as_str(),
|
||||
title = entry.title.as_str(),
|
||||
class = entry.class.as_str(),
|
||||
"Skipping container entry"
|
||||
);
|
||||
return None;
|
||||
/// Single UPnP ContentDirectory backend implementation.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct UpnpMediaServer {
|
||||
info: MediaServerInfo,
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
impl UpnpMediaServer {
|
||||
pub fn new(info: MediaServerInfo, timeout: Duration) -> Self {
|
||||
Self { info, timeout }
|
||||
}
|
||||
|
||||
// Skip "live stream" entries (heuristic from example)
|
||||
if entry.title.to_ascii_lowercase().contains("live stream") {
|
||||
debug!(
|
||||
server_id = server.id().0.as_str(),
|
||||
entry_id = entry.id.as_str(),
|
||||
title = entry.title.as_str(),
|
||||
"Skipping 'live stream' entry"
|
||||
);
|
||||
return None;
|
||||
pub fn id(&self) -> &ServerId {
|
||||
&self.info.id
|
||||
}
|
||||
|
||||
// Find an audio resource
|
||||
let resource = entry.resources.iter().find(|res| res.is_audio());
|
||||
|
||||
if resource.is_none() {
|
||||
warn!(
|
||||
server_id = server.id().0.as_str(),
|
||||
entry_id = entry.id.as_str(),
|
||||
title = entry.title.as_str(),
|
||||
class = entry.class.as_str(),
|
||||
resource_count = entry.resources.len(),
|
||||
resources = ?entry.resources.iter().map(|r| &r.protocol_info).collect::<Vec<_>>(),
|
||||
"No audio resource found for entry"
|
||||
);
|
||||
return None;
|
||||
pub fn info(&self) -> &MediaServerInfo {
|
||||
&self.info
|
||||
}
|
||||
|
||||
let resource = resource.unwrap();
|
||||
fn browse_with_flag(
|
||||
&self,
|
||||
object_id: &str,
|
||||
browse_flag: &str,
|
||||
start: u32,
|
||||
count: u32,
|
||||
) -> Result<Vec<MediaEntry>> {
|
||||
let start_str = start.to_string();
|
||||
let count_str = count.to_string();
|
||||
let args = vec![
|
||||
("ObjectID", object_id.to_string()),
|
||||
("BrowseFlag", browse_flag.to_string()),
|
||||
("Filter", "*".to_string()),
|
||||
("StartingIndex", start_str),
|
||||
("RequestedCount", count_str),
|
||||
("SortCriteria", String::new()),
|
||||
];
|
||||
|
||||
let metadata = TrackMetadata {
|
||||
title: Some(entry.title.clone()),
|
||||
artist: entry.artist.clone(),
|
||||
album: entry.album.clone(),
|
||||
genre: entry.genre.clone(),
|
||||
album_art_uri: entry.album_art_uri.clone(),
|
||||
date: entry.date.clone(),
|
||||
track_number: entry.track_number.clone(),
|
||||
creator: entry.creator.clone(),
|
||||
};
|
||||
let response = self.invoke_content_directory("Browse", None, args)?;
|
||||
let envelope = response
|
||||
.envelope
|
||||
.ok_or_else(|| anyhow!("Missing SOAP envelope in Browse response"))?;
|
||||
let didl_xml = extract_result_payload(&envelope, "BrowseResponse")?;
|
||||
map_didl_entries(&didl_xml)
|
||||
}
|
||||
|
||||
debug!(
|
||||
server_id = server.id().0.as_str(),
|
||||
entry_id = entry.id.as_str(),
|
||||
title = entry.title.as_str(),
|
||||
uri = resource.uri.as_str(),
|
||||
"Created playback item"
|
||||
);
|
||||
fn search_impl(
|
||||
&self,
|
||||
container_id: &str,
|
||||
query: &str,
|
||||
start: u32,
|
||||
count: u32,
|
||||
) -> Result<Vec<MediaEntry>> {
|
||||
let start_str = start.to_string();
|
||||
let count_str = count.to_string();
|
||||
|
||||
Some(PlaybackItem {
|
||||
media_server_id: server.id().clone(),
|
||||
backend_id: usize::MAX,
|
||||
didl_id: entry.id.clone(),
|
||||
uri: resource.uri.clone(),
|
||||
protocol_info: resource.protocol_info.clone(),
|
||||
metadata: Some(metadata),
|
||||
})
|
||||
let args = vec![
|
||||
("ContainerID", container_id.to_string()),
|
||||
("SearchCriteria", query.to_string()),
|
||||
("Filter", "*".to_string()),
|
||||
("StartingIndex", start_str),
|
||||
("RequestedCount", count_str),
|
||||
("SortCriteria", String::new()),
|
||||
];
|
||||
|
||||
let response = self.invoke_content_directory("Search", Some("search"), args)?;
|
||||
let envelope = response
|
||||
.envelope
|
||||
.ok_or_else(|| anyhow!("Missing SOAP envelope in Search response"))?;
|
||||
let didl_xml = extract_result_payload(&envelope, "SearchResponse")?;
|
||||
map_didl_entries(&didl_xml)
|
||||
}
|
||||
|
||||
fn invoke_content_directory(
|
||||
&self,
|
||||
action: &str,
|
||||
op_name: Option<&str>,
|
||||
args: Vec<(&'static str, String)>,
|
||||
) -> Result<SoapCallResult> {
|
||||
let op = op_name.unwrap_or(action);
|
||||
let (control_url, service_type) = self.content_directory_endpoints(op)?;
|
||||
let borrowed_args: Vec<(&str, &str)> = args.iter().map(|(k, v)| (*k, v.as_str())).collect();
|
||||
|
||||
let call_result = invoke_upnp_action_with_timeout(
|
||||
control_url,
|
||||
service_type,
|
||||
action,
|
||||
&borrowed_args,
|
||||
Some(self.timeout),
|
||||
)?;
|
||||
|
||||
if !call_result.status.is_success() {
|
||||
if let Some(env) = &call_result.envelope {
|
||||
if let Some(err) = parse_upnp_error(env) {
|
||||
if should_map_to_not_supported(action, op_name, err.error_code) {
|
||||
return Err(server_op_not_supported(op, "UpnpMediaServer"));
|
||||
}
|
||||
|
||||
return Err(anyhow!(
|
||||
"{} failed with UPnP error {}: {}",
|
||||
action,
|
||||
err.error_code,
|
||||
err.error_description
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return Err(anyhow!(
|
||||
"{} failed with HTTP status {} and body: {}",
|
||||
action,
|
||||
call_result.status,
|
||||
call_result.raw_body
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(env) = &call_result.envelope {
|
||||
if let Some(err) = parse_upnp_error(env) {
|
||||
if should_map_to_not_supported(action, op_name, err.error_code) {
|
||||
return Err(server_op_not_supported(op, "UpnpMediaServer"));
|
||||
}
|
||||
|
||||
return Err(anyhow!(
|
||||
"{} returned UPnP error {}: {}",
|
||||
action,
|
||||
err.error_code,
|
||||
err.error_description
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(call_result)
|
||||
}
|
||||
|
||||
fn content_directory_endpoints(&self, op_name: &str) -> Result<(&str, &str)> {
|
||||
if !self.info.has_content_directory {
|
||||
return Err(server_op_not_supported(op_name, "UpnpMediaServer"));
|
||||
}
|
||||
|
||||
let control_url = self
|
||||
.info
|
||||
.content_directory_control_url
|
||||
.as_deref()
|
||||
.ok_or_else(|| server_op_not_supported(op_name, "UpnpMediaServer"))?;
|
||||
let service_type = self
|
||||
.info
|
||||
.content_directory_service_type
|
||||
.as_deref()
|
||||
.ok_or_else(|| server_op_not_supported(op_name, "UpnpMediaServer"))?;
|
||||
|
||||
Ok((control_url, service_type))
|
||||
}
|
||||
}
|
||||
|
||||
impl MediaBrowser for UpnpMediaServer {
|
||||
fn browse_root(&self) -> Result<Vec<MediaEntry>, ControlPointError> {
|
||||
fn browse_root(&self) -> Result<Vec<MediaEntry>> {
|
||||
self.browse_with_flag("0", "BrowseDirectChildren", 0, 0)
|
||||
}
|
||||
|
||||
fn browse_children(
|
||||
&self,
|
||||
object_id: &str,
|
||||
start: u32,
|
||||
count: u32,
|
||||
) -> Result<Vec<MediaEntry>, ControlPointError> {
|
||||
fn browse_children(&self, object_id: &str, start: u32, count: u32) -> Result<Vec<MediaEntry>> {
|
||||
self.browse_with_flag(object_id, "BrowseDirectChildren", start, count)
|
||||
}
|
||||
|
||||
fn browse_object(&self, object_id: &str) -> Result<MediaEntry, ControlPointError> {
|
||||
fn browse_object(&self, object_id: &str) -> Result<MediaEntry> {
|
||||
let entries = self.browse_with_flag(object_id, "BrowseMetadata", 0, 1)?;
|
||||
entries.into_iter().next().ok_or_else(|| {
|
||||
ControlPointError::MediaServerError(format!(
|
||||
"Object {} was not returned by the server",
|
||||
object_id
|
||||
))
|
||||
})
|
||||
entries
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("Object {} was not returned by the server", object_id))
|
||||
}
|
||||
|
||||
fn search(
|
||||
@@ -615,24 +321,19 @@ impl MediaBrowser for UpnpMediaServer {
|
||||
query: &str,
|
||||
start: u32,
|
||||
count: u32,
|
||||
) -> Result<Vec<MediaEntry>, ControlPointError> {
|
||||
) -> Result<Vec<MediaEntry>> {
|
||||
self.search_impl(container_id, query, start, count)
|
||||
}
|
||||
}
|
||||
|
||||
fn map_didl_entries(xml: &str) -> Result<Vec<MediaEntry>, ControlPointError> {
|
||||
fn map_didl_entries(xml: &str) -> Result<Vec<MediaEntry>> {
|
||||
let trimmed = xml.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let didl: DIDLLite = pmodidl::parse_metadata::<DIDLLite>(trimmed)
|
||||
.map_err(|err| {
|
||||
ControlPointError::MediaServerError(format!(
|
||||
"Failed to parse DIDL-Lite payload: {}",
|
||||
err
|
||||
))
|
||||
})?
|
||||
.map_err(|err| anyhow!("Failed to parse DIDL-Lite payload: {}", err))?
|
||||
.data;
|
||||
|
||||
let mut entries = Vec::new();
|
||||
@@ -691,23 +392,11 @@ fn map_didl_entries(xml: &str) -> Result<Vec<MediaEntry>, ControlPointError> {
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn extract_result_payload(
|
||||
envelope: &SoapEnvelope,
|
||||
response_suffix: &str,
|
||||
) -> Result<String, ControlPointError> {
|
||||
let response =
|
||||
find_child_with_suffix(&envelope.body.content, response_suffix).ok_or_else(|| {
|
||||
ControlPointError::MediaServerError(format!(
|
||||
"Missing {} element in SOAP body",
|
||||
response_suffix
|
||||
))
|
||||
})?;
|
||||
let result_elem = find_child_with_suffix(response, "Result").ok_or_else(|| {
|
||||
ControlPointError::MediaServerError(format!(
|
||||
"Missing Result element in {}",
|
||||
response_suffix
|
||||
))
|
||||
})?;
|
||||
fn extract_result_payload(envelope: &SoapEnvelope, response_suffix: &str) -> Result<String> {
|
||||
let response = find_child_with_suffix(&envelope.body.content, response_suffix)
|
||||
.ok_or_else(|| anyhow!("Missing {} element in SOAP body", response_suffix))?;
|
||||
let result_elem = find_child_with_suffix(response, "Result")
|
||||
.ok_or_else(|| anyhow!("Missing Result element in {}", response_suffix))?;
|
||||
|
||||
let payload = result_elem
|
||||
.get_text()
|
||||
@@ -768,6 +457,14 @@ fn should_map_to_not_supported(action: &str, op_name: Option<&str>, error_code:
|
||||
cd_not_supported && (error_code == optional_code || error_code == invalid_action_code)
|
||||
}
|
||||
|
||||
fn server_op_not_supported(op: &str, backend: &str) -> anyhow::Error {
|
||||
anyhow!(
|
||||
"MusicServer operation '{}' is not supported by backend '{}'",
|
||||
op,
|
||||
backend
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct UpnpError {
|
||||
pub error_code: u32,
|
||||
|
||||
@@ -8,23 +8,18 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use crossbeam_channel::{Receiver, Sender, unbounded};
|
||||
use tracing::{debug, error, info, warn};
|
||||
use tracing::{debug, info, warn};
|
||||
use ureq::{Agent, http};
|
||||
use xmltree::{Element, XMLNode};
|
||||
|
||||
use crate::events::MediaServerEventBus;
|
||||
use crate::media_server::MusicServer;
|
||||
use crate::media_server::{MediaServerInfo, ServerId};
|
||||
use crate::model::MediaServerEvent;
|
||||
use crate::registry::DeviceRegistry;
|
||||
use crate::upnp_clients::resolve_control_url;
|
||||
use crate::{DeviceId, DeviceIdentity, DeviceOnline};
|
||||
use crate::provider::resolve_control_url;
|
||||
use crate::registry::{DeviceRegistry, DeviceRegistryRead};
|
||||
|
||||
const SUBSCRIPTION_TIMEOUT_SECS: u64 = 300;
|
||||
const RENEWAL_SAFETY_MARGIN_SECS: u64 = 60;
|
||||
const HTTP_READ_TIMEOUT_SECS: u64 = 5;
|
||||
const WORKER_LOOP_INTERVAL_MILLIS: u64 = 250;
|
||||
const RETRY_DELAY_SECS: u64 = 15;
|
||||
const SUBSCRIPTION_RESET_DELAY_SECS: u64 = 5;
|
||||
|
||||
/// Launch the media server event runtime responsible for subscribing
|
||||
/// to ContentDirectory updates and forwarding notifications on the bus.
|
||||
@@ -70,47 +65,11 @@ struct IncomingNotify {
|
||||
body: Vec<u8>,
|
||||
}
|
||||
|
||||
impl IncomingNotify {
|
||||
fn validate_sid(&self, expected: &Option<String>) -> bool {
|
||||
match (&self.sid, expected) {
|
||||
(Some(received), Some(expected)) => expected.eq_ignore_ascii_case(received),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages retry timing for subscription operations
|
||||
struct RetryPolicy {
|
||||
retry_after: Instant,
|
||||
}
|
||||
|
||||
impl RetryPolicy {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
retry_after: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn should_retry(&self) -> bool {
|
||||
Instant::now() >= self.retry_after
|
||||
}
|
||||
|
||||
fn defer_retry(&mut self) {
|
||||
self.retry_after = Instant::now() + Duration::from_secs(RETRY_DELAY_SECS);
|
||||
}
|
||||
|
||||
fn schedule_soon(&mut self) {
|
||||
self.retry_after = Instant::now() + Duration::from_secs(SUBSCRIPTION_RESET_DELAY_SECS);
|
||||
}
|
||||
}
|
||||
|
||||
fn run_http_listener(listener: TcpListener, notify_tx: Sender<IncomingNotify>) {
|
||||
for stream in listener.incoming() {
|
||||
match stream {
|
||||
Ok(mut stream) => {
|
||||
if let Err(err) =
|
||||
stream.set_read_timeout(Some(Duration::from_secs(HTTP_READ_TIMEOUT_SECS)))
|
||||
{
|
||||
if let Err(err) = stream.set_read_timeout(Some(Duration::from_secs(5))) {
|
||||
warn!("Failed to set read timeout on notify connection: {}", err);
|
||||
}
|
||||
|
||||
@@ -220,8 +179,8 @@ struct MediaServerEventWorker {
|
||||
http_timeout: Duration,
|
||||
notify_rx: Receiver<IncomingNotify>,
|
||||
listener_port: u16,
|
||||
subscriptions: HashMap<DeviceId, SubscriptionState>,
|
||||
path_index: HashMap<String, DeviceId>,
|
||||
subscriptions: HashMap<ServerId, SubscriptionState>,
|
||||
path_index: HashMap<String, ServerId>,
|
||||
}
|
||||
|
||||
impl MediaServerEventWorker {
|
||||
@@ -248,7 +207,7 @@ impl MediaServerEventWorker {
|
||||
self.drain_notifications();
|
||||
self.refresh_servers();
|
||||
self.renew_expiring();
|
||||
thread::sleep(Duration::from_millis(WORKER_LOOP_INTERVAL_MILLIS));
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,60 +218,54 @@ impl MediaServerEventWorker {
|
||||
}
|
||||
|
||||
fn refresh_servers(&mut self) {
|
||||
let servers = {
|
||||
let server_infos = {
|
||||
let reg = self.registry.read().unwrap();
|
||||
match reg.list_servers() {
|
||||
Ok(servers) => servers,
|
||||
Err(e) => {
|
||||
error!("Failed to list servers: {}", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
reg.list_servers()
|
||||
};
|
||||
|
||||
let mut active: HashSet<DeviceId> = HashSet::new();
|
||||
let mut active: HashSet<ServerId> = HashSet::new();
|
||||
|
||||
for server in servers {
|
||||
if !server.is_online() || !server.has_content_directory() {
|
||||
for info in server_infos {
|
||||
if !info.online || !info.has_content_directory {
|
||||
continue;
|
||||
}
|
||||
|
||||
active.insert(server.id());
|
||||
active.insert(info.id.clone());
|
||||
let entry = self
|
||||
.subscriptions
|
||||
.entry(server.id())
|
||||
.or_insert_with(|| SubscriptionState::from_music_server(&server));
|
||||
entry.update_from_server(&server);
|
||||
.entry(info.id.clone())
|
||||
.or_insert_with(|| SubscriptionState::new(info.clone()));
|
||||
entry.update(info);
|
||||
self.path_index
|
||||
.insert(entry.callback_path.clone(), entry.device_id.clone());
|
||||
.insert(entry.callback_path.clone(), entry.info.id.clone());
|
||||
|
||||
if entry.event_sub_url.is_none() {
|
||||
if entry.retry_policy.should_retry() {
|
||||
match fetch_event_sub_url(&entry.location, self.http_timeout) {
|
||||
if entry.should_retry() {
|
||||
match fetch_event_sub_url(&entry.info.location, self.http_timeout) {
|
||||
Ok(Some(url)) => {
|
||||
debug!(
|
||||
server = entry.friendly_name.as_str(),
|
||||
server = entry.info.friendly_name.as_str(),
|
||||
callback = url.as_str(),
|
||||
"ContentDirectory eventSub URL resolved"
|
||||
);
|
||||
entry.event_sub_url = Some(url);
|
||||
entry.retry_policy = RetryPolicy::new();
|
||||
entry.retry_after = Instant::now();
|
||||
}
|
||||
Ok(None) => {
|
||||
debug!(
|
||||
server = entry.friendly_name.as_str(),
|
||||
server = entry.info.friendly_name.as_str(),
|
||||
"No ContentDirectory eventSub URL found"
|
||||
);
|
||||
entry.retry_policy.defer_retry();
|
||||
entry.defer_retry();
|
||||
continue;
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
server = entry.friendly_name.as_str(),
|
||||
server = entry.info.friendly_name.as_str(),
|
||||
error = %err,
|
||||
"Failed to fetch ContentDirectory eventSub URL"
|
||||
);
|
||||
entry.retry_policy.defer_retry();
|
||||
entry.defer_retry();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -321,21 +274,21 @@ impl MediaServerEventWorker {
|
||||
}
|
||||
}
|
||||
|
||||
if entry.sid.is_none() && entry.retry_policy.should_retry() {
|
||||
if entry.sid.is_none() && entry.should_retry() {
|
||||
if let Err(err) =
|
||||
Self::subscribe_entry(self.listener_port, self.http_timeout, entry)
|
||||
{
|
||||
warn!(
|
||||
server = entry.friendly_name.as_str(),
|
||||
server = entry.info.friendly_name.as_str(),
|
||||
error = %err,
|
||||
"ContentDirectory SUBSCRIBE failed"
|
||||
);
|
||||
entry.retry_policy.defer_retry();
|
||||
entry.defer_retry();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let stale_ids: Vec<DeviceId> = self
|
||||
let stale_ids: Vec<ServerId> = self
|
||||
.subscriptions
|
||||
.keys()
|
||||
.filter(|id| !active.contains(*id))
|
||||
@@ -365,7 +318,7 @@ impl MediaServerEventWorker {
|
||||
if let Some(entry) = self.subscriptions.get_mut(&id) {
|
||||
if let Err(err) = Self::renew_entry(self.http_timeout, entry) {
|
||||
warn!(
|
||||
server = entry.friendly_name.as_str(),
|
||||
server = entry.info.friendly_name.as_str(),
|
||||
error = %err,
|
||||
"Failed to renew ContentDirectory subscription"
|
||||
);
|
||||
@@ -385,10 +338,8 @@ impl MediaServerEventWorker {
|
||||
.as_ref()
|
||||
.context("EventSub URL missing for server")?;
|
||||
|
||||
let (host_header, timeout_header) = build_subscribe_headers(event_url)?;
|
||||
|
||||
let (remote_host, remote_port) =
|
||||
parse_host_port(event_url).context("Cannot extract host for callback")?;
|
||||
parse_host_port(event_url).context("Cannot extract host for SUBSCRIBE")?;
|
||||
let local_ip = determine_local_ip(&remote_host, remote_port)
|
||||
.context("Cannot determine local IP for callback")?;
|
||||
|
||||
@@ -400,11 +351,13 @@ impl MediaServerEventWorker {
|
||||
);
|
||||
|
||||
debug!(
|
||||
server = entry.friendly_name.as_str(),
|
||||
server = entry.info.friendly_name.as_str(),
|
||||
callback = callback_url.as_str(),
|
||||
"Subscribing to ContentDirectory events"
|
||||
);
|
||||
|
||||
let host_header = format!("{}:{}", remote_host, remote_port);
|
||||
let timeout_header = format!("Second-{}", SUBSCRIPTION_TIMEOUT_SECS);
|
||||
let callback_header = format!("<{}>", callback_url);
|
||||
|
||||
let request = http::Request::builder()
|
||||
@@ -438,10 +391,10 @@ impl MediaServerEventWorker {
|
||||
|
||||
entry.sid = Some(sid);
|
||||
entry.expires_at = Some(Instant::now() + timeout);
|
||||
entry.retry_policy.schedule_soon();
|
||||
entry.retry_after = Instant::now() + Duration::from_secs(5);
|
||||
|
||||
info!(
|
||||
server = entry.friendly_name.as_str(),
|
||||
server = entry.info.friendly_name.as_str(),
|
||||
"Subscribed to ContentDirectory events (timeout {}s)",
|
||||
timeout.as_secs()
|
||||
);
|
||||
@@ -459,9 +412,10 @@ impl MediaServerEventWorker {
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.context("SID missing for renew")?;
|
||||
|
||||
let (host_header, timeout_header) = build_subscribe_headers(event_url)?;
|
||||
|
||||
let (remote_host, remote_port) =
|
||||
parse_host_port(event_url).context("Cannot extract host for renew")?;
|
||||
let host_header = format!("{}:{}", remote_host, remote_port);
|
||||
let timeout_header = format!("Second-{}", SUBSCRIPTION_TIMEOUT_SECS);
|
||||
let request = http::Request::builder()
|
||||
.method("SUBSCRIBE")
|
||||
.uri(event_url)
|
||||
@@ -485,7 +439,7 @@ impl MediaServerEventWorker {
|
||||
.unwrap_or(Duration::from_secs(SUBSCRIPTION_TIMEOUT_SECS));
|
||||
entry.expires_at = Some(Instant::now() + timeout);
|
||||
debug!(
|
||||
server = entry.friendly_name.as_str(),
|
||||
server = entry.info.friendly_name.as_str(),
|
||||
"Renewed ContentDirectory subscription"
|
||||
);
|
||||
Ok(())
|
||||
@@ -514,7 +468,7 @@ impl MediaServerEventWorker {
|
||||
Ok(req) => req,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
server = entry.friendly_name.as_str(),
|
||||
server = entry.info.friendly_name.as_str(),
|
||||
error = %err,
|
||||
"Failed to build UNSUBSCRIBE request"
|
||||
);
|
||||
@@ -526,12 +480,12 @@ impl MediaServerEventWorker {
|
||||
Ok(response) => {
|
||||
if response.status().is_success() {
|
||||
debug!(
|
||||
server = entry.friendly_name.as_str(),
|
||||
server = entry.info.friendly_name.as_str(),
|
||||
"Unsubscribed from ContentDirectory events"
|
||||
);
|
||||
} else {
|
||||
warn!(
|
||||
server = entry.friendly_name.as_str(),
|
||||
server = entry.info.friendly_name.as_str(),
|
||||
status = %response.status(),
|
||||
"UNSUBSCRIBE returned non-success status"
|
||||
);
|
||||
@@ -539,7 +493,7 @@ impl MediaServerEventWorker {
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
server = entry.friendly_name.as_str(),
|
||||
server = entry.info.friendly_name.as_str(),
|
||||
error = %err,
|
||||
"UNSUBSCRIBE request failed"
|
||||
);
|
||||
@@ -557,30 +511,32 @@ impl MediaServerEventWorker {
|
||||
return;
|
||||
};
|
||||
|
||||
if !notify.validate_sid(&entry.sid) {
|
||||
debug!(
|
||||
server = entry.friendly_name.as_str(),
|
||||
expected_sid = entry.sid.as_deref().unwrap_or("none"),
|
||||
received_sid = notify.sid.as_deref().unwrap_or("none"),
|
||||
"Ignoring notify with mismatched SID"
|
||||
);
|
||||
return;
|
||||
if let (Some(expected), Some(received)) = (&entry.sid, ¬ify.sid) {
|
||||
if !expected.eq_ignore_ascii_case(received) {
|
||||
debug!(
|
||||
server = entry.info.friendly_name.as_str(),
|
||||
expected_sid = expected.as_str(),
|
||||
received_sid = received.as_str(),
|
||||
"Ignoring notify with mismatched SID"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
for event in parse_notify_payload(&entry.device_id, ¬ify.body) {
|
||||
for event in parse_notify_payload(&entry.info.id, ¬ify.body) {
|
||||
match &event {
|
||||
MediaServerEvent::GlobalUpdated {
|
||||
system_update_id, ..
|
||||
} => {
|
||||
debug!(
|
||||
server = entry.friendly_name.as_str(),
|
||||
server = entry.info.friendly_name.as_str(),
|
||||
update_id = system_update_id.unwrap_or_default(),
|
||||
"Broadcasting MediaServerEvent::GlobalUpdated"
|
||||
);
|
||||
}
|
||||
MediaServerEvent::ContainersUpdated { container_ids, .. } => {
|
||||
debug!(
|
||||
server = entry.friendly_name.as_str(),
|
||||
server = entry.info.friendly_name.as_str(),
|
||||
changed_containers = container_ids.join(",").as_str(),
|
||||
"Broadcasting MediaServerEvent::ContainersUpdated"
|
||||
);
|
||||
@@ -595,63 +551,52 @@ impl MediaServerEventWorker {
|
||||
}
|
||||
|
||||
struct SubscriptionState {
|
||||
device_id: DeviceId,
|
||||
location: String,
|
||||
friendly_name: String,
|
||||
info: MediaServerInfo,
|
||||
event_sub_url: Option<String>,
|
||||
sid: Option<String>,
|
||||
expires_at: Option<Instant>,
|
||||
callback_path: String,
|
||||
retry_policy: RetryPolicy,
|
||||
retry_after: Instant,
|
||||
}
|
||||
|
||||
impl SubscriptionState {
|
||||
/// Creates a new subscription state from a MusicServer
|
||||
fn from_music_server(server: &MusicServer) -> Self {
|
||||
fn new(info: MediaServerInfo) -> Self {
|
||||
Self {
|
||||
callback_path: build_callback_path(&server.id()),
|
||||
device_id: server.id(),
|
||||
location: server.location().to_string(),
|
||||
friendly_name: server.friendly_name().to_string(),
|
||||
callback_path: build_callback_path(&info.id),
|
||||
info,
|
||||
event_sub_url: None,
|
||||
sid: None,
|
||||
expires_at: None,
|
||||
retry_policy: RetryPolicy::new(),
|
||||
retry_after: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the subscription state from a MusicServer
|
||||
fn update_from_server(&mut self, server: &MusicServer) {
|
||||
let new_location = server.location();
|
||||
if self.location != new_location {
|
||||
// Location changed - invalidate subscription
|
||||
fn update(&mut self, info: MediaServerInfo) {
|
||||
if self.info.location != info.location {
|
||||
self.event_sub_url = None;
|
||||
self.sid = None;
|
||||
self.expires_at = None;
|
||||
self.retry_policy = RetryPolicy::new();
|
||||
self.retry_after = Instant::now();
|
||||
}
|
||||
self.device_id = server.id();
|
||||
self.location = new_location.to_string();
|
||||
self.friendly_name = server.friendly_name().to_string();
|
||||
self.info = info;
|
||||
}
|
||||
|
||||
fn should_retry(&self) -> bool {
|
||||
Instant::now() >= self.retry_after
|
||||
}
|
||||
|
||||
fn defer_retry(&mut self) {
|
||||
self.retry_after = Instant::now() + Duration::from_secs(15);
|
||||
}
|
||||
|
||||
fn reset_subscription(&mut self) {
|
||||
self.sid = None;
|
||||
self.expires_at = None;
|
||||
self.retry_policy.schedule_soon();
|
||||
self.retry_after = Instant::now() + Duration::from_secs(5);
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds HTTP headers common to SUBSCRIBE requests
|
||||
fn build_subscribe_headers(event_url: &str) -> Result<(String, String)> {
|
||||
let (remote_host, remote_port) =
|
||||
parse_host_port(event_url).context("Cannot extract host for SUBSCRIBE")?;
|
||||
let host_header = format!("{}:{}", remote_host, remote_port);
|
||||
let timeout_header = format!("Second-{}", SUBSCRIPTION_TIMEOUT_SECS);
|
||||
Ok((host_header, timeout_header))
|
||||
}
|
||||
|
||||
fn build_callback_path(id: &DeviceId) -> String {
|
||||
fn build_callback_path(id: &ServerId) -> String {
|
||||
let mut sanitized = String::new();
|
||||
for ch in id.0.chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
@@ -668,7 +613,7 @@ fn build_callback_path(id: &DeviceId) -> String {
|
||||
format!("/media-server-events/{}-{:x}", sanitized, suffix)
|
||||
}
|
||||
|
||||
fn parse_notify_payload(server_id: &DeviceId, body: &[u8]) -> Vec<MediaServerEvent> {
|
||||
fn parse_notify_payload(server_id: &ServerId, body: &[u8]) -> Vec<MediaServerEvent> {
|
||||
let mut events = Vec::new();
|
||||
let reader = std::io::Cursor::new(body);
|
||||
let Ok(root) = Element::parse(reader) else {
|
||||
@@ -682,21 +627,27 @@ fn parse_notify_payload(server_id: &DeviceId, body: &[u8]) -> Vec<MediaServerEve
|
||||
let mut system_update_id: Option<u32> = None;
|
||||
let mut container_ids: Vec<String> = Vec::new();
|
||||
|
||||
// Navigate through property elements
|
||||
for property in xml_children(&root) {
|
||||
for child in xml_children(property) {
|
||||
match child.name.as_str() {
|
||||
"SystemUpdateID" => {
|
||||
system_update_id = child
|
||||
.get_text()
|
||||
.and_then(|text| text.trim().parse::<u32>().ok());
|
||||
}
|
||||
"ContainerUpdateIDs" => {
|
||||
if let Some(text) = child.get_text() {
|
||||
container_ids = parse_container_update_ids(text.as_ref());
|
||||
for property in root.children.iter().filter_map(|node| match node {
|
||||
XMLNode::Element(elem) => Some(elem),
|
||||
_ => None,
|
||||
}) {
|
||||
for child in property.children.iter().filter_map(|node| match node {
|
||||
XMLNode::Element(elem) => Some(elem),
|
||||
_ => None,
|
||||
}) {
|
||||
if child.name == "SystemUpdateID" {
|
||||
if let Some(text) = child.get_text() {
|
||||
let trimmed = text.trim();
|
||||
if let Ok(value) = trimmed.parse::<u32>() {
|
||||
system_update_id = Some(value);
|
||||
} else {
|
||||
system_update_id = None;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
} else if child.name == "ContainerUpdateIDs" {
|
||||
if let Some(text) = child.get_text() {
|
||||
container_ids = parse_container_update_ids(text.as_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -718,14 +669,6 @@ fn parse_notify_payload(server_id: &DeviceId, body: &[u8]) -> Vec<MediaServerEve
|
||||
events
|
||||
}
|
||||
|
||||
/// Helper to iterate over XML element children (filters out non-element nodes)
|
||||
fn xml_children(element: &Element) -> impl Iterator<Item = &Element> {
|
||||
element.children.iter().filter_map(|node| match node {
|
||||
XMLNode::Element(elem) => Some(elem),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_container_update_ids(raw: &str) -> Vec<String> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
@@ -757,9 +700,14 @@ fn parse_container_update_ids(raw: &str) -> Vec<String> {
|
||||
}
|
||||
|
||||
fn child_text(element: &Element, name: &str) -> Option<String> {
|
||||
xml_children(element)
|
||||
.find(|child| child.name == name)
|
||||
.and_then(|child| child.get_text().map(|cow| cow.into_owned()))
|
||||
for node in &element.children {
|
||||
if let XMLNode::Element(child) = node {
|
||||
if child.name == name {
|
||||
return child.get_text().map(|cow| cow.into_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn fetch_event_sub_url(location: &str, timeout: Duration) -> Result<Option<String>> {
|
||||
@@ -784,18 +732,20 @@ fn fetch_event_sub_url(location: &str, timeout: Duration) -> Result<Option<Strin
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
for service in xml_children(service_list) {
|
||||
let Some(service_type) = child_text(service, "serviceType") else {
|
||||
continue;
|
||||
};
|
||||
if !service_type
|
||||
.to_ascii_lowercase()
|
||||
.contains("urn:schemas-upnp-org:service:contentdirectory:")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(event_sub) = child_text(service, "eventSubURL") {
|
||||
return Ok(Some(resolve_control_url(location, &event_sub)));
|
||||
for node in &service_list.children {
|
||||
if let XMLNode::Element(service) = node {
|
||||
let Some(service_type) = child_text(service, "serviceType") else {
|
||||
continue;
|
||||
};
|
||||
if !service_type
|
||||
.to_ascii_lowercase()
|
||||
.contains("urn:schemas-upnp-org:service:contentdirectory:")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(event_sub) = child_text(service, "eventSubURL") {
|
||||
return Ok(Some(resolve_control_url(location, &event_sub)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,87 +1,9 @@
|
||||
use crate::music_renderer::{PlaybackPositionInfo, PlaylistBinding};
|
||||
use crate::{DeviceId, DeviceIdentity};
|
||||
use crate::capabilities::{PlaybackPositionInfo, PlaybackState};
|
||||
use crate::control_point::PlaylistBinding;
|
||||
use crate::media_server::{MediaServerInfo, ServerId};
|
||||
|
||||
/// Basic device information for event notifications
|
||||
/// Contains only the essential identification fields
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DeviceBasicInfo {
|
||||
pub id: DeviceId,
|
||||
pub friendly_name: String,
|
||||
pub model_name: String,
|
||||
pub manufacturer: String,
|
||||
}
|
||||
|
||||
impl DeviceBasicInfo {
|
||||
/// Create from any type implementing DeviceIdentity
|
||||
pub fn from_device<D: DeviceIdentity>(device: &D) -> Self {
|
||||
Self {
|
||||
id: device.id(),
|
||||
friendly_name: device.friendly_name().to_string(),
|
||||
model_name: device.model_name().to_string(),
|
||||
manufacturer: device.manufacturer().to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// High-level playback state across backends.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum PlaybackState {
|
||||
Stopped,
|
||||
Playing,
|
||||
Paused,
|
||||
Transitioning,
|
||||
NoMedia,
|
||||
/// Backend-specific or unknown state string.
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl PlaybackState {
|
||||
/// Map a raw UPnP AVTransport CurrentTransportState string
|
||||
/// to a logical PlaybackState.
|
||||
pub fn from_upnp_state(raw: &str) -> Self {
|
||||
let s = raw.trim().to_ascii_uppercase();
|
||||
match s.as_str() {
|
||||
"STOPPED" => PlaybackState::Stopped,
|
||||
"PLAYING" => PlaybackState::Playing,
|
||||
"PAUSED_PLAYBACK" => PlaybackState::Paused,
|
||||
// States from the AVTransport spec that we normalize:
|
||||
"PAUSED_RECORDING" => PlaybackState::Paused,
|
||||
"RECORDING" => PlaybackState::Playing,
|
||||
// Common vendor-specific states:
|
||||
"TRANSITIONING" => PlaybackState::Transitioning,
|
||||
"BUFFERING" | "PREPARING" => PlaybackState::Transitioning,
|
||||
"NO_MEDIA_PRESENT" => PlaybackState::NoMedia,
|
||||
_ => PlaybackState::Unknown(raw.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a human-readable label for the playback state.
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
PlaybackState::Stopped => "STOPPED",
|
||||
PlaybackState::Playing => "PLAYING",
|
||||
PlaybackState::Paused => "PAUSED",
|
||||
PlaybackState::Transitioning => "TRANSITIONING",
|
||||
PlaybackState::NoMedia => "NO_MEDIA",
|
||||
PlaybackState::Unknown(s) => s.as_str(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Indicates the source of the current playback.
|
||||
///
|
||||
/// This helps the control point distinguish between playback initiated
|
||||
/// from the queue vs external sources (e.g., user playing from another app).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum PlaybackSource {
|
||||
/// No playback active or source unknown.
|
||||
#[default]
|
||||
None,
|
||||
/// Playback was started from the control point's queue.
|
||||
FromQueue,
|
||||
/// Playback was started externally (e.g., from another app).
|
||||
External,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct RendererId(pub String);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TrackMetadata {
|
||||
@@ -95,11 +17,11 @@ pub struct TrackMetadata {
|
||||
pub creator: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Copy)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum RendererProtocol {
|
||||
UpnpAvOnly,
|
||||
OpenHomeOnly,
|
||||
OpenHomeHybrid,
|
||||
Hybrid,
|
||||
ChromecastOnly,
|
||||
}
|
||||
|
||||
@@ -125,74 +47,6 @@ pub struct RendererCapabilities {
|
||||
}
|
||||
|
||||
impl RendererCapabilities {
|
||||
pub fn make(
|
||||
has_avtransport: bool,
|
||||
has_avtransport_set_next: bool,
|
||||
has_rendering_control: bool,
|
||||
has_connection_manager: bool,
|
||||
has_linkplay_http: bool,
|
||||
has_arylic_tcp: bool,
|
||||
has_oh_playlist: bool,
|
||||
has_oh_volume: bool,
|
||||
has_oh_info: bool,
|
||||
has_oh_time: bool,
|
||||
has_oh_radio: bool,
|
||||
has_chromecast: bool,
|
||||
) -> Self {
|
||||
RendererCapabilities {
|
||||
has_avtransport,
|
||||
has_avtransport_set_next,
|
||||
has_rendering_control,
|
||||
has_connection_manager,
|
||||
has_linkplay_http,
|
||||
has_arylic_tcp,
|
||||
has_oh_playlist,
|
||||
has_oh_volume,
|
||||
has_oh_info,
|
||||
has_oh_time,
|
||||
has_oh_radio,
|
||||
has_chromecast,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_avtransport(&self) -> bool {
|
||||
self.has_avtransport
|
||||
}
|
||||
pub fn has_avtransport_set_next(&self) -> bool {
|
||||
self.has_avtransport_set_next
|
||||
}
|
||||
pub fn has_rendering_control(&self) -> bool {
|
||||
self.has_rendering_control
|
||||
}
|
||||
pub fn has_connection_manager(&self) -> bool {
|
||||
self.has_connection_manager
|
||||
}
|
||||
pub fn has_linkplay_http(&self) -> bool {
|
||||
self.has_linkplay_http
|
||||
}
|
||||
pub fn has_arylic_tcp(&self) -> bool {
|
||||
self.has_arylic_tcp
|
||||
}
|
||||
pub fn has_oh_playlist(&self) -> bool {
|
||||
self.has_oh_playlist
|
||||
}
|
||||
pub fn has_oh_volume(&self) -> bool {
|
||||
self.has_oh_volume
|
||||
}
|
||||
pub fn has_oh_info(&self) -> bool {
|
||||
self.has_oh_info
|
||||
}
|
||||
pub fn has_oh_time(&self) -> bool {
|
||||
self.has_oh_time
|
||||
}
|
||||
pub fn has_oh_radio(&self) -> bool {
|
||||
self.has_oh_radio
|
||||
}
|
||||
|
||||
pub fn has_chromecast(&self) -> bool {
|
||||
self.has_chromecast
|
||||
}
|
||||
|
||||
pub fn supports_set_next(&self) -> bool {
|
||||
self.has_avtransport && self.has_avtransport_set_next
|
||||
}
|
||||
@@ -200,264 +54,98 @@ impl RendererCapabilities {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RendererInfo {
|
||||
id: DeviceId,
|
||||
udn: String,
|
||||
friendly_name: String,
|
||||
model_name: String,
|
||||
manufacturer: String,
|
||||
pub id: RendererId,
|
||||
pub udn: String,
|
||||
pub friendly_name: String,
|
||||
pub model_name: String,
|
||||
pub manufacturer: String,
|
||||
|
||||
protocol: RendererProtocol,
|
||||
capabilities: RendererCapabilities,
|
||||
pub protocol: RendererProtocol,
|
||||
pub capabilities: RendererCapabilities,
|
||||
|
||||
location: String,
|
||||
pub location: String,
|
||||
pub server_header: String,
|
||||
pub online: bool,
|
||||
pub last_seen: std::time::SystemTime,
|
||||
pub max_age: u32,
|
||||
|
||||
server_header: String,
|
||||
avtransport_service_type: Option<String>,
|
||||
avtransport_control_url: Option<String>,
|
||||
rendering_control_service_type: Option<String>,
|
||||
rendering_control_control_url: Option<String>,
|
||||
connection_manager_service_type: Option<String>,
|
||||
connection_manager_control_url: Option<String>,
|
||||
oh_playlist_service_type: Option<String>,
|
||||
oh_playlist_control_url: Option<String>,
|
||||
oh_playlist_event_sub_url: Option<String>,
|
||||
oh_info_service_type: Option<String>,
|
||||
oh_info_control_url: Option<String>,
|
||||
oh_info_event_sub_url: Option<String>,
|
||||
oh_time_service_type: Option<String>,
|
||||
oh_time_control_url: Option<String>,
|
||||
oh_time_event_sub_url: Option<String>,
|
||||
oh_volume_service_type: Option<String>,
|
||||
oh_volume_control_url: Option<String>,
|
||||
oh_radio_service_type: Option<String>,
|
||||
oh_radio_control_url: Option<String>,
|
||||
oh_product_service_type: Option<String>,
|
||||
oh_product_control_url: Option<String>,
|
||||
}
|
||||
|
||||
impl RendererInfo {
|
||||
pub fn make(
|
||||
id: DeviceId,
|
||||
udn: String,
|
||||
friendly_name: String,
|
||||
model_name: String,
|
||||
manufacturer: String,
|
||||
protocol: RendererProtocol,
|
||||
capabilities: RendererCapabilities,
|
||||
location: String,
|
||||
server_header: String,
|
||||
avtransport_service_type: Option<String>,
|
||||
avtransport_control_url: Option<String>,
|
||||
rendering_control_service_type: Option<String>,
|
||||
rendering_control_control_url: Option<String>,
|
||||
connection_manager_service_type: Option<String>,
|
||||
connection_manager_control_url: Option<String>,
|
||||
oh_playlist_service_type: Option<String>,
|
||||
oh_playlist_control_url: Option<String>,
|
||||
oh_playlist_event_sub_url: Option<String>,
|
||||
oh_info_service_type: Option<String>,
|
||||
oh_info_control_url: Option<String>,
|
||||
oh_info_event_sub_url: Option<String>,
|
||||
oh_time_service_type: Option<String>,
|
||||
oh_time_control_url: Option<String>,
|
||||
oh_time_event_sub_url: Option<String>,
|
||||
oh_volume_service_type: Option<String>,
|
||||
oh_volume_control_url: Option<String>,
|
||||
oh_radio_service_type: Option<String>,
|
||||
oh_radio_control_url: Option<String>,
|
||||
oh_product_service_type: Option<String>,
|
||||
oh_product_control_url: Option<String>,
|
||||
) -> RendererInfo {
|
||||
RendererInfo {
|
||||
id,
|
||||
udn,
|
||||
friendly_name,
|
||||
model_name,
|
||||
manufacturer,
|
||||
protocol,
|
||||
capabilities,
|
||||
location,
|
||||
server_header,
|
||||
avtransport_service_type,
|
||||
avtransport_control_url,
|
||||
rendering_control_service_type,
|
||||
rendering_control_control_url,
|
||||
connection_manager_service_type,
|
||||
connection_manager_control_url,
|
||||
oh_playlist_service_type,
|
||||
oh_playlist_control_url,
|
||||
oh_playlist_event_sub_url,
|
||||
oh_info_service_type,
|
||||
oh_info_control_url,
|
||||
oh_info_event_sub_url,
|
||||
oh_time_service_type,
|
||||
oh_time_control_url,
|
||||
oh_time_event_sub_url,
|
||||
oh_volume_service_type,
|
||||
oh_volume_control_url,
|
||||
oh_radio_service_type,
|
||||
oh_radio_control_url,
|
||||
oh_product_service_type,
|
||||
oh_product_control_url,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn protocol(&self) -> RendererProtocol {
|
||||
self.protocol
|
||||
}
|
||||
|
||||
pub fn capabilities(&self) -> &RendererCapabilities {
|
||||
&self.capabilities
|
||||
}
|
||||
|
||||
pub fn avtransport_service_type(&self) -> Option<String> {
|
||||
self.avtransport_service_type.clone()
|
||||
}
|
||||
|
||||
pub fn avtransport_control_url(&self) -> Option<String> {
|
||||
self.avtransport_control_url.clone()
|
||||
}
|
||||
pub fn rendering_control_service_type(&self) -> Option<String> {
|
||||
self.rendering_control_service_type.clone()
|
||||
}
|
||||
pub fn rendering_control_control_url(&self) -> Option<String> {
|
||||
self.rendering_control_control_url.clone()
|
||||
}
|
||||
pub fn connection_manager_service_type(&self) -> Option<String> {
|
||||
self.connection_manager_service_type.clone()
|
||||
}
|
||||
pub fn connection_manager_control_url(&self) -> Option<String> {
|
||||
self.connection_manager_control_url.clone()
|
||||
}
|
||||
pub fn oh_playlist_service_type(&self) -> Option<String> {
|
||||
self.oh_playlist_service_type.clone()
|
||||
}
|
||||
pub fn oh_playlist_control_url(&self) -> Option<String> {
|
||||
self.oh_playlist_control_url.clone()
|
||||
}
|
||||
pub fn oh_playlist_event_sub_url(&self) -> Option<String> {
|
||||
self.oh_playlist_event_sub_url.clone()
|
||||
}
|
||||
pub fn oh_info_service_type(&self) -> Option<String> {
|
||||
self.oh_info_service_type.clone()
|
||||
}
|
||||
pub fn oh_info_control_url(&self) -> Option<String> {
|
||||
self.oh_info_control_url.clone()
|
||||
}
|
||||
pub fn oh_info_event_sub_url(&self) -> Option<String> {
|
||||
self.oh_info_event_sub_url.clone()
|
||||
}
|
||||
pub fn oh_time_service_type(&self) -> Option<String> {
|
||||
self.oh_time_service_type.clone()
|
||||
}
|
||||
pub fn oh_time_control_url(&self) -> Option<String> {
|
||||
self.oh_time_control_url.clone()
|
||||
}
|
||||
pub fn oh_time_event_sub_url(&self) -> Option<String> {
|
||||
self.oh_time_event_sub_url.clone()
|
||||
}
|
||||
pub fn oh_volume_service_type(&self) -> Option<String> {
|
||||
self.oh_volume_service_type.clone()
|
||||
}
|
||||
pub fn oh_volume_control_url(&self) -> Option<String> {
|
||||
self.oh_volume_control_url.clone()
|
||||
}
|
||||
pub fn oh_radio_service_type(&self) -> Option<String> {
|
||||
self.oh_radio_service_type.clone()
|
||||
}
|
||||
pub fn oh_radio_control_url(&self) -> Option<String> {
|
||||
self.oh_radio_control_url.clone()
|
||||
}
|
||||
pub fn oh_product_service_type(&self) -> Option<String> {
|
||||
self.oh_product_service_type.clone()
|
||||
}
|
||||
pub fn oh_product_control_url(&self) -> Option<String> {
|
||||
self.oh_product_control_url.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl DeviceIdentity for RendererInfo {
|
||||
fn id(&self) -> DeviceId {
|
||||
self.id.clone()
|
||||
}
|
||||
fn udn(&self) -> &str {
|
||||
&self.udn
|
||||
}
|
||||
fn friendly_name(&self) -> &str {
|
||||
&self.friendly_name
|
||||
}
|
||||
fn model_name(&self) -> &str {
|
||||
&self.model_name
|
||||
}
|
||||
fn manufacturer(&self) -> &str {
|
||||
&self.manufacturer
|
||||
}
|
||||
fn location(&self) -> &str {
|
||||
&self.location
|
||||
}
|
||||
fn server_header(&self) -> &str {
|
||||
&self.server_header
|
||||
}
|
||||
|
||||
fn is_a_music_renderer(&self) -> bool {
|
||||
true
|
||||
}
|
||||
pub avtransport_service_type: Option<String>,
|
||||
pub avtransport_control_url: Option<String>,
|
||||
pub rendering_control_service_type: Option<String>,
|
||||
pub rendering_control_control_url: Option<String>,
|
||||
pub connection_manager_service_type: Option<String>,
|
||||
pub connection_manager_control_url: Option<String>,
|
||||
pub oh_playlist_service_type: Option<String>,
|
||||
pub oh_playlist_control_url: Option<String>,
|
||||
pub oh_playlist_event_sub_url: Option<String>,
|
||||
pub oh_info_service_type: Option<String>,
|
||||
pub oh_info_control_url: Option<String>,
|
||||
pub oh_info_event_sub_url: Option<String>,
|
||||
pub oh_time_service_type: Option<String>,
|
||||
pub oh_time_control_url: Option<String>,
|
||||
pub oh_time_event_sub_url: Option<String>,
|
||||
pub oh_volume_service_type: Option<String>,
|
||||
pub oh_volume_control_url: Option<String>,
|
||||
pub oh_radio_service_type: Option<String>,
|
||||
pub oh_radio_control_url: Option<String>,
|
||||
pub oh_product_service_type: Option<String>,
|
||||
pub oh_product_control_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum RendererEvent {
|
||||
StateChanged {
|
||||
id: DeviceId,
|
||||
id: RendererId,
|
||||
state: PlaybackState,
|
||||
},
|
||||
PositionChanged {
|
||||
id: DeviceId,
|
||||
id: RendererId,
|
||||
position: PlaybackPositionInfo,
|
||||
},
|
||||
VolumeChanged {
|
||||
id: DeviceId,
|
||||
id: RendererId,
|
||||
volume: u16,
|
||||
},
|
||||
MuteChanged {
|
||||
id: DeviceId,
|
||||
id: RendererId,
|
||||
mute: bool,
|
||||
},
|
||||
MetadataChanged {
|
||||
id: DeviceId,
|
||||
id: RendererId,
|
||||
metadata: TrackMetadata,
|
||||
},
|
||||
QueueUpdated {
|
||||
id: DeviceId,
|
||||
id: RendererId,
|
||||
queue_length: usize,
|
||||
},
|
||||
BindingChanged {
|
||||
id: DeviceId,
|
||||
id: RendererId,
|
||||
binding: Option<PlaylistBinding>,
|
||||
},
|
||||
Online {
|
||||
id: DeviceId,
|
||||
info: DeviceBasicInfo,
|
||||
id: RendererId,
|
||||
info: RendererInfo,
|
||||
},
|
||||
Offline {
|
||||
id: DeviceId,
|
||||
id: RendererId,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum MediaServerEvent {
|
||||
GlobalUpdated {
|
||||
server_id: DeviceId,
|
||||
server_id: ServerId,
|
||||
system_update_id: Option<u32>,
|
||||
},
|
||||
ContainersUpdated {
|
||||
server_id: DeviceId,
|
||||
server_id: ServerId,
|
||||
container_ids: Vec<String>,
|
||||
},
|
||||
Online {
|
||||
server_id: DeviceId,
|
||||
info: DeviceBasicInfo,
|
||||
server_id: ServerId,
|
||||
info: MediaServerInfo,
|
||||
},
|
||||
Offline {
|
||||
server_id: DeviceId,
|
||||
server_id: ServerId,
|
||||
},
|
||||
}
|
||||
|
||||
521
pmocontrol/src/music_renderer.rs
Normal file
521
pmocontrol/src/music_renderer.rs
Normal file
@@ -0,0 +1,521 @@
|
||||
//! Backend-agnostic music renderer façade for PMOMusic.
|
||||
//!
|
||||
//! `MusicRenderer` wraps every supported backend (UPnP AV/DLNA, OpenHome,
|
||||
//! LinkPlay HTTP, Arylic TCP, Chromecast, and the hybrid UPnP + Arylic pairing) behind a
|
||||
//! single control surface. Higher layers in PMOMusic must only interact with
|
||||
//! renderers through this type so that transport, volume, and state queries
|
||||
//! stay backend-neutral.
|
||||
|
||||
use std::sync::{Arc, OnceLock, RwLock};
|
||||
|
||||
use crate::capabilities::{PlaybackPositionInfo, PlaybackStatus};
|
||||
use crate::control_point::RendererRuntimeStateMut;
|
||||
use crate::control_point::music_queue::MusicQueue;
|
||||
use crate::control_point::openhome_queue::didl_id_from_metadata;
|
||||
use crate::media_server::ServerId;
|
||||
use crate::model::{RendererId, RendererInfo, RendererProtocol};
|
||||
use crate::openhome_client::parse_track_metadata_from_didl;
|
||||
use crate::openhome_playlist::OpenHomePlaylistSnapshot;
|
||||
use crate::queue_backend::PlaybackItem;
|
||||
use crate::{
|
||||
ArylicTcpRenderer, ChromecastRenderer, DeviceRegistry, LinkPlayRenderer, OpenHomeRenderer,
|
||||
PlaybackPosition, PlaybackState, TransportControl, UpnpRenderer, VolumeControl,
|
||||
};
|
||||
use anyhow::{Result, anyhow};
|
||||
use tracing::warn;
|
||||
|
||||
/// Backend-agnostic façade exposing transport, volume, and status contracts.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum MusicRenderer {
|
||||
/// Classic UPnP AV / DLNA renderer (AVTransport + RenderingControl).
|
||||
Upnp(UpnpRenderer),
|
||||
/// Renderer powered by OpenHome services.
|
||||
OpenHome(OpenHomeRenderer),
|
||||
/// Renderer controlled via the LinkPlay HTTP API.
|
||||
LinkPlay(LinkPlayRenderer),
|
||||
/// Renderer reachable through the Arylic TCP control protocol (port 8899).
|
||||
ArylicTcp(ArylicTcpRenderer),
|
||||
/// Renderer controlled via the Google Cast protocol (Chromecast).
|
||||
Chromecast(ChromecastRenderer),
|
||||
/// Combined backend using UPnP for transport + volume writes and Arylic TCP
|
||||
/// to read detailed playback information as well as live volume/mute state.
|
||||
HybridUpnpArylic {
|
||||
upnp: UpnpRenderer,
|
||||
arylic: ArylicTcpRenderer,
|
||||
},
|
||||
}
|
||||
|
||||
/// Build a standardized error when an operation is not supported by a backend.
|
||||
pub(crate) fn op_not_supported(op: &str, backend: &str) -> anyhow::Error {
|
||||
anyhow!(
|
||||
"MusicRenderer operation '{}' is not supported by backend '{}'",
|
||||
op,
|
||||
backend
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RendererRuntimeState {
|
||||
pub queue: MusicQueue,
|
||||
}
|
||||
|
||||
pub trait OpenHomeQueueProvider: Send + Sync + 'static {
|
||||
fn renderer_state(&self, renderer_id: &RendererId) -> Result<RendererRuntimeState>;
|
||||
fn renderer_state_mut<'a>(
|
||||
&'a self,
|
||||
renderer_id: &RendererId,
|
||||
) -> Result<RendererRuntimeStateMut<'a>>;
|
||||
fn invalidate_openhome_cache(&self, renderer_id: &RendererId) -> Result<()>;
|
||||
}
|
||||
|
||||
static OPENHOME_QUEUE_PROVIDER: OnceLock<Arc<dyn OpenHomeQueueProvider>> = OnceLock::new();
|
||||
|
||||
pub fn set_openhome_queue_provider(provider: Arc<dyn OpenHomeQueueProvider>) {
|
||||
let _ = OPENHOME_QUEUE_PROVIDER.set(provider);
|
||||
}
|
||||
|
||||
impl MusicRenderer {
|
||||
/// Renderer identifier (stable within the registry).
|
||||
pub fn id(&self) -> &RendererId {
|
||||
match self {
|
||||
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.id(),
|
||||
MusicRenderer::OpenHome(r) => r.id(),
|
||||
MusicRenderer::Upnp(r) => r.id(),
|
||||
MusicRenderer::LinkPlay(r) => r.id(),
|
||||
MusicRenderer::ArylicTcp(r) => r.id(),
|
||||
MusicRenderer::Chromecast(r) => r.id(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-friendly name reported by the device.
|
||||
pub fn friendly_name(&self) -> &str {
|
||||
match self {
|
||||
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.friendly_name(),
|
||||
MusicRenderer::OpenHome(r) => r.friendly_name(),
|
||||
MusicRenderer::Upnp(r) => r.friendly_name(),
|
||||
MusicRenderer::LinkPlay(r) => r.friendly_name(),
|
||||
MusicRenderer::ArylicTcp(r) => r.friendly_name(),
|
||||
MusicRenderer::Chromecast(r) => r.friendly_name(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Protocol classification (UPnP AV only, OpenHome only, hybrid).
|
||||
pub fn protocol(&self) -> &RendererProtocol {
|
||||
&self.info().protocol
|
||||
}
|
||||
|
||||
/// Full static info as stored in the registry.
|
||||
pub fn info(&self) -> &RendererInfo {
|
||||
match self {
|
||||
MusicRenderer::HybridUpnpArylic { arylic, .. } => &arylic.info,
|
||||
MusicRenderer::OpenHome(r) => &r.info,
|
||||
MusicRenderer::Upnp(r) => &r.info,
|
||||
MusicRenderer::LinkPlay(r) => &r.info,
|
||||
MusicRenderer::ArylicTcp(r) => &r.info,
|
||||
MusicRenderer::Chromecast(r) => &r.info,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return a reference to the underlying UPnP backend, if any.
|
||||
pub fn as_upnp(&self) -> Option<&UpnpRenderer> {
|
||||
match self {
|
||||
MusicRenderer::Upnp(r) => Some(r),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct a music renderer from a [`RendererInfo`] and the registry.
|
||||
///
|
||||
/// Returns `None` when no supported backend can be built for this renderer.
|
||||
/// UPnP AV / hybrid renderers map either to [`MusicRenderer::LinkPlay`] (when supported)
|
||||
/// or [`MusicRenderer::Upnp`].
|
||||
pub fn from_registry_info(
|
||||
info: RendererInfo,
|
||||
registry: &Arc<RwLock<DeviceRegistry>>,
|
||||
) -> Option<Self> {
|
||||
if matches!(
|
||||
info.protocol,
|
||||
RendererProtocol::OpenHomeOnly | RendererProtocol::Hybrid
|
||||
) {
|
||||
if let Some(renderer) = {
|
||||
let renderer = OpenHomeRenderer::new(info.clone());
|
||||
renderer.has_any_openhome_service().then_some(renderer)
|
||||
} {
|
||||
return Some(MusicRenderer::OpenHome(renderer));
|
||||
}
|
||||
|
||||
if matches!(info.protocol, RendererProtocol::OpenHomeOnly) {
|
||||
warn!(
|
||||
renderer = info.friendly_name.as_str(),
|
||||
"Renderer advertises OpenHome only but exposes no usable services"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
if matches!(info.protocol, RendererProtocol::ChromecastOnly) {
|
||||
if let Ok(renderer) = ChromecastRenderer::from_renderer_info(info.clone()) {
|
||||
return Some(MusicRenderer::Chromecast(renderer));
|
||||
}
|
||||
warn!(
|
||||
renderer = info.friendly_name.as_str(),
|
||||
"Failed to build Chromecast renderer"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
match info.protocol {
|
||||
RendererProtocol::UpnpAvOnly | RendererProtocol::Hybrid => {
|
||||
let has_arylic = info.capabilities.has_arylic_tcp;
|
||||
let has_avtransport = info.capabilities.has_avtransport;
|
||||
|
||||
if has_arylic && has_avtransport {
|
||||
// Construire UpnpRenderer
|
||||
let upnp = UpnpRenderer::from_registry(info.clone(), registry);
|
||||
|
||||
// Construire ArylicTcpRenderer
|
||||
match ArylicTcpRenderer::from_renderer_info(info.clone()) {
|
||||
Ok(arylic) => {
|
||||
return Some(MusicRenderer::HybridUpnpArylic { upnp, arylic });
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"Failed to build Arylic TCP backend for {}: {}. Falling back to UPnP only.",
|
||||
info.friendly_name, err
|
||||
);
|
||||
return Some(MusicRenderer::Upnp(upnp));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pas d’Arylic : logique existante
|
||||
if info.capabilities.has_linkplay_http {
|
||||
if let Ok(lp) = LinkPlayRenderer::from_renderer_info(info.clone()) {
|
||||
return Some(MusicRenderer::LinkPlay(lp));
|
||||
}
|
||||
}
|
||||
|
||||
Some(MusicRenderer::Upnp(UpnpRenderer::from_registry(
|
||||
info, registry,
|
||||
)))
|
||||
}
|
||||
RendererProtocol::OpenHomeOnly => None,
|
||||
RendererProtocol::ChromecastOnly => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn openhome_playlist_snapshot(&self) -> Result<OpenHomePlaylistSnapshot> {
|
||||
self.fetch_openhome_playlist_snapshot()
|
||||
}
|
||||
|
||||
pub(crate) fn fetch_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_len(&self) -> Result<usize> {
|
||||
match self {
|
||||
MusicRenderer::OpenHome(renderer) => renderer.openhome_playlist_len(),
|
||||
_ => Err(op_not_supported(
|
||||
"openhome_playlist_len",
|
||||
self.unsupported_backend_name(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn openhome_playlist_ids(&self) -> Result<Vec<u32>> {
|
||||
match self {
|
||||
MusicRenderer::OpenHome(renderer) => renderer.openhome_playlist_ids(),
|
||||
_ => Err(op_not_supported(
|
||||
"openhome_playlist_ids",
|
||||
self.unsupported_backend_name(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn openhome_playlist_clear(&self) -> Result<()> {
|
||||
if let Some(provider) = OPENHOME_QUEUE_PROVIDER.get() {
|
||||
let result = {
|
||||
let mut state = provider.renderer_state_mut(self.id())?;
|
||||
match &mut *state.queue {
|
||||
MusicQueue::OpenHome(queue) => queue.clear(),
|
||||
_ => Err(op_not_supported(
|
||||
"openhome_playlist_clear",
|
||||
self.unsupported_backend_name(),
|
||||
)),
|
||||
}
|
||||
};
|
||||
if result.is_ok() {
|
||||
provider.invalidate_openhome_cache(self.id())?;
|
||||
}
|
||||
result
|
||||
} else {
|
||||
self.fetch_openhome_playlist_clear()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn fetch_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> {
|
||||
if let Some(provider) = OPENHOME_QUEUE_PROVIDER.get() {
|
||||
let result = {
|
||||
let mut state = provider.renderer_state_mut(self.id())?;
|
||||
match &mut *state.queue {
|
||||
MusicQueue::OpenHome(queue) => {
|
||||
let playback_item =
|
||||
Self::playback_item_from_params(self.id(), uri, metadata)?;
|
||||
queue.add_playback_item(playback_item, after_id, play)
|
||||
}
|
||||
_ => Err(op_not_supported(
|
||||
"openhome_playlist_add_track",
|
||||
self.unsupported_backend_name(),
|
||||
)),
|
||||
}
|
||||
};
|
||||
if result.is_ok() {
|
||||
provider.invalidate_openhome_cache(self.id())?;
|
||||
}
|
||||
result
|
||||
} else {
|
||||
self.fetch_openhome_playlist_add_track(uri, metadata, after_id, play)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn openhome_playlist_play_id(&self, id: u32) -> Result<()> {
|
||||
if let Some(provider) = OPENHOME_QUEUE_PROVIDER.get() {
|
||||
let result = {
|
||||
let mut state = provider.renderer_state_mut(self.id())?;
|
||||
match &mut *state.queue {
|
||||
MusicQueue::OpenHome(queue) => queue.select_track_id(id),
|
||||
_ => Err(op_not_supported(
|
||||
"openhome_playlist_play_id",
|
||||
self.unsupported_backend_name(),
|
||||
)),
|
||||
}
|
||||
};
|
||||
if result.is_ok() {
|
||||
provider.invalidate_openhome_cache(self.id())?;
|
||||
}
|
||||
result
|
||||
} else {
|
||||
self.fetch_openhome_playlist_play_id(id)
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported_backend_name(&self) -> &'static str {
|
||||
match self {
|
||||
MusicRenderer::Upnp(_) => "UPnP",
|
||||
MusicRenderer::OpenHome(_) => "OpenHome",
|
||||
MusicRenderer::LinkPlay(_) => "LinkPlay",
|
||||
MusicRenderer::ArylicTcp(_) => "ArylicTcp",
|
||||
MusicRenderer::Chromecast(_) => "Chromecast",
|
||||
MusicRenderer::HybridUpnpArylic { .. } => "HybridUpnpArylic",
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_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(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_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 playback_item_from_params(
|
||||
renderer_id: &RendererId,
|
||||
uri: &str,
|
||||
metadata_xml: &str,
|
||||
) -> Result<PlaybackItem> {
|
||||
let metadata = parse_track_metadata_from_didl(metadata_xml);
|
||||
let didl_id = didl_id_from_metadata(metadata_xml)
|
||||
.unwrap_or_else(|| format!("openhome:{}", renderer_id.0));
|
||||
Ok(PlaybackItem {
|
||||
media_server_id: ServerId(format!("openhome:{}", renderer_id.0)),
|
||||
didl_id,
|
||||
uri: uri.to_string(),
|
||||
// OpenHome tracks don't provide protocolInfo, use generic default
|
||||
protocol_info: "http-get:*:audio/*:*".to_string(),
|
||||
metadata,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Transport control façade that dispatches to whichever backend can fulfill
|
||||
/// the request, returning a standardized error if the backend lacks support.
|
||||
impl TransportControl for MusicRenderer {
|
||||
fn play_uri(&self, uri: &str, meta: &str) -> Result<()> {
|
||||
match self {
|
||||
MusicRenderer::Upnp(upnp) => upnp.play_uri(uri, meta),
|
||||
MusicRenderer::OpenHome(oh) => oh.play_uri(uri, meta),
|
||||
MusicRenderer::LinkPlay(lp) => lp.play_uri(uri, meta),
|
||||
MusicRenderer::ArylicTcp(_) => Err(op_not_supported("play_uri", "ArylicTcp")),
|
||||
MusicRenderer::Chromecast(cc) => cc.play_uri(uri, meta),
|
||||
MusicRenderer::HybridUpnpArylic { upnp, .. } => upnp.play_uri(uri, meta),
|
||||
}
|
||||
}
|
||||
|
||||
fn play(&self) -> Result<()> {
|
||||
match self {
|
||||
MusicRenderer::Upnp(upnp) => upnp.play(),
|
||||
MusicRenderer::OpenHome(oh) => oh.play(),
|
||||
MusicRenderer::LinkPlay(lp) => lp.play(),
|
||||
MusicRenderer::ArylicTcp(ary) => ary.play(),
|
||||
MusicRenderer::Chromecast(cc) => cc.play(),
|
||||
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.play(),
|
||||
}
|
||||
}
|
||||
|
||||
fn pause(&self) -> Result<()> {
|
||||
match self {
|
||||
MusicRenderer::Upnp(upnp) => upnp.pause(),
|
||||
MusicRenderer::OpenHome(oh) => oh.pause(),
|
||||
MusicRenderer::LinkPlay(lp) => lp.pause(),
|
||||
MusicRenderer::ArylicTcp(ary) => ary.pause(),
|
||||
MusicRenderer::Chromecast(cc) => cc.pause(),
|
||||
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.pause(),
|
||||
}
|
||||
}
|
||||
|
||||
fn stop(&self) -> Result<()> {
|
||||
match self {
|
||||
MusicRenderer::Upnp(upnp) => upnp.stop(),
|
||||
MusicRenderer::OpenHome(oh) => oh.stop(),
|
||||
MusicRenderer::LinkPlay(lp) => lp.stop(),
|
||||
MusicRenderer::ArylicTcp(ary) => ary.stop(),
|
||||
MusicRenderer::Chromecast(cc) => cc.stop(),
|
||||
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.stop(),
|
||||
}
|
||||
}
|
||||
|
||||
fn seek_rel_time(&self, hhmmss: &str) -> Result<()> {
|
||||
match self {
|
||||
MusicRenderer::Upnp(upnp) => upnp.seek_rel_time(hhmmss),
|
||||
MusicRenderer::OpenHome(oh) => oh.seek_rel_time(hhmmss),
|
||||
MusicRenderer::LinkPlay(lp) => lp.seek_rel_time(hhmmss),
|
||||
MusicRenderer::ArylicTcp(_) => Err(op_not_supported("seek_rel_time", "ArylicTcp")),
|
||||
MusicRenderer::Chromecast(cc) => cc.seek_rel_time(hhmmss),
|
||||
MusicRenderer::HybridUpnpArylic { upnp, .. } => upnp.seek_rel_time(hhmmss),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Volume and mute controls exposed via the façade.
|
||||
///
|
||||
/// Hybrid backends may read via Arylic TCP and write via UPnP, but callers
|
||||
/// always depend on a single [`VolumeControl`] entry point.
|
||||
impl VolumeControl for MusicRenderer {
|
||||
fn volume(&self) -> Result<u16> {
|
||||
match self {
|
||||
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.volume(),
|
||||
MusicRenderer::ArylicTcp(ary) => ary.volume(),
|
||||
MusicRenderer::OpenHome(oh) => oh.volume(),
|
||||
MusicRenderer::Upnp(upnp) => upnp.volume(),
|
||||
MusicRenderer::LinkPlay(lp) => lp.volume(),
|
||||
MusicRenderer::Chromecast(cc) => cc.volume(),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_volume(&self, vol: u16) -> Result<()> {
|
||||
match self {
|
||||
MusicRenderer::HybridUpnpArylic { upnp, .. } => upnp.set_volume(vol),
|
||||
MusicRenderer::ArylicTcp(ary) => ary.set_volume(vol),
|
||||
MusicRenderer::OpenHome(oh) => oh.set_volume(vol),
|
||||
MusicRenderer::Upnp(upnp) => upnp.set_volume(vol),
|
||||
MusicRenderer::LinkPlay(lp) => lp.set_volume(vol),
|
||||
MusicRenderer::Chromecast(cc) => cc.set_volume(vol),
|
||||
}
|
||||
}
|
||||
|
||||
fn mute(&self) -> Result<bool> {
|
||||
match self {
|
||||
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.mute(),
|
||||
MusicRenderer::OpenHome(r) => r.mute(),
|
||||
MusicRenderer::Upnp(r) => r.get_master_mute(),
|
||||
MusicRenderer::LinkPlay(r) => r.mute(),
|
||||
MusicRenderer::ArylicTcp(r) => r.mute(),
|
||||
MusicRenderer::Chromecast(cc) => cc.mute(),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_mute(&self, m: bool) -> Result<()> {
|
||||
match self {
|
||||
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.set_mute(m),
|
||||
MusicRenderer::OpenHome(r) => r.set_mute(m),
|
||||
MusicRenderer::Upnp(r) => r.set_master_mute(m),
|
||||
MusicRenderer::LinkPlay(r) => r.set_mute(m),
|
||||
MusicRenderer::ArylicTcp(r) => r.set_mute(m),
|
||||
MusicRenderer::Chromecast(cc) => cc.set_mute(m),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Playback-state queries sourced from the backend best suited for the job.
|
||||
///
|
||||
/// Each backend reports into [`PlaybackState`], ensuring consumers never have
|
||||
/// to reason about protocol-specific state machines.
|
||||
impl PlaybackStatus for MusicRenderer {
|
||||
fn playback_state(&self) -> Result<PlaybackState> {
|
||||
match self {
|
||||
MusicRenderer::Upnp(r) => PlaybackStatus::playback_state(r),
|
||||
MusicRenderer::OpenHome(r) => PlaybackStatus::playback_state(r),
|
||||
MusicRenderer::LinkPlay(r) => r.playback_state(),
|
||||
MusicRenderer::ArylicTcp(r) => r.playback_state(),
|
||||
MusicRenderer::Chromecast(cc) => cc.playback_state(),
|
||||
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.playback_state(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Playback-position queries that always yield a [`PlaybackPositionInfo`]
|
||||
/// regardless of the backend providing the raw transport data.
|
||||
impl PlaybackPosition for MusicRenderer {
|
||||
fn playback_position(&self) -> Result<PlaybackPositionInfo> {
|
||||
match self {
|
||||
MusicRenderer::Upnp(r) => r.playback_position(),
|
||||
MusicRenderer::OpenHome(r) => r.playback_position(),
|
||||
MusicRenderer::LinkPlay(r) => r.playback_position(),
|
||||
MusicRenderer::ArylicTcp(r) => r.playback_position(),
|
||||
MusicRenderer::Chromecast(cc) => cc.playback_position(),
|
||||
MusicRenderer::HybridUpnpArylic { arylic, .. } => arylic.playback_position(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Deserialize;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::DeviceIdentity;
|
||||
use crate::arylic_client::{
|
||||
ARYLIC_TCP_PORT, DEFAULT_TIMEOUT_SECS, send_command_no_response, send_command_optional,
|
||||
send_command_required,
|
||||
};
|
||||
use crate::errors::ControlPointError;
|
||||
use crate::linkplay_client::extract_linkplay_host;
|
||||
use crate::model::{PlaybackState, RendererInfo};
|
||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||
use crate::music_renderer::capabilities::{
|
||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, VolumeControl,
|
||||
};
|
||||
use crate::music_renderer::musicrenderer::MusicRendererBackend;
|
||||
use crate::music_renderer::time_utils::{format_hhmmss, ms_to_seconds, parse_hhmmss_strict};
|
||||
|
||||
/// Raw response from Arylic MCU+PINFGET command
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ArylicPlaybackInfoRaw {
|
||||
status: String,
|
||||
curpos: String,
|
||||
totlen: String,
|
||||
#[serde(default)]
|
||||
vol: Option<String>,
|
||||
#[serde(default)]
|
||||
mute: Option<String>,
|
||||
#[serde(default)]
|
||||
plicount: Option<String>,
|
||||
#[serde(default)]
|
||||
plicurr: Option<String>,
|
||||
}
|
||||
|
||||
/// Backend speaking the Arylic TCP control protocol (port 8899).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArylicTcpRenderer {
|
||||
host: String,
|
||||
port: u16,
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
impl ArylicTcpRenderer {
|
||||
fn send_required(&self, cmd: &str, expected: &[&str]) -> Result<String, ControlPointError> {
|
||||
send_command_required(&self.host, self.port, self.timeout, cmd, expected)
|
||||
}
|
||||
|
||||
fn send_optional(
|
||||
&self,
|
||||
cmd: &str,
|
||||
expected: &[&str],
|
||||
) -> Result<Option<String>, ControlPointError> {
|
||||
send_command_optional(&self.host, self.port, self.timeout, cmd, expected)
|
||||
}
|
||||
|
||||
fn send_no_response(&self, cmd: &str) -> Result<(), ControlPointError> {
|
||||
send_command_no_response(&self.host, self.port, self.timeout, cmd)
|
||||
}
|
||||
|
||||
fn fetch_playback_info(&self) -> Result<ArylicPlaybackInfo, ControlPointError> {
|
||||
let payload = self.send_required("MCU+PINFGET", &["AXX+PLY+INF"])?;
|
||||
match parse_playback_info(&payload) {
|
||||
Ok(info) => Ok(info),
|
||||
Err(err) => {
|
||||
debug!(
|
||||
"Failed to parse Arylic playback info for {}: {}",
|
||||
self.host, err
|
||||
);
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_volume_command(value: u16) -> String {
|
||||
format!("MCU+VOL+{:03}", value.min(100))
|
||||
}
|
||||
|
||||
fn parse_volume_payload(payload: &str) -> Result<u16, ControlPointError> {
|
||||
let data = payload.strip_prefix("AXX+VOL+").ok_or_else(|| {
|
||||
ControlPointError::ArilycTcpError(format!("Unexpected volume response: {}", payload))
|
||||
})?;
|
||||
let value: u16 = data.trim().parse().map_err(|_| {
|
||||
ControlPointError::ArilycTcpError(format!("Invalid volume value: {}", data))
|
||||
})?;
|
||||
Ok(value.min(100))
|
||||
}
|
||||
|
||||
fn parse_mute_payload(payload: &str) -> Result<bool, ControlPointError> {
|
||||
let data = payload.strip_prefix("AXX+MUT+").ok_or_else(|| {
|
||||
ControlPointError::ArilycTcpError(format!("Unexpected mute response: {}", payload))
|
||||
})?;
|
||||
match data.trim() {
|
||||
"000" | "0" => Ok(false),
|
||||
"001" | "1" => Ok(true),
|
||||
other => Err(ControlPointError::ArilycTcpError(format!(
|
||||
"Invalid mute value: {}",
|
||||
other
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RendererFromMediaRendererInfo for ArylicTcpRenderer {
|
||||
fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||
let host = extract_linkplay_host(info.location()).ok_or_else(|| {
|
||||
ControlPointError::ArilycTcpError(format!(
|
||||
"Renderer {} has no valid LOCATION host",
|
||||
info.udn()
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
host,
|
||||
port: ARYLIC_TCP_PORT,
|
||||
timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
|
||||
})
|
||||
}
|
||||
|
||||
fn to_backend(self) -> MusicRendererBackend {
|
||||
MusicRendererBackend::ArylicTcp(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl TransportControl for ArylicTcpRenderer {
|
||||
fn play_uri(&self, _uri: &str, _meta: &str) -> Result<(), ControlPointError> {
|
||||
Err(ControlPointError::upnp_operation_not_supported(
|
||||
"Arylic TCP backend does not support direct URL loading.",
|
||||
"ArylicTcpRenderer",
|
||||
))
|
||||
}
|
||||
|
||||
fn play(&self) -> Result<(), ControlPointError> {
|
||||
self.send_no_response("MCU+PLY-PLA")
|
||||
}
|
||||
|
||||
fn pause(&self) -> Result<(), ControlPointError> {
|
||||
let _ = self.send_optional("MCU+PLY-PUS", &["AXX+PLY+"])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stop(&self) -> Result<(), ControlPointError> {
|
||||
self.send_no_response("MCU+PLY-STP")
|
||||
}
|
||||
|
||||
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
||||
let _ = parse_hhmmss_strict(hhmmss)?;
|
||||
Err(ControlPointError::ArilycTcpError(
|
||||
"Arylic TCP seek_rel_time is not implemented yet for this device.".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl VolumeControl for ArylicTcpRenderer {
|
||||
fn volume(&self) -> Result<u16, ControlPointError> {
|
||||
if let Ok(info) = self.fetch_playback_info() {
|
||||
if let Some(vol) = info.volume {
|
||||
return Ok(vol);
|
||||
}
|
||||
debug!(
|
||||
"Arylic playback info for {} missing volume, falling back to VOL GET",
|
||||
self.host
|
||||
);
|
||||
}
|
||||
let payload = self.send_required("MCU+VOL+GET", &["AXX+VOL+"])?;
|
||||
Self::parse_volume_payload(&payload)
|
||||
}
|
||||
|
||||
fn set_volume(&self, v: u16) -> Result<(), ControlPointError> {
|
||||
let command = Self::format_volume_command(v);
|
||||
let _ = self.send_optional(&command, &["AXX+VOL+"])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mute(&self) -> Result<bool, ControlPointError> {
|
||||
if let Ok(info) = self.fetch_playback_info() {
|
||||
if let Some(mute) = info.mute {
|
||||
return Ok(mute);
|
||||
}
|
||||
debug!(
|
||||
"Arylic playback info for {} missing mute, falling back to MUT GET",
|
||||
self.host
|
||||
);
|
||||
}
|
||||
let payload = self.send_required("MCU+MUT+GET", &["AXX+MUT+"])?;
|
||||
Self::parse_mute_payload(&payload)
|
||||
}
|
||||
|
||||
fn set_mute(&self, m: bool) -> Result<(), ControlPointError> {
|
||||
let command = if m { "MCU+MUT+001" } else { "MCU+MUT+000" };
|
||||
let payload = self.send_required(command, &["AXX+MUT+"])?;
|
||||
let _ = Self::parse_mute_payload(&payload)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl PlaybackStatus for ArylicTcpRenderer {
|
||||
fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
|
||||
let info = self.fetch_playback_info()?;
|
||||
Ok(info.playback_state())
|
||||
}
|
||||
}
|
||||
|
||||
impl PlaybackPosition for ArylicTcpRenderer {
|
||||
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
|
||||
let info = self.fetch_playback_info()?;
|
||||
Ok(info.position_info())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ArylicPlaybackInfo {
|
||||
status_raw: String,
|
||||
curpos_ms: u64,
|
||||
totlen_ms: u64,
|
||||
volume: Option<u16>,
|
||||
mute: Option<bool>,
|
||||
playlist_size: Option<u32>,
|
||||
track_index: Option<u32>,
|
||||
}
|
||||
|
||||
impl ArylicPlaybackInfo {
|
||||
fn playback_state(&self) -> PlaybackState {
|
||||
match self.status_raw.as_str() {
|
||||
"play" => PlaybackState::Playing,
|
||||
"pause" => PlaybackState::Paused,
|
||||
"stop" => PlaybackState::Stopped,
|
||||
other => PlaybackState::Unknown(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn position_info(&self) -> PlaybackPositionInfo {
|
||||
let track = match (self.track_index, self.playlist_size) {
|
||||
(Some(idx), Some(count)) if count > 0 => Some(idx.min(count)),
|
||||
(Some(idx), _) => Some(idx),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
PlaybackPositionInfo {
|
||||
track,
|
||||
rel_time: Some(format_hhmmss(ms_to_seconds(self.curpos_ms))),
|
||||
abs_time: None,
|
||||
track_duration: if self.totlen_ms > 0 {
|
||||
Some(format_hhmmss(ms_to_seconds(self.totlen_ms)))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
track_metadata: None,
|
||||
track_uri: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_playback_info(payload: &str) -> Result<ArylicPlaybackInfo, ControlPointError> {
|
||||
let json_blob = payload.strip_prefix("AXX+PLY+INF").ok_or_else(|| {
|
||||
ControlPointError::ArilycTcpError(format!("Unexpected playback info prefix: {}", payload))
|
||||
})?;
|
||||
|
||||
// Extract JSON object between { and }, ignoring trailing & and other garbage
|
||||
// Arylic devices send: AXX+PLY+INF{...json...}&
|
||||
let json_start = json_blob.find('{').ok_or_else(|| {
|
||||
ControlPointError::ArilycTcpError(format!(
|
||||
"No JSON object found in playback info: {}",
|
||||
payload
|
||||
))
|
||||
})?;
|
||||
|
||||
let json_end = json_blob.rfind('}').ok_or_else(|| {
|
||||
ControlPointError::ArilycTcpError(format!(
|
||||
"No JSON object end found in playback info: {}",
|
||||
payload
|
||||
))
|
||||
})?;
|
||||
|
||||
let json_blob = &json_blob[json_start..=json_end];
|
||||
|
||||
let raw: ArylicPlaybackInfoRaw = serde_json::from_str(json_blob).map_err(|e| {
|
||||
ControlPointError::ArilycTcpError(format!(
|
||||
"Failed to parse Arylic playback info JSON: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
let curpos_ms = raw.curpos.parse::<u64>().map_err(|_| {
|
||||
ControlPointError::ArilycTcpError(format!("Invalid curpos value: {}", raw.curpos))
|
||||
})?;
|
||||
|
||||
let totlen_ms = raw.totlen.parse::<u64>().map_err(|_| {
|
||||
ControlPointError::ArilycTcpError(format!("Invalid totlen value: {}", raw.totlen))
|
||||
})?;
|
||||
|
||||
let volume = raw.vol.and_then(|raw_vol| match raw_vol.parse::<u16>() {
|
||||
Ok(value) => Some(value.min(100)),
|
||||
Err(err) => {
|
||||
debug!("Invalid Arylic `vol` value {}: {}", raw_vol, err);
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let mute = raw.mute.and_then(|raw_mute| match raw_mute.as_str() {
|
||||
"1" => Some(true),
|
||||
"0" => Some(false),
|
||||
other => {
|
||||
debug!("Invalid Arylic `mute` value {}", other);
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let playlist_size = raw
|
||||
.plicount
|
||||
.and_then(|raw_count| match raw_count.parse::<u32>() {
|
||||
Ok(count) if count > 0 => Some(count),
|
||||
Ok(_) => None,
|
||||
Err(err) => {
|
||||
debug!("Invalid Arylic `plicount` value {}: {}", raw_count, err);
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let track_index = raw
|
||||
.plicurr
|
||||
.and_then(|raw_idx| match raw_idx.parse::<u32>() {
|
||||
Ok(idx) if idx > 0 => Some(idx),
|
||||
Ok(_) => None,
|
||||
Err(err) => {
|
||||
debug!("Invalid Arylic `plicurr` value {}: {}", raw_idx, err);
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
Ok(ArylicPlaybackInfo {
|
||||
status_raw: raw.status,
|
||||
curpos_ms,
|
||||
totlen_ms,
|
||||
volume,
|
||||
mute,
|
||||
playlist_size,
|
||||
track_index,
|
||||
})
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
// pmocontrol/src/capabilities.rs
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::{errors::ControlPointError, model::PlaybackState};
|
||||
|
||||
/// Logical playback position across backends.
|
||||
///
|
||||
/// Times peuvent être soit en secondes, soit en "HH:MM:SS" selon ce que
|
||||
/// tu préfères pour la façade; ici je reste en String pour garder la
|
||||
/// même granularité que UPnP sans parser.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PlaybackPositionInfo {
|
||||
pub track: Option<u32>,
|
||||
pub rel_time: Option<String>, // position courante
|
||||
pub abs_time: Option<String>, // si pertinent
|
||||
pub track_duration: Option<String>, // durée totale
|
||||
pub track_metadata: Option<String>, // DIDL-Lite XML from GetPositionInfo
|
||||
pub track_uri: Option<String>, // Current track URI
|
||||
}
|
||||
pub trait PlaybackPosition {
|
||||
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError>;
|
||||
}
|
||||
|
||||
/// Generic abstraction for playback status (transport state).
|
||||
///
|
||||
/// For UPnP AV, this is backed by AVTransport::GetTransportInfo.
|
||||
/// For OpenHome, a future implementation will adapt from OH Info/Time.
|
||||
pub trait PlaybackStatus {
|
||||
fn playback_state(&self) -> Result<PlaybackState, ControlPointError>;
|
||||
}
|
||||
|
||||
/// Abstraction générique des capacités de transport (lecture / pause / stop / seek)
|
||||
/// indépendamment du protocole sous-jacent (UPnP AV, OpenHome, ...).
|
||||
pub trait TransportControl {
|
||||
/// Set la ressource à lire (URI + métadonnées) et/ou commence la lecture.
|
||||
///
|
||||
/// Selon l'implémentation, cette méthode peut soit :
|
||||
/// - faire un "Set...URI" + "Play" (cas UPnP AV),
|
||||
/// - ou configurer la file de lecture (cas OpenHome, etc.).
|
||||
fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError>;
|
||||
|
||||
/// Démarre ou reprend la lecture.
|
||||
fn play(&self) -> Result<(), ControlPointError>;
|
||||
|
||||
/// Met la lecture en pause.
|
||||
fn pause(&self) -> Result<(), ControlPointError>;
|
||||
|
||||
/// Arrête la lecture.
|
||||
fn stop(&self) -> Result<(), ControlPointError>;
|
||||
|
||||
/// Seek à un temps relatif (HH:MM:SS) si supporté.
|
||||
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError>;
|
||||
}
|
||||
|
||||
/// Abstraction générique des capacités de contrôle de volume / mute.
|
||||
pub trait VolumeControl {
|
||||
/// Retourne le volume logique courant (échelle dépendante du renderer).
|
||||
fn volume(&self) -> Result<u16, ControlPointError>;
|
||||
|
||||
/// Définit le volume logique (échelle dépendante du renderer).
|
||||
fn set_volume(&self, v: u16) -> Result<(), ControlPointError>;
|
||||
|
||||
/// Indique si le renderer est muet (mute activé).
|
||||
fn mute(&self) -> Result<bool, ControlPointError>;
|
||||
|
||||
/// Active ou désactive le mute.
|
||||
fn set_mute(&self, m: bool) -> Result<(), ControlPointError>;
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
use std::fmt;
|
||||
use std::time::Duration;
|
||||
|
||||
use ureq::Agent;
|
||||
|
||||
use crate::DeviceIdentity;
|
||||
use crate::linkplay_client::{LinkPlayStatus, build_agent, extract_linkplay_host, fetch_status_for_host, percent_encode};
|
||||
use crate::music_renderer::capabilities::{
|
||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl,
|
||||
VolumeControl,
|
||||
};
|
||||
use crate::music_renderer::time_utils::{parse_hhmmss_strict};
|
||||
use crate::errors::ControlPointError;
|
||||
use crate::model::{RendererInfo, PlaybackState};
|
||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||
use crate::music_renderer::musicrenderer::MusicRendererBackend;
|
||||
|
||||
const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 3;
|
||||
|
||||
|
||||
/// Renderer backend for devices exposing the LinkPlay HTTP API.
|
||||
#[derive(Clone)]
|
||||
pub struct LinkPlayRenderer {
|
||||
host: String,
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
impl fmt::Debug for LinkPlayRenderer {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("LinkPlayRenderer")
|
||||
.field("host", &self.host)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl LinkPlayRenderer {
|
||||
|
||||
fn agent(&self) -> Agent {
|
||||
build_agent(self.timeout)
|
||||
}
|
||||
|
||||
fn send_player_command(&self, command: &str) -> Result<(), ControlPointError> {
|
||||
let url = format!(
|
||||
"http://{}/httpapi.asp?command=setPlayerCmd:{}",
|
||||
self.host, command
|
||||
);
|
||||
self.agent()
|
||||
.get(&url)
|
||||
.call()
|
||||
.map_err(|_| ControlPointError::ArilycTcpError(format!("LinkPlay command {} failed for {}", command, self.host)))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fetch_status(&self) -> Result<LinkPlayStatus, ControlPointError> {
|
||||
fetch_status_for_host(&self.host, self.timeout)
|
||||
}
|
||||
}
|
||||
|
||||
impl RendererFromMediaRendererInfo for LinkPlayRenderer {
|
||||
fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||
let host = extract_linkplay_host(&info.location())
|
||||
.ok_or_else(|| ControlPointError::LinkPlayError(format!("Renderer {} has no valid LOCATION host", info.udn())))?;
|
||||
|
||||
Ok(Self {
|
||||
host,
|
||||
timeout: Duration::from_secs(DEFAULT_HTTP_TIMEOUT_SECS),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
fn to_backend(self) -> MusicRendererBackend {
|
||||
MusicRendererBackend::LinkPlay(self)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
impl TransportControl for LinkPlayRenderer {
|
||||
fn play_uri(&self, uri: &str, _meta: &str) -> Result<(), ControlPointError> {
|
||||
let encoded = percent_encode(uri);
|
||||
self.send_player_command(&format!("play:{}", encoded))
|
||||
}
|
||||
|
||||
fn play(&self) -> Result<(), ControlPointError> {
|
||||
self.send_player_command("resume")
|
||||
}
|
||||
|
||||
fn pause(&self) -> Result<(), ControlPointError> {
|
||||
self.send_player_command("pause")
|
||||
}
|
||||
|
||||
fn stop(&self) -> Result<(), ControlPointError> {
|
||||
self.send_player_command("stop")
|
||||
}
|
||||
|
||||
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
||||
let secs = parse_hhmmss_strict(hhmmss)?;
|
||||
self.send_player_command(&format!("seek:{}", secs))
|
||||
}
|
||||
}
|
||||
|
||||
impl VolumeControl for LinkPlayRenderer {
|
||||
fn volume(&self) -> Result<u16, ControlPointError> {
|
||||
Ok(self.fetch_status()?.volume)
|
||||
}
|
||||
|
||||
fn set_volume(&self, v: u16) -> Result<(), ControlPointError> {
|
||||
let value = v.min(100);
|
||||
self.send_player_command(&format!("vol:{}", value))
|
||||
}
|
||||
|
||||
fn mute(&self) -> Result<bool, ControlPointError> {
|
||||
Ok(self.fetch_status()?.mute)
|
||||
}
|
||||
|
||||
fn set_mute(&self, m: bool) -> Result<(), ControlPointError> {
|
||||
self.send_player_command(if m { "mute:1" } else { "mute:0" })
|
||||
}
|
||||
}
|
||||
|
||||
impl PlaybackStatus for LinkPlayRenderer {
|
||||
fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
|
||||
Ok(self.fetch_status()?.playback_state())
|
||||
}
|
||||
}
|
||||
|
||||
impl PlaybackPosition for LinkPlayRenderer {
|
||||
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
|
||||
Ok(self.fetch_status()?.position_info())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
mod arylic_tcp;
|
||||
mod linkplay_renderer;
|
||||
|
||||
mod upnp_renderer;
|
||||
|
||||
mod openhome;
|
||||
mod openhome_renderer;
|
||||
|
||||
mod capabilities;
|
||||
mod chromecast_renderer;
|
||||
|
||||
mod musicrenderer;
|
||||
pub mod time_utils;
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
pub use crate::music_renderer::capabilities::{
|
||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
|
||||
};
|
||||
pub use crate::music_renderer::musicrenderer::{MusicRenderer, PlaylistBinding};
|
||||
use crate::{
|
||||
RendererInfo, errors::ControlPointError, music_renderer::musicrenderer::MusicRendererBackend,
|
||||
};
|
||||
|
||||
pub trait RendererFromMediaRendererInfo {
|
||||
fn from_renderer_info(renderer: &RendererInfo) -> Result<Self, ControlPointError>
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
fn to_backend(self) -> MusicRendererBackend
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
fn build_from_renderer_info(
|
||||
renderer: &RendererInfo,
|
||||
) -> Result<MusicRendererBackend, ControlPointError>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
let instance = Self::from_renderer_info(renderer)?;
|
||||
Ok(instance.to_backend())
|
||||
}
|
||||
|
||||
fn make_from_renderer_info(
|
||||
renderer: &RendererInfo,
|
||||
) -> Result<Arc<Mutex<MusicRendererBackend>>, ControlPointError>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
let backend = Self::build_from_renderer_info(renderer)?;
|
||||
Ok(Arc::new(Mutex::new(backend)))
|
||||
}
|
||||
}
|
||||
@@ -1,965 +0,0 @@
|
||||
//! Backend-agnostic music renderer façade for PMOMusic.
|
||||
//!
|
||||
//! `MusicRenderer` wraps every supported backend (UPnP AV/DLNA, OpenHome,
|
||||
//! LinkPlay HTTP, Arylic TCP, Chromecast, and the hybrid UPnP + Arylic pairing) behind a
|
||||
//! single control surface. Higher layers in PMOMusic must only interact with
|
||||
//! renderers through this type so that transport, volume, and state queries
|
||||
//! stay backend-neutral.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use crate::errors::ControlPointError;
|
||||
use crate::model::{PlaybackSource, PlaybackState, RendererInfo, RendererProtocol, TrackMetadata};
|
||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||
use crate::music_renderer::arylic_tcp::ArylicTcpRenderer;
|
||||
use crate::music_renderer::capabilities::{
|
||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, VolumeControl,
|
||||
};
|
||||
use crate::music_renderer::chromecast_renderer::ChromecastRenderer;
|
||||
use crate::music_renderer::linkplay_renderer::LinkPlayRenderer;
|
||||
use crate::music_renderer::openhome_renderer::OpenHomeRenderer;
|
||||
use crate::music_renderer::upnp_renderer::UpnpRenderer;
|
||||
use crate::online::DeviceConnectionState;
|
||||
use crate::queue::{
|
||||
EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueFromRendererInfo, QueueSnapshot,
|
||||
};
|
||||
use crate::{DeviceId, DeviceIdentity, DeviceOnline};
|
||||
|
||||
use tracing::warn;
|
||||
|
||||
/// Describes a renderer's attachment to a media server playlist container.
|
||||
///
|
||||
/// When a renderer is attached to a playlist, the ControlPoint monitors the container
|
||||
/// for updates and automatically refreshes the queue when changes are detected.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PlaylistBinding {
|
||||
/// MediaServer that owns the playlist container.
|
||||
pub server_id: DeviceId,
|
||||
/// DIDL-Lite object id of the playlist container.
|
||||
pub container_id: String,
|
||||
/// True once at least one ContainerUpdateIDs notification has been seen.
|
||||
pub(crate) has_seen_update: bool,
|
||||
/// Flag used internally to signal that the queue should be refreshed
|
||||
/// from the server container.
|
||||
pub(crate) pending_refresh: bool,
|
||||
/// Whether the next refresh should auto-start playback if the renderer is idle.
|
||||
pub(crate) auto_play_on_refresh: bool,
|
||||
}
|
||||
|
||||
/// Backend-agnostic façade exposing transport, volume, and status contracts.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum MusicRendererBackend {
|
||||
/// Classic UPnP AV / DLNA renderer (AVTransport + RenderingControl).
|
||||
Upnp(UpnpRenderer),
|
||||
/// Renderer powered by OpenHome services.
|
||||
OpenHome(OpenHomeRenderer),
|
||||
/// Renderer controlled via the LinkPlay HTTP API.
|
||||
LinkPlay(LinkPlayRenderer),
|
||||
/// Renderer reachable through the Arylic TCP control protocol (port 8899).
|
||||
ArylicTcp(ArylicTcpRenderer),
|
||||
/// Renderer controlled via the Google Cast protocol (Chromecast).
|
||||
Chromecast(ChromecastRenderer),
|
||||
/// Combined backend using UPnP for transport + volume writes and Arylic TCP
|
||||
/// to read detailed playback information as well as live volume/mute state.
|
||||
HybridUpnpArylic {
|
||||
upnp: UpnpRenderer,
|
||||
arylic: ArylicTcpRenderer,
|
||||
},
|
||||
}
|
||||
|
||||
/// Internal state for tracking playback and control flow.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct MusicRendererState {
|
||||
/// Last known track metadata (cached to avoid repeated queries).
|
||||
last_metadata: Option<TrackMetadata>,
|
||||
/// Source of the current playback (queue vs external).
|
||||
playback_source: PlaybackSource,
|
||||
/// Flag to distinguish user-requested stop from automatic events.
|
||||
user_stop_requested: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MusicRenderer {
|
||||
info: RendererInfo,
|
||||
connection: Arc<Mutex<DeviceConnectionState>>,
|
||||
backend: Arc<Mutex<MusicRendererBackend>>,
|
||||
queue: Arc<Mutex<MusicQueue>>,
|
||||
playlist_binding: Arc<Mutex<Option<PlaylistBinding>>>,
|
||||
state: Arc<Mutex<MusicRendererState>>,
|
||||
}
|
||||
|
||||
impl MusicRenderer {
|
||||
pub fn new(
|
||||
info: RendererInfo,
|
||||
backend: Arc<Mutex<MusicRendererBackend>>,
|
||||
queue: Arc<Mutex<MusicQueue>>,
|
||||
) -> Arc<MusicRenderer> {
|
||||
let connection = DeviceConnectionState::new();
|
||||
|
||||
let renderer = MusicRenderer {
|
||||
info,
|
||||
connection: Arc::new(Mutex::new(connection)),
|
||||
backend,
|
||||
queue,
|
||||
playlist_binding: Arc::new(Mutex::new(None)),
|
||||
state: Arc::new(Mutex::new(MusicRendererState::default())),
|
||||
};
|
||||
|
||||
Arc::new(renderer)
|
||||
}
|
||||
|
||||
pub fn from_renderer_info(info: &RendererInfo) -> Result<MusicRenderer, ControlPointError> {
|
||||
let connection = Arc::new(Mutex::new(DeviceConnectionState::new()));
|
||||
let backend = MusicRendererBackend::make_from_renderer_info(info)?;
|
||||
let queue = MusicQueue::make_from_renderer_info(info)?;
|
||||
|
||||
let renderer = MusicRenderer {
|
||||
info: info.clone(),
|
||||
connection,
|
||||
backend,
|
||||
queue,
|
||||
playlist_binding: Arc::new(Mutex::new(None)),
|
||||
state: Arc::new(Mutex::new(MusicRendererState::default())),
|
||||
};
|
||||
Ok(renderer)
|
||||
}
|
||||
|
||||
pub fn info(&self) -> &RendererInfo {
|
||||
&self.info
|
||||
}
|
||||
|
||||
/// Returns the protocol.
|
||||
fn protocol(&self) -> RendererProtocol {
|
||||
self.info.protocol()
|
||||
}
|
||||
|
||||
fn is_upnp(&self) -> bool {
|
||||
match &*self.backend.lock().expect("Backend mutex poisoned") {
|
||||
MusicRendererBackend::Upnp(_) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_openhome(&self) -> bool {
|
||||
match &*self.backend.lock().expect("Backend mutex poisoned") {
|
||||
MusicRendererBackend::OpenHome(_) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_linkplay(&self) -> bool {
|
||||
match &*self.backend.lock().expect("Backend mutex poisoned") {
|
||||
MusicRendererBackend::LinkPlay(_) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_arylictcp(&self) -> bool {
|
||||
match &*self.backend.lock().expect("Backend mutex poisoned") {
|
||||
MusicRendererBackend::ArylicTcp(_) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_chromecast(&self) -> bool {
|
||||
match &*self.backend.lock().expect("Backend mutex poisoned") {
|
||||
MusicRendererBackend::Chromecast(_) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_hybridupnparylic(&self) -> bool {
|
||||
match &*self.backend.lock().expect("Backend mutex poisoned") {
|
||||
MusicRendererBackend::HybridUpnpArylic { .. } => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if this renderer is known to support SetNextAVTransportURI.
|
||||
pub fn supports_set_next(&self) -> bool {
|
||||
self.info.capabilities().supports_set_next()
|
||||
}
|
||||
|
||||
/// Prepare the renderer for attaching a new playlist by clearing the queue and stopping playback.
|
||||
pub fn clear_for_playlist_attach(&self) -> Result<(), ControlPointError> {
|
||||
// Clear the queue first
|
||||
self.queue
|
||||
.lock()
|
||||
.expect("Queue mutex poisoned")
|
||||
.clear_queue()?;
|
||||
|
||||
// Then stop playback (ignore errors if already stopped)
|
||||
self.backend
|
||||
.lock()
|
||||
.expect("Backend mutex poisoned")
|
||||
.stop()
|
||||
.or_else(|err| {
|
||||
warn!(
|
||||
renderer = self.id().0.as_str(),
|
||||
error = %err,
|
||||
"Stop failed when preparing for playlist attach (continuing anyway)"
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Get the current queue snapshot.
|
||||
pub fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.expect("Queue mutex poisoned")
|
||||
.queue_snapshot()
|
||||
}
|
||||
|
||||
/// Get the current queue item without advancing.
|
||||
/// Returns the item and count of remaining items after current.
|
||||
pub fn peek_current(&self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.expect("Queue mutex poisoned")
|
||||
.peek_current()
|
||||
}
|
||||
|
||||
/// Get the count of items remaining after the current index.
|
||||
pub fn upcoming_len(&self) -> Result<usize, ControlPointError> {
|
||||
self.queue
|
||||
.lock()
|
||||
.expect("Queue mutex poisoned")
|
||||
.upcoming_len()
|
||||
}
|
||||
|
||||
/// Play the current item from the queue.
|
||||
pub fn play_current_from_queue(&self) -> Result<(), ControlPointError> {
|
||||
let queue = self.queue.lock().expect("Queue mutex poisoned");
|
||||
let backend = self.backend.lock().expect("Backend mutex poisoned");
|
||||
|
||||
// Get the current item from the queue
|
||||
let (item, _remaining) = queue.peek_current()?.ok_or_else(|| {
|
||||
ControlPointError::QueueError("Queue is empty, cannot play current".to_string())
|
||||
})?;
|
||||
|
||||
// Build DIDL metadata if available
|
||||
let metadata_xml = item
|
||||
.metadata
|
||||
.as_ref()
|
||||
.map(|m| build_didl_lite_metadata(m, &item.uri, &item.protocol_info))
|
||||
.unwrap_or_default();
|
||||
|
||||
// Play the URI using the backend
|
||||
backend.play_uri(&item.uri, &metadata_xml)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Advance to and play the next item from the queue.
|
||||
pub fn play_next_from_queue(&self) -> Result<(), ControlPointError> {
|
||||
let mut queue = self.queue.lock().expect("Queue mutex poisoned");
|
||||
let backend = self.backend.lock().expect("Backend mutex poisoned");
|
||||
|
||||
// Dequeue the next item
|
||||
let (item, _remaining) = queue
|
||||
.dequeue_next()?
|
||||
.ok_or_else(|| ControlPointError::QueueError("No next item in queue".to_string()))?;
|
||||
|
||||
// Build DIDL metadata if available
|
||||
let metadata_xml = item
|
||||
.metadata
|
||||
.as_ref()
|
||||
.map(|m| build_didl_lite_metadata(m, &item.uri, &item.protocol_info))
|
||||
.unwrap_or_default();
|
||||
|
||||
// Play the URI using the backend
|
||||
backend.play_uri(&item.uri, &metadata_xml)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Transport control: play URI with metadata
|
||||
pub fn play_uri(&self, uri: &str, metadata: &str) -> Result<(), ControlPointError> {
|
||||
self.backend
|
||||
.lock()
|
||||
.expect("Backend mutex poisoned")
|
||||
.play_uri(uri, metadata)
|
||||
}
|
||||
|
||||
/// Transport control: play
|
||||
pub fn play(&self) -> Result<(), ControlPointError> {
|
||||
self.backend.lock().expect("Backend mutex poisoned").play()
|
||||
}
|
||||
|
||||
/// Transport control: pause
|
||||
pub fn pause(&self) -> Result<(), ControlPointError> {
|
||||
self.backend.lock().expect("Backend mutex poisoned").pause()
|
||||
}
|
||||
|
||||
/// Transport control: stop
|
||||
pub fn stop(&self) -> Result<(), ControlPointError> {
|
||||
self.backend.lock().expect("Backend mutex poisoned").stop()
|
||||
}
|
||||
|
||||
/// Set the next URI for gapless playback (UPnP AVTransport only).
|
||||
///
|
||||
/// Returns Ok if the backend supports this feature and it succeeded.
|
||||
/// Returns Err if not supported or if it failed.
|
||||
pub fn set_next_uri(&self, uri: &str, metadata: &str) -> Result<(), ControlPointError> {
|
||||
let backend = self.backend.lock().expect("Backend mutex poisoned");
|
||||
|
||||
match &*backend {
|
||||
MusicRendererBackend::Upnp(upnp) => upnp.set_next_uri(uri, metadata),
|
||||
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.set_next_uri(uri, metadata),
|
||||
_ => Err(ControlPointError::ControlPoint(
|
||||
"SetNextURI not supported by this backend".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Transport control: seek to relative time
|
||||
pub fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
||||
self.backend
|
||||
.lock()
|
||||
.expect("Backend mutex poisoned")
|
||||
.seek_rel_time(hhmmss)
|
||||
}
|
||||
|
||||
/// Volume control: get current volume
|
||||
pub fn volume(&self) -> Result<u16, ControlPointError> {
|
||||
self.backend
|
||||
.lock()
|
||||
.expect("Backend mutex poisoned")
|
||||
.volume()
|
||||
}
|
||||
|
||||
/// Volume control: set volume
|
||||
pub fn set_volume(&self, vol: u16) -> Result<(), ControlPointError> {
|
||||
self.backend
|
||||
.lock()
|
||||
.expect("Backend mutex poisoned")
|
||||
.set_volume(vol)
|
||||
}
|
||||
|
||||
/// Volume control: get mute state
|
||||
pub fn mute(&self) -> Result<bool, ControlPointError> {
|
||||
self.backend.lock().expect("Backend mutex poisoned").mute()
|
||||
}
|
||||
|
||||
/// Volume control: set mute state
|
||||
pub fn set_mute(&self, m: bool) -> Result<(), ControlPointError> {
|
||||
self.backend
|
||||
.lock()
|
||||
.expect("Backend mutex poisoned")
|
||||
.set_mute(m)
|
||||
}
|
||||
|
||||
/// Get playback state
|
||||
pub fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
|
||||
self.backend
|
||||
.lock()
|
||||
.expect("Backend mutex poisoned")
|
||||
.playback_state()
|
||||
}
|
||||
|
||||
/// Get playback position
|
||||
pub fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
|
||||
self.backend
|
||||
.lock()
|
||||
.expect("Backend mutex poisoned")
|
||||
.playback_position()
|
||||
}
|
||||
|
||||
/// Sets the playlist binding for this renderer.
|
||||
pub fn set_playlist_binding(&self, binding: Option<PlaylistBinding>) {
|
||||
*self
|
||||
.playlist_binding
|
||||
.lock()
|
||||
.expect("Playlist binding mutex poisoned") = binding;
|
||||
}
|
||||
|
||||
/// Gets the current playlist binding, if any.
|
||||
pub fn get_playlist_binding(&self) -> Option<PlaylistBinding> {
|
||||
self.playlist_binding
|
||||
.lock()
|
||||
.expect("Playlist binding mutex poisoned")
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Clears the playlist binding.
|
||||
pub fn clear_playlist_binding(&self) {
|
||||
*self
|
||||
.playlist_binding
|
||||
.lock()
|
||||
.expect("Playlist binding mutex poisoned") = None;
|
||||
}
|
||||
|
||||
/// Marks the current playlist binding for refresh if it matches the given server and container.
|
||||
/// Returns true if a matching binding was found and marked.
|
||||
pub fn mark_binding_for_refresh(&self, server_id: &DeviceId, container_ids: &[String]) -> bool {
|
||||
let mut binding_guard = self
|
||||
.playlist_binding
|
||||
.lock()
|
||||
.expect("Playlist binding mutex poisoned");
|
||||
|
||||
if let Some(binding) = binding_guard.as_mut() {
|
||||
if &binding.server_id == server_id && container_ids.contains(&binding.container_id) {
|
||||
binding.pending_refresh = true;
|
||||
binding.has_seen_update = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Checks if the binding has pending_refresh flag set.
|
||||
/// Returns false if no binding exists.
|
||||
pub fn has_pending_refresh(&self) -> bool {
|
||||
self.playlist_binding
|
||||
.lock()
|
||||
.expect("Playlist binding mutex poisoned")
|
||||
.as_ref()
|
||||
.map(|b| b.pending_refresh)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Resets the pending_refresh flag to false.
|
||||
/// Does nothing if no binding exists.
|
||||
pub fn reset_pending_refresh(&self) {
|
||||
if let Some(binding) = self
|
||||
.playlist_binding
|
||||
.lock()
|
||||
.expect("Playlist binding mutex poisoned")
|
||||
.as_mut()
|
||||
{
|
||||
binding.pending_refresh = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks the binding as pending refresh.
|
||||
/// Returns true if a binding exists and was marked, false otherwise.
|
||||
pub fn mark_pending_refresh(&self) -> bool {
|
||||
self.playlist_binding
|
||||
.lock()
|
||||
.expect("Playlist binding mutex poisoned")
|
||||
.as_mut()
|
||||
.map(|binding| {
|
||||
binding.pending_refresh = true;
|
||||
true
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Consumes and returns the auto_play_on_refresh flag, resetting it to false.
|
||||
/// Returns false if no binding exists.
|
||||
pub fn consume_auto_play(&self) -> bool {
|
||||
self.playlist_binding
|
||||
.lock()
|
||||
.expect("Playlist binding mutex poisoned")
|
||||
.as_mut()
|
||||
.map(|binding| {
|
||||
let auto_play = binding.auto_play_on_refresh;
|
||||
binding.auto_play_on_refresh = false;
|
||||
auto_play
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Add items to the queue using the specified enqueue mode.
|
||||
pub fn enqueue_items(
|
||||
&self,
|
||||
items: Vec<PlaybackItem>,
|
||||
mode: EnqueueMode,
|
||||
) -> Result<(), ControlPointError> {
|
||||
let mut queue = self.queue.lock().expect("Queue mutex poisoned");
|
||||
queue.enqueue_items(items, mode)
|
||||
}
|
||||
|
||||
/// Synchronize the queue with new items while preserving the current track.
|
||||
///
|
||||
/// This method intelligently updates the queue:
|
||||
/// - If the current track is in the new items, it keeps playing at the new position
|
||||
/// - If the current track is NOT in the new items, it's preserved as the first item
|
||||
/// - If there's no current track, the queue is simply replaced
|
||||
pub fn sync_queue(&self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||
let mut queue = self.queue.lock().expect("Queue mutex poisoned");
|
||||
queue.sync_queue(items)
|
||||
}
|
||||
|
||||
/// Set the current queue index (for advanced use).
|
||||
/// Note: This does NOT start playback. Use select_queue_track() to play.
|
||||
pub fn set_queue_index(&self, index: Option<usize>) -> Result<(), ControlPointError> {
|
||||
let mut queue = self.queue.lock().expect("Queue mutex poisoned");
|
||||
queue.set_index(index)
|
||||
}
|
||||
|
||||
/// Clears the renderer's queue using the generic QueueBackend trait.
|
||||
pub fn clear_queue(&self) -> Result<(), ControlPointError> {
|
||||
let mut queue = self.queue.lock().expect("Queue mutex poisoned");
|
||||
queue.clear_queue()
|
||||
}
|
||||
|
||||
/// Adds a track to the queue.
|
||||
///
|
||||
/// This is primarily for backends with persistent queues (OpenHome).
|
||||
/// For backends without this capability, it returns an error.
|
||||
///
|
||||
/// Returns the backend-specific track ID if applicable.
|
||||
pub fn add_track_to_queue(
|
||||
&self,
|
||||
_uri: &str,
|
||||
_metadata: &str,
|
||||
_after_id: Option<u32>,
|
||||
_play: bool,
|
||||
) -> Result<Option<u32>, ControlPointError> {
|
||||
// This operation requires backend-specific APIs (especially for OpenHome)
|
||||
// that aren't exposed through the generic QueueBackend trait.
|
||||
// The generic implementation cannot support this without more context
|
||||
// (media_server_id, didl_id, protocol_info, etc.).
|
||||
Err(ControlPointError::QueueError(
|
||||
"add_track_to_queue requires backend-specific implementation".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Selects and plays a specific track from the queue by ID.
|
||||
///
|
||||
/// Converts the track ID to a position using the generic QueueBackend trait,
|
||||
/// then plays the track.
|
||||
pub fn select_queue_track(&self, track_id: u32) -> Result<(), ControlPointError> {
|
||||
let queue = self.queue.lock().expect("Queue mutex poisoned");
|
||||
|
||||
// Convert track ID to position using generic QueueBackend trait
|
||||
let position = queue.id_to_position(track_id)?;
|
||||
drop(queue);
|
||||
|
||||
// Set the index
|
||||
let mut queue = self.queue.lock().expect("Queue mutex poisoned");
|
||||
queue.set_index(Some(position))?;
|
||||
|
||||
// Get the item to play
|
||||
let item = queue
|
||||
.get_item(position)?
|
||||
.ok_or_else(|| ControlPointError::QueueError("Track not found".to_string()))?;
|
||||
drop(queue);
|
||||
|
||||
// Build metadata XML
|
||||
let metadata_xml = item
|
||||
.metadata
|
||||
.as_ref()
|
||||
.map(|m| build_didl_lite_metadata(m, &item.uri, &item.protocol_info))
|
||||
.unwrap_or_default();
|
||||
|
||||
// Play the item using TransportControl
|
||||
let backend = self.backend.lock().expect("Backend mutex poisoned");
|
||||
backend.play_uri(&item.uri, &metadata_xml)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Synchronizes the queue state with the backend.
|
||||
///
|
||||
/// For backends with persistent queues (OpenHome), this refreshes the local view.
|
||||
/// For others, this is essentially a no-op (just reads the current state).
|
||||
pub fn sync_queue_state(&self) -> Result<(), ControlPointError> {
|
||||
let queue = self.queue.lock().expect("Queue mutex poisoned");
|
||||
// Calling queue_snapshot() triggers a refresh for backends that need it
|
||||
let _ = queue.queue_snapshot()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Plays the current item from the backend queue.
|
||||
///
|
||||
/// This is primarily for backends with persistent queues (OpenHome).
|
||||
pub fn play_current_from_backend_queue(&self) -> Result<(), ControlPointError> {
|
||||
let queue = self.queue.lock().expect("Queue mutex poisoned");
|
||||
|
||||
// Get current track ID using generic QueueBackend trait
|
||||
let track_id = queue
|
||||
.current_track()?
|
||||
.ok_or_else(|| ControlPointError::QueueError("No current track".to_string()))?;
|
||||
|
||||
drop(queue);
|
||||
|
||||
// Play it using select_queue_track
|
||||
self.select_queue_track(track_id)
|
||||
}
|
||||
|
||||
/// Returns a reference to the queue (read-only access via lock).
|
||||
/// This is a convenience method to avoid repetitive `queue.lock().unwrap()` patterns.
|
||||
pub fn get_queue(&self) -> std::sync::MutexGuard<'_, MusicQueue> {
|
||||
self.queue.lock().unwrap()
|
||||
}
|
||||
|
||||
/// Returns a mutable reference to the queue (write access via lock).
|
||||
/// This is a convenience method to avoid repetitive `queue.lock().unwrap()` patterns.
|
||||
pub fn get_queue_mut(&self) -> std::sync::MutexGuard<'_, MusicQueue> {
|
||||
self.queue.lock().unwrap()
|
||||
}
|
||||
|
||||
// --- Playback State Management ---
|
||||
|
||||
/// Gets the last known track metadata.
|
||||
pub fn last_metadata(&self) -> Option<TrackMetadata> {
|
||||
self.state.lock().unwrap().last_metadata.clone()
|
||||
}
|
||||
|
||||
/// Sets the last known track metadata.
|
||||
pub fn set_last_metadata(&self, metadata: Option<TrackMetadata>) {
|
||||
self.state.lock().unwrap().last_metadata = metadata;
|
||||
}
|
||||
|
||||
/// Gets the current playback source.
|
||||
pub fn playback_source(&self) -> PlaybackSource {
|
||||
self.state.lock().unwrap().playback_source
|
||||
}
|
||||
|
||||
/// Sets the playback source.
|
||||
pub fn set_playback_source(&self, source: PlaybackSource) {
|
||||
self.state.lock().unwrap().playback_source = source;
|
||||
}
|
||||
|
||||
/// Checks if currently playing from queue.
|
||||
pub fn is_playing_from_queue(&self) -> bool {
|
||||
matches!(
|
||||
self.state.lock().unwrap().playback_source,
|
||||
PlaybackSource::FromQueue
|
||||
)
|
||||
}
|
||||
|
||||
/// Marks playback as external if currently idle (source is None).
|
||||
pub fn mark_external_if_idle(&self) {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
if matches!(state.playback_source, PlaybackSource::None) {
|
||||
state.playback_source = PlaybackSource::External;
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks that the user requested a stop (to prevent auto-advance).
|
||||
pub fn mark_user_stop_requested(&self) {
|
||||
self.state.lock().unwrap().user_stop_requested = true;
|
||||
}
|
||||
|
||||
/// Checks and clears the user stop requested flag.
|
||||
pub fn check_and_clear_user_stop_requested(&self) -> bool {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let was_requested = state.user_stop_requested;
|
||||
state.user_stop_requested = false;
|
||||
was_requested
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to build DIDL-Lite metadata XML from TrackMetadata
|
||||
fn build_didl_lite_metadata(metadata: &TrackMetadata, uri: &str, protocol_info: &str) -> String {
|
||||
format!(
|
||||
r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">
|
||||
<item id="0" parentID="-1" restricted="1">
|
||||
<dc:title>{}</dc:title>
|
||||
<dc:creator>{}</dc:creator>
|
||||
<upnp:artist>{}</upnp:artist>
|
||||
<upnp:album>{}</upnp:album>
|
||||
{}
|
||||
<res protocolInfo="{}">{}</res>
|
||||
</item>
|
||||
</DIDL-Lite>"#,
|
||||
metadata.title.as_deref().unwrap_or("Unknown Title"),
|
||||
metadata
|
||||
.creator
|
||||
.as_deref()
|
||||
.or(metadata.artist.as_deref())
|
||||
.unwrap_or("Unknown Artist"),
|
||||
metadata.artist.as_deref().unwrap_or("Unknown Artist"),
|
||||
metadata.album.as_deref().unwrap_or("Unknown Album"),
|
||||
metadata
|
||||
.album_art_uri
|
||||
.as_ref()
|
||||
.map(|art_uri| format!("<upnp:albumArtURI>{}</upnp:albumArtURI>", art_uri))
|
||||
.unwrap_or_default(),
|
||||
protocol_info,
|
||||
uri
|
||||
)
|
||||
}
|
||||
|
||||
impl DeviceIdentity for MusicRenderer {
|
||||
fn id(&self) -> DeviceId {
|
||||
self.info.id()
|
||||
}
|
||||
|
||||
fn udn(&self) -> &str {
|
||||
&*self.info.udn()
|
||||
}
|
||||
|
||||
fn friendly_name(&self) -> &str {
|
||||
&self.info.friendly_name()
|
||||
}
|
||||
|
||||
fn model_name(&self) -> &str {
|
||||
&self.info.model_name()
|
||||
}
|
||||
fn manufacturer(&self) -> &str {
|
||||
&self.info.manufacturer()
|
||||
}
|
||||
fn location(&self) -> &str {
|
||||
&self.info.location()
|
||||
}
|
||||
fn server_header(&self) -> &str {
|
||||
&self.info.server_header()
|
||||
}
|
||||
}
|
||||
|
||||
impl DeviceOnline for MusicRenderer {
|
||||
fn is_online(&self) -> bool {
|
||||
self.connection
|
||||
.lock()
|
||||
.expect("Connection mutex poisoned")
|
||||
.is_online()
|
||||
}
|
||||
|
||||
fn last_seen(&self) -> SystemTime {
|
||||
self.connection
|
||||
.lock()
|
||||
.expect("Connection mutex poisoned")
|
||||
.last_seen()
|
||||
}
|
||||
|
||||
fn has_been_seen_now(&self, max_age: u32) {
|
||||
self.connection
|
||||
.lock()
|
||||
.expect("Connection mutex poisoned")
|
||||
.has_been_seen_now(max_age)
|
||||
}
|
||||
|
||||
fn mark_as_offline(&self) {
|
||||
self.connection
|
||||
.lock()
|
||||
.expect("Connection mutex poisoned")
|
||||
.mark_as_offline()
|
||||
}
|
||||
|
||||
fn max_age(&self) -> u32 {
|
||||
self.connection
|
||||
.lock()
|
||||
.expect("Connection mutex poisoned")
|
||||
.max_age()
|
||||
}
|
||||
}
|
||||
|
||||
impl RendererFromMediaRendererInfo for MusicRendererBackend {
|
||||
fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||
// Try OpenHome first for OpenHome-capable renderers
|
||||
match info.protocol() {
|
||||
RendererProtocol::OpenHomeOnly | RendererProtocol::OpenHomeHybrid => {
|
||||
if let Ok(backend) = OpenHomeRenderer::build_from_renderer_info(info) {
|
||||
return Ok(backend);
|
||||
}
|
||||
// If OpenHomeOnly failed, it's an error
|
||||
if matches!(info.protocol(), RendererProtocol::OpenHomeOnly) {
|
||||
return Err(ControlPointError::MusicRendererBackendBuild(format!(
|
||||
"OpenHomeOnly renderer {} has no usable OpenHome services",
|
||||
info.friendly_name()
|
||||
)));
|
||||
}
|
||||
// OpenHomeHybrid: fall through to try UPnP-based backends
|
||||
}
|
||||
RendererProtocol::ChromecastOnly => {
|
||||
return ChromecastRenderer::build_from_renderer_info(info);
|
||||
}
|
||||
RendererProtocol::UpnpAvOnly => {
|
||||
// Will be handled below
|
||||
}
|
||||
}
|
||||
|
||||
// UPnP-based renderers (UpnpAvOnly or OpenHomeHybrid fallback)
|
||||
let has_arylic = info.capabilities().has_arylic_tcp;
|
||||
let has_avtransport = info.capabilities().has_avtransport();
|
||||
|
||||
// Try Hybrid UPnP + Arylic (special case: combines two backends)
|
||||
if has_arylic && has_avtransport {
|
||||
let upnp_backend = UpnpRenderer::build_from_renderer_info(info)?;
|
||||
if let MusicRendererBackend::Upnp(upnp) = upnp_backend {
|
||||
match ArylicTcpRenderer::build_from_renderer_info(info) {
|
||||
Ok(MusicRendererBackend::ArylicTcp(arylic)) => {
|
||||
return Ok(MusicRendererBackend::HybridUpnpArylic { upnp, arylic });
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
renderer = info.friendly_name(),
|
||||
error = %err,
|
||||
"Failed to build Arylic TCP backend, falling back to UPnP only"
|
||||
);
|
||||
return Ok(MusicRendererBackend::Upnp(upnp));
|
||||
}
|
||||
_ => unreachable!(
|
||||
"ArylicTcpRenderer::build_from_renderer_info should return ArylicTcp variant"
|
||||
),
|
||||
}
|
||||
} else {
|
||||
unreachable!("UpnpRenderer::build_from_renderer_info should return Upnp variant");
|
||||
}
|
||||
}
|
||||
|
||||
// Try LinkPlay
|
||||
if info.capabilities().has_linkplay_http() {
|
||||
if let Ok(backend) = LinkPlayRenderer::build_from_renderer_info(info) {
|
||||
return Ok(backend);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to UPnP
|
||||
if has_avtransport {
|
||||
return UpnpRenderer::build_from_renderer_info(info);
|
||||
}
|
||||
|
||||
// No suitable backend found
|
||||
Err(ControlPointError::MusicRendererBackendBuild(format!(
|
||||
"No suitable backend for renderer {}",
|
||||
info.friendly_name()
|
||||
)))
|
||||
}
|
||||
|
||||
fn to_backend(self) -> MusicRendererBackend {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Transport control façade that dispatches to whichever backend can fulfill
|
||||
/// the request, returning a standardized error if the backend lacks support.
|
||||
impl TransportControl for MusicRendererBackend {
|
||||
fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicRendererBackend::Upnp(upnp) => upnp.play_uri(uri, meta),
|
||||
MusicRendererBackend::OpenHome(oh) => oh.play_uri(uri, meta),
|
||||
MusicRendererBackend::LinkPlay(lp) => lp.play_uri(uri, meta),
|
||||
MusicRendererBackend::ArylicTcp(_) => Err(
|
||||
ControlPointError::upnp_operation_not_supported("play_uri", "ArylicTcp"),
|
||||
),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.play_uri(uri, meta),
|
||||
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.play_uri(uri, meta),
|
||||
}
|
||||
}
|
||||
|
||||
fn play(&self) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicRendererBackend::Upnp(upnp) => upnp.play(),
|
||||
MusicRendererBackend::OpenHome(oh) => oh.play(),
|
||||
MusicRendererBackend::LinkPlay(lp) => lp.play(),
|
||||
MusicRendererBackend::ArylicTcp(ary) => ary.play(),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.play(),
|
||||
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.play(),
|
||||
}
|
||||
}
|
||||
|
||||
fn pause(&self) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicRendererBackend::Upnp(upnp) => upnp.pause(),
|
||||
MusicRendererBackend::OpenHome(oh) => oh.pause(),
|
||||
MusicRendererBackend::LinkPlay(lp) => lp.pause(),
|
||||
MusicRendererBackend::ArylicTcp(ary) => ary.pause(),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.pause(),
|
||||
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.pause(),
|
||||
}
|
||||
}
|
||||
|
||||
fn stop(&self) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicRendererBackend::Upnp(upnp) => upnp.stop(),
|
||||
MusicRendererBackend::OpenHome(oh) => oh.stop(),
|
||||
MusicRendererBackend::LinkPlay(lp) => lp.stop(),
|
||||
MusicRendererBackend::ArylicTcp(ary) => ary.stop(),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.stop(),
|
||||
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.stop(),
|
||||
}
|
||||
}
|
||||
|
||||
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicRendererBackend::Upnp(upnp) => upnp.seek_rel_time(hhmmss),
|
||||
MusicRendererBackend::OpenHome(oh) => oh.seek_rel_time(hhmmss),
|
||||
MusicRendererBackend::LinkPlay(lp) => lp.seek_rel_time(hhmmss),
|
||||
MusicRendererBackend::ArylicTcp(_) => Err(
|
||||
ControlPointError::upnp_operation_not_supported("seek_rel_time", "ArylicTcp"),
|
||||
),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.seek_rel_time(hhmmss),
|
||||
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.seek_rel_time(hhmmss),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Volume and mute controls exposed via the façade.
|
||||
///
|
||||
/// Hybrid backends may read via Arylic TCP and write via UPnP, but callers
|
||||
/// always depend on a single [`VolumeControl`] entry point.
|
||||
impl VolumeControl for MusicRendererBackend {
|
||||
fn volume(&self) -> Result<u16, ControlPointError> {
|
||||
match self {
|
||||
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.volume(),
|
||||
MusicRendererBackend::ArylicTcp(ary) => ary.volume(),
|
||||
MusicRendererBackend::OpenHome(oh) => oh.volume(),
|
||||
MusicRendererBackend::Upnp(upnp) => upnp.volume(),
|
||||
MusicRendererBackend::LinkPlay(lp) => lp.volume(),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.volume(),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_volume(&self, vol: u16) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.set_volume(vol),
|
||||
MusicRendererBackend::ArylicTcp(ary) => ary.set_volume(vol),
|
||||
MusicRendererBackend::OpenHome(oh) => oh.set_volume(vol),
|
||||
MusicRendererBackend::Upnp(upnp) => upnp.set_volume(vol),
|
||||
MusicRendererBackend::LinkPlay(lp) => lp.set_volume(vol),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.set_volume(vol),
|
||||
}
|
||||
}
|
||||
|
||||
fn mute(&self) -> Result<bool, ControlPointError> {
|
||||
match self {
|
||||
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.mute(),
|
||||
MusicRendererBackend::OpenHome(r) => r.mute(),
|
||||
MusicRendererBackend::Upnp(r) => r.mute(),
|
||||
MusicRendererBackend::LinkPlay(r) => r.mute(),
|
||||
MusicRendererBackend::ArylicTcp(r) => r.mute(),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.mute(),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_mute(&self, m: bool) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.set_mute(m),
|
||||
MusicRendererBackend::OpenHome(r) => r.set_mute(m),
|
||||
MusicRendererBackend::Upnp(r) => r.set_mute(m),
|
||||
MusicRendererBackend::LinkPlay(r) => r.set_mute(m),
|
||||
MusicRendererBackend::ArylicTcp(r) => r.set_mute(m),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.set_mute(m),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Playback-state queries sourced from the backend best suited for the job.
|
||||
///
|
||||
/// Each backend reports into [`PlaybackState`], ensuring consumers never have
|
||||
/// to reason about protocol-specific state machines.
|
||||
impl PlaybackStatus for MusicRendererBackend {
|
||||
fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
|
||||
match self {
|
||||
MusicRendererBackend::Upnp(r) => PlaybackStatus::playback_state(r),
|
||||
MusicRendererBackend::OpenHome(r) => PlaybackStatus::playback_state(r),
|
||||
MusicRendererBackend::LinkPlay(r) => r.playback_state(),
|
||||
MusicRendererBackend::ArylicTcp(r) => r.playback_state(),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.playback_state(),
|
||||
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.playback_state(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Playback-position queries that always yield a [`PlaybackPositionInfo`]
|
||||
/// regardless of the backend providing the raw transport data.
|
||||
impl PlaybackPosition for MusicRendererBackend {
|
||||
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
|
||||
match self {
|
||||
MusicRendererBackend::Upnp(r) => r.playback_position(),
|
||||
MusicRendererBackend::OpenHome(r) => r.playback_position(),
|
||||
MusicRendererBackend::LinkPlay(r) => r.playback_position(),
|
||||
MusicRendererBackend::ArylicTcp(r) => r.playback_position(),
|
||||
MusicRendererBackend::Chromecast(cc) => cc.playback_position(),
|
||||
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.playback_position(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
use crate::{
|
||||
model::RendererInfo,
|
||||
upnp_clients::{
|
||||
OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient, OhTimeClient,
|
||||
OhVolumeClient,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum OhServiceKind {
|
||||
Playlist,
|
||||
Info,
|
||||
Time,
|
||||
Volume,
|
||||
Product,
|
||||
}
|
||||
|
||||
impl OhServiceKind {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
OhServiceKind::Playlist => "playlist",
|
||||
OhServiceKind::Info => "info",
|
||||
OhServiceKind::Time => "time",
|
||||
OhServiceKind::Volume => "volume",
|
||||
OhServiceKind::Product => "product",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OhServiceEndpoint {
|
||||
pub control_url: String,
|
||||
pub service_type: String,
|
||||
}
|
||||
|
||||
fn endpoint_for(info: &RendererInfo, kind: OhServiceKind) -> Option<OhServiceEndpoint> {
|
||||
let (control_url, service_type) = match kind {
|
||||
OhServiceKind::Playlist => (
|
||||
info.oh_playlist_control_url()?,
|
||||
info.oh_playlist_service_type()?,
|
||||
),
|
||||
OhServiceKind::Info => (info.oh_info_control_url()?, info.oh_info_service_type()?),
|
||||
OhServiceKind::Time => (info.oh_time_control_url()?, info.oh_time_service_type()?),
|
||||
OhServiceKind::Volume => (
|
||||
info.oh_volume_control_url()?,
|
||||
info.oh_volume_service_type()?,
|
||||
),
|
||||
OhServiceKind::Product => (
|
||||
info.oh_product_control_url()?,
|
||||
info.oh_product_service_type()?,
|
||||
),
|
||||
};
|
||||
|
||||
Some(OhServiceEndpoint {
|
||||
control_url: control_url,
|
||||
service_type: service_type,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn control_url_for(info: &RendererInfo, kind: OhServiceKind) -> Option<String> {
|
||||
endpoint_for(info, kind).map(|endpoint| endpoint.control_url)
|
||||
}
|
||||
|
||||
pub fn service_type_for(info: &RendererInfo, kind: OhServiceKind) -> Option<String> {
|
||||
endpoint_for(info, kind).map(|endpoint| endpoint.service_type)
|
||||
}
|
||||
|
||||
pub fn build_playlist_client(info: &RendererInfo) -> Option<OhPlaylistClient> {
|
||||
let endpoint = endpoint_for(info, OhServiceKind::Playlist)?;
|
||||
Some(OhPlaylistClient::new(
|
||||
endpoint.control_url.to_string(),
|
||||
endpoint.service_type.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn build_info_client(info: &RendererInfo) -> Option<OhInfoClient> {
|
||||
let endpoint = endpoint_for(info, OhServiceKind::Info)?;
|
||||
Some(OhInfoClient::new(
|
||||
endpoint.control_url.to_string(),
|
||||
endpoint.service_type.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn build_time_client(info: &RendererInfo) -> Option<OhTimeClient> {
|
||||
let endpoint = endpoint_for(info, OhServiceKind::Time)?;
|
||||
Some(OhTimeClient::new(
|
||||
endpoint.control_url.to_string(),
|
||||
endpoint.service_type.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn build_volume_client(info: &RendererInfo) -> Option<OhVolumeClient> {
|
||||
let endpoint = endpoint_for(info, OhServiceKind::Volume)?;
|
||||
Some(OhVolumeClient::new(
|
||||
endpoint.control_url.to_string(),
|
||||
endpoint.service_type.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn build_product_client(info: &RendererInfo) -> Option<OhProductClient> {
|
||||
let endpoint = endpoint_for(info, OhServiceKind::Product)?;
|
||||
Some(OhProductClient::new(
|
||||
endpoint.control_url.to_string(),
|
||||
endpoint.service_type.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn build_radio_client(info: &RendererInfo) -> Option<OhRadioClient> {
|
||||
let control_url = info.oh_radio_control_url()?;
|
||||
let service_type = info.oh_radio_service_type()?;
|
||||
Some(OhRadioClient::new(control_url, service_type))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
DeviceId,
|
||||
model::{RendererCapabilities, RendererInfo, RendererProtocol},
|
||||
};
|
||||
|
||||
fn sample_renderer_info() -> RendererInfo {
|
||||
RendererInfo::make(
|
||||
DeviceId("renderer".into()),
|
||||
"renderer".into(),
|
||||
"Renderer".into(),
|
||||
"Model".into(),
|
||||
"Maker".into(),
|
||||
RendererProtocol::OpenHomeOnly,
|
||||
RendererCapabilities::default(),
|
||||
"http://host:1234/description.xml".into(),
|
||||
"test".into(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("urn:av-openhome-org:service:Playlist:1".into()),
|
||||
Some("http://host/oh/playlist".into()),
|
||||
Some("http://host/events/playlist".into()),
|
||||
Some("urn:av-openhome-org:service:Info:1".into()),
|
||||
Some("http://host/oh/info".into()),
|
||||
Some("http://host/events/info".into()),
|
||||
Some("urn:av-openhome-org:service:Time:1".into()),
|
||||
Some("http://host/oh/time".into()),
|
||||
Some("http://host/events/time".into()),
|
||||
Some("urn:av-openhome-org:service:Volume:1".into()),
|
||||
Some("http://host/oh/volume".into()),
|
||||
None,
|
||||
None,
|
||||
Some("urn:av-openhome-org:service:Product:1".into()),
|
||||
Some("http://host/oh/product".into()),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selects_correct_playlist_endpoint() {
|
||||
let info = sample_renderer_info();
|
||||
let endpoint = endpoint_for(&info, OhServiceKind::Playlist).unwrap();
|
||||
assert_eq!(endpoint.control_url, "http://host/oh/playlist");
|
||||
assert_eq!(
|
||||
endpoint.service_type,
|
||||
"urn:av-openhome-org:service:Playlist:1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_and_playlist_use_different_urls() {
|
||||
let info = sample_renderer_info();
|
||||
let playlist = control_url_for(&info, OhServiceKind::Playlist).unwrap();
|
||||
let info_url = control_url_for(&info, OhServiceKind::Info).unwrap();
|
||||
assert_ne!(playlist, info_url);
|
||||
}
|
||||
}
|
||||
@@ -1,332 +0,0 @@
|
||||
use crate::DeviceIdentity;
|
||||
use crate::music_renderer::capabilities::{
|
||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl, VolumeControl,
|
||||
};
|
||||
use crate::music_renderer::time_utils::{format_hhmmss_u32, parse_time_flexible};
|
||||
|
||||
use crate::errors::ControlPointError;
|
||||
use crate::model::{PlaybackState, RendererInfo};
|
||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||
use crate::music_renderer::musicrenderer::MusicRendererBackend;
|
||||
use crate::music_renderer::openhome::{
|
||||
build_info_client, build_playlist_client, build_product_client, build_radio_client,
|
||||
build_time_client, build_volume_client,
|
||||
};
|
||||
use crate::upnp_clients::{
|
||||
OPENHOME_PLAYLIST_HEAD_ID, OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient,
|
||||
OhTimeClient, OhVolumeClient,
|
||||
};
|
||||
use tracing::debug;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OpenHomeRenderer {
|
||||
playlist: Option<OhPlaylistClient>,
|
||||
info_client: Option<OhInfoClient>,
|
||||
time_client: Option<OhTimeClient>,
|
||||
volume_client: Option<OhVolumeClient>,
|
||||
product_client: Option<OhProductClient>,
|
||||
#[allow(dead_code)]
|
||||
radio_client: Option<OhRadioClient>,
|
||||
}
|
||||
|
||||
impl OpenHomeRenderer {
|
||||
pub fn new(
|
||||
playlist: Option<OhPlaylistClient>,
|
||||
info_client: Option<OhInfoClient>,
|
||||
time_client: Option<OhTimeClient>,
|
||||
volume_client: Option<OhVolumeClient>,
|
||||
product_client: Option<OhProductClient>,
|
||||
radio_client: Option<OhRadioClient>,
|
||||
) -> Self {
|
||||
Self {
|
||||
playlist,
|
||||
info_client,
|
||||
time_client,
|
||||
volume_client,
|
||||
product_client,
|
||||
radio_client,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_playlist(&self) -> bool {
|
||||
self.playlist.is_some()
|
||||
}
|
||||
|
||||
pub fn has_info(&self) -> bool {
|
||||
self.info_client.is_some()
|
||||
}
|
||||
|
||||
pub fn has_time(&self) -> bool {
|
||||
self.time_client.is_some()
|
||||
}
|
||||
|
||||
pub fn has_volume(&self) -> bool {
|
||||
self.volume_client.is_some()
|
||||
}
|
||||
|
||||
pub fn has_any_openhome_service(&self) -> bool {
|
||||
self.has_playlist() || self.has_info() || self.has_time() || self.has_volume()
|
||||
}
|
||||
|
||||
fn playlist_client_for(&self, op: &str) -> Result<&OhPlaylistClient, ControlPointError> {
|
||||
let playlist = self.playlist.as_ref().ok_or_else(|| {
|
||||
ControlPointError::upnp_operation_not_supported(op, "OpenHome Playlist")
|
||||
})?;
|
||||
self.ensure_playlist_source_selected()?;
|
||||
Ok(playlist)
|
||||
}
|
||||
|
||||
fn info_client_for(&self, op: &str) -> Result<&OhInfoClient, ControlPointError> {
|
||||
self.info_client
|
||||
.as_ref()
|
||||
.ok_or_else(|| ControlPointError::upnp_operation_not_supported(op, "OpenHome Info"))
|
||||
}
|
||||
|
||||
fn time_client_for(&self, op: &str) -> Result<&OhTimeClient, ControlPointError> {
|
||||
self.time_client
|
||||
.as_ref()
|
||||
.ok_or_else(|| ControlPointError::upnp_operation_not_supported(op, "OpenHome Time"))
|
||||
}
|
||||
|
||||
fn volume_client_for(&self, op: &str) -> Result<&OhVolumeClient, ControlPointError> {
|
||||
self.volume_client
|
||||
.as_ref()
|
||||
.ok_or_else(|| ControlPointError::upnp_operation_not_supported(op, "OpenHome Volume"))
|
||||
}
|
||||
|
||||
fn ensure_playlist_source_selected(&self) -> Result<(), ControlPointError> {
|
||||
if let Some(product) = &self.product_client {
|
||||
product.ensure_playlist_source_selected()
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// pub(crate) fn snapshot_openhome_playlist(&self) -> Result<OpenHomePlaylistSnapshot> {
|
||||
// let playlist = self.playlist_client_for("snapshot_openhome_playlist")?;
|
||||
// let entries = playlist.read_all_tracks()?;
|
||||
|
||||
// // Get current track ID from the playlist service
|
||||
// let current_id = playlist.id().ok();
|
||||
|
||||
// let current_index =
|
||||
// current_id.and_then(|id| entries.iter().position(|entry| entry.id == id));
|
||||
|
||||
// debug!(
|
||||
// current_id = ?current_id,
|
||||
// current_index = ?current_index,
|
||||
// track_count = entries.len(),
|
||||
// "snapshot_openhome_playlist completed"
|
||||
// );
|
||||
|
||||
// let tracks = entries.iter().map(convert_oh_track_entry).collect();
|
||||
|
||||
// Ok(OpenHomePlaylistSnapshot {
|
||||
// current_id,
|
||||
// current_index,
|
||||
// tracks,
|
||||
// })
|
||||
// }
|
||||
|
||||
/// Retourne la longueur de la playlist OpenHome sans récupérer toutes les métadonnées.
|
||||
/// Plus rapide que snapshot_openhome_playlist() pour juste connaître le nombre de pistes.
|
||||
pub(crate) fn openhome_playlist_len(&self) -> Result<usize, ControlPointError> {
|
||||
let playlist = self.playlist_client_for("openhome_playlist_len")?;
|
||||
let ids = playlist.id_array()?;
|
||||
Ok(ids.len())
|
||||
}
|
||||
|
||||
/// Retourne les IDs des pistes de la playlist OpenHome.
|
||||
/// Plus rapide que snapshot_openhome_playlist() car ne récupère pas les métadonnées.
|
||||
pub(crate) fn openhome_playlist_ids(&self) -> Result<Vec<u32>, ControlPointError> {
|
||||
let playlist = self.playlist_client_for("openhome_playlist_ids")?;
|
||||
playlist.id_array()
|
||||
}
|
||||
|
||||
pub(crate) fn clear_openhome_playlist(&self) -> Result<(), ControlPointError> {
|
||||
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, ControlPointError> {
|
||||
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(OPENHOME_PLAYLIST_HEAD_ID),
|
||||
};
|
||||
|
||||
let new_id = playlist.insert(insert_after, uri, metadata)?;
|
||||
if play {
|
||||
playlist.seek_id(new_id)?;
|
||||
}
|
||||
Ok(new_id)
|
||||
}
|
||||
|
||||
pub(crate) fn play_openhome_track_id(&self, id: u32) -> Result<(), ControlPointError> {
|
||||
let playlist = self.playlist_client_for("play_openhome_track_id")?;
|
||||
playlist.seek_id(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl RendererFromMediaRendererInfo for OpenHomeRenderer {
|
||||
fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||
let renderer = OpenHomeRenderer::new(
|
||||
build_playlist_client(&info),
|
||||
build_info_client(&info),
|
||||
build_time_client(&info),
|
||||
build_volume_client(&info),
|
||||
build_product_client(&info),
|
||||
build_radio_client(&info),
|
||||
);
|
||||
|
||||
if renderer.has_any_openhome_service() {
|
||||
Ok(renderer)
|
||||
} else {
|
||||
Err(ControlPointError::OpenHomeNotAValidDevice(format!(
|
||||
"{:?}",
|
||||
info.id()
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn to_backend(self) -> MusicRendererBackend {
|
||||
MusicRendererBackend::OpenHome(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl TransportControl for OpenHomeRenderer {
|
||||
fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
|
||||
let playlist = self.playlist_client_for("play_uri")?;
|
||||
|
||||
if let Err(err) = playlist.delete_all() {
|
||||
debug!(
|
||||
// renderer = self.info.id.0.as_str(),
|
||||
error = %err,
|
||||
"Failed to clear OpenHome playlist before insert"
|
||||
);
|
||||
}
|
||||
|
||||
// Reuse the same insertion logic as the queue path so that we honor
|
||||
// renderer expectations (IdArray sequencing, etc.).
|
||||
self.add_track_openhome(uri, meta, None, true)?;
|
||||
|
||||
// Start playback (like UPnP renderer does with avt.play())
|
||||
playlist.play()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn play(&self) -> Result<(), ControlPointError> {
|
||||
let playlist = self.playlist_client_for("play")?;
|
||||
playlist.play()
|
||||
}
|
||||
|
||||
fn pause(&self) -> Result<(), ControlPointError> {
|
||||
let playlist = self.playlist_client_for("pause")?;
|
||||
playlist.pause()
|
||||
}
|
||||
|
||||
fn stop(&self) -> Result<(), ControlPointError> {
|
||||
let playlist = self.playlist_client_for("stop")?;
|
||||
playlist.stop()
|
||||
}
|
||||
|
||||
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
||||
let seconds = parse_time_flexible(hhmmss)?;
|
||||
let playlist = self.playlist_client_for("seek_rel_time")?;
|
||||
playlist.seek_second_absolute(seconds)
|
||||
}
|
||||
}
|
||||
|
||||
impl VolumeControl for OpenHomeRenderer {
|
||||
fn volume(&self) -> Result<u16, ControlPointError> {
|
||||
let client = self.volume_client_for("volume")?;
|
||||
client.volume()
|
||||
}
|
||||
|
||||
fn set_volume(&self, v: u16) -> Result<(), ControlPointError> {
|
||||
let client = self.volume_client_for("set_volume")?;
|
||||
client.set_volume(v)
|
||||
}
|
||||
|
||||
fn mute(&self) -> Result<bool, ControlPointError> {
|
||||
let client = self.volume_client_for("mute")?;
|
||||
client.mute()
|
||||
}
|
||||
|
||||
fn set_mute(&self, m: bool) -> Result<(), ControlPointError> {
|
||||
let client = self.volume_client_for("set_mute")?;
|
||||
client.set_mute(m)
|
||||
}
|
||||
}
|
||||
|
||||
impl PlaybackStatus for OpenHomeRenderer {
|
||||
fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
|
||||
let client = self.playlist_client_for("playback_state")?;
|
||||
let state = client.transport_state()?;
|
||||
Ok(map_openhome_state(&state))
|
||||
}
|
||||
}
|
||||
|
||||
impl PlaybackPosition for OpenHomeRenderer {
|
||||
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
|
||||
let time_info = self.time_client_for("playback_position")?.position()?;
|
||||
|
||||
let mut track_id = None;
|
||||
let mut track_uri = None;
|
||||
let mut track_metadata_xml = None;
|
||||
|
||||
if let Some(playlist_client) = &self.playlist {
|
||||
match playlist_client.id() {
|
||||
Ok(id) => track_id = Some(id),
|
||||
Err(err) => debug!(
|
||||
// renderer = self.info.id.0.as_str(),
|
||||
error = %err,
|
||||
"Failed to read OpenHome track id"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(info_client) = &self.info_client {
|
||||
match info_client.track() {
|
||||
Ok(track) => {
|
||||
track_uri = Some(track.uri);
|
||||
track_metadata_xml = track.metadata_xml;
|
||||
}
|
||||
Err(err) => debug!(
|
||||
// renderer = self.info.id.0.as_str(),
|
||||
error = %err,
|
||||
"Failed to read OpenHome track metadata"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(PlaybackPositionInfo {
|
||||
track: track_id,
|
||||
rel_time: Some(format_hhmmss_u32(time_info.elapsed_secs)),
|
||||
abs_time: None,
|
||||
track_duration: Some(format_hhmmss_u32(time_info.duration_secs)),
|
||||
track_metadata: track_metadata_xml,
|
||||
track_uri,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) 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()),
|
||||
}
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
//! Time formatting and parsing utilities for music renderers.
|
||||
//!
|
||||
//! This module provides utilities to convert between different time representations:
|
||||
//! - HH:MM:SS format (UPnP standard)
|
||||
//! - Seconds (u32/u64)
|
||||
//! - Milliseconds
|
||||
//!
|
||||
//! All functions are designed to be robust and provide clear error messages.
|
||||
|
||||
use crate::errors::ControlPointError;
|
||||
|
||||
/// Formats a duration in seconds as HH:MM:SS.
|
||||
///
|
||||
/// # Examples
|
||||
/// ```
|
||||
/// # use pmocontrol::music_renderer::time_utils::format_hhmmss;
|
||||
/// assert_eq!(format_hhmmss(0), "00:00:00");
|
||||
/// assert_eq!(format_hhmmss(61), "00:01:01");
|
||||
/// assert_eq!(format_hhmmss(3661), "01:01:01");
|
||||
/// ```
|
||||
pub fn format_hhmmss(seconds: u64) -> String {
|
||||
let hours = seconds / 3600;
|
||||
let minutes = (seconds % 3600) / 60;
|
||||
let secs = seconds % 60;
|
||||
format!("{:02}:{:02}:{:02}", hours, minutes, secs)
|
||||
}
|
||||
|
||||
/// Formats a duration in seconds as HH:MM:SS (u32 variant).
|
||||
///
|
||||
/// Convenience wrapper for u32 values.
|
||||
pub fn format_hhmmss_u32(seconds: u32) -> String {
|
||||
format_hhmmss(seconds as u64)
|
||||
}
|
||||
|
||||
/// Formats a duration in seconds as HH:MM:SS (f64 variant).
|
||||
///
|
||||
/// Used by Chromecast which returns floating point durations.
|
||||
/// Rounds to nearest second.
|
||||
pub fn format_hhmmss_f64(seconds: f64) -> String {
|
||||
format_hhmmss(seconds.round() as u64)
|
||||
}
|
||||
|
||||
/// Parses a time string in HH:MM:SS, MM:SS, or SS format to seconds.
|
||||
///
|
||||
/// This is the most flexible parser, supporting multiple formats:
|
||||
/// - "HH:MM:SS" → hours * 3600 + minutes * 60 + seconds
|
||||
/// - "MM:SS" → minutes * 60 + seconds
|
||||
/// - "SS" → seconds
|
||||
///
|
||||
/// # Examples
|
||||
/// ```
|
||||
/// # use pmocontrol::music_renderer::time_utils::parse_time_flexible;
|
||||
/// assert_eq!(parse_time_flexible("01:02:03").unwrap(), 3723);
|
||||
/// assert_eq!(parse_time_flexible("02:03").unwrap(), 123);
|
||||
/// assert_eq!(parse_time_flexible("42").unwrap(), 42);
|
||||
/// ```
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if:
|
||||
/// - The input has more than 3 parts
|
||||
/// - Any part is not a valid u32
|
||||
pub fn parse_time_flexible(input: &str) -> Result<u32, ControlPointError> {
|
||||
let parts: Vec<&str> = input.split(':').collect();
|
||||
|
||||
if parts.is_empty() || parts.len() > 3 {
|
||||
return Err(ControlPointError::InvalidTimeFormat(
|
||||
format!("Invalid time format '{}': expected HH:MM:SS, MM:SS, or SS", input)
|
||||
));
|
||||
}
|
||||
|
||||
let mut total = 0u32;
|
||||
for part in parts {
|
||||
let value = part.parse::<u32>().map_err(|_| {
|
||||
ControlPointError::InvalidTimeFormat(
|
||||
format!("Invalid numeric value '{}' in time string '{}'", part, input)
|
||||
)
|
||||
})?;
|
||||
total = total * 60 + value;
|
||||
}
|
||||
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// Parses a strict HH:MM:SS format to seconds.
|
||||
///
|
||||
/// This parser requires exactly 3 components separated by colons,
|
||||
/// and validates that minutes and seconds are < 60.
|
||||
///
|
||||
/// # Examples
|
||||
/// ```
|
||||
/// # use pmocontrol::music_renderer::time_utils::parse_hhmmss_strict;
|
||||
/// assert_eq!(parse_hhmmss_strict("01:02:03").unwrap(), 3723);
|
||||
/// assert!(parse_hhmmss_strict("02:03").is_err()); // requires HH:MM:SS
|
||||
/// assert!(parse_hhmmss_strict("00:61:00").is_err()); // minutes > 59
|
||||
/// ```
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns an error if:
|
||||
/// - The format is not exactly HH:MM:SS
|
||||
/// - Minutes or seconds are >= 60
|
||||
/// - Any component is not a valid u64
|
||||
pub fn parse_hhmmss_strict(input: &str) -> Result<u64, ControlPointError> {
|
||||
let parts: Vec<&str> = input.split(':').collect();
|
||||
|
||||
if parts.len() != 3 {
|
||||
return Err(ControlPointError::InvalidTimeFormat(
|
||||
format!("Invalid time format '{}': expected exactly HH:MM:SS", input)
|
||||
));
|
||||
}
|
||||
|
||||
let hours: u64 = parts[0].parse().map_err(|_| {
|
||||
ControlPointError::InvalidTimeFormat(
|
||||
format!("Invalid hour component in '{}'", input)
|
||||
)
|
||||
})?;
|
||||
|
||||
let minutes: u64 = parts[1].parse().map_err(|_| {
|
||||
ControlPointError::InvalidTimeFormat(
|
||||
format!("Invalid minute component in '{}'", input)
|
||||
)
|
||||
})?;
|
||||
|
||||
let seconds: u64 = parts[2].parse().map_err(|_| {
|
||||
ControlPointError::InvalidTimeFormat(
|
||||
format!("Invalid second component in '{}'", input)
|
||||
)
|
||||
})?;
|
||||
|
||||
if minutes >= 60 || seconds >= 60 {
|
||||
return Err(ControlPointError::InvalidTimeFormat(
|
||||
format!("Invalid time '{}': minutes and seconds must be < 60", input)
|
||||
));
|
||||
}
|
||||
|
||||
Ok(hours * 3600 + minutes * 60 + seconds)
|
||||
}
|
||||
|
||||
/// Converts milliseconds to seconds (rounding down).
|
||||
#[inline]
|
||||
pub fn ms_to_seconds(milliseconds: u64) -> u64 {
|
||||
milliseconds / 1000
|
||||
}
|
||||
|
||||
/// Converts seconds to milliseconds.
|
||||
#[inline]
|
||||
pub fn seconds_to_ms(seconds: u64) -> u64 {
|
||||
seconds * 1000
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_format_hhmmss() {
|
||||
assert_eq!(format_hhmmss(0), "00:00:00");
|
||||
assert_eq!(format_hhmmss(1), "00:00:01");
|
||||
assert_eq!(format_hhmmss(60), "00:01:00");
|
||||
assert_eq!(format_hhmmss(61), "00:01:01");
|
||||
assert_eq!(format_hhmmss(3600), "01:00:00");
|
||||
assert_eq!(format_hhmmss(3661), "01:01:01");
|
||||
assert_eq!(format_hhmmss(86399), "23:59:59");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_hhmmss_f64() {
|
||||
assert_eq!(format_hhmmss_f64(123.4), "00:02:03");
|
||||
assert_eq!(format_hhmmss_f64(123.6), "00:02:04");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_time_flexible() {
|
||||
// HH:MM:SS format
|
||||
assert_eq!(parse_time_flexible("01:02:03").unwrap(), 3723);
|
||||
assert_eq!(parse_time_flexible("00:00:00").unwrap(), 0);
|
||||
assert_eq!(parse_time_flexible("23:59:59").unwrap(), 86399);
|
||||
|
||||
// MM:SS format
|
||||
assert_eq!(parse_time_flexible("02:03").unwrap(), 123);
|
||||
assert_eq!(parse_time_flexible("00:00").unwrap(), 0);
|
||||
|
||||
// SS format
|
||||
assert_eq!(parse_time_flexible("42").unwrap(), 42);
|
||||
assert_eq!(parse_time_flexible("0").unwrap(), 0);
|
||||
|
||||
// Errors
|
||||
assert!(parse_time_flexible("").is_err());
|
||||
assert!(parse_time_flexible("1:2:3:4").is_err());
|
||||
assert!(parse_time_flexible("abc").is_err());
|
||||
assert!(parse_time_flexible("1:abc").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_hhmmss_strict() {
|
||||
assert_eq!(parse_hhmmss_strict("01:02:03").unwrap(), 3723);
|
||||
assert_eq!(parse_hhmmss_strict("00:00:00").unwrap(), 0);
|
||||
assert_eq!(parse_hhmmss_strict("23:59:59").unwrap(), 86399);
|
||||
|
||||
// Errors - wrong format
|
||||
assert!(parse_hhmmss_strict("02:03").is_err());
|
||||
assert!(parse_hhmmss_strict("42").is_err());
|
||||
|
||||
// Errors - invalid values
|
||||
assert!(parse_hhmmss_strict("00:60:00").is_err());
|
||||
assert!(parse_hhmmss_strict("00:00:60").is_err());
|
||||
assert!(parse_hhmmss_strict("abc:00:00").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ms_conversions() {
|
||||
assert_eq!(ms_to_seconds(1000), 1);
|
||||
assert_eq!(ms_to_seconds(1500), 1); // rounds down
|
||||
assert_eq!(ms_to_seconds(999), 0);
|
||||
|
||||
assert_eq!(seconds_to_ms(1), 1000);
|
||||
assert_eq!(seconds_to_ms(0), 0);
|
||||
}
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
use crate::errors::ControlPointError;
|
||||
use crate::music_renderer::RendererFromMediaRendererInfo;
|
||||
use crate::music_renderer::capabilities::{
|
||||
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl,
|
||||
VolumeControl,
|
||||
};
|
||||
use crate::model::PlaybackState;
|
||||
use crate::music_renderer::musicrenderer::MusicRendererBackend;
|
||||
use crate::upnp_clients::{
|
||||
AvTransportClient, ConnectionInfo, ConnectionManagerClient, PositionInfo, ProtocolInfo,
|
||||
RenderingControlClient,
|
||||
};
|
||||
use crate::{DeviceIdentity, RendererInfo};
|
||||
|
||||
/// High-level handle representing a renderer and its optional AVTransport client.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct UpnpRenderer {
|
||||
avtransport: Option<AvTransportClient>,
|
||||
rendering_control: Option<RenderingControlClient>,
|
||||
connection_manager: Option<ConnectionManagerClient>,
|
||||
has_avtransport_set_next: bool,
|
||||
}
|
||||
|
||||
impl UpnpRenderer {
|
||||
/// Retourne true si le renderer à un service de type AVTransport.
|
||||
pub fn has_avtransport(&self) -> bool {
|
||||
self.avtransport.is_some()
|
||||
}
|
||||
|
||||
/// Retourne true si le renderer à un service de type RenderingControl.
|
||||
pub fn has_rendering_control(&self) -> bool {
|
||||
self.rendering_control.is_some()
|
||||
}
|
||||
|
||||
/// Retourne true si le renderer à un service de type ConnectionManager.
|
||||
pub fn has_connection_manager(&self) -> bool {
|
||||
self.connection_manager.is_some()
|
||||
}
|
||||
|
||||
/// Retourne le client de type AVTransport contenant les URL de controle et d'abonnement.
|
||||
pub fn avtransport(&self) -> Result<&AvTransportClient, ControlPointError> {
|
||||
self.avtransport.as_ref().ok_or_else(|| {
|
||||
ControlPointError::upnp_operation_not_supported("AvTransport", "Renderer")
|
||||
})
|
||||
}
|
||||
|
||||
/// Retourne le client de type RenderingControl contenant les URL de controle et d'abonnement.
|
||||
pub fn rendering_control(&self) -> Result<&RenderingControlClient, ControlPointError> {
|
||||
self.rendering_control.as_ref().ok_or_else(|| {
|
||||
ControlPointError::upnp_operation_not_supported("RenderingControl", "Renderer")
|
||||
})
|
||||
}
|
||||
|
||||
/// Retourne le client de type ConnectionManager contenant les URL de controle et d'abonnement.
|
||||
pub fn connection_manager(&self) -> Result<&ConnectionManagerClient, ControlPointError> {
|
||||
self.connection_manager.as_ref().ok_or_else(|| {
|
||||
ControlPointError::upnp_operation_not_supported("ConnectionManager", "Renderer")
|
||||
})
|
||||
}
|
||||
|
||||
pub fn protocol_info(&self) -> Result<ProtocolInfo, ControlPointError> {
|
||||
let cm = self.connection_manager()?;
|
||||
cm.get_protocol_info()
|
||||
}
|
||||
|
||||
pub fn connection_ids(&self) -> Result<Vec<i32>, ControlPointError> {
|
||||
let cm = self.connection_manager()?;
|
||||
cm.get_current_connection_ids()
|
||||
}
|
||||
|
||||
pub fn connection_info(&self, connection_id: i32) -> Result<ConnectionInfo, ControlPointError> {
|
||||
let cm = self.connection_manager()?;
|
||||
cm.get_current_connection_info(connection_id)
|
||||
}
|
||||
|
||||
pub fn set_next_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
|
||||
if !self.has_avtransport_set_next {
|
||||
return Err(ControlPointError::upnp_operation_not_supported(
|
||||
"SetNextAVTransportURI",
|
||||
"Renderer",
|
||||
));
|
||||
}
|
||||
let avt = self.avtransport()?;
|
||||
avt.set_next_av_transport_uri(uri, meta)
|
||||
}
|
||||
}
|
||||
|
||||
impl RendererFromMediaRendererInfo for UpnpRenderer {
|
||||
fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||
// Prepare le service AVTTransport
|
||||
let avtransport = match (
|
||||
info.avtransport_control_url(),
|
||||
info.avtransport_service_type(),
|
||||
) {
|
||||
(Some(url), Some(service)) => Some(AvTransportClient::new(url, service)),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Prepare le service RenderingControl
|
||||
let rendering_control = match (
|
||||
info.rendering_control_control_url(),
|
||||
info.rendering_control_service_type(),
|
||||
) {
|
||||
(Some(url), Some(service)) => Some(RenderingControlClient::new(url, service)),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Prepare le service ConnectionManager
|
||||
let connection_manager = match (
|
||||
info.connection_manager_control_url(),
|
||||
info.connection_manager_service_type(),
|
||||
) {
|
||||
(Some(url), Some(service)) => Some(ConnectionManagerClient::new(url, service)),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Exige AVTTransport et RenderingControl au minimum
|
||||
if avtransport.is_none() || rendering_control.is_none() {
|
||||
return Err(ControlPointError::UpnpError(format!(
|
||||
"Some mandatory services are missing on {:?}",
|
||||
info.id(),
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
avtransport,
|
||||
rendering_control,
|
||||
connection_manager,
|
||||
has_avtransport_set_next: info.capabilities().has_avtransport_set_next(),
|
||||
})
|
||||
}
|
||||
|
||||
fn to_backend(self) -> MusicRendererBackend {
|
||||
MusicRendererBackend::Upnp(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Implémentation UPnP AV de `TransportControl` pour [`UpnpRenderer`].
|
||||
///
|
||||
/// Cette impl se base sur AVTransport (InstanceID = 0).
|
||||
impl TransportControl for UpnpRenderer {
|
||||
fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
|
||||
let avt = self.avtransport()?;
|
||||
avt.set_av_transport_uri(uri, meta)?;
|
||||
avt.play(0, "1")
|
||||
}
|
||||
|
||||
fn play(&self) -> Result<(), ControlPointError> {
|
||||
let avt = self.avtransport()?;
|
||||
avt.play(0, "1")
|
||||
}
|
||||
|
||||
fn pause(&self) -> Result<(), ControlPointError> {
|
||||
let avt = self.avtransport()?;
|
||||
avt.pause(0)
|
||||
}
|
||||
|
||||
fn stop(&self) -> Result<(), ControlPointError> {
|
||||
let avt = self.avtransport()?;
|
||||
avt.stop(0)
|
||||
}
|
||||
|
||||
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
|
||||
let avt = self.avtransport()?;
|
||||
avt.seek(0, "REL_TIME", hhmmss)
|
||||
}
|
||||
}
|
||||
|
||||
/// Implémentation UPnP RenderingControl de `VolumeControl` pour [`UpnpRenderer`].
|
||||
///
|
||||
/// Cette impl se base sur le channel "Master" (InstanceID = 0).
|
||||
impl VolumeControl for UpnpRenderer {
|
||||
fn volume(&self) -> Result<u16, ControlPointError> {
|
||||
let rc = self.rendering_control()?;
|
||||
rc.get_volume(0, "Master")
|
||||
}
|
||||
|
||||
fn set_volume(&self, v: u16) -> Result<(), ControlPointError> {
|
||||
let rc = self.rendering_control()?;
|
||||
rc.set_volume(0, "Master", v)
|
||||
}
|
||||
|
||||
fn mute(&self) -> Result<bool, ControlPointError> {
|
||||
let rc = self.rendering_control()?;
|
||||
rc.get_mute(0, "Master")
|
||||
}
|
||||
|
||||
fn set_mute(&self, m: bool) -> Result<(), ControlPointError> {
|
||||
let rc = self.rendering_control()?;
|
||||
rc.set_mute(0, "Master", m)
|
||||
}
|
||||
}
|
||||
|
||||
/// Implémentation UPnP AV de `PlaybackStatus` pour [`UpnpRenderer`].
|
||||
///
|
||||
/// Utilise AVTransport::GetTransportInfo(InstanceID=0).
|
||||
impl PlaybackStatus for UpnpRenderer {
|
||||
fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
|
||||
let avt = self.avtransport()?;
|
||||
let info = avt.get_transport_info(0)?;
|
||||
Ok(PlaybackState::from_upnp_state(
|
||||
&info.current_transport_state,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl PlaybackPosition for UpnpRenderer {
|
||||
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
|
||||
let avt = self.avtransport()?;
|
||||
let raw: PositionInfo = avt.get_position_info(0)?;
|
||||
|
||||
Ok(PlaybackPositionInfo {
|
||||
track: Some(raw.track),
|
||||
rel_time: raw.rel_time,
|
||||
abs_time: raw.abs_time,
|
||||
track_duration: raw.track_duration,
|
||||
track_metadata: raw.track_metadata,
|
||||
track_uri: raw.track_uri,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
use std::{
|
||||
sync::{Arc, Mutex},
|
||||
time::SystemTime,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DeviceConnectionState {
|
||||
online: bool,
|
||||
last_seen: SystemTime,
|
||||
max_age: u32,
|
||||
}
|
||||
|
||||
pub trait DeviceOnline {
|
||||
fn is_online(&self) -> bool;
|
||||
fn last_seen(&self) -> SystemTime;
|
||||
fn has_been_seen_now(&self, max_age: u32);
|
||||
fn mark_as_offline(&self);
|
||||
fn max_age(&self) -> u32;
|
||||
}
|
||||
|
||||
impl DeviceConnectionState {
|
||||
pub fn new() -> Self {
|
||||
DeviceConnectionState {
|
||||
online: true,
|
||||
last_seen: SystemTime::now(),
|
||||
max_age: 1800,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn make() -> Arc<Mutex<Self>> {
|
||||
Arc::new(Mutex::new(DeviceConnectionState::new()))
|
||||
}
|
||||
|
||||
pub fn is_online(&self) -> bool {
|
||||
self.online
|
||||
}
|
||||
|
||||
pub fn last_seen(&self) -> SystemTime {
|
||||
self.last_seen
|
||||
}
|
||||
|
||||
pub fn max_age(&self) -> u32 {
|
||||
self.max_age
|
||||
}
|
||||
|
||||
pub fn has_been_seen_now(&mut self, max_age: u32) {
|
||||
self.last_seen = SystemTime::now();
|
||||
self.max_age = max_age;
|
||||
self.online = true;
|
||||
}
|
||||
|
||||
pub fn mark_as_offline(&mut self) {
|
||||
self.online = false;
|
||||
}
|
||||
}
|
||||
@@ -149,6 +149,28 @@ pub struct QueueSnapshot {
|
||||
pub current_index: Option<usize>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OPENHOME PLAYLIST
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
pub use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
|
||||
|
||||
/// Requête pour ajouter un track à la playlist OpenHome
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[derive(Debug, Clone, Deserialize, ToSchema)]
|
||||
pub struct OpenHomePlaylistAddRequest {
|
||||
/// URI du flux à insérer
|
||||
pub uri: String,
|
||||
/// Métadonnées DIDL-Lite complètes
|
||||
pub metadata: String,
|
||||
/// ID devant lequel insérer (None => fin de playlist)
|
||||
pub after_id: Option<u32>,
|
||||
/// Si true, démarre immédiatement la lecture du track inséré
|
||||
#[serde(default)]
|
||||
pub play: bool,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MEDIA SERVERS
|
||||
// ============================================================================
|
||||
@@ -296,22 +318,20 @@ pub struct ErrorResponse {
|
||||
description = r#"
|
||||
# API REST pour le Control Point PMOMusic
|
||||
|
||||
Cette API permet de contrôler les renderers audio et de naviguer dans les serveurs de médias de manière agnostique du backend.
|
||||
Cette API permet de contrôler les renderers UPnP et de naviguer dans les serveurs de médias.
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
### Renderers
|
||||
- **Découverte** : Liste des renderers disponibles (tous types: UPnP AV, OpenHome, LinkPlay, Chromecast)
|
||||
- **Découverte** : Liste des renderers disponibles
|
||||
- **État** : Récupération de l'état détaillé d'un renderer
|
||||
- **Contrôle transport** : Play, pause, stop, resume, next
|
||||
- **Contrôle transport** : Play, pause, stop, next
|
||||
- **Contrôle volume** : Lecture et modification du volume / mute
|
||||
- **Queue unifiée** : Gestion de la queue de lecture (indépendante du backend)
|
||||
- **Navigation dans la queue** : Saut à un index spécifique
|
||||
- **Queue** : Gestion de la queue de lecture
|
||||
|
||||
### Playlists
|
||||
- **Binding** : Attachement de la queue à un container playlist d'un serveur
|
||||
- **Synchronisation automatique** : Mise à jour de la queue lors des changements côté serveur
|
||||
- **Auto-play** : Option pour démarrer la lecture automatiquement
|
||||
|
||||
### Serveurs de médias
|
||||
- **Découverte** : Liste des serveurs disponibles
|
||||
@@ -319,12 +339,11 @@ Cette API permet de contrôler les renderers audio et de naviguer dans les serve
|
||||
|
||||
## Architecture
|
||||
|
||||
Le Control Point PMOMusic est un point de contrôle multi-backend qui :
|
||||
1. Découvre automatiquement les renderers et serveurs via SSDP et mDNS
|
||||
2. Maintient un registre unifié des devices actifs
|
||||
3. Abstrait les différences entre backends (UPnP AV, OpenHome, LinkPlay, Arylic TCP, Chromecast)
|
||||
4. Gère une queue de lecture unifiée avec synchronisation optionnelle aux playlists serveur
|
||||
5. Expose une API REST cohérente indépendante du type de renderer
|
||||
Le Control Point PMOMusic est un point de contrôle UPnP qui :
|
||||
1. Découvre automatiquement les renderers et serveurs via SSDP
|
||||
2. Maintient un registre des devices actifs
|
||||
3. Permet le contrôle unifié des renderers (UPnP AV, LinkPlay, Arylic TCP)
|
||||
4. Gère une queue de lecture locale avec synchronisation optionnelle
|
||||
|
||||
## Exemples d'utilisation
|
||||
|
||||
@@ -333,33 +352,12 @@ Le Control Point PMOMusic est un point de contrôle multi-backend qui :
|
||||
GET /control/renderers
|
||||
```
|
||||
|
||||
### Obtenir l'état complet d'un renderer
|
||||
```
|
||||
GET /control/renderers/{renderer_id}/full
|
||||
```
|
||||
|
||||
### Contrôler la lecture
|
||||
### Contrôler un renderer
|
||||
```
|
||||
POST /control/renderers/{renderer_id}/play
|
||||
POST /control/renderers/{renderer_id}/pause
|
||||
POST /control/renderers/{renderer_id}/stop
|
||||
POST /control/renderers/{renderer_id}/resume
|
||||
POST /control/renderers/{renderer_id}/next
|
||||
```
|
||||
|
||||
### Naviguer dans la queue
|
||||
```
|
||||
POST /control/renderers/{renderer_id}/queue/seek
|
||||
Body: {"index": 5}
|
||||
```
|
||||
|
||||
### Contrôler le volume
|
||||
```
|
||||
POST /control/renderers/{renderer_id}/volume/set
|
||||
Body: {"volume": 50}
|
||||
POST /control/renderers/{renderer_id}/volume/up
|
||||
POST /control/renderers/{renderer_id}/volume/down
|
||||
POST /control/renderers/{renderer_id}/mute/toggle
|
||||
```
|
||||
|
||||
### Attacher une playlist
|
||||
@@ -367,17 +365,7 @@ POST /control/renderers/{renderer_id}/mute/toggle
|
||||
POST /control/renderers/{renderer_id}/binding/attach
|
||||
Body: {
|
||||
"server_id": "uuid:...",
|
||||
"container_id": "0$/Music/MyPlaylist",
|
||||
"auto_play": true
|
||||
}
|
||||
```
|
||||
|
||||
### Jouer du contenu
|
||||
```
|
||||
POST /control/renderers/{renderer_id}/queue/play
|
||||
Body: {
|
||||
"server_id": "uuid:...",
|
||||
"object_id": "0$/Music/Track.flac"
|
||||
"container_id": "0$/Music/MyPlaylist"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -396,15 +384,16 @@ GET /control/servers/{server_id}/containers/{container_id}
|
||||
paths(
|
||||
crate::pmoserver_ext::list_renderers,
|
||||
crate::pmoserver_ext::get_renderer_state,
|
||||
crate::pmoserver_ext::get_renderer_full_snapshot,
|
||||
crate::pmoserver_ext::get_renderer_queue,
|
||||
crate::pmoserver_ext::get_renderer_binding,
|
||||
crate::pmoserver_ext::get_openhome_playlist,
|
||||
crate::pmoserver_ext::clear_openhome_playlist,
|
||||
crate::pmoserver_ext::add_openhome_playlist_item,
|
||||
crate::pmoserver_ext::play_openhome_track,
|
||||
crate::pmoserver_ext::play_renderer,
|
||||
crate::pmoserver_ext::pause_renderer,
|
||||
crate::pmoserver_ext::stop_renderer,
|
||||
crate::pmoserver_ext::resume_renderer,
|
||||
crate::pmoserver_ext::next_renderer,
|
||||
crate::pmoserver_ext::seek_queue_index,
|
||||
crate::pmoserver_ext::set_renderer_volume,
|
||||
crate::pmoserver_ext::volume_up_renderer,
|
||||
crate::pmoserver_ext::volume_down_renderer,
|
||||
@@ -426,16 +415,17 @@ GET /control/servers/{server_id}/containers/{container_id}
|
||||
RendererState,
|
||||
CurrentTrackMetadata,
|
||||
AttachedPlaylistInfo,
|
||||
FullRendererSnapshot,
|
||||
QueueItem,
|
||||
QueueSnapshot,
|
||||
OpenHomePlaylistSnapshot,
|
||||
OpenHomePlaylistTrack,
|
||||
OpenHomePlaylistAddRequest,
|
||||
MediaServerSummary,
|
||||
ContainerEntry,
|
||||
BrowseResponse,
|
||||
VolumeSetRequest,
|
||||
AttachPlaylistRequest,
|
||||
PlayContentRequest,
|
||||
SeekQueueRequest,
|
||||
SuccessResponse,
|
||||
ErrorResponse,
|
||||
)),
|
||||
|
||||
187
pmocontrol/src/openhome.rs
Normal file
187
pmocontrol/src/openhome.rs
Normal file
@@ -0,0 +1,187 @@
|
||||
use crate::model::RendererInfo;
|
||||
use crate::openhome_client::{
|
||||
OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient, OhTimeClient, OhVolumeClient,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum OhServiceKind {
|
||||
Playlist,
|
||||
Info,
|
||||
Time,
|
||||
Volume,
|
||||
Product,
|
||||
}
|
||||
|
||||
impl OhServiceKind {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
OhServiceKind::Playlist => "playlist",
|
||||
OhServiceKind::Info => "info",
|
||||
OhServiceKind::Time => "time",
|
||||
OhServiceKind::Volume => "volume",
|
||||
OhServiceKind::Product => "product",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OhServiceEndpoint<'a> {
|
||||
pub control_url: &'a str,
|
||||
pub service_type: &'a str,
|
||||
}
|
||||
|
||||
fn endpoint_for<'a>(info: &'a RendererInfo, kind: OhServiceKind) -> Option<OhServiceEndpoint<'a>> {
|
||||
let (control_url, service_type) = match kind {
|
||||
OhServiceKind::Playlist => (
|
||||
info.oh_playlist_control_url.as_deref()?,
|
||||
info.oh_playlist_service_type.as_deref()?,
|
||||
),
|
||||
OhServiceKind::Info => (
|
||||
info.oh_info_control_url.as_deref()?,
|
||||
info.oh_info_service_type.as_deref()?,
|
||||
),
|
||||
OhServiceKind::Time => (
|
||||
info.oh_time_control_url.as_deref()?,
|
||||
info.oh_time_service_type.as_deref()?,
|
||||
),
|
||||
OhServiceKind::Volume => (
|
||||
info.oh_volume_control_url.as_deref()?,
|
||||
info.oh_volume_service_type.as_deref()?,
|
||||
),
|
||||
OhServiceKind::Product => (
|
||||
info.oh_product_control_url.as_deref()?,
|
||||
info.oh_product_service_type.as_deref()?,
|
||||
),
|
||||
};
|
||||
|
||||
Some(OhServiceEndpoint {
|
||||
control_url,
|
||||
service_type,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn control_url_for<'a>(info: &'a RendererInfo, kind: OhServiceKind) -> Option<&'a str> {
|
||||
endpoint_for(info, kind).map(|endpoint| endpoint.control_url)
|
||||
}
|
||||
|
||||
pub fn service_type_for<'a>(info: &'a RendererInfo, kind: OhServiceKind) -> Option<&'a str> {
|
||||
endpoint_for(info, kind).map(|endpoint| endpoint.service_type)
|
||||
}
|
||||
|
||||
pub fn build_playlist_client(info: &RendererInfo) -> Option<OhPlaylistClient> {
|
||||
let endpoint = endpoint_for(info, OhServiceKind::Playlist)?;
|
||||
Some(OhPlaylistClient::new(
|
||||
endpoint.control_url.to_string(),
|
||||
endpoint.service_type.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn build_info_client(info: &RendererInfo) -> Option<OhInfoClient> {
|
||||
let endpoint = endpoint_for(info, OhServiceKind::Info)?;
|
||||
Some(OhInfoClient::new(
|
||||
endpoint.control_url.to_string(),
|
||||
endpoint.service_type.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn build_time_client(info: &RendererInfo) -> Option<OhTimeClient> {
|
||||
let endpoint = endpoint_for(info, OhServiceKind::Time)?;
|
||||
Some(OhTimeClient::new(
|
||||
endpoint.control_url.to_string(),
|
||||
endpoint.service_type.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn build_volume_client(info: &RendererInfo) -> Option<OhVolumeClient> {
|
||||
let endpoint = endpoint_for(info, OhServiceKind::Volume)?;
|
||||
Some(OhVolumeClient::new(
|
||||
endpoint.control_url.to_string(),
|
||||
endpoint.service_type.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn build_product_client(info: &RendererInfo) -> Option<OhProductClient> {
|
||||
let endpoint = endpoint_for(info, OhServiceKind::Product)?;
|
||||
Some(OhProductClient::new(
|
||||
endpoint.control_url.to_string(),
|
||||
endpoint.service_type.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
pub 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(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::{RendererCapabilities, RendererId, RendererInfo, RendererProtocol};
|
||||
|
||||
fn sample_renderer_info() -> RendererInfo {
|
||||
RendererInfo {
|
||||
id: RendererId("renderer".into()),
|
||||
udn: "renderer".into(),
|
||||
friendly_name: "Renderer".into(),
|
||||
model_name: "Model".into(),
|
||||
manufacturer: "Maker".into(),
|
||||
protocol: RendererProtocol::OpenHomeOnly,
|
||||
capabilities: RendererCapabilities::default(),
|
||||
location: "http://host:1234/description.xml".into(),
|
||||
server_header: "test".into(),
|
||||
online: true,
|
||||
last_seen: std::time::SystemTime::now(),
|
||||
max_age: 1800,
|
||||
avtransport_service_type: None,
|
||||
avtransport_control_url: None,
|
||||
rendering_control_service_type: None,
|
||||
rendering_control_control_url: None,
|
||||
connection_manager_service_type: None,
|
||||
connection_manager_control_url: None,
|
||||
oh_playlist_service_type: Some("urn:av-openhome-org:service:Playlist:1".into()),
|
||||
oh_playlist_control_url: Some("http://host/oh/playlist".into()),
|
||||
oh_playlist_event_sub_url: Some("http://host/events/playlist".into()),
|
||||
oh_info_service_type: Some("urn:av-openhome-org:service:Info:1".into()),
|
||||
oh_info_control_url: Some("http://host/oh/info".into()),
|
||||
oh_info_event_sub_url: Some("http://host/events/info".into()),
|
||||
oh_time_service_type: Some("urn:av-openhome-org:service:Time:1".into()),
|
||||
oh_time_control_url: Some("http://host/oh/time".into()),
|
||||
oh_time_event_sub_url: Some("http://host/events/time".into()),
|
||||
oh_volume_service_type: Some("urn:av-openhome-org:service:Volume:1".into()),
|
||||
oh_volume_control_url: Some("http://host/oh/volume".into()),
|
||||
oh_radio_service_type: None,
|
||||
oh_radio_control_url: None,
|
||||
oh_product_service_type: Some("urn:av-openhome-org:service:Product:1".into()),
|
||||
oh_product_control_url: Some("http://host/oh/product".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selects_correct_playlist_endpoint() {
|
||||
let info = sample_renderer_info();
|
||||
let endpoint = endpoint_for(&info, OhServiceKind::Playlist).unwrap();
|
||||
assert_eq!(endpoint.control_url, "http://host/oh/playlist");
|
||||
assert_eq!(
|
||||
endpoint.service_type,
|
||||
"urn:av-openhome-org:service:Playlist:1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_when_service_missing() {
|
||||
let mut info = sample_renderer_info();
|
||||
info.oh_playlist_control_url = None;
|
||||
assert!(endpoint_for(&info, OhServiceKind::Playlist).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_and_playlist_use_different_urls() {
|
||||
let info = sample_renderer_info();
|
||||
let playlist = control_url_for(&info, OhServiceKind::Playlist).unwrap();
|
||||
let info_url = control_url_for(&info, OhServiceKind::Info).unwrap();
|
||||
assert_ne!(playlist, info_url);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,20 @@
|
||||
use crate::errors::ControlPointError;
|
||||
use crate::model::TrackMetadata;
|
||||
use crate::soap_client::{
|
||||
decode_base64, ensure_success_with_envelope as ensure_success, extract_child_text,
|
||||
extract_child_text_any, extract_child_text_local, extract_child_text_optional,
|
||||
extract_child_text_optional_local, find_child_with_suffix, handle_action_response,
|
||||
invoke_upnp_action, parse_bool, parse_visible_flag,
|
||||
};
|
||||
use anyhow::{Result, anyhow};
|
||||
use pmodidl::DIDLLite;
|
||||
use tracing::{debug, info, trace, warn};
|
||||
use crate::soap_client::{invoke_upnp_action, parse_upnp_error, SoapCallResult};
|
||||
use anyhow::{anyhow, Result};
|
||||
use pmoupnp::soap::SoapEnvelope;
|
||||
use tracing::{debug, info, warn};
|
||||
use xmltree::{Element, XMLNode};
|
||||
|
||||
use crate::model::RendererInfo;
|
||||
|
||||
/// Value used by OpenHome renderers to indicate "insert at the head".
|
||||
/// Several implementations, including upmpdcli, expect zero rather than the
|
||||
/// historical 0xFFFFFFFF sentinel.
|
||||
pub const OPENHOME_PLAYLIST_HEAD_ID: u32 = 0;
|
||||
|
||||
pub trait OhTrack {
|
||||
fn uri(&self) -> &str;
|
||||
fn metadata_xml(&self) -> Option<&str>;
|
||||
|
||||
fn metadata(&self) -> Option<TrackMetadata> {
|
||||
self.metadata_xml()
|
||||
.as_deref()
|
||||
.and_then(parse_track_metadata_from_didl)
|
||||
}
|
||||
|
||||
fn didl_id(&self) -> Option<String> {
|
||||
let trimmed = self.metadata_xml()?.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let parsed = pmodidl::parse_metadata::<DIDLLite>(trimmed).ok()?;
|
||||
parsed.data.items.first().map(|item| item.id.clone())
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OhTrackEntry {
|
||||
pub id: u32,
|
||||
pub uri: String,
|
||||
pub metadata_xml: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -46,20 +24,10 @@ pub struct OhInfoTrack {
|
||||
}
|
||||
|
||||
impl OhInfoTrack {
|
||||
pub fn new(uri: &str, metadata_xml: Option<&str>) -> Self {
|
||||
OhInfoTrack {
|
||||
uri: uri.to_string(),
|
||||
metadata_xml: metadata_xml.map(|s| s.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OhTrack for OhInfoTrack {
|
||||
fn metadata_xml(&self) -> Option<&str> {
|
||||
self.metadata_xml.as_deref()
|
||||
}
|
||||
fn uri(&self) -> &str {
|
||||
&self.uri
|
||||
pub fn metadata(&self) -> Option<TrackMetadata> {
|
||||
self.metadata_xml
|
||||
.as_deref()
|
||||
.and_then(parse_track_metadata_from_didl)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,52 +51,12 @@ pub struct OhProductSource {
|
||||
pub visible: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OhInfoClient {
|
||||
pub control_url: String,
|
||||
pub service_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OhPlaylistClient {
|
||||
pub control_url: String,
|
||||
pub service_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OhTrackEntry {
|
||||
pub id: u32,
|
||||
uri: String,
|
||||
metadata_xml: String,
|
||||
}
|
||||
|
||||
impl OhTrackEntry {
|
||||
pub fn new(id: u32, uri: &str, metadata_xml: &str) -> Self {
|
||||
OhTrackEntry {
|
||||
id,
|
||||
uri: uri.to_string(),
|
||||
metadata_xml: metadata_xml.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(&self) -> u32 {
|
||||
self.id
|
||||
}
|
||||
}
|
||||
|
||||
impl OhTrack for OhTrackEntry {
|
||||
fn metadata_xml(&self) -> Option<&str> {
|
||||
if self.metadata_xml.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(&self.metadata_xml)
|
||||
}
|
||||
}
|
||||
fn uri(&self) -> &str {
|
||||
&self.uri
|
||||
}
|
||||
}
|
||||
|
||||
impl OhPlaylistClient {
|
||||
pub fn new(control_url: String, service_type: String) -> Self {
|
||||
Self {
|
||||
@@ -137,48 +65,7 @@ impl OhPlaylistClient {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||
let control_url = info.oh_playlist_control_url().ok_or_else(|| {
|
||||
ControlPointError::OpenHomeError("Cannot create playlist control client".to_string())
|
||||
})?;
|
||||
|
||||
let service_type = info.oh_playlist_service_type().ok_or_else(|| {
|
||||
ControlPointError::OpenHomeError("Cannot create playlist service client".to_string())
|
||||
})?;
|
||||
Ok(OhPlaylistClient::new(control_url, service_type))
|
||||
}
|
||||
|
||||
/// Report the uri and metadata for a given track id.
|
||||
/// Returns a 800 fault code if the given id is not in the playlist.
|
||||
pub fn read(&self, id: u32) -> Result<OhTrackEntry, ControlPointError> {
|
||||
let id_str = id.to_string();
|
||||
let args = [("IdList", id_str.as_str())];
|
||||
|
||||
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Read", &args)?;
|
||||
|
||||
if call_result.status == 800 {
|
||||
return Err(ControlPointError::OpenHomeError(format!(
|
||||
"Id {} not found in OpenHome playlist",
|
||||
id
|
||||
)));
|
||||
}
|
||||
|
||||
let envelope: &pmoupnp::soap::SoapEnvelope = ensure_success("Read", &call_result)?;
|
||||
|
||||
let response =
|
||||
find_child_with_suffix(&envelope.body.content, "ReadResponse").ok_or_else(|| {
|
||||
ControlPointError::OpenHomeError(format!(
|
||||
"Missing ReadResponse element in SOAP body"
|
||||
))
|
||||
})?;
|
||||
|
||||
let track_uri = extract_child_text(response, "Uri")?;
|
||||
let metadata = extract_child_text(response, "Metadata")?;
|
||||
|
||||
Ok(OhTrackEntry::new(id, &track_uri, &metadata))
|
||||
}
|
||||
|
||||
pub fn read_list(&self, id_list: &[u32]) -> Result<Vec<OhTrackEntry>, ControlPointError> {
|
||||
pub fn read_list(&self, id_list: &[u32]) -> Result<Vec<OhTrackEntry>> {
|
||||
if id_list.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
@@ -193,15 +80,12 @@ impl OhPlaylistClient {
|
||||
let call_result =
|
||||
invoke_upnp_action(&self.control_url, &self.service_type, "ReadList", &args)?;
|
||||
|
||||
let envelope: &pmoupnp::soap::SoapEnvelope = ensure_success("ReadList", &call_result)?;
|
||||
let envelope = ensure_success("ReadList", &call_result)?;
|
||||
let response = find_child_with_suffix(&envelope.body.content, "ReadListResponse")
|
||||
.ok_or_else(|| {
|
||||
ControlPointError::OpenHomeError(format!(
|
||||
"Missing ReadListResponse element in SOAP body"
|
||||
))
|
||||
})?;
|
||||
.ok_or_else(|| anyhow!("Missing ReadListResponse element in SOAP body"))?;
|
||||
|
||||
let track_list_b64 = extract_child_text_any(response, &["TrackList", "Value"])?;
|
||||
let track_list_b64 =
|
||||
extract_child_text_any(response, &["TrackList", "Value"])?;
|
||||
let track_list_sample: String = track_list_b64.chars().take(256).collect();
|
||||
debug!(
|
||||
control_url = self.control_url.as_str(),
|
||||
@@ -212,12 +96,7 @@ impl OhPlaylistClient {
|
||||
parse_track_list(&track_list_b64)
|
||||
}
|
||||
|
||||
pub fn insert(
|
||||
&self,
|
||||
after_id: u32,
|
||||
uri: &str,
|
||||
metadata: &str,
|
||||
) -> Result<u32, ControlPointError> {
|
||||
pub fn insert(&self, after_id: u32, uri: &str, metadata: &str) -> Result<u32> {
|
||||
let after_id_str = after_id.to_string();
|
||||
let args = [
|
||||
("AfterId", after_id_str.as_str()),
|
||||
@@ -228,25 +107,19 @@ impl OhPlaylistClient {
|
||||
let call_result =
|
||||
invoke_upnp_action(&self.control_url, &self.service_type, "Insert", &args)?;
|
||||
|
||||
let envelope = ensure_success("Insert", &call_result)
|
||||
.map_err(|e| ControlPointError::SoapAction(format!("{}", e)))?;
|
||||
|
||||
let envelope = ensure_success("Insert", &call_result)?;
|
||||
let response = find_child_with_suffix(&envelope.body.content, "InsertResponse")
|
||||
.ok_or_else(|| {
|
||||
ControlPointError::UpnpMissingReturnValue("InsertResponse".to_string())
|
||||
})?;
|
||||
|
||||
let new_id_text = extract_child_text_any(response, &["NewId", "Value"])?;
|
||||
.ok_or_else(|| anyhow!("Missing InsertResponse element in SOAP body"))?;
|
||||
let new_id_text =
|
||||
extract_child_text_any(response, &["NewId", "Value"])?;
|
||||
let new_id = new_id_text
|
||||
.parse::<u32>()
|
||||
.map_err(|_| ControlPointError::UpnpBadReturnValue("NewId".to_string(), new_id_text))?;
|
||||
.map_err(|_| anyhow!("Invalid NewId value: {}", new_id_text))?;
|
||||
|
||||
Ok(new_id)
|
||||
}
|
||||
|
||||
/// The id of the current track (the track currently playing or that would be played
|
||||
/// if the Play action was invoked). Or 0 if the playlist is empty.
|
||||
pub fn seek_id(&self, id: u32) -> Result<(), ControlPointError> {
|
||||
pub fn play_id(&self, id: u32) -> Result<()> {
|
||||
let id_str = id.to_string();
|
||||
let args = [("Value", id_str.as_str())];
|
||||
|
||||
@@ -256,65 +129,58 @@ impl OhPlaylistClient {
|
||||
handle_action_response("SeekId", &call_result)
|
||||
}
|
||||
|
||||
pub fn transport_state(&self) -> Result<String, ControlPointError> {
|
||||
pub fn transport_state(&self) -> Result<String> {
|
||||
let call_result =
|
||||
invoke_upnp_action(&self.control_url, &self.service_type, "TransportState", &[])?;
|
||||
|
||||
let envelope = ensure_success("TransportState", &call_result)?;
|
||||
|
||||
let response = find_child_with_suffix(&envelope.body.content, "TransportStateResponse")
|
||||
.ok_or_else(|| {
|
||||
ControlPointError::UpnpMissingReturnValue("TransportStateResponse".to_string())
|
||||
})?;
|
||||
|
||||
.ok_or_else(|| anyhow!("Missing TransportStateResponse element in SOAP body"))?;
|
||||
let state = extract_child_text_any(response, &["State", "Value"])?;
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
pub fn id(&self) -> Result<u32, ControlPointError> {
|
||||
pub fn id(&self) -> Result<u32> {
|
||||
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Id", &[])?;
|
||||
|
||||
let envelope = ensure_success("Id", &call_result)?;
|
||||
|
||||
let response = find_child_with_suffix(&envelope.body.content, "IdResponse").ok_or_else(|| {
|
||||
ControlPointError::OpenHomeError(format!(
|
||||
"Missing ReadResponse element in SOAP body"
|
||||
))
|
||||
})?;
|
||||
let id_text = extract_child_text(response, "Value")?;
|
||||
let id = id_text.parse::<u32>().map_err(|_| {
|
||||
ControlPointError::UpnpBadReturnValue("Playlist.Id".to_string(), id_text)
|
||||
})?;
|
||||
let response = find_child_with_suffix(&envelope.body.content, "IdResponse")
|
||||
.ok_or_else(|| anyhow!("Missing IdResponse element in SOAP body"))?;
|
||||
let id_text = extract_child_text(response, "Id")?;
|
||||
let id = id_text
|
||||
.parse::<u32>()
|
||||
.map_err(|_| anyhow!("Invalid Info.Id value: {}", id_text))?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub fn play(&self) -> Result<(), ControlPointError> {
|
||||
|
||||
pub fn play(&self) -> Result<()> {
|
||||
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Play", &[])?;
|
||||
handle_action_response("Play", &call_result)
|
||||
}
|
||||
|
||||
pub fn pause(&self) -> Result<(), ControlPointError> {
|
||||
pub fn pause(&self) -> Result<()> {
|
||||
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Pause", &[])?;
|
||||
handle_action_response("Pause", &call_result)
|
||||
}
|
||||
|
||||
pub fn stop(&self) -> Result<(), ControlPointError> {
|
||||
pub fn stop(&self) -> Result<()> {
|
||||
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Stop", &[])?;
|
||||
handle_action_response("Stop", &call_result)
|
||||
}
|
||||
|
||||
pub fn next(&self) -> Result<(), ControlPointError> {
|
||||
pub fn next(&self) -> Result<()> {
|
||||
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Next", &[])?;
|
||||
handle_action_response("Next", &call_result)
|
||||
}
|
||||
|
||||
pub fn previous(&self) -> Result<(), ControlPointError> {
|
||||
pub fn previous(&self) -> Result<()> {
|
||||
let call_result =
|
||||
invoke_upnp_action(&self.control_url, &self.service_type, "Previous", &[])?;
|
||||
handle_action_response("Previous", &call_result)
|
||||
}
|
||||
|
||||
pub fn seek_second_absolute(&self, second: u32) -> Result<(), ControlPointError> {
|
||||
pub fn seek_second_absolute(&self, second: u32) -> Result<()> {
|
||||
let second_str = second.to_string();
|
||||
let args = [("Second", second_str.as_str())];
|
||||
let call_result = invoke_upnp_action(
|
||||
@@ -327,7 +193,7 @@ impl OhPlaylistClient {
|
||||
handle_action_response("SeekSecondAbsolute", &call_result)
|
||||
}
|
||||
|
||||
pub fn delete_id(&self, id: u32) -> Result<(), ControlPointError> {
|
||||
pub fn delete_id(&self, id: u32) -> Result<()> {
|
||||
let id_str = id.to_string();
|
||||
let args = [("Value", id_str.as_str())];
|
||||
|
||||
@@ -345,7 +211,7 @@ impl OhPlaylistClient {
|
||||
/// - Ok(true) if the ID was successfully deleted
|
||||
/// - Ok(false) if the ID didn't exist (logged as warning)
|
||||
/// - Err(_) for other errors (network issues, etc.)
|
||||
pub fn delete_id_if_exists(&self, id: u32) -> Result<bool, ControlPointError> {
|
||||
pub fn delete_id_if_exists(&self, id: u32) -> Result<bool> {
|
||||
match self.delete_id(id) {
|
||||
Ok(()) => Ok(true),
|
||||
Err(err) => {
|
||||
@@ -358,8 +224,7 @@ impl OhPlaylistClient {
|
||||
|| err_msg.contains("does not exist")
|
||||
|| err_msg.contains("unknown")
|
||||
|| err_msg.contains("500") // HTTP 500 = renderer in inconsistent state
|
||||
|| err_msg.contains("Action Failed")
|
||||
// UPnP error 501
|
||||
|| err_msg.contains("Action Failed") // UPnP error 501
|
||||
{
|
||||
warn!(
|
||||
control_url = self.control_url.as_str(),
|
||||
@@ -375,38 +240,49 @@ impl OhPlaylistClient {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_all(&self) -> Result<(), ControlPointError> {
|
||||
pub fn delete_all(&self) -> Result<()> {
|
||||
let call_result =
|
||||
invoke_upnp_action(&self.control_url, &self.service_type, "DeleteAll", &[])?;
|
||||
handle_action_response("DeleteAll", &call_result)
|
||||
}
|
||||
|
||||
pub fn tracks_max(&self) -> Result<u32, ControlPointError> {
|
||||
pub fn current_id(&self) -> Result<String> {
|
||||
let call_result =
|
||||
invoke_upnp_action(&self.control_url, &self.service_type, "Id", &[])?;
|
||||
let envelope = ensure_success("Id", &call_result)?;
|
||||
let response = find_child_with_suffix(&envelope.body.content, "IdResponse")
|
||||
.ok_or_else(|| anyhow!("Missing IdResponse element in SOAP body"))?;
|
||||
let value: String = extract_child_text_any(response, &["Value"])?;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub fn tracks_max(&self) -> Result<u32> {
|
||||
let call_result =
|
||||
invoke_upnp_action(&self.control_url, &self.service_type, "TracksMax", &[])?;
|
||||
|
||||
let envelope = ensure_success("TracksMax", &call_result)?;
|
||||
let response = find_child_with_suffix(&envelope.body.content, "TracksMaxResponse")
|
||||
.ok_or_else(|| {
|
||||
ControlPointError::UpnpMissingReturnValue("TracksMaxResponse".to_string())
|
||||
})?;
|
||||
.ok_or_else(|| anyhow!("Missing TracksMaxResponse element in SOAP body"))?;
|
||||
let value_text: String = extract_child_text_any(response, &["Value"])?;
|
||||
let value = value_text.parse::<u32>().map_err(|_| {
|
||||
ControlPointError::UpnpBadReturnValue("Playlist.Id".to_string(), value_text)
|
||||
})?;
|
||||
let value = value_text
|
||||
.parse::<u32>()
|
||||
.map_err(|_| anyhow!("Invalid TracksMax value: {}", value_text))?;
|
||||
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub fn id_array(&self) -> Result<Vec<u32>, ControlPointError> {
|
||||
pub fn id_array(&self) -> Result<Vec<u32>> {
|
||||
let call_result =
|
||||
invoke_upnp_action(&self.control_url, &self.service_type, "IdArray", &[])?;
|
||||
let envelope = ensure_success("IdArray", &call_result)?;
|
||||
let response = find_child_with_suffix(&envelope.body.content, "IdArrayResponse")
|
||||
.ok_or_else(|| ControlPointError::upnp_missing_return_value("IdArrayResponse"))?;
|
||||
.ok_or_else(|| anyhow!("Missing IdArrayResponse element in SOAP body"))?;
|
||||
|
||||
// Try to extract the array element. If missing, assume empty playlist.
|
||||
let array_text = match extract_child_text_any(response, &["Array", "IdArray", "Value"]) {
|
||||
let array_text = match extract_child_text_any(
|
||||
response,
|
||||
&["Array", "IdArray", "Value"],
|
||||
) {
|
||||
Ok(text) => text,
|
||||
Err(_) => {
|
||||
// Element not found - playlist is likely empty
|
||||
@@ -421,10 +297,10 @@ impl OhPlaylistClient {
|
||||
|
||||
let bytes = decode_base64(&array_text)?;
|
||||
if bytes.len() % 4 != 0 {
|
||||
return Err(ControlPointError::SoapAction(format!(
|
||||
return Err(anyhow!(
|
||||
"Invalid IdArray payload length {} (expected multiple of 4)",
|
||||
bytes.len()
|
||||
)));
|
||||
));
|
||||
}
|
||||
|
||||
let mut ids = Vec::with_capacity(bytes.len() / 4);
|
||||
@@ -434,7 +310,7 @@ impl OhPlaylistClient {
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
pub fn read_all_tracks(&self) -> Result<Vec<OhTrackEntry>, ControlPointError> {
|
||||
pub fn read_all_tracks(&self) -> Result<Vec<OhTrackEntry>> {
|
||||
let ids = self.id_array()?;
|
||||
debug!(
|
||||
control_url = self.control_url.as_str(),
|
||||
@@ -483,6 +359,12 @@ impl OhPlaylistClient {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OhInfoClient {
|
||||
pub control_url: String,
|
||||
pub service_type: String,
|
||||
}
|
||||
|
||||
impl OhInfoClient {
|
||||
pub fn new(control_url: String, service_type: String) -> Self {
|
||||
Self {
|
||||
@@ -491,17 +373,6 @@ impl OhInfoClient {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||
let control_url = info.oh_info_control_url().ok_or_else(|| {
|
||||
ControlPointError::OpenHomeError("Cannot create info control client".to_string())
|
||||
})?;
|
||||
|
||||
let service_type = info.oh_info_service_type().ok_or_else(|| {
|
||||
ControlPointError::OpenHomeError("Cannot create info service client".to_string())
|
||||
})?;
|
||||
Ok(OhInfoClient::new(control_url, service_type))
|
||||
}
|
||||
|
||||
pub fn track(&self) -> Result<OhInfoTrack> {
|
||||
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Track", &[])?;
|
||||
|
||||
@@ -509,7 +380,8 @@ impl OhInfoClient {
|
||||
let response = find_child_with_suffix(&envelope.body.content, "TrackResponse")
|
||||
.ok_or_else(|| anyhow!("Missing TrackResponse element in SOAP body"))?;
|
||||
|
||||
let uri = extract_child_text_any(response, &["Uri", "Uri", "Value"]).unwrap_or_default();
|
||||
let uri = extract_child_text_any(response, &["Uri", "Uri", "Value"])
|
||||
.unwrap_or_default();
|
||||
let metadata_xml = extract_child_text_optional(response, "Metadata")
|
||||
.unwrap_or(None)
|
||||
.filter(|s| !s.is_empty());
|
||||
@@ -535,7 +407,8 @@ impl OhInfoClient {
|
||||
let response = find_child_with_suffix(&envelope.body.content, "NextResponse")
|
||||
.ok_or_else(|| anyhow!("Missing NextResponse element in SOAP body"))?;
|
||||
|
||||
let uri = extract_child_text_any(response, &["Uri", "Value"]).unwrap_or_default();
|
||||
let uri = extract_child_text_any(response, &["Uri", "Value"])
|
||||
.unwrap_or_default();
|
||||
let metadata_xml = extract_child_text_optional(response, "Metadata")
|
||||
.unwrap_or(None)
|
||||
.filter(|s| !s.is_empty());
|
||||
@@ -563,41 +436,25 @@ impl OhTimeClient {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||
let control_url = info.oh_time_control_url().ok_or_else(|| {
|
||||
ControlPointError::OpenHomeError("Cannot create time control client".to_string())
|
||||
})?;
|
||||
|
||||
let service_type = info.oh_time_service_type().ok_or_else(|| {
|
||||
ControlPointError::OpenHomeError("Cannot create time service client".to_string())
|
||||
})?;
|
||||
Ok(OhTimeClient::new(control_url, service_type))
|
||||
}
|
||||
|
||||
pub fn position(&self) -> Result<OhTimePosition, ControlPointError> {
|
||||
pub fn position(&self) -> Result<OhTimePosition> {
|
||||
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Time", &[])?;
|
||||
|
||||
let envelope = ensure_success("Time", &call_result)?;
|
||||
let response = find_child_with_suffix(&envelope.body.content, "TimeResponse")
|
||||
.ok_or_else(|| ControlPointError::UpnpMissingReturnValue("TimeResponse".to_string()))?;
|
||||
.ok_or_else(|| anyhow!("Missing TimeResponse element in SOAP body"))?;
|
||||
|
||||
let track_count = extract_child_text_any(response, &["TrackCount", "Value"])?
|
||||
.parse::<u32>()
|
||||
.map_err(|_| {
|
||||
ControlPointError::UpnpBadReturnValue("TrackCount".to_string(), "".to_string())
|
||||
})?;
|
||||
|
||||
let duration_secs = extract_child_text_any(response, &["Duration", "Value"])?
|
||||
.parse::<u32>()
|
||||
.map_err(|_| {
|
||||
ControlPointError::UpnpBadReturnValue("Duration".to_string(), "".to_string())
|
||||
})?;
|
||||
|
||||
let elapsed_secs = extract_child_text_any(response, &["Seconds", "Value"])?
|
||||
.parse::<u32>()
|
||||
.map_err(|_| {
|
||||
ControlPointError::UpnpBadReturnValue("Second".to_string(), "".to_string())
|
||||
})?;
|
||||
let track_count =
|
||||
extract_child_text_any(response, &["TrackCount", "Value"])?
|
||||
.parse::<u32>()
|
||||
.map_err(|_| anyhow!("Invalid TrackCount value in Time response"))?;
|
||||
let duration_secs =
|
||||
extract_child_text_any(response, &["Duration","Value"])?
|
||||
.parse::<u32>()
|
||||
.map_err(|_| anyhow!("Invalid Duration value in Time response"))?;
|
||||
let elapsed_secs =
|
||||
extract_child_text_any(response, &["Seconds", "Value"])?
|
||||
.parse::<u32>()
|
||||
.map_err(|_| anyhow!("Invalid Seconds value in Time response"))?;
|
||||
|
||||
Ok(OhTimePosition {
|
||||
track_count,
|
||||
@@ -621,34 +478,19 @@ impl OhVolumeClient {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||
let control_url = info.oh_volume_control_url().ok_or_else(|| {
|
||||
ControlPointError::OpenHomeError("Cannot create volume control client".to_string())
|
||||
})?;
|
||||
|
||||
let service_type = info.oh_volume_service_type().ok_or_else(|| {
|
||||
ControlPointError::OpenHomeError("Cannot create volume service client".to_string())
|
||||
})?;
|
||||
Ok(OhVolumeClient::new(control_url, service_type))
|
||||
}
|
||||
|
||||
pub fn volume(&self) -> Result<u16, ControlPointError> {
|
||||
pub fn volume(&self) -> Result<u16> {
|
||||
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Volume", &[])?;
|
||||
let envelope = ensure_success("Volume", &call_result)?;
|
||||
let response = find_child_with_suffix(&envelope.body.content, "VolumeResponse")
|
||||
.ok_or_else(|| {
|
||||
ControlPointError::UpnpMissingReturnValue("VolumeResponse".to_string())
|
||||
})?;
|
||||
|
||||
let value = extract_child_text_any(response, &["Value"])?;
|
||||
.ok_or_else(|| anyhow!("Missing VolumeResponse element in SOAP body"))?;
|
||||
let value = extract_child_text_any(response, &["Volume", "Value"])?;
|
||||
let parsed = value
|
||||
.parse::<u32>()
|
||||
.map_err(|_| ControlPointError::UpnpBadReturnValue("volume".to_string(), value))?;
|
||||
|
||||
.map_err(|_| anyhow!("Invalid volume value: {}", value))?;
|
||||
Ok(parsed.min(u16::MAX as u32) as u16)
|
||||
}
|
||||
|
||||
pub fn set_volume(&self, vol: u16) -> Result<(), ControlPointError> {
|
||||
pub fn set_volume(&self, vol: u16) -> Result<()> {
|
||||
let vol_str = vol.to_string();
|
||||
let args = [("Value", vol_str.as_str())];
|
||||
let call_result =
|
||||
@@ -656,20 +498,18 @@ impl OhVolumeClient {
|
||||
handle_action_response("SetVolume", &call_result)
|
||||
}
|
||||
|
||||
pub fn mute(&self) -> Result<bool, ControlPointError> {
|
||||
pub fn mute(&self) -> Result<bool> {
|
||||
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Mute", &[])?;
|
||||
let envelope = ensure_success("Mute", &call_result)?;
|
||||
let response = find_child_with_suffix(&envelope.body.content, "MuteResponse")
|
||||
.ok_or_else(|| ControlPointError::UpnpMissingReturnValue("MuteResponse".to_string()))?;
|
||||
|
||||
let value = extract_child_text_any(response, &["Value"])?;
|
||||
|
||||
Ok(parse_bool(&value))
|
||||
.ok_or_else(|| anyhow!("Missing MuteResponse element in SOAP body"))?;
|
||||
let value = extract_child_text_any(response, &["Mute", "Value"])?;
|
||||
parse_bool(&value)
|
||||
}
|
||||
|
||||
pub fn set_mute(&self, mute: bool) -> Result<(), ControlPointError> {
|
||||
pub fn set_mute(&self, mute: bool) -> Result<()> {
|
||||
let mute_str = if mute { "1" } else { "0" };
|
||||
let args = [("Value", mute_str)];
|
||||
let args = [("Mute", mute_str)];
|
||||
let call_result =
|
||||
invoke_upnp_action(&self.control_url, &self.service_type, "SetMute", &args)?;
|
||||
handle_action_response("SetMute", &call_result)
|
||||
@@ -690,18 +530,7 @@ impl OhRadioClient {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||
let control_url = info.oh_radio_control_url().ok_or_else(|| {
|
||||
ControlPointError::OpenHomeError("Cannot create radio control client".to_string())
|
||||
})?;
|
||||
|
||||
let service_type = info.oh_radio_service_type().ok_or_else(|| {
|
||||
ControlPointError::OpenHomeError("Cannot create radio service client".to_string())
|
||||
})?;
|
||||
Ok(OhRadioClient::new(control_url, service_type))
|
||||
}
|
||||
|
||||
pub fn play_channel(&self, id: u32) -> Result<(), ControlPointError> {
|
||||
pub fn play_channel(&self, id: u32) -> Result<()> {
|
||||
let id_str = id.to_string();
|
||||
let args = [("Id", id_str.as_str())];
|
||||
let call_result =
|
||||
@@ -719,7 +548,8 @@ impl OhRadioClient {
|
||||
let response = find_child_with_suffix(&envelope.body.content, "ChannelResponse")
|
||||
.ok_or_else(|| anyhow!("Missing ChannelResponse element in SOAP body"))?;
|
||||
|
||||
let uri = extract_child_text_any(response, &["Uri", "Value"]).unwrap_or_default();
|
||||
let uri = extract_child_text_any(response, &["Uri", "Value"])
|
||||
.unwrap_or_default();
|
||||
let metadata_xml = extract_child_text_optional(response, "Metadata")
|
||||
.unwrap_or(None)
|
||||
.filter(|s| !s.is_empty());
|
||||
@@ -742,17 +572,6 @@ impl OhProductClient {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||
let control_url = info.oh_product_control_url().ok_or_else(|| {
|
||||
ControlPointError::OpenHomeError("Cannot create product control client".to_string())
|
||||
})?;
|
||||
|
||||
let service_type = info.oh_product_service_type().ok_or_else(|| {
|
||||
ControlPointError::OpenHomeError("Cannot create product service client".to_string())
|
||||
})?;
|
||||
Ok(OhProductClient::new(control_url, service_type))
|
||||
}
|
||||
|
||||
pub fn source_xml(&self) -> Result<Vec<OhProductSource>> {
|
||||
let call_result =
|
||||
invoke_upnp_action(&self.control_url, &self.service_type, "SourceXml", &[])?;
|
||||
@@ -763,22 +582,19 @@ impl OhProductClient {
|
||||
parse_product_source_list(&xml)
|
||||
}
|
||||
|
||||
pub fn source_index(&self) -> Result<u32, ControlPointError> {
|
||||
pub fn source_index(&self) -> Result<u32> {
|
||||
let call_result =
|
||||
invoke_upnp_action(&self.control_url, &self.service_type, "SourceIndex", &[])?;
|
||||
let envelope = ensure_success("SourceIndex", &call_result)?;
|
||||
let response = find_child_with_suffix(&envelope.body.content, "SourceIndexResponse")
|
||||
.ok_or_else(|| {
|
||||
ControlPointError::UpnpMissingReturnValue("SourceIndexResponse".to_string())
|
||||
})?;
|
||||
|
||||
.ok_or_else(|| anyhow!("Missing SourceIndexResponse element in SOAP body"))?;
|
||||
let value = extract_child_text_any(response, &["Index", "Value"])?;
|
||||
value
|
||||
.parse::<u32>()
|
||||
.map_err(|_| ControlPointError::UpnpBadReturnValue("volume".to_string(), value))
|
||||
.map_err(|_| anyhow!("Invalid Product.SourceIndex value: {}", value))
|
||||
}
|
||||
|
||||
pub fn set_source_index(&self, index: u32) -> Result<(), ControlPointError> {
|
||||
pub fn set_source_index(&self, index: u32) -> Result<()> {
|
||||
let value = index.to_string();
|
||||
let args = [("Index", value.as_str())];
|
||||
let call_result = invoke_upnp_action(
|
||||
@@ -790,10 +606,8 @@ impl OhProductClient {
|
||||
handle_action_response("SetSourceIndex", &call_result)
|
||||
}
|
||||
|
||||
pub fn ensure_playlist_source_selected(&self) -> Result<(), ControlPointError> {
|
||||
let sources = self
|
||||
.source_xml()
|
||||
.map_err(|e| ControlPointError::SoapAction(format!("{}", e)))?;
|
||||
pub fn ensure_playlist_source_selected(&self) -> Result<()> {
|
||||
let sources = self.source_xml()?;
|
||||
|
||||
// Log all available sources for diagnostics
|
||||
debug!(
|
||||
@@ -801,9 +615,8 @@ impl OhProductClient {
|
||||
source_count = sources.len(),
|
||||
"OpenHome Product sources available"
|
||||
);
|
||||
|
||||
for (idx, source) in sources.iter().enumerate() {
|
||||
trace!(
|
||||
debug!(
|
||||
control_url = self.control_url.as_str(),
|
||||
index = idx,
|
||||
name = source.name.as_str(),
|
||||
@@ -822,7 +635,7 @@ impl OhProductClient {
|
||||
available_types = ?sources.iter().map(|s| s.source_type.as_str()).collect::<Vec<_>>(),
|
||||
"OpenHome Product source list does not expose a Playlist entry"
|
||||
);
|
||||
ControlPointError::OpenHomeNoPlaylistEntry()
|
||||
anyhow!("OpenHome Product source list does not expose a Playlist entry")
|
||||
})?;
|
||||
let playlist_index = playlist_index as u32;
|
||||
let current_index = self.source_index()?;
|
||||
@@ -833,9 +646,7 @@ impl OhProductClient {
|
||||
control_url = self.control_url.as_str(),
|
||||
current_index,
|
||||
current_source_name = current_source.map(|s| s.name.as_str()).unwrap_or("unknown"),
|
||||
current_source_type = current_source
|
||||
.map(|s| s.source_type.as_str())
|
||||
.unwrap_or("unknown"),
|
||||
current_source_type = current_source.map(|s| s.source_type.as_str()).unwrap_or("unknown"),
|
||||
playlist_index,
|
||||
needs_switch = current_index != playlist_index,
|
||||
"OpenHome source state"
|
||||
@@ -897,16 +708,7 @@ pub fn parse_track_metadata_from_didl(xml: &str) -> Option<TrackMetadata> {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn didl_id_from_metadata(xml: &str) -> Option<String> {
|
||||
if xml.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let parsed = pmodidl::parse_metadata::<DIDLLite>(xml).ok()?;
|
||||
parsed.data.items.first().map(|item| item.id.clone())
|
||||
}
|
||||
|
||||
fn parse_track_list(payload: &str) -> Result<Vec<OhTrackEntry>, ControlPointError> {
|
||||
fn parse_track_list(payload: &str) -> Result<Vec<OhTrackEntry>> {
|
||||
let trimmed = payload.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
@@ -916,11 +718,8 @@ fn parse_track_list(payload: &str) -> Result<Vec<OhTrackEntry>, ControlPointErro
|
||||
(trimmed.to_string(), false)
|
||||
} else {
|
||||
let bytes = decode_base64(trimmed)?;
|
||||
let decoded = String::from_utf8(bytes).map_err(|err| {
|
||||
ControlPointError::OpenHomeError(format!(
|
||||
"TrackList payload not valid UTF-8 after base64 decode: {err}"
|
||||
))
|
||||
})?;
|
||||
let decoded = String::from_utf8(bytes)
|
||||
.map_err(|err| anyhow!("TrackList payload not valid UTF-8 after base64 decode: {err}"))?;
|
||||
(decoded, true)
|
||||
};
|
||||
let xml_sample: String = xml.chars().take(256).collect();
|
||||
@@ -932,9 +731,8 @@ fn parse_track_list(payload: &str) -> Result<Vec<OhTrackEntry>, ControlPointErro
|
||||
);
|
||||
|
||||
let mut reader = std::io::Cursor::new(xml.as_bytes());
|
||||
let root = Element::parse(&mut reader).map_err(|err| {
|
||||
ControlPointError::OpenHomeError(format!("Failed to parse OpenHome TrackList XML: {}", err))
|
||||
})?;
|
||||
let root = Element::parse(&mut reader)
|
||||
.map_err(|err| anyhow!("Failed to parse OpenHome TrackList XML: {}", err))?;
|
||||
let mut entries = Vec::new();
|
||||
|
||||
for node in &root.children {
|
||||
@@ -948,7 +746,7 @@ fn parse_track_list(payload: &str) -> Result<Vec<OhTrackEntry>, ControlPointErro
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn parse_track_entry(elem: &Element) -> Result<OhTrackEntry, ControlPointError> {
|
||||
fn parse_track_entry(elem: &Element) -> Result<OhTrackEntry> {
|
||||
let id_text = extract_child_text_local(elem, "Id")?;
|
||||
|
||||
// Some renderers (like upmpdcli) return a comma-separated list of IDs in the Id element
|
||||
@@ -961,14 +759,14 @@ fn parse_track_entry(elem: &Element) -> Result<OhTrackEntry, ControlPointError>
|
||||
raw_id = id_text.as_str(),
|
||||
"Multi-value Id element detected - forcing fallback to individual reads"
|
||||
);
|
||||
return Err(ControlPointError::OpenHomeError(format!(
|
||||
return Err(anyhow!(
|
||||
"Renderer returned comma-separated IDs in single Entry - need individual reads"
|
||||
)));
|
||||
));
|
||||
}
|
||||
|
||||
let id = id_text.parse::<u32>().map_err(|_| {
|
||||
ControlPointError::OpenHomeError(format!("Invalid OpenHome Entry Id: {}", id_text))
|
||||
})?;
|
||||
let id = id_text
|
||||
.parse::<u32>()
|
||||
.map_err(|_| anyhow!("Invalid OpenHome Entry Id: {}", id_text))?;
|
||||
|
||||
let uri = extract_child_text_local(elem, "Uri")?;
|
||||
let metadata_xml = extract_child_text_optional_local(elem, "Metadata")?.unwrap_or_default();
|
||||
@@ -1011,7 +809,181 @@ fn parse_product_source_list(xml: &str) -> Result<Vec<OhProductSource>> {
|
||||
Ok(sources)
|
||||
}
|
||||
|
||||
fn is_invalid_entry_id_error(err: &ControlPointError) -> bool {
|
||||
fn parse_visible_flag(value: &str) -> bool {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.eq_ignore_ascii_case("true") {
|
||||
return true;
|
||||
}
|
||||
if trimmed.eq_ignore_ascii_case("false") {
|
||||
return false;
|
||||
}
|
||||
trimmed == "1"
|
||||
}
|
||||
|
||||
fn ensure_success<'a>(action: &str, call_result: &'a SoapCallResult) -> Result<&'a SoapEnvelope> {
|
||||
if !call_result.status.is_success() {
|
||||
if let Some(env) = &call_result.envelope {
|
||||
if let Some(err) = parse_upnp_error(env) {
|
||||
return Err(anyhow!(
|
||||
"{action} failed with UPnP error {}: {} (HTTP status {})",
|
||||
err.error_code,
|
||||
err.error_description,
|
||||
call_result.status
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return Err(anyhow!(
|
||||
"{action} failed with HTTP status {} and body: {}",
|
||||
call_result.status,
|
||||
call_result.raw_body
|
||||
));
|
||||
}
|
||||
|
||||
let envelope = call_result
|
||||
.envelope
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("Missing SOAP envelope in {action} response"))?;
|
||||
|
||||
if let Some(err) = parse_upnp_error(envelope) {
|
||||
return Err(anyhow!(
|
||||
"{action} returned UPnP error {}: {} (HTTP status {})",
|
||||
err.error_code,
|
||||
err.error_description,
|
||||
call_result.status
|
||||
));
|
||||
}
|
||||
|
||||
Ok(envelope)
|
||||
}
|
||||
|
||||
fn handle_action_response(action: &str, call_result: &SoapCallResult) -> Result<()> {
|
||||
ensure_success(action, call_result)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_child_with_suffix<'a>(parent: &'a Element, suffix: &str) -> Option<&'a Element> {
|
||||
parent.children.iter().find_map(|node| match node {
|
||||
XMLNode::Element(elem) if elem.name.ends_with(suffix) => Some(elem),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn find_child_with_local_name<'a>(parent: &'a Element, local: &str) -> Option<&'a Element> {
|
||||
parent.children.iter().find_map(|node| {
|
||||
if let XMLNode::Element(elem) = node {
|
||||
if elem.name == local || elem.name.ends_with(&format!(":{}", local)) {
|
||||
return Some(elem);
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_child_text(parent: &Element, suffix: &str) -> Result<String> {
|
||||
let child = find_child_with_suffix(parent, suffix)
|
||||
.ok_or_else(|| anyhow!("Missing {suffix} element in response"))?;
|
||||
|
||||
let text = child
|
||||
.get_text()
|
||||
.map(|t| t.trim().to_string())
|
||||
.ok_or_else(|| anyhow!("{suffix} element missing text in response"))?;
|
||||
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
fn extract_child_text_optional(parent: &Element, suffix: &str) -> Result<Option<String>> {
|
||||
if let Some(child) = find_child_with_suffix(parent, suffix) {
|
||||
let text = child
|
||||
.get_text()
|
||||
.map(|t| t.trim().to_string())
|
||||
.unwrap_or_default();
|
||||
Ok(Some(text))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_child_text_any(parent: &Element, suffixes: &[&str]) -> Result<String> {
|
||||
for suffix in suffixes {
|
||||
if let Ok(text) = extract_child_text(parent, suffix) {
|
||||
return Ok(text);
|
||||
}
|
||||
}
|
||||
Err(anyhow!(
|
||||
"Missing {} element in response",
|
||||
suffixes.join(" or ")
|
||||
))
|
||||
}
|
||||
|
||||
fn extract_child_text_local(parent: &Element, local: &str) -> Result<String> {
|
||||
let child = find_child_with_local_name(parent, local)
|
||||
.ok_or_else(|| anyhow!("Missing {local} element in response"))?;
|
||||
let text = child
|
||||
.get_text()
|
||||
.map(|t| t.trim().to_string())
|
||||
.ok_or_else(|| anyhow!("{local} element missing text in response"))?;
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
fn extract_child_text_optional_local(parent: &Element, local: &str) -> Result<Option<String>> {
|
||||
if let Some(child) = find_child_with_local_name(parent, local) {
|
||||
let text = child
|
||||
.get_text()
|
||||
.map(|t| t.trim().to_string())
|
||||
.unwrap_or_default();
|
||||
Ok(Some(text))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_bool(value: &str) -> Result<bool> {
|
||||
match value.trim() {
|
||||
"0" => Ok(false),
|
||||
"1" => Ok(true),
|
||||
other => Err(anyhow!("Invalid boolean value '{}'", other)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn decode_base64(input: &str) -> Result<Vec<u8>> {
|
||||
fn value(byte: u8) -> Option<u8> {
|
||||
match byte {
|
||||
b'A'..=b'Z' => Some(byte - b'A'),
|
||||
b'a'..=b'z' => Some(byte - b'a' + 26),
|
||||
b'0'..=b'9' => Some(byte - b'0' + 52),
|
||||
b'+' => Some(62),
|
||||
b'/' => Some(63),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
let mut output = Vec::new();
|
||||
let mut buffer: u32 = 0;
|
||||
let mut bits_collected: u8 = 0;
|
||||
|
||||
for byte in input.bytes() {
|
||||
if byte == b'=' {
|
||||
break;
|
||||
}
|
||||
if byte == b'\r' || byte == b'\n' || byte == b' ' || byte == b'\t' {
|
||||
continue;
|
||||
}
|
||||
let val =
|
||||
value(byte).ok_or_else(|| anyhow!("Invalid base64 character '{}'", byte as char))?;
|
||||
buffer = (buffer << 6) | (val as u32);
|
||||
bits_collected += 6;
|
||||
if bits_collected >= 8 {
|
||||
bits_collected -= 8;
|
||||
let out = (buffer >> bits_collected) & 0xFF;
|
||||
output.push(out as u8);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn is_invalid_entry_id_error(err: &anyhow::Error) -> bool {
|
||||
let msg = format!("{err}");
|
||||
msg.contains("Invalid OpenHome Entry Id") || msg.contains("comma-separated IDs")
|
||||
}
|
||||
@@ -1026,7 +998,8 @@ mod tests {
|
||||
let xml = r#"<u:InsertResponse xmlns:u="urn:av-openhome-org:service:Playlist:1"><NewId>1337</NewId></u:InsertResponse>"#;
|
||||
let mut cursor = Cursor::new(xml.as_bytes());
|
||||
let response = Element::parse(&mut cursor).expect("valid xml");
|
||||
let value = extract_child_text_any(&response, &["NewId", "Value"]).unwrap();
|
||||
let value =
|
||||
extract_child_text_any(&response, &["NewId", "Value"]).unwrap();
|
||||
assert_eq!(value, "1337");
|
||||
}
|
||||
|
||||
@@ -1035,7 +1008,9 @@ mod tests {
|
||||
let xml = r#"<u:ReadListResponse xmlns:u="urn:av-openhome-org:service:Playlist:1"><TrackList>PGVudHJ5PjwvZW50cnk+</TrackList></u:ReadListResponse>"#;
|
||||
let mut cursor = Cursor::new(xml.as_bytes());
|
||||
let response = Element::parse(&mut cursor).expect("valid xml");
|
||||
let value = extract_child_text_any(&response, &["TrackList", "Value"]).expect("tracklist");
|
||||
let value =
|
||||
extract_child_text_any(&response, &["TrackList", "Value"])
|
||||
.expect("tracklist");
|
||||
assert_eq!(value, "PGVudHJ5PjwvZW50cnk+");
|
||||
}
|
||||
|
||||
@@ -1044,8 +1019,11 @@ mod tests {
|
||||
let xml = r#"<u:IdArrayResponse xmlns:u="urn:av-openhome-org:service:Playlist:1"><Token>1</Token><Array>AAAAAQAAAAI=</Array></u:IdArrayResponse>"#;
|
||||
let mut cursor = Cursor::new(xml.as_bytes());
|
||||
let response = Element::parse(&mut cursor).expect("valid xml");
|
||||
let value = extract_child_text_any(&response, &["Array", "IdArray", "Value"])
|
||||
.expect("array content");
|
||||
let value = extract_child_text_any(
|
||||
&response,
|
||||
&["Array", "IdArray", "Value"],
|
||||
)
|
||||
.expect("array content");
|
||||
assert_eq!(value, "AAAAAQAAAAI=");
|
||||
}
|
||||
}
|
||||
31
pmocontrol/src/openhome_playlist.rs
Normal file
31
pmocontrol/src/openhome_playlist.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
/// 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>,
|
||||
/// Position courante dans la playlist (si connue).
|
||||
pub current_index: Option<usize>,
|
||||
/// 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>,
|
||||
}
|
||||
410
pmocontrol/src/openhome_renderer.rs
Normal file
410
pmocontrol/src/openhome_renderer.rs
Normal file
@@ -0,0 +1,410 @@
|
||||
use crate::capabilities::{
|
||||
PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl,
|
||||
VolumeControl,
|
||||
};
|
||||
use crate::model::{RendererId, RendererInfo, RendererProtocol};
|
||||
use crate::music_renderer::op_not_supported;
|
||||
use crate::openhome::{
|
||||
build_info_client, build_playlist_client, build_product_client, build_radio_client,
|
||||
build_time_client, build_volume_client,
|
||||
};
|
||||
use crate::openhome_client::{
|
||||
parse_track_metadata_from_didl, OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient,
|
||||
OhTimeClient, OhTrackEntry, OhVolumeClient, OPENHOME_PLAYLIST_HEAD_ID,
|
||||
};
|
||||
use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
|
||||
use anyhow::{anyhow, Result};
|
||||
use tracing::debug;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OpenHomeRenderer {
|
||||
pub info: RendererInfo,
|
||||
playlist: Option<OhPlaylistClient>,
|
||||
info_client: Option<OhInfoClient>,
|
||||
time_client: Option<OhTimeClient>,
|
||||
volume_client: Option<OhVolumeClient>,
|
||||
product_client: Option<OhProductClient>,
|
||||
#[allow(dead_code)]
|
||||
radio_client: Option<OhRadioClient>,
|
||||
}
|
||||
|
||||
impl OpenHomeRenderer {
|
||||
pub fn new(info: RendererInfo) -> Self {
|
||||
Self {
|
||||
playlist: build_playlist_client(&info),
|
||||
info_client: build_info_client(&info),
|
||||
time_client: build_time_client(&info),
|
||||
volume_client: build_volume_client(&info),
|
||||
product_client: build_product_client(&info),
|
||||
radio_client: build_radio_client(&info),
|
||||
info,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &RendererId {
|
||||
&self.info.id
|
||||
}
|
||||
|
||||
pub fn friendly_name(&self) -> &str {
|
||||
&self.info.friendly_name
|
||||
}
|
||||
|
||||
pub fn protocol(&self) -> &RendererProtocol {
|
||||
&self.info.protocol
|
||||
}
|
||||
|
||||
pub fn has_playlist(&self) -> bool {
|
||||
self.playlist.is_some()
|
||||
}
|
||||
|
||||
pub fn has_info(&self) -> bool {
|
||||
self.info_client.is_some()
|
||||
}
|
||||
|
||||
pub fn has_time(&self) -> bool {
|
||||
self.time_client.is_some()
|
||||
}
|
||||
|
||||
pub fn has_volume(&self) -> bool {
|
||||
self.volume_client.is_some()
|
||||
}
|
||||
|
||||
pub fn has_any_openhome_service(&self) -> bool {
|
||||
self.has_playlist() || self.has_info() || self.has_time() || self.has_volume()
|
||||
}
|
||||
|
||||
fn playlist_client_for(&self, op: &str) -> Result<&OhPlaylistClient> {
|
||||
let playlist = self
|
||||
.playlist
|
||||
.as_ref()
|
||||
.ok_or_else(|| op_not_supported(op, "OpenHome Playlist"))?;
|
||||
self.ensure_playlist_source_selected()?;
|
||||
Ok(playlist)
|
||||
}
|
||||
|
||||
fn info_client_for(&self, op: &str) -> Result<&OhInfoClient> {
|
||||
self.info_client
|
||||
.as_ref()
|
||||
.ok_or_else(|| op_not_supported(op, "OpenHome Info"))
|
||||
}
|
||||
|
||||
fn time_client_for(&self, op: &str) -> Result<&OhTimeClient> {
|
||||
self.time_client
|
||||
.as_ref()
|
||||
.ok_or_else(|| op_not_supported(op, "OpenHome Time"))
|
||||
}
|
||||
|
||||
fn volume_client_for(&self, op: &str) -> Result<&OhVolumeClient> {
|
||||
self.volume_client
|
||||
.as_ref()
|
||||
.ok_or_else(|| op_not_supported(op, "OpenHome Volume"))
|
||||
}
|
||||
|
||||
fn ensure_playlist_source_selected(&self) -> Result<()> {
|
||||
if let Some(product) = &self.product_client {
|
||||
product.ensure_playlist_source_selected().map_err(|err| {
|
||||
anyhow!(
|
||||
"Failed to select OpenHome Playlist source for {}: {}",
|
||||
self.info.id.0,
|
||||
err
|
||||
)
|
||||
})
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn snapshot_openhome_playlist(&self) -> Result<OpenHomePlaylistSnapshot> {
|
||||
let playlist = self.playlist_client_for("snapshot_openhome_playlist")?;
|
||||
let entries = playlist.read_all_tracks()?;
|
||||
|
||||
// Essayer d'obtenir current_id depuis Info.Id()
|
||||
let mut current_id = self
|
||||
.playlist
|
||||
.as_ref()
|
||||
.and_then(|client| {
|
||||
match client.id() {
|
||||
Ok(id) => {
|
||||
debug!(
|
||||
renderer = self.info.id.0.as_str(),
|
||||
current_id = id,
|
||||
"OpenHome Info service returned current_id"
|
||||
);
|
||||
Some(id)
|
||||
}
|
||||
Err(err) => {
|
||||
debug!(
|
||||
renderer = self.info.id.0.as_str(),
|
||||
error = %err,
|
||||
"OpenHome Info.Id() failed, will try Info.Track()"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Fallback: Si Info.Id() échoue, essayer Info.Track() et matcher l'URI
|
||||
if current_id.is_none() {
|
||||
if let Some(client) = self.info_client.as_ref() {
|
||||
match client.track() {
|
||||
Ok(track_info) => {
|
||||
debug!(
|
||||
renderer = self.info.id.0.as_str(),
|
||||
track_uri = track_info.uri.as_str(),
|
||||
"OpenHome Info.Track() returned, searching by URI"
|
||||
);
|
||||
// Trouver l'entry qui matche cet URI
|
||||
current_id = entries.iter()
|
||||
.find(|entry| entry.uri == track_info.uri)
|
||||
.map(|entry| {
|
||||
debug!(
|
||||
renderer = self.info.id.0.as_str(),
|
||||
found_id = entry.id,
|
||||
"Found current_id by matching URI"
|
||||
);
|
||||
entry.id
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
debug!(
|
||||
renderer = self.info.id.0.as_str(),
|
||||
error = %err,
|
||||
"OpenHome Info.Track() also failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let current_index =
|
||||
current_id.and_then(|id| entries.iter().position(|entry| entry.id == id));
|
||||
|
||||
debug!(
|
||||
renderer = self.info.id.0.as_str(),
|
||||
current_id = ?current_id,
|
||||
current_index = ?current_index,
|
||||
track_count = entries.len(),
|
||||
"snapshot_openhome_playlist completed"
|
||||
);
|
||||
|
||||
let tracks = entries.iter().map(convert_oh_track_entry).collect();
|
||||
|
||||
Ok(OpenHomePlaylistSnapshot {
|
||||
renderer_id: self.info.id.0.clone(),
|
||||
current_id,
|
||||
current_index,
|
||||
tracks,
|
||||
})
|
||||
}
|
||||
|
||||
/// Retourne la longueur de la playlist OpenHome sans récupérer toutes les métadonnées.
|
||||
/// Plus rapide que snapshot_openhome_playlist() pour juste connaître le nombre de pistes.
|
||||
pub(crate) fn openhome_playlist_len(&self) -> Result<usize> {
|
||||
let playlist = self.playlist_client_for("openhome_playlist_len")?;
|
||||
let ids = playlist.id_array()?;
|
||||
Ok(ids.len())
|
||||
}
|
||||
|
||||
/// Retourne les IDs des pistes de la playlist OpenHome.
|
||||
/// Plus rapide que snapshot_openhome_playlist() car ne récupère pas les métadonnées.
|
||||
pub(crate) fn openhome_playlist_ids(&self) -> Result<Vec<u32>> {
|
||||
let playlist = self.playlist_client_for("openhome_playlist_ids")?;
|
||||
playlist.id_array()
|
||||
}
|
||||
|
||||
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(OPENHOME_PLAYLIST_HEAD_ID),
|
||||
};
|
||||
|
||||
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 {
|
||||
fn play_uri(&self, uri: &str, meta: &str) -> Result<()> {
|
||||
let playlist = self.playlist_client_for("play_uri")?;
|
||||
|
||||
if let Err(err) = playlist.delete_all() {
|
||||
debug!(
|
||||
renderer = self.info.id.0.as_str(),
|
||||
error = %err,
|
||||
"Failed to clear OpenHome playlist before insert"
|
||||
);
|
||||
}
|
||||
|
||||
// Reuse the same insertion logic as the queue path so that we honor
|
||||
// renderer expectations (IdArray sequencing, etc.).
|
||||
self.add_track_openhome(uri, meta, None, true)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn play(&self) -> Result<()> {
|
||||
let playlist = self.playlist_client_for("play")?;
|
||||
playlist.play()
|
||||
}
|
||||
|
||||
fn pause(&self) -> Result<()> {
|
||||
let playlist = self.playlist_client_for("pause")?;
|
||||
playlist.pause()
|
||||
}
|
||||
|
||||
fn stop(&self) -> Result<()> {
|
||||
let playlist = self.playlist_client_for("stop")?;
|
||||
playlist.stop()
|
||||
}
|
||||
|
||||
fn seek_rel_time(&self, hhmmss: &str) -> Result<()> {
|
||||
let seconds = parse_hms(hhmmss).ok_or_else(|| {
|
||||
anyhow!(
|
||||
"Invalid HH:MM:SS format for OpenHome SeekSecondAbsolute: {}",
|
||||
hhmmss
|
||||
)
|
||||
})?;
|
||||
let playlist = self.playlist_client_for("seek_rel_time")?;
|
||||
playlist.seek_second_absolute(seconds)
|
||||
}
|
||||
}
|
||||
|
||||
impl VolumeControl for OpenHomeRenderer {
|
||||
fn volume(&self) -> Result<u16> {
|
||||
let client = self.volume_client_for("volume")?;
|
||||
client.volume()
|
||||
}
|
||||
|
||||
fn set_volume(&self, v: u16) -> Result<()> {
|
||||
let client = self.volume_client_for("set_volume")?;
|
||||
client.set_volume(v)
|
||||
}
|
||||
|
||||
fn mute(&self) -> Result<bool> {
|
||||
let client = self.volume_client_for("mute")?;
|
||||
client.mute()
|
||||
}
|
||||
|
||||
fn set_mute(&self, m: bool) -> Result<()> {
|
||||
let client = self.volume_client_for("set_mute")?;
|
||||
client.set_mute(m)
|
||||
}
|
||||
}
|
||||
|
||||
impl PlaybackStatus for OpenHomeRenderer {
|
||||
fn playback_state(&self) -> Result<PlaybackState> {
|
||||
let client = self.playlist_client_for("playback_state")?;
|
||||
let state = client.transport_state()?;
|
||||
Ok(map_openhome_state(&state))
|
||||
}
|
||||
}
|
||||
|
||||
impl PlaybackPosition for OpenHomeRenderer {
|
||||
fn playback_position(&self) -> Result<PlaybackPositionInfo> {
|
||||
let time_info = self.time_client_for("playback_position")?.position()?;
|
||||
|
||||
let mut track_id = None;
|
||||
let mut track_uri = None;
|
||||
let mut track_metadata_xml = None;
|
||||
|
||||
if let Some(playlist_client) = &self.playlist {
|
||||
match playlist_client.id() {
|
||||
Ok(id) => track_id = Some(id),
|
||||
Err(err) => debug!(
|
||||
renderer = self.info.id.0.as_str(),
|
||||
error = %err,
|
||||
"Failed to read OpenHome track id"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(info_client) = &self.info_client {
|
||||
match info_client.track() {
|
||||
Ok(track) => {
|
||||
track_uri = Some(track.uri);
|
||||
track_metadata_xml = track.metadata_xml;
|
||||
}
|
||||
Err(err) => debug!(
|
||||
renderer = self.info.id.0.as_str(),
|
||||
error = %err,
|
||||
"Failed to read OpenHome track metadata"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(PlaybackPositionInfo {
|
||||
track: track_id,
|
||||
rel_time: Some(format_seconds(time_info.elapsed_secs)),
|
||||
abs_time: None,
|
||||
track_duration: Some(format_seconds(time_info.duration_secs)),
|
||||
track_metadata: track_metadata_xml,
|
||||
track_uri,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_hms(input: &str) -> Option<u32> {
|
||||
let parts: Vec<&str> = input.split(':').collect();
|
||||
if parts.is_empty() || parts.len() > 3 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut total = 0u32;
|
||||
for part in parts {
|
||||
let value = part.parse::<u32>().ok()?;
|
||||
total = total * 60 + value;
|
||||
}
|
||||
Some(total)
|
||||
}
|
||||
|
||||
pub(crate) 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()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) 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}")
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -4,22 +4,25 @@
|
||||
//! et naviguer dans les serveurs de médias.
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use crate::control_point::ControlPoint;
|
||||
use crate::control_point::{
|
||||
ControlPoint, OpenHomeAccessError, OPENHOME_SNAPSHOT_CACHE_TTL,
|
||||
};
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use crate::media_server::{MediaBrowser, playback_item_from_entry};
|
||||
use crate::media_server::{MediaBrowser, MediaEntry, MusicServer, ServerId};
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use crate::model::{RendererCapabilities, RendererProtocol};
|
||||
use crate::model::{RendererCapabilities, RendererId, RendererProtocol, TrackMetadata};
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use crate::openapi::{
|
||||
AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry, ErrorResponse,
|
||||
FullRendererSnapshot, MediaServerSummary, PlayContentRequest, QueueSnapshot,
|
||||
RendererCapabilitiesSummary, RendererProtocolSummary, RendererState, RendererSummary,
|
||||
SeekQueueRequest, SuccessResponse, VolumeSetRequest,
|
||||
FullRendererSnapshot, MediaServerSummary, OpenHomePlaylistAddRequest, OpenHomePlaylistSnapshot,
|
||||
PlayContentRequest, QueueItem, QueueSnapshot, RendererCapabilitiesSummary,
|
||||
RendererProtocolSummary, RendererState, RendererSummary, SeekQueueRequest, SuccessResponse,
|
||||
VolumeSetRequest,
|
||||
};
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use crate::queue::PlaybackItem;
|
||||
use crate::queue_backend::PlaybackItem;
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use crate::{DeviceId, DeviceIdentity, DeviceOnline};
|
||||
use crate::{PlaybackPosition, PlaybackStatus, TransportControl, VolumeControl};
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use async_trait::async_trait;
|
||||
@@ -44,6 +47,8 @@ use utoipa::OpenApi;
|
||||
#[cfg(feature = "pmoserver")]
|
||||
const BROWSE_PAGE_SIZE: u32 = 100;
|
||||
#[cfg(feature = "pmoserver")]
|
||||
const MEDIA_SERVER_SOAP_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
#[cfg(feature = "pmoserver")]
|
||||
const BROWSE_REQUEST_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
// Timeouts for simple commands (play/pause/stop)
|
||||
@@ -98,12 +103,12 @@ async fn list_renderers(State(state): State<ControlPointState>) -> Json<Vec<Rend
|
||||
.map(|r| {
|
||||
let info = r.info();
|
||||
RendererSummary {
|
||||
id: r.id().0.clone(),
|
||||
friendly_name: r.friendly_name().to_string(),
|
||||
model_name: r.model_name().to_string(),
|
||||
protocol: protocol_summary(&info.protocol()),
|
||||
capabilities: capability_summary(&info.capabilities()),
|
||||
online: r.is_online(),
|
||||
id: info.id.0.clone(),
|
||||
friendly_name: info.friendly_name.clone(),
|
||||
model_name: info.model_name.clone(),
|
||||
protocol: protocol_summary(&info.protocol),
|
||||
capabilities: capability_summary(&info.capabilities),
|
||||
online: info.online,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -129,7 +134,7 @@ async fn get_renderer_state(
|
||||
State(state): State<ControlPointState>,
|
||||
Path(renderer_id): Path<String>,
|
||||
) -> Result<Json<RendererState>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let rid = DeviceId(renderer_id.clone());
|
||||
let rid = RendererId(renderer_id.clone());
|
||||
let snapshot = state
|
||||
.control_point
|
||||
.renderer_full_snapshot(&rid)
|
||||
@@ -155,7 +160,7 @@ async fn get_renderer_full_snapshot(
|
||||
State(state): State<ControlPointState>,
|
||||
Path(renderer_id): Path<String>,
|
||||
) -> Result<Json<FullRendererSnapshot>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let rid = DeviceId(renderer_id.clone());
|
||||
let rid = RendererId(renderer_id.clone());
|
||||
let snapshot = state
|
||||
.control_point
|
||||
.renderer_full_snapshot(&rid)
|
||||
@@ -186,7 +191,7 @@ async fn get_renderer_queue(
|
||||
State(state): State<ControlPointState>,
|
||||
Path(renderer_id): Path<String>,
|
||||
) -> Result<Json<QueueSnapshot>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let rid = DeviceId(renderer_id.clone());
|
||||
let rid = RendererId(renderer_id.clone());
|
||||
let snapshot = state
|
||||
.control_point
|
||||
.renderer_full_snapshot(&rid)
|
||||
@@ -213,7 +218,7 @@ async fn get_renderer_binding(
|
||||
State(state): State<ControlPointState>,
|
||||
Path(renderer_id): Path<String>,
|
||||
) -> Result<Json<Option<AttachedPlaylistInfo>>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let rid = DeviceId(renderer_id.clone());
|
||||
let rid = RendererId(renderer_id.clone());
|
||||
let snapshot = state
|
||||
.control_point
|
||||
.renderer_full_snapshot(&rid)
|
||||
@@ -245,7 +250,7 @@ async fn play_renderer(
|
||||
State(state): State<ControlPointState>,
|
||||
Path(renderer_id): Path<String>,
|
||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let rid = DeviceId(renderer_id.clone());
|
||||
let rid = RendererId(renderer_id.clone());
|
||||
let renderer = state
|
||||
.control_point
|
||||
.music_renderer_by_id(&rid)
|
||||
@@ -321,7 +326,7 @@ async fn pause_renderer(
|
||||
State(state): State<ControlPointState>,
|
||||
Path(renderer_id): Path<String>,
|
||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let rid = DeviceId(renderer_id.clone());
|
||||
let rid = RendererId(renderer_id.clone());
|
||||
let renderer = state
|
||||
.control_point
|
||||
.music_renderer_by_id(&rid)
|
||||
@@ -397,7 +402,7 @@ async fn stop_renderer(
|
||||
State(state): State<ControlPointState>,
|
||||
Path(renderer_id): Path<String>,
|
||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let rid = DeviceId(renderer_id.clone());
|
||||
let rid = RendererId(renderer_id.clone());
|
||||
state
|
||||
.control_point
|
||||
.music_renderer_by_id(&rid)
|
||||
@@ -474,7 +479,7 @@ async fn resume_renderer(
|
||||
State(state): State<ControlPointState>,
|
||||
Path(renderer_id): Path<String>,
|
||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let rid = DeviceId(renderer_id.clone());
|
||||
let rid = RendererId(renderer_id.clone());
|
||||
state
|
||||
.control_point
|
||||
.music_renderer_by_id(&rid)
|
||||
@@ -555,7 +560,7 @@ async fn next_renderer(
|
||||
State(state): State<ControlPointState>,
|
||||
Path(renderer_id): Path<String>,
|
||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let rid = DeviceId(renderer_id.clone());
|
||||
let rid = RendererId(renderer_id.clone());
|
||||
state
|
||||
.control_point
|
||||
.music_renderer_by_id(&rid)
|
||||
@@ -636,7 +641,7 @@ async fn seek_queue_index(
|
||||
Path(renderer_id): Path<String>,
|
||||
Json(payload): Json<SeekQueueRequest>,
|
||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let rid = DeviceId(renderer_id.clone());
|
||||
let rid = RendererId(renderer_id.clone());
|
||||
state
|
||||
.control_point
|
||||
.music_renderer_by_id(&rid)
|
||||
@@ -652,8 +657,9 @@ async fn seek_queue_index(
|
||||
let control_point = Arc::clone(&state.control_point);
|
||||
let rid_for_task = rid.clone();
|
||||
let index = payload.index;
|
||||
let seek_task =
|
||||
tokio::task::spawn_blocking(move || control_point.play_queue_index(&rid_for_task, index));
|
||||
let seek_task = tokio::task::spawn_blocking(move || {
|
||||
control_point.play_queue_index(&rid_for_task, index)
|
||||
});
|
||||
|
||||
time::timeout(TRANSPORT_COMMAND_TIMEOUT, seek_task)
|
||||
.await
|
||||
@@ -721,7 +727,7 @@ async fn set_renderer_volume(
|
||||
Path(renderer_id): Path<String>,
|
||||
Json(req): Json<VolumeSetRequest>,
|
||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let rid = DeviceId(renderer_id.clone());
|
||||
let rid = RendererId(renderer_id.clone());
|
||||
|
||||
let renderer = state
|
||||
.control_point
|
||||
@@ -799,7 +805,7 @@ async fn volume_up_renderer(
|
||||
State(state): State<ControlPointState>,
|
||||
Path(renderer_id): Path<String>,
|
||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let rid = DeviceId(renderer_id.clone());
|
||||
let rid = RendererId(renderer_id.clone());
|
||||
|
||||
let renderer = state
|
||||
.control_point
|
||||
@@ -884,7 +890,7 @@ async fn volume_down_renderer(
|
||||
State(state): State<ControlPointState>,
|
||||
Path(renderer_id): Path<String>,
|
||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let rid = DeviceId(renderer_id.clone());
|
||||
let rid = RendererId(renderer_id.clone());
|
||||
|
||||
let renderer = state
|
||||
.control_point
|
||||
@@ -969,7 +975,7 @@ async fn toggle_mute_renderer(
|
||||
State(state): State<ControlPointState>,
|
||||
Path(renderer_id): Path<String>,
|
||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let rid = DeviceId(renderer_id.clone());
|
||||
let rid = RendererId(renderer_id.clone());
|
||||
|
||||
let renderer = state
|
||||
.control_point
|
||||
@@ -1056,8 +1062,8 @@ async fn attach_playlist_binding(
|
||||
Path(renderer_id): Path<String>,
|
||||
Json(req): Json<AttachPlaylistRequest>,
|
||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let rid = DeviceId(renderer_id.clone());
|
||||
let sid = DeviceId(req.server_id.clone());
|
||||
let rid = RendererId(renderer_id.clone());
|
||||
let sid = ServerId(req.server_id.clone());
|
||||
let container_id = req.container_id.clone();
|
||||
let control_point = Arc::clone(&state.control_point);
|
||||
|
||||
@@ -1138,7 +1144,7 @@ async fn detach_playlist_binding(
|
||||
State(state): State<ControlPointState>,
|
||||
Path(renderer_id): Path<String>,
|
||||
) -> Json<SuccessResponse> {
|
||||
let rid = DeviceId(renderer_id.clone());
|
||||
let rid = RendererId(renderer_id.clone());
|
||||
|
||||
state.control_point.detach_queue_playlist(&rid);
|
||||
|
||||
@@ -1152,6 +1158,297 @@ async fn detach_playlist_binding(
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HANDLERS - OPENHOME PLAYLIST
|
||||
// ============================================================================
|
||||
|
||||
/// GET /control/renderers/{renderer_id}/oh/playlist - Snapshot de la playlist OH
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/renderers/{renderer_id}/oh/playlist",
|
||||
params(
|
||||
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Playlist OpenHome", body = OpenHomePlaylistSnapshot),
|
||||
(status = 404, description = "Renderer non trouvé ou sans service OH", body = ErrorResponse)
|
||||
),
|
||||
tag = "control"
|
||||
)]
|
||||
async fn get_openhome_playlist(
|
||||
State(state): State<ControlPointState>,
|
||||
Path(renderer_id): Path<String>,
|
||||
) -> Result<Json<OpenHomePlaylistSnapshot>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let rid = RendererId(renderer_id.clone());
|
||||
let control_point = Arc::clone(&state.control_point);
|
||||
let rid_for_task = rid.clone();
|
||||
|
||||
let fetch_task = tokio::task::spawn_blocking(move || {
|
||||
control_point.get_cached_openhome_playlist_snapshot(
|
||||
&rid_for_task,
|
||||
OPENHOME_SNAPSHOT_CACHE_TTL,
|
||||
)
|
||||
});
|
||||
|
||||
let snapshot = fetch_task
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!(
|
||||
renderer = renderer_id.as_str(),
|
||||
error = %e,
|
||||
"Join error while fetching OpenHome playlist"
|
||||
);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Internal task error: {}", e),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.map_err(|e| {
|
||||
warn!(
|
||||
renderer = renderer_id.as_str(),
|
||||
error = %e,
|
||||
"Failed to read OpenHome playlist"
|
||||
);
|
||||
map_openhome_error(&rid, e, "read OpenHome playlist")
|
||||
})?;
|
||||
|
||||
Ok(Json(snapshot))
|
||||
}
|
||||
|
||||
/// POST /control/renderers/{renderer_id}/oh/playlist/clear - Vide la playlist OH
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/renderers/{renderer_id}/oh/playlist/clear",
|
||||
params(
|
||||
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Playlist vidée", body = SuccessResponse),
|
||||
(status = 404, description = "Renderer non trouvé ou sans service OH", body = ErrorResponse)
|
||||
),
|
||||
tag = "control"
|
||||
)]
|
||||
async fn clear_openhome_playlist(
|
||||
State(state): State<ControlPointState>,
|
||||
Path(renderer_id): Path<String>,
|
||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let rid = RendererId(renderer_id.clone());
|
||||
let control_point = Arc::clone(&state.control_point);
|
||||
let rid_for_task = rid.clone();
|
||||
|
||||
let clear_task =
|
||||
tokio::task::spawn_blocking(move || control_point.clear_openhome_playlist(&rid_for_task));
|
||||
|
||||
time::timeout(QUEUE_COMMAND_TIMEOUT, clear_task)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
warn!(
|
||||
renderer = renderer_id.as_str(),
|
||||
timeout = QUEUE_COMMAND_TIMEOUT.as_secs(),
|
||||
"Clearing OpenHome playlist timed out"
|
||||
);
|
||||
(
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
Json(ErrorResponse {
|
||||
error: format!(
|
||||
"Clear playlist timed out after {}s",
|
||||
QUEUE_COMMAND_TIMEOUT.as_secs()
|
||||
),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.map_err(|e| {
|
||||
warn!(
|
||||
renderer = renderer_id.as_str(),
|
||||
error = %e,
|
||||
"Join error while clearing OpenHome playlist"
|
||||
);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Internal task error: {}", e),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.map_err(|e| {
|
||||
warn!(
|
||||
renderer = renderer_id.as_str(),
|
||||
error = %e,
|
||||
"Failed to clear OpenHome playlist"
|
||||
);
|
||||
map_openhome_error(&rid, e, "clear OpenHome playlist")
|
||||
})?;
|
||||
|
||||
Ok(Json(SuccessResponse {
|
||||
message: "OpenHome playlist cleared".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /control/renderers/{renderer_id}/oh/playlist/add - Ajoute un track OH
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/renderers/{renderer_id}/oh/playlist/add",
|
||||
params(
|
||||
("renderer_id" = String, Path, description = "ID unique du renderer")
|
||||
),
|
||||
request_body = OpenHomePlaylistAddRequest,
|
||||
responses(
|
||||
(status = 200, description = "Track ajouté", body = SuccessResponse),
|
||||
(status = 404, description = "Renderer non trouvé ou sans service OH", body = ErrorResponse)
|
||||
),
|
||||
tag = "control"
|
||||
)]
|
||||
async fn add_openhome_playlist_item(
|
||||
State(state): State<ControlPointState>,
|
||||
Path(renderer_id): Path<String>,
|
||||
Json(req): Json<OpenHomePlaylistAddRequest>,
|
||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let rid = RendererId(renderer_id.clone());
|
||||
let control_point = Arc::clone(&state.control_point);
|
||||
let rid_for_task = rid.clone();
|
||||
|
||||
let add_task = tokio::task::spawn_blocking(move || {
|
||||
control_point.add_openhome_track(
|
||||
&rid_for_task,
|
||||
&req.uri,
|
||||
&req.metadata,
|
||||
req.after_id,
|
||||
req.play,
|
||||
)
|
||||
});
|
||||
|
||||
time::timeout(QUEUE_COMMAND_TIMEOUT, add_task)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
warn!(
|
||||
renderer = renderer_id.as_str(),
|
||||
timeout = QUEUE_COMMAND_TIMEOUT.as_secs(),
|
||||
"Adding OpenHome track timed out"
|
||||
);
|
||||
(
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
Json(ErrorResponse {
|
||||
error: format!(
|
||||
"Add track timed out after {}s",
|
||||
QUEUE_COMMAND_TIMEOUT.as_secs()
|
||||
),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.map_err(|e| {
|
||||
warn!(
|
||||
renderer = renderer_id.as_str(),
|
||||
error = %e,
|
||||
"Join error while adding OpenHome track"
|
||||
);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Internal task error: {}", e),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.map_err(|e| {
|
||||
warn!(
|
||||
renderer = renderer_id.as_str(),
|
||||
error = %e,
|
||||
"Failed to add OpenHome track"
|
||||
);
|
||||
map_openhome_error(&rid, e, "add OpenHome track")
|
||||
})?;
|
||||
|
||||
Ok(Json(SuccessResponse {
|
||||
message: "Track added to OpenHome playlist".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /control/renderers/{renderer_id}/oh/playlist/play/{track_id} - PlayId OH
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/renderers/{renderer_id}/oh/playlist/play/{track_id}",
|
||||
params(
|
||||
("renderer_id" = String, Path, description = "ID unique du renderer"),
|
||||
("track_id" = String, Path, description = "ID OpenHome du morceau")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Lecture démarrée", body = SuccessResponse),
|
||||
(status = 404, description = "Renderer non trouvé ou sans service OH", body = ErrorResponse)
|
||||
),
|
||||
tag = "control"
|
||||
)]
|
||||
async fn play_openhome_track(
|
||||
State(state): State<ControlPointState>,
|
||||
Path((renderer_id, track_id)): Path<(String, String)>,
|
||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let rid = RendererId(renderer_id.clone());
|
||||
let parsed_id = track_id.parse::<u32>().map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Invalid track id '{}': {}", track_id, e),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let control_point = Arc::clone(&state.control_point);
|
||||
let rid_for_task = rid.clone();
|
||||
|
||||
let play_task = tokio::task::spawn_blocking(move || {
|
||||
control_point.play_openhome_track_id(&rid_for_task, parsed_id)
|
||||
});
|
||||
|
||||
time::timeout(QUEUE_COMMAND_TIMEOUT, play_task)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
warn!(
|
||||
renderer = renderer_id.as_str(),
|
||||
timeout = QUEUE_COMMAND_TIMEOUT.as_secs(),
|
||||
"PlayId command timed out"
|
||||
);
|
||||
(
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
Json(ErrorResponse {
|
||||
error: format!(
|
||||
"Play track timed out after {}s",
|
||||
QUEUE_COMMAND_TIMEOUT.as_secs()
|
||||
),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.map_err(|e| {
|
||||
warn!(
|
||||
renderer = renderer_id.as_str(),
|
||||
error = %e,
|
||||
"Join error while playing OpenHome track"
|
||||
);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Internal task error: {}", e),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.map_err(|e| {
|
||||
warn!(
|
||||
renderer = renderer_id.as_str(),
|
||||
error = %e,
|
||||
track_id = parsed_id,
|
||||
"Failed to start OpenHome track"
|
||||
);
|
||||
map_openhome_error(&rid, e, "play OpenHome track")
|
||||
})?;
|
||||
|
||||
Ok(Json(SuccessResponse {
|
||||
message: format!("Playing OpenHome track {}", parsed_id),
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HANDLERS - QUEUE CONTENT
|
||||
// ============================================================================
|
||||
@@ -1177,8 +1474,8 @@ async fn play_content(
|
||||
Path(renderer_id): Path<String>,
|
||||
Json(req): Json<PlayContentRequest>,
|
||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let rid = DeviceId(renderer_id.clone());
|
||||
let sid = DeviceId(req.server_id.clone());
|
||||
let rid = RendererId(renderer_id.clone());
|
||||
let sid = ServerId(req.server_id.clone());
|
||||
let object_id = req.object_id.clone();
|
||||
let object_id_for_log = object_id.clone();
|
||||
|
||||
@@ -1306,8 +1603,8 @@ async fn add_to_queue(
|
||||
Path(renderer_id): Path<String>,
|
||||
Json(req): Json<PlayContentRequest>,
|
||||
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let rid = DeviceId(renderer_id.clone());
|
||||
let sid = DeviceId(req.server_id.clone());
|
||||
let rid = RendererId(renderer_id.clone());
|
||||
let sid = ServerId(req.server_id.clone());
|
||||
let object_id = req.object_id.clone();
|
||||
let object_id_for_log = object_id.clone();
|
||||
|
||||
@@ -1407,15 +1704,15 @@ async fn add_to_queue(
|
||||
tag = "control"
|
||||
)]
|
||||
async fn list_servers(State(state): State<ControlPointState>) -> Json<Vec<MediaServerSummary>> {
|
||||
let servers = state.control_point.list_media_servers().unwrap_or_default();
|
||||
let servers = state.control_point.list_media_servers();
|
||||
|
||||
let summaries: Vec<MediaServerSummary> = servers
|
||||
.into_iter()
|
||||
.map(|s| MediaServerSummary {
|
||||
id: s.id().0.clone(),
|
||||
friendly_name: s.friendly_name().to_string(),
|
||||
model_name: s.model_name().to_string(),
|
||||
online: s.is_online(),
|
||||
id: s.id.0,
|
||||
friendly_name: s.friendly_name,
|
||||
model_name: s.model_name,
|
||||
online: s.online,
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -1442,9 +1739,9 @@ async fn browse_container(
|
||||
State(state): State<ControlPointState>,
|
||||
Path((server_id, container_id)): Path<(String, String)>,
|
||||
) -> Result<Json<BrowseResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let sid = DeviceId(server_id.clone());
|
||||
let sid = ServerId(server_id.clone());
|
||||
|
||||
let server = state.control_point.media_server(&sid).ok_or_else(|| {
|
||||
let server_info = state.control_point.media_server(&sid).ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
@@ -1453,7 +1750,7 @@ async fn browse_container(
|
||||
)
|
||||
})?;
|
||||
|
||||
if !server.is_online() {
|
||||
if !server_info.online {
|
||||
return Err((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ErrorResponse {
|
||||
@@ -1462,7 +1759,7 @@ async fn browse_container(
|
||||
));
|
||||
}
|
||||
|
||||
if !server.has_content_directory() {
|
||||
if !server_info.has_content_directory {
|
||||
return Err((
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
Json(ErrorResponse {
|
||||
@@ -1471,11 +1768,21 @@ async fn browse_container(
|
||||
));
|
||||
}
|
||||
|
||||
let music_server =
|
||||
MusicServer::from_info(&server_info, MEDIA_SERVER_SOAP_TIMEOUT).map_err(|e| {
|
||||
warn!("Failed to create MusicServer for {}: {}", server_id, e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Failed to initialize server: {}", e),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Use spawn_blocking to avoid blocking the async runtime with synchronous SOAP calls
|
||||
let container_id_clone = container_id.clone();
|
||||
let server_clone = server.clone();
|
||||
let browse_task = tokio::task::spawn_blocking(move || {
|
||||
server_clone.browse_children(&container_id_clone, 0, BROWSE_PAGE_SIZE)
|
||||
music_server.browse_children(&container_id_clone, 0, BROWSE_PAGE_SIZE)
|
||||
});
|
||||
|
||||
let entries = time::timeout(BROWSE_REQUEST_TIMEOUT, browse_task)
|
||||
@@ -1559,6 +1866,32 @@ fn map_snapshot_error(
|
||||
)
|
||||
}
|
||||
|
||||
#[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).
|
||||
///
|
||||
/// This function browses the server to get the entries and converts them to PlaybackItem.
|
||||
@@ -1566,53 +1899,74 @@ fn map_snapshot_error(
|
||||
#[cfg(feature = "pmoserver")]
|
||||
fn fetch_playback_items(
|
||||
control_point: &ControlPoint,
|
||||
server_id: &DeviceId,
|
||||
server_id: &ServerId,
|
||||
object_id: &str,
|
||||
) -> anyhow::Result<Vec<PlaybackItem>> {
|
||||
// Get server from registry
|
||||
let server = control_point
|
||||
// Get server info from registry
|
||||
let server_info = control_point
|
||||
.media_server(server_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Server {} not found", server_id.0))?;
|
||||
|
||||
if !server.is_online() {
|
||||
if !server_info.online {
|
||||
return Err(anyhow::anyhow!("Server {} is offline", server_id.0));
|
||||
}
|
||||
|
||||
if !server.has_content_directory() {
|
||||
if !server_info.has_content_directory {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Server {} does not support ContentDirectory",
|
||||
server_id.0
|
||||
));
|
||||
}
|
||||
|
||||
// Browse the object to get entries
|
||||
let entries = server.browse_children(object_id, 0, BROWSE_PAGE_SIZE)?;
|
||||
// Create MusicServer
|
||||
let music_server = MusicServer::from_info(&server_info, MEDIA_SERVER_SOAP_TIMEOUT)?;
|
||||
|
||||
debug!(
|
||||
server_id = server_id.0.as_str(),
|
||||
object_id = object_id,
|
||||
total_entries = entries.len(),
|
||||
containers = entries.iter().filter(|e| e.is_container).count(),
|
||||
items_count = entries.iter().filter(|e| !e.is_container).count(),
|
||||
"Browse returned entries"
|
||||
);
|
||||
// Browse the object to get entries
|
||||
let entries = music_server.browse_children(object_id, 0, BROWSE_PAGE_SIZE)?;
|
||||
|
||||
// Convert to PlaybackItem
|
||||
let items: Vec<PlaybackItem> = entries
|
||||
.iter()
|
||||
.filter_map(|entry| playback_item_from_entry(server.clone(), entry))
|
||||
.filter_map(|entry| playback_item_from_entry(&music_server, entry))
|
||||
.collect();
|
||||
|
||||
if items.is_empty() && !entries.is_empty() {
|
||||
warn!(
|
||||
server_id = server_id.0.as_str(),
|
||||
object_id = object_id,
|
||||
total_entries = entries.len(),
|
||||
"No playable items found - all entries were filtered out"
|
||||
);
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// Helper to convert a MediaEntry to a PlaybackItem.
|
||||
#[cfg(feature = "pmoserver")]
|
||||
fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option<PlaybackItem> {
|
||||
// Ignore containers
|
||||
if entry.is_container {
|
||||
return None;
|
||||
}
|
||||
|
||||
Ok(items)
|
||||
// Skip "live stream" entries
|
||||
if entry.title.to_ascii_lowercase().contains("live stream") {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Find an audio resource
|
||||
let resource = entry.resources.iter().find(|res| res.is_audio())?;
|
||||
|
||||
let metadata = TrackMetadata {
|
||||
title: Some(entry.title.clone()),
|
||||
artist: entry.artist.clone(),
|
||||
album: entry.album.clone(),
|
||||
genre: entry.genre.clone(),
|
||||
album_art_uri: entry.album_art_uri.clone(),
|
||||
date: entry.date.clone(),
|
||||
track_number: entry.track_number.clone(),
|
||||
creator: entry.creator.clone(),
|
||||
};
|
||||
|
||||
Some(PlaybackItem {
|
||||
media_server_id: server.id().clone(),
|
||||
didl_id: entry.id.clone(),
|
||||
uri: resource.uri.clone(),
|
||||
protocol_info: resource.protocol_info.clone(),
|
||||
metadata: Some(metadata),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
@@ -1620,7 +1974,7 @@ fn protocol_summary(protocol: &RendererProtocol) -> RendererProtocolSummary {
|
||||
match protocol {
|
||||
RendererProtocol::UpnpAvOnly => RendererProtocolSummary::Upnp,
|
||||
RendererProtocol::OpenHomeOnly => RendererProtocolSummary::Openhome,
|
||||
RendererProtocol::OpenHomeHybrid => RendererProtocolSummary::Hybrid,
|
||||
RendererProtocol::Hybrid => RendererProtocolSummary::Hybrid,
|
||||
RendererProtocol::ChromecastOnly => RendererProtocolSummary::Chromecast,
|
||||
}
|
||||
}
|
||||
@@ -1670,10 +2024,7 @@ pub fn create_api_router(state: ControlPointState, control_point: Arc<ControlPoi
|
||||
.route("/renderers/{renderer_id}/resume", post(resume_renderer))
|
||||
.route("/renderers/{renderer_id}/next", post(next_renderer))
|
||||
// Queue control
|
||||
.route(
|
||||
"/renderers/{renderer_id}/queue/seek",
|
||||
post(seek_queue_index),
|
||||
)
|
||||
.route("/renderers/{renderer_id}/queue/seek", post(seek_queue_index))
|
||||
// Volume control
|
||||
.route(
|
||||
"/renderers/{renderer_id}/volume/set",
|
||||
@@ -1700,6 +2051,23 @@ pub fn create_api_router(state: ControlPointState, control_point: Arc<ControlPoi
|
||||
"/renderers/{renderer_id}/binding/detach",
|
||||
post(detach_playlist_binding),
|
||||
)
|
||||
// OpenHome playlist
|
||||
.route(
|
||||
"/renderers/{renderer_id}/oh/playlist",
|
||||
get(get_openhome_playlist),
|
||||
)
|
||||
.route(
|
||||
"/renderers/{renderer_id}/oh/playlist/clear",
|
||||
post(clear_openhome_playlist),
|
||||
)
|
||||
.route(
|
||||
"/renderers/{renderer_id}/oh/playlist/add",
|
||||
post(add_openhome_playlist_item),
|
||||
)
|
||||
.route(
|
||||
"/renderers/{renderer_id}/oh/playlist/play/{track_id}",
|
||||
post(play_openhome_track),
|
||||
)
|
||||
// Queue content
|
||||
.route("/renderers/{renderer_id}/queue/play", post(play_content))
|
||||
.route("/renderers/{renderer_id}/queue/add", post(add_to_queue))
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
use std::io::BufReader;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use quick_xml::{Error as XmlError, Reader, events::Event};
|
||||
use quick_xml::{events::Event, Error as XmlError, Reader};
|
||||
use thiserror::Error;
|
||||
use tracing::{debug};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::DeviceId;
|
||||
use crate::discovery::arylic::detect_arylic_tcp;
|
||||
use crate::linkplay_client::{extract_linkplay_host, fetch_status_for_host};
|
||||
use crate::media_server::UpnpMediaServer;
|
||||
use crate::model::{RendererCapabilities, RendererInfo, RendererProtocol};
|
||||
use crate::upnp_clients::{AvTransportClient,resolve_control_url};
|
||||
use crate::arylic_tcp::detect_arylic_tcp;
|
||||
use crate::avtransport_client::AvTransportClient;
|
||||
use crate::discovery::{DeviceDescriptionProvider, DiscoveredEndpoint};
|
||||
use crate::linkplay::detect_linkplay_http;
|
||||
use crate::media_server::{MediaServerInfo, ServerId};
|
||||
use crate::model::{RendererCapabilities, RendererId, RendererInfo, RendererProtocol};
|
||||
|
||||
use ureq::Agent;
|
||||
|
||||
@@ -31,11 +31,8 @@ pub enum DescriptionError {
|
||||
|
||||
/// Parsed device description, plus (optionally) AVTransport endpoint.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ParsedDeviceDescription {
|
||||
timeout_secs: u64,
|
||||
udn: String,
|
||||
location: String,
|
||||
server_header: String,
|
||||
struct ParsedDeviceDescription {
|
||||
udn: Option<String>,
|
||||
device_type: Option<String>,
|
||||
friendly_name: Option<String>,
|
||||
manufacturer: Option<String>,
|
||||
@@ -77,23 +74,47 @@ pub struct ParsedDeviceDescription {
|
||||
}
|
||||
|
||||
impl ParsedDeviceDescription {
|
||||
fn require_fields(self) -> Result<Self, DescriptionError> {
|
||||
if self.device_type.is_none() {
|
||||
return Err(DescriptionError::MissingField("deviceType"));
|
||||
}
|
||||
if self.friendly_name.is_none() {
|
||||
return Err(DescriptionError::MissingField("friendlyName"));
|
||||
}
|
||||
if self.model_name.is_none() {
|
||||
return Err(DescriptionError::MissingField("modelName"));
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP-based XML description provider (UPnP device description.xml)
|
||||
pub struct HttpXmlDescriptionProvider {
|
||||
timeout_secs: u64,
|
||||
}
|
||||
|
||||
impl HttpXmlDescriptionProvider {
|
||||
pub fn new(timeout_secs: u64) -> Self {
|
||||
Self { timeout_secs }
|
||||
}
|
||||
|
||||
/// Fetch and parse the device description.xml at endpoint.location.
|
||||
pub fn new(
|
||||
udn: &str,
|
||||
location: &str,
|
||||
server_header: &str,
|
||||
timeout_secs: u64,
|
||||
) -> Result<Self, DescriptionError> {
|
||||
debug!("Fetching description for {} at {}", udn, location);
|
||||
fn fetch_and_parse(
|
||||
&self,
|
||||
endpoint: &DiscoveredEndpoint,
|
||||
) -> Result<ParsedDeviceDescription, DescriptionError> {
|
||||
debug!(
|
||||
"Fetching description for {} at {}",
|
||||
endpoint.udn, endpoint.location
|
||||
);
|
||||
|
||||
let config = Agent::config_builder()
|
||||
.timeout_global(Some(std::time::Duration::from_secs(timeout_secs)))
|
||||
.timeout_global(Some(std::time::Duration::from_secs(self.timeout_secs)))
|
||||
.build();
|
||||
|
||||
let agent: Agent = config.into();
|
||||
|
||||
let response = agent.get(location).call()?;
|
||||
let response = agent.get(&endpoint.location).call()?;
|
||||
|
||||
// response: http::Response<ureq::Body>
|
||||
let (_parts, body) = response.into_parts();
|
||||
@@ -103,16 +124,14 @@ impl ParsedDeviceDescription {
|
||||
|
||||
let mut reader = Reader::from_reader(BufReader::new(body_reader));
|
||||
reader.config_mut().trim_text(true);
|
||||
debug!("Parsing description XML for {} at {}", udn, location);
|
||||
debug!(
|
||||
"Parsing description XML for {} at {}",
|
||||
endpoint.udn, endpoint.location
|
||||
);
|
||||
|
||||
let mut buf = Vec::new();
|
||||
let mut parsed = ParsedDeviceDescription::default();
|
||||
|
||||
parsed.timeout_secs=timeout_secs;
|
||||
parsed.location = location.to_string();
|
||||
parsed.udn = udn.to_string();
|
||||
parsed.server_header = server_header.to_string();
|
||||
|
||||
let mut in_device = false;
|
||||
let mut in_service = false;
|
||||
let mut current_tag: Option<String> = None;
|
||||
@@ -168,7 +187,7 @@ impl ParsedDeviceDescription {
|
||||
parsed.avtransport_control_url = Some(ctrl.clone());
|
||||
debug!(
|
||||
"Found AVTransport service for {}: type={} controlURL={}",
|
||||
udn, st, ctrl
|
||||
endpoint.udn, st, ctrl
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -183,7 +202,7 @@ impl ParsedDeviceDescription {
|
||||
Some(ctrl.clone());
|
||||
debug!(
|
||||
"Found RenderingControl service for {}: type={} controlURL={}",
|
||||
udn, st, ctrl
|
||||
endpoint.udn, st, ctrl
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -198,7 +217,7 @@ impl ParsedDeviceDescription {
|
||||
Some(ctrl.clone());
|
||||
debug!(
|
||||
"Found ConnectionManager service for {}: type={} controlURL={}",
|
||||
udn, st, ctrl
|
||||
endpoint.udn, st, ctrl
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -213,7 +232,7 @@ impl ParsedDeviceDescription {
|
||||
Some(ctrl.clone());
|
||||
debug!(
|
||||
"Found ContentDirectory service for {}: type={} controlURL={}",
|
||||
udn, st, ctrl
|
||||
endpoint.udn, st, ctrl
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -228,7 +247,7 @@ impl ParsedDeviceDescription {
|
||||
}
|
||||
debug!(
|
||||
"Found OpenHome Playlist for {}: type={} controlURL={}",
|
||||
udn, st, ctrl
|
||||
endpoint.udn, st, ctrl
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -243,7 +262,7 @@ impl ParsedDeviceDescription {
|
||||
}
|
||||
debug!(
|
||||
"Found OpenHome Info for {}: type={} controlURL={}",
|
||||
udn, st, ctrl
|
||||
endpoint.udn, st, ctrl
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -258,7 +277,7 @@ impl ParsedDeviceDescription {
|
||||
}
|
||||
debug!(
|
||||
"Found OpenHome Time for {}: type={} controlURL={}",
|
||||
udn, st, ctrl
|
||||
endpoint.udn, st, ctrl
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -269,7 +288,7 @@ impl ParsedDeviceDescription {
|
||||
parsed.oh_volume_control_url = Some(ctrl.clone());
|
||||
debug!(
|
||||
"Found OpenHome Volume for {}: type={} controlURL={}",
|
||||
udn, st, ctrl
|
||||
endpoint.udn, st, ctrl
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -280,7 +299,7 @@ impl ParsedDeviceDescription {
|
||||
parsed.oh_radio_control_url = Some(ctrl.clone());
|
||||
debug!(
|
||||
"Found OpenHome Radio for {}: type={} controlURL={}",
|
||||
udn, st, ctrl
|
||||
endpoint.udn, st, ctrl
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -291,7 +310,7 @@ impl ParsedDeviceDescription {
|
||||
parsed.oh_product_control_url = Some(ctrl.clone());
|
||||
debug!(
|
||||
"Found OpenHome Product for {}: type={} controlURL={}",
|
||||
udn, st, ctrl
|
||||
endpoint.udn, st, ctrl
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -315,7 +334,7 @@ impl ParsedDeviceDescription {
|
||||
|
||||
match tag.as_str() {
|
||||
"UDN" => {
|
||||
parsed.udn = text;
|
||||
parsed.udn = Some(text);
|
||||
}
|
||||
"deviceType" => {
|
||||
parsed.device_type = Some(text);
|
||||
@@ -354,274 +373,188 @@ impl ParsedDeviceDescription {
|
||||
parsed.require_fields()
|
||||
}
|
||||
|
||||
pub fn build_renderer(
|
||||
fn build_renderer(
|
||||
&self,
|
||||
endpoint: &DiscoveredEndpoint,
|
||||
parsed: &ParsedDeviceDescription,
|
||||
) -> Option<RendererInfo> {
|
||||
let device_type = self.device_type.as_ref()?.to_ascii_lowercase();
|
||||
let device_type = parsed.device_type.as_ref()?.to_ascii_lowercase();
|
||||
if !device_type.contains("urn:schemas-upnp-org:device:mediarenderer:")
|
||||
&& !device_type.contains("urn:av-openhome-org:device:mediarenderer:")
|
||||
&& !device_type.contains("urn:av-openhome-org:device:source:")
|
||||
{
|
||||
debug!(
|
||||
"build_renderer: ignoring deviceType for {}: {}",
|
||||
self.udn, device_type
|
||||
endpoint.udn, device_type
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
let udn = self.udn.to_ascii_lowercase();
|
||||
let mut caps = detect_renderer_capabilities(&self.service_types);
|
||||
if detect_linkplay_http(&self.location, Duration::from_secs(self.timeout_secs.max(1))) {
|
||||
let raw_udn = parsed
|
||||
.udn
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| endpoint.udn.as_str());
|
||||
let udn = raw_udn.to_ascii_lowercase();
|
||||
let mut caps = detect_renderer_capabilities(&parsed.service_types);
|
||||
if detect_linkplay_http(
|
||||
&endpoint.location,
|
||||
Duration::from_secs(self.timeout_secs.max(1)),
|
||||
) {
|
||||
caps.has_linkplay_http = true;
|
||||
}
|
||||
if detect_arylic_tcp(&self.location, Duration::from_secs(self.timeout_secs.max(1))) {
|
||||
if detect_arylic_tcp(
|
||||
&endpoint.location,
|
||||
Duration::from_secs(self.timeout_secs.max(1)),
|
||||
) {
|
||||
caps.has_arylic_tcp = true;
|
||||
}
|
||||
let protocol = detect_renderer_protocol(&caps);
|
||||
let now = SystemTime::now();
|
||||
|
||||
Some(RendererInfo::make(
|
||||
DeviceId(udn.clone()),
|
||||
Some(RendererInfo {
|
||||
id: RendererId(udn.clone()),
|
||||
udn,
|
||||
self.friendly_name.clone().unwrap_or_default(),
|
||||
self.model_name.clone().unwrap_or_default(),
|
||||
self.manufacturer.clone().unwrap_or_default(),
|
||||
friendly_name: parsed.friendly_name.clone().unwrap_or_default(),
|
||||
model_name: parsed.model_name.clone().unwrap_or_default(),
|
||||
manufacturer: parsed.manufacturer.clone().unwrap_or_default(),
|
||||
protocol,
|
||||
caps,
|
||||
self.location.clone(),
|
||||
self.server_header.clone(),
|
||||
self.avtransport_service_type.clone(),
|
||||
self
|
||||
capabilities: caps,
|
||||
location: endpoint.location.clone(),
|
||||
server_header: endpoint.server_header.clone(),
|
||||
online: true,
|
||||
last_seen: now,
|
||||
max_age: endpoint.max_age,
|
||||
avtransport_service_type: parsed.avtransport_service_type.clone(),
|
||||
avtransport_control_url: parsed
|
||||
.avtransport_control_url
|
||||
.as_ref()
|
||||
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
||||
self.rendering_control_service_type.clone(),
|
||||
self
|
||||
.map(|ctrl| resolve_control_url(&endpoint.location, ctrl)),
|
||||
rendering_control_service_type: parsed.rendering_control_service_type.clone(),
|
||||
rendering_control_control_url: parsed
|
||||
.rendering_control_control_url
|
||||
.as_ref()
|
||||
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
||||
self.connection_manager_service_type.clone(),
|
||||
self
|
||||
.map(|ctrl| resolve_control_url(&endpoint.location, ctrl)),
|
||||
connection_manager_service_type: parsed.connection_manager_service_type.clone(),
|
||||
connection_manager_control_url: parsed
|
||||
.connection_manager_control_url
|
||||
.as_ref()
|
||||
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
||||
self.oh_playlist_service_type.clone(),
|
||||
self
|
||||
.map(|ctrl| resolve_control_url(&endpoint.location, ctrl)),
|
||||
oh_playlist_service_type: parsed.oh_playlist_service_type.clone(),
|
||||
oh_playlist_control_url: parsed
|
||||
.oh_playlist_control_url
|
||||
.as_ref()
|
||||
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
||||
self
|
||||
.map(|ctrl| resolve_control_url(&endpoint.location, ctrl)),
|
||||
oh_playlist_event_sub_url: parsed
|
||||
.oh_playlist_event_sub_url
|
||||
.as_ref()
|
||||
.map(|url| resolve_control_url(&self.location, url)),
|
||||
self.oh_info_service_type.clone(),
|
||||
self
|
||||
.map(|url| resolve_control_url(&endpoint.location, url)),
|
||||
oh_info_service_type: parsed.oh_info_service_type.clone(),
|
||||
oh_info_control_url: parsed
|
||||
.oh_info_control_url
|
||||
.as_ref()
|
||||
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
||||
self
|
||||
.map(|ctrl| resolve_control_url(&endpoint.location, ctrl)),
|
||||
oh_info_event_sub_url: parsed
|
||||
.oh_info_event_sub_url
|
||||
.as_ref()
|
||||
.map(|url| resolve_control_url(&self.location, url)),
|
||||
self.oh_time_service_type.clone(),
|
||||
self
|
||||
.map(|url| resolve_control_url(&endpoint.location, url)),
|
||||
oh_time_service_type: parsed.oh_time_service_type.clone(),
|
||||
oh_time_control_url: parsed
|
||||
.oh_time_control_url
|
||||
.as_ref()
|
||||
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
||||
self
|
||||
.map(|ctrl| resolve_control_url(&endpoint.location, ctrl)),
|
||||
oh_time_event_sub_url: parsed
|
||||
.oh_time_event_sub_url
|
||||
.as_ref()
|
||||
.map(|url| resolve_control_url(&self.location, url)),
|
||||
self.oh_volume_service_type.clone(),
|
||||
self
|
||||
.map(|url| resolve_control_url(&endpoint.location, url)),
|
||||
oh_volume_service_type: parsed.oh_volume_service_type.clone(),
|
||||
oh_volume_control_url: parsed
|
||||
.oh_volume_control_url
|
||||
.as_ref()
|
||||
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
||||
self.oh_radio_service_type.clone(),
|
||||
self
|
||||
.map(|ctrl| resolve_control_url(&endpoint.location, ctrl)),
|
||||
oh_radio_service_type: parsed.oh_radio_service_type.clone(),
|
||||
oh_radio_control_url: parsed
|
||||
.oh_radio_control_url
|
||||
.as_ref()
|
||||
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
||||
self.oh_product_service_type.clone(),
|
||||
self
|
||||
.map(|ctrl| resolve_control_url(&endpoint.location, ctrl)),
|
||||
oh_product_service_type: parsed.oh_product_service_type.clone(),
|
||||
oh_product_control_url: parsed
|
||||
.oh_product_control_url
|
||||
.as_ref()
|
||||
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
|
||||
))
|
||||
.map(|ctrl| resolve_control_url(&endpoint.location, ctrl)),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_server(
|
||||
fn build_server(
|
||||
&self,
|
||||
) -> Option<UpnpMediaServer> {
|
||||
let device_type = self.device_type.as_ref()?.to_ascii_lowercase();
|
||||
endpoint: &DiscoveredEndpoint,
|
||||
parsed: &ParsedDeviceDescription,
|
||||
) -> Option<MediaServerInfo> {
|
||||
let device_type = parsed.device_type.as_ref()?.to_ascii_lowercase();
|
||||
if !device_type.contains("urn:schemas-upnp-org:device:mediaserver:") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let udn = self.udn.to_ascii_lowercase();
|
||||
let has_content_directory = self.service_types.iter().any(|st| {
|
||||
let raw_udn = parsed
|
||||
.udn
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| endpoint.udn.as_str());
|
||||
let udn = raw_udn.to_ascii_lowercase();
|
||||
let has_content_directory = parsed.service_types.iter().any(|st| {
|
||||
st.to_ascii_lowercase()
|
||||
.contains("urn:schemas-upnp-org:service:contentdirectory:")
|
||||
});
|
||||
let now = SystemTime::now();
|
||||
|
||||
let content_directory_control_url = self
|
||||
let content_directory_control_url = parsed
|
||||
.content_directory_control_url
|
||||
.as_ref()
|
||||
.map(|ctrl| resolve_control_url(&self.location, ctrl));
|
||||
.map(|ctrl| resolve_control_url(&endpoint.location, ctrl));
|
||||
|
||||
Some(UpnpMediaServer::new(
|
||||
DeviceId(udn.clone()),
|
||||
Some(MediaServerInfo {
|
||||
id: ServerId(udn.clone()),
|
||||
udn,
|
||||
self.friendly_name.clone().unwrap_or_default(),
|
||||
self.model_name.clone().unwrap_or_default(),
|
||||
self.manufacturer.clone().unwrap_or_default(),
|
||||
self.location.clone(),
|
||||
self.server_header.clone(),
|
||||
friendly_name: parsed.friendly_name.clone().unwrap_or_default(),
|
||||
model_name: parsed.model_name.clone().unwrap_or_default(),
|
||||
manufacturer: parsed.manufacturer.clone().unwrap_or_default(),
|
||||
location: endpoint.location.clone(),
|
||||
server_header: endpoint.server_header.clone(),
|
||||
online: true,
|
||||
last_seen: now,
|
||||
max_age: endpoint.max_age,
|
||||
has_content_directory,
|
||||
self.content_directory_service_type.clone(),
|
||||
content_directory_service_type: parsed.content_directory_service_type.clone(),
|
||||
content_directory_control_url,
|
||||
))
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
/// New helper: build an AvTransportClient directly from a discovered endpoint.
|
||||
///
|
||||
/// Returns Ok(Some(client)) if an AVTransport service with a controlURL is present,
|
||||
/// Ok(None) if no AVTransport service was found.
|
||||
pub fn build_avtransport_client(
|
||||
&self,
|
||||
endpoint: &DiscoveredEndpoint,
|
||||
) -> Result<Option<AvTransportClient>, DescriptionError> {
|
||||
let service_type = match &self.avtransport_service_type {
|
||||
let parsed = self.fetch_and_parse(endpoint)?;
|
||||
|
||||
let service_type = match &parsed.avtransport_service_type {
|
||||
Some(st) => st.clone(),
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let raw_control = match &self.avtransport_control_url {
|
||||
let raw_control = match &parsed.avtransport_control_url {
|
||||
Some(ctrl) => ctrl.clone(),
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let control_url = resolve_control_url(&self.location, &raw_control);
|
||||
let control_url = resolve_control_url(&endpoint.location, &raw_control);
|
||||
debug!(
|
||||
"AVTransport client for {}: service_type={} control_url={}",
|
||||
&self.udn, service_type, control_url
|
||||
endpoint.udn, service_type, control_url
|
||||
);
|
||||
|
||||
Ok(Some(AvTransportClient::new(control_url, service_type)))
|
||||
}
|
||||
|
||||
fn require_fields(self) -> Result<Self, DescriptionError> {
|
||||
if self.device_type.is_none() {
|
||||
return Err(DescriptionError::MissingField("deviceType"));
|
||||
}
|
||||
if self.friendly_name.is_none() {
|
||||
return Err(DescriptionError::MissingField("friendlyName"));
|
||||
}
|
||||
if self.model_name.is_none() {
|
||||
return Err(DescriptionError::MissingField("modelName"));
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
pub fn udn(&self) -> Option<String> {
|
||||
Some(self.udn.clone())
|
||||
}
|
||||
pub fn device_type(&self) -> Option<String> {
|
||||
self.device_type.clone()
|
||||
}
|
||||
pub fn friendly_name(&self) -> Option<String> {
|
||||
self.friendly_name.clone()
|
||||
}
|
||||
pub fn manufacturer(&self) -> Option<String> {
|
||||
self.manufacturer.clone()
|
||||
}
|
||||
pub fn model_name(&self) -> Option<String> {
|
||||
self.model_name.clone()
|
||||
}
|
||||
pub fn service_types(&self) -> Vec<String> {
|
||||
self.service_types.clone()
|
||||
}
|
||||
|
||||
// New: AVTransport endpoint (if present in serviceList)
|
||||
pub fn avtransport_service_type(&self) -> Option<String> {
|
||||
self.avtransport_service_type.clone()
|
||||
}
|
||||
pub fn avtransport_control_url(&self) -> Option<String> {
|
||||
self.avtransport_control_url.clone()
|
||||
}
|
||||
|
||||
// RenderingControl endpoint (if present in serviceList)
|
||||
pub fn rendering_control_service_type(&self) -> Option<String> {
|
||||
self.rendering_control_service_type.clone()
|
||||
}
|
||||
pub fn rendering_control_control_url(&self) -> Option<String> {
|
||||
self.rendering_control_control_url.clone()
|
||||
}
|
||||
|
||||
// ConnectionManager endpoint (if present in serviceList)
|
||||
pub fn connection_manager_service_type(&self) -> Option<String> {
|
||||
self.connection_manager_service_type.clone()
|
||||
}
|
||||
pub fn connection_manager_control_url(&self) -> Option<String> {
|
||||
self.connection_manager_control_url.clone()
|
||||
}
|
||||
|
||||
// ContentDirectory endpoint (if present in serviceList)
|
||||
pub fn content_directory_service_type(&self) -> Option<String> {
|
||||
self.content_directory_service_type.clone()
|
||||
}
|
||||
pub fn content_directory_control_url(&self) -> Option<String> {
|
||||
self.content_directory_control_url.clone()
|
||||
}
|
||||
|
||||
// OpenHome endpoints (if present in serviceList)
|
||||
pub fn oh_playlist_service_type(&self) -> Option<String> {
|
||||
self.oh_playlist_service_type.clone()
|
||||
}
|
||||
pub fn oh_playlist_control_url(&self) -> Option<String> {
|
||||
self.oh_playlist_control_url.clone()
|
||||
}
|
||||
pub fn oh_playlist_event_sub_url(&self) -> Option<String> {
|
||||
self.oh_playlist_event_sub_url.clone()
|
||||
}
|
||||
pub fn oh_info_service_type(&self) -> Option<String> {
|
||||
self.oh_info_service_type.clone()
|
||||
}
|
||||
pub fn oh_info_control_url(&self) -> Option<String> {
|
||||
self.oh_info_control_url.clone()
|
||||
}
|
||||
pub fn oh_info_event_sub_url(&self) -> Option<String> {
|
||||
self.oh_info_event_sub_url.clone()
|
||||
}
|
||||
pub fn oh_time_service_type(&self) -> Option<String> {
|
||||
self.oh_time_service_type.clone()
|
||||
}
|
||||
pub fn oh_time_control_url(&self) -> Option<String> {
|
||||
self.oh_time_control_url.clone()
|
||||
}
|
||||
pub fn oh_time_event_sub_url(&self) -> Option<String> {
|
||||
self.oh_time_event_sub_url.clone()
|
||||
}
|
||||
pub fn oh_volume_service_type(&self) -> Option<String> {
|
||||
self.oh_volume_service_type.clone()
|
||||
}
|
||||
pub fn oh_volume_control_url(&self) -> Option<String> {
|
||||
self.oh_volume_control_url.clone()
|
||||
}
|
||||
pub fn oh_radio_service_type(&self) -> Option<String> {
|
||||
self.oh_radio_service_type.clone()
|
||||
}
|
||||
pub fn oh_radio_control_url(&self) -> Option<String> {
|
||||
self.oh_radio_control_url.clone()
|
||||
}
|
||||
pub fn oh_product_service_type(&self) -> Option<String> {
|
||||
self.oh_product_service_type.clone()
|
||||
}
|
||||
pub fn oh_product_control_url(&self) -> Option<String> {
|
||||
self.oh_product_control_url.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP-based XML description provider (UPnP device description.xml)
|
||||
pub struct HttpXmlDescriptionProvider {
|
||||
timeout_secs: u64,
|
||||
}
|
||||
|
||||
// --- capabilities detection unchanged ---
|
||||
@@ -671,30 +604,98 @@ fn detect_renderer_protocol(caps: &RendererCapabilities) -> RendererProtocol {
|
||||
|| caps.has_oh_radio;
|
||||
|
||||
match (has_upnp_av, has_openhome) {
|
||||
(true, true) => RendererProtocol::OpenHomeHybrid,
|
||||
(true, true) => RendererProtocol::Hybrid,
|
||||
(true, false) => RendererProtocol::UpnpAvOnly,
|
||||
(false, true) => RendererProtocol::OpenHomeOnly,
|
||||
(false, false) => RendererProtocol::UpnpAvOnly,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a possibly relative controlURL against the description URL.
|
||||
///
|
||||
/// - If `control_url` is already absolute (starts with http:// or https://), it is returned as-is.
|
||||
/// - Otherwise, it is resolved against the scheme://host:port of `description_url`.
|
||||
pub(crate) fn resolve_control_url(description_url: &str, control_url: &str) -> String {
|
||||
if control_url.starts_with("http://") || control_url.starts_with("https://") {
|
||||
return control_url.to_string();
|
||||
}
|
||||
|
||||
// Extract "scheme://host[:port]" from description_url
|
||||
if let Some((scheme, rest)) = description_url.split_once("://") {
|
||||
if let Some(pos) = rest.find('/') {
|
||||
let authority = &rest[..pos];
|
||||
let base = format!("{}://{}", scheme, authority);
|
||||
|
||||
|
||||
/// Detect whether a renderer exposes the LinkPlay HTTP API.
|
||||
pub fn detect_linkplay_http(location: &str, timeout: Duration) -> bool {
|
||||
let Some(host) = extract_linkplay_host(location) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
match fetch_status_for_host(&host, timeout) {
|
||||
Ok(_) => true,
|
||||
Err(err) => {
|
||||
debug!(
|
||||
"LinkPlay detection failed for {} (host={}): {}",
|
||||
location, host, err
|
||||
);
|
||||
false
|
||||
if control_url.starts_with('/') {
|
||||
return format!("{}{}", base, control_url);
|
||||
} else {
|
||||
return format!("{}/{}", base, control_url);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: just return the raw control_url if we cannot parse
|
||||
control_url.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::resolve_control_url;
|
||||
|
||||
#[test]
|
||||
fn resolves_relative_path_against_description() {
|
||||
let base = "http://192.0.2.10:49152/device.xml";
|
||||
let control = "/upnp/control/playlist";
|
||||
let resolved = resolve_control_url(base, control);
|
||||
assert_eq!(resolved, "http://192.0.2.10:49152/upnp/control/playlist");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_absolute_url_untouched() {
|
||||
let url = "http://renderer.local:1400/MediaRenderer/Control";
|
||||
let resolved = resolve_control_url("http://example.invalid/device.xml", url);
|
||||
assert_eq!(resolved, url);
|
||||
}
|
||||
}
|
||||
|
||||
impl DeviceDescriptionProvider for HttpXmlDescriptionProvider {
|
||||
fn build_renderer_info(&self, endpoint: &DiscoveredEndpoint) -> Option<RendererInfo> {
|
||||
match self.fetch_and_parse(endpoint) {
|
||||
Ok(parsed) => {
|
||||
let device_type = parsed.device_type.as_deref().unwrap_or("unknown");
|
||||
debug!(
|
||||
"Renderer description OK for {} at {} (deviceType={})",
|
||||
endpoint.udn, endpoint.location, device_type
|
||||
);
|
||||
self.build_renderer(endpoint, &parsed)
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"Failed to fetch/parse renderer description for {} at {}: {}",
|
||||
endpoint.udn, endpoint.location, err
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_server_info(&self, endpoint: &DiscoveredEndpoint) -> Option<MediaServerInfo> {
|
||||
match self.fetch_and_parse(endpoint) {
|
||||
Ok(parsed) => {
|
||||
let device_type = parsed.device_type.as_deref().unwrap_or("unknown");
|
||||
debug!(
|
||||
"Server description OK for {} at {} (deviceType={})",
|
||||
endpoint.udn, endpoint.location, device_type
|
||||
);
|
||||
self.build_server(endpoint, &parsed)
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"Failed to fetch/parse server description for {} at {}: {}",
|
||||
endpoint.udn, endpoint.location, err
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,328 +0,0 @@
|
||||
//! Internal (local) queue implementation for PMOControl.
|
||||
//!
|
||||
//! This module provides a concrete implementation of the generic
|
||||
//! `QueueBackend` trait for queues that are fully managed inside the
|
||||
//! ControlPoint, without delegating playlist management to a remote
|
||||
//! backend (like OpenHome).
|
||||
//!
|
||||
//! In this design, each queue instance is associated to exactly one
|
||||
//! renderer. The queue does not need to know the renderer identifier:
|
||||
//! it is "bound" to the renderer by construction, and will be stored
|
||||
//! directly in the runtime (inside a higher-level `MusicQueue` enum).
|
||||
//!
|
||||
//! This internal queue:
|
||||
//! - owns its list of `PlaybackItem`s,
|
||||
//! - maintains a `current_index`,
|
||||
//! - never starts playback (transport control is handled elsewhere).
|
||||
|
||||
|
||||
use crate::{
|
||||
DeviceId, DeviceIdentity, RendererInfo,
|
||||
errors::ControlPointError,
|
||||
queue::{MusicQueue, PlaybackItem, QueueBackend, QueueFromRendererInfo, QueueSnapshot},
|
||||
};
|
||||
|
||||
/// Internal/local queue implementation.
|
||||
///
|
||||
/// This is the simplest possible queue backend:
|
||||
/// - a `Vec<PlaybackItem>`
|
||||
/// - plus an optional `current_index`.
|
||||
///
|
||||
/// It does not talk to any remote service. All operations are pure
|
||||
/// structural mutations on in-memory data.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct InternalQueue {
|
||||
renderer_id: DeviceId,
|
||||
items: Vec<PlaybackItem>,
|
||||
current_index: Option<usize>,
|
||||
}
|
||||
|
||||
impl InternalQueue {
|
||||
/// Creates an empty internal queue.
|
||||
pub fn new(renderer_id: DeviceId) -> Self {
|
||||
Self {
|
||||
renderer_id,
|
||||
items: Vec::new(),
|
||||
current_index: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_renderer_info(info: &RendererInfo) -> Result<InternalQueue, ControlPointError> {
|
||||
Ok(InternalQueue::new(info.id()))
|
||||
}
|
||||
|
||||
/// Exposes a read-only view of the underlying items.
|
||||
pub fn items(&self) -> &[PlaybackItem] {
|
||||
&self.items
|
||||
}
|
||||
}
|
||||
|
||||
impl QueueBackend for InternalQueue {
|
||||
fn len(&self) -> Result<usize, ControlPointError> {
|
||||
Ok(self.items.len())
|
||||
}
|
||||
|
||||
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
|
||||
let ids: Vec<u32> = (0..self.len()?).map(|i| i as u32).collect();
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
|
||||
Ok(id as usize)
|
||||
}
|
||||
|
||||
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
|
||||
u32::try_from(id).map_err(|_| {
|
||||
ControlPointError::QueueError(format!(
|
||||
"Position {} exceeds u32::MAX",
|
||||
id
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
|
||||
match self.current_index {
|
||||
None => Ok(None),
|
||||
Some(i) => {
|
||||
u32::try_from(i)
|
||||
.map(Some)
|
||||
.map_err(|_| {
|
||||
ControlPointError::QueueError(format!(
|
||||
"Current index {} exceeds u32::MAX",
|
||||
i
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
|
||||
Ok(self.current_index)
|
||||
}
|
||||
|
||||
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
||||
let mut items = self.items.clone();
|
||||
for (i, item) in items.iter_mut().enumerate() {
|
||||
item.backend_id = i;
|
||||
}
|
||||
|
||||
Ok(QueueSnapshot {
|
||||
items,
|
||||
current_index: self.current_index,
|
||||
})
|
||||
}
|
||||
|
||||
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
|
||||
match index {
|
||||
None => {
|
||||
self.current_index = None;
|
||||
}
|
||||
Some(i) => {
|
||||
if i < self.items.len() {
|
||||
self.current_index = Some(i);
|
||||
} else {
|
||||
return Err(ControlPointError::QueueError(format!(
|
||||
"Index out of bound {} >= {}",
|
||||
i,
|
||||
self.items.len()
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
current_index: Option<usize>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
self.items = items;
|
||||
self.current_index = current_index.filter(|&i| i < self.items.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>
|
||||
) -> Result<(), ControlPointError> {
|
||||
if items.is_empty() {
|
||||
return self.replace_queue(Vec::new(), None);
|
||||
}
|
||||
|
||||
// Récupérer l'item actuel
|
||||
let current = self.current_index
|
||||
.and_then(|idx| self.items.get(idx).map(|item| (idx, item.uri.clone())));
|
||||
|
||||
if let Some((_current_idx, current_uri)) = current {
|
||||
// Chercher l'item actuel dans la nouvelle liste (par URI)
|
||||
let new_idx = items.iter().position(|item| item.uri == current_uri);
|
||||
|
||||
if let Some(new_idx) = new_idx {
|
||||
// Item trouvé dans la nouvelle liste
|
||||
self.replace_queue(items, Some(new_idx))
|
||||
} else {
|
||||
// Item pas trouvé, le garder comme premier
|
||||
let current_item = self.items[self.current_index.unwrap()].clone();
|
||||
let mut new_items = Vec::with_capacity(items.len() + 1);
|
||||
new_items.push(current_item);
|
||||
new_items.extend(items);
|
||||
self.replace_queue(new_items, Some(0))
|
||||
}
|
||||
} else {
|
||||
// Pas d'item actuel
|
||||
self.replace_queue(items, None)
|
||||
}
|
||||
}
|
||||
|
||||
fn enqueue_items(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
mode: crate::queue::EnqueueMode,
|
||||
) -> Result<(), ControlPointError> {
|
||||
use crate::queue::EnqueueMode;
|
||||
|
||||
match mode {
|
||||
EnqueueMode::AppendToEnd => {
|
||||
self.items.extend(items);
|
||||
}
|
||||
EnqueueMode::InsertAfterCurrent => {
|
||||
let insert_pos = self.current_index
|
||||
.map(|i| (i + 1).min(self.items.len()))
|
||||
.unwrap_or(0);
|
||||
|
||||
for (offset, item) in items.into_iter().enumerate() {
|
||||
self.items.insert(insert_pos + offset, item);
|
||||
}
|
||||
}
|
||||
EnqueueMode::ReplaceAll => {
|
||||
self.items = items;
|
||||
self.current_index = None;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||
if index < self.items.len() {
|
||||
Ok(self.items.get(index).cloned())
|
||||
} else {
|
||||
Err(ControlPointError::QueueError(format!(
|
||||
"get_item index out of bound {} >= {}",
|
||||
index,
|
||||
self.items.len()
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
// Optimized helpers for InternalQueue
|
||||
fn clear_queue(&mut self) -> Result<(), ControlPointError> {
|
||||
self.items.clear();
|
||||
self.current_index = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> Result<bool, ControlPointError> {
|
||||
Ok(self.items.is_empty())
|
||||
}
|
||||
|
||||
fn upcoming_len(&self) -> Result<usize, ControlPointError> {
|
||||
let len = self.items.len();
|
||||
match self.current_index {
|
||||
None => Ok(len),
|
||||
Some(idx) => Ok(len.saturating_sub(idx + 1)),
|
||||
}
|
||||
}
|
||||
|
||||
fn upcoming_items(&self) -> Result<Vec<PlaybackItem>, ControlPointError> {
|
||||
let items = match self.current_index {
|
||||
None => self.items.clone(),
|
||||
Some(idx) => self.items.iter().skip(idx + 1).cloned().collect(),
|
||||
};
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
fn peek_current(&self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
||||
if self.items.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let len = self.items.len();
|
||||
let (item, resolved_index) = match self.current_index {
|
||||
Some(idx) if idx < len => (self.items.get(idx).cloned(), Some(idx)),
|
||||
_ => (self.items.first().cloned(), None),
|
||||
};
|
||||
|
||||
let item = match item {
|
||||
Some(item) => item,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let remaining = match resolved_index {
|
||||
Some(idx) => len.saturating_sub(idx + 1),
|
||||
None => len,
|
||||
};
|
||||
|
||||
Ok(Some((item, remaining)))
|
||||
}
|
||||
|
||||
fn dequeue_next(&mut self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
||||
if self.items.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let len = self.items.len();
|
||||
let next_index = match self.current_index {
|
||||
None => 0,
|
||||
Some(idx) => {
|
||||
let candidate = idx + 1;
|
||||
if candidate >= len {
|
||||
return Ok(None);
|
||||
}
|
||||
candidate
|
||||
}
|
||||
};
|
||||
|
||||
let Some(item) = self.items.get(next_index).cloned() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let remaining = len.saturating_sub(next_index + 1);
|
||||
self.current_index = Some(next_index);
|
||||
Ok(Some((item, remaining)))
|
||||
}
|
||||
|
||||
fn append_or_init_index(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||
let was_empty = self.items.is_empty();
|
||||
self.items.extend(items);
|
||||
|
||||
if was_empty && !self.items.is_empty() {
|
||||
self.current_index = Some(0);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
|
||||
if index < self.items.len() {
|
||||
self.items[index] = item;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ControlPointError::QueueError(format!(
|
||||
"Index out of bound {} >= {}",
|
||||
index,
|
||||
self.items.len()
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl QueueFromRendererInfo for InternalQueue {
|
||||
fn from_renderer_info(renderer: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||
InternalQueue::from_renderer_info(renderer)
|
||||
}
|
||||
|
||||
fn to_backend(self) -> MusicQueue {
|
||||
MusicQueue::Internal(self)
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
mod backend;
|
||||
mod interne;
|
||||
mod music_queue;
|
||||
mod openhome;
|
||||
mod snapshot;
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
pub use backend::{EnqueueMode, QueueBackend};
|
||||
pub use music_queue::MusicQueue;
|
||||
pub use snapshot::{PlaybackItem, QueueSnapshot};
|
||||
|
||||
// Internal queue implementations - not part of the public API
|
||||
pub(crate) use interne::InternalQueue;
|
||||
pub(crate) use openhome::OpenHomeQueue;
|
||||
|
||||
use crate::{RendererInfo, errors::ControlPointError};
|
||||
|
||||
pub trait QueueFromRendererInfo {
|
||||
fn from_renderer_info(renderer: &RendererInfo) -> Result<Self, ControlPointError>
|
||||
where
|
||||
Self: Sized;
|
||||
|
||||
fn to_backend(self) -> MusicQueue;
|
||||
|
||||
fn build_from_renderer_info(renderer: &RendererInfo) -> Result<MusicQueue, ControlPointError>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
let instance = Self::from_renderer_info(renderer)?;
|
||||
Ok(instance.to_backend())
|
||||
}
|
||||
|
||||
fn make_from_renderer_info(
|
||||
renderer: &RendererInfo,
|
||||
) -> Result<Arc<Mutex<MusicQueue>>, ControlPointError>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
let backend = Self::build_from_renderer_info(renderer)?;
|
||||
Ok(Arc::new(Mutex::new(backend)))
|
||||
}
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
use crate::errors::ControlPointError;
|
||||
use crate::queue::{
|
||||
EnqueueMode, InternalQueue, OpenHomeQueue, QueueBackend, QueueFromRendererInfo,
|
||||
};
|
||||
use crate::{PlaybackItem, QueueSnapshot, RendererInfo};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum MusicQueue {
|
||||
Internal(InternalQueue),
|
||||
OpenHome(OpenHomeQueue),
|
||||
}
|
||||
|
||||
impl MusicQueue {
|
||||
/// Creates a queue appropriate for the given renderer.
|
||||
/// This is the factory method used by QueueFromRendererInfo trait.
|
||||
pub fn from_renderer_info(info: &RendererInfo) -> Result<MusicQueue, ControlPointError> {
|
||||
if info.capabilities().has_oh_playlist() {
|
||||
Ok(MusicQueue::OpenHome(OpenHomeQueue::from_renderer_info(
|
||||
info,
|
||||
)?))
|
||||
} else {
|
||||
Ok(MusicQueue::Internal(InternalQueue::from_renderer_info(
|
||||
info,
|
||||
)?))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl QueueBackend for MusicQueue {
|
||||
// Primitives
|
||||
fn len(&self) -> Result<usize, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.len(),
|
||||
MusicQueue::OpenHome(q) => q.len(),
|
||||
}
|
||||
}
|
||||
|
||||
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.track_ids(),
|
||||
MusicQueue::OpenHome(q) => q.track_ids(),
|
||||
}
|
||||
}
|
||||
|
||||
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.id_to_position(id),
|
||||
MusicQueue::OpenHome(q) => q.id_to_position(id),
|
||||
}
|
||||
}
|
||||
|
||||
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.position_to_id(id),
|
||||
MusicQueue::OpenHome(q) => q.position_to_id(id),
|
||||
}
|
||||
}
|
||||
|
||||
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.current_track(),
|
||||
MusicQueue::OpenHome(q) => q.current_track(),
|
||||
}
|
||||
}
|
||||
|
||||
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.current_index(),
|
||||
MusicQueue::OpenHome(q) => q.current_index(),
|
||||
}
|
||||
}
|
||||
|
||||
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.queue_snapshot(),
|
||||
MusicQueue::OpenHome(q) => q.queue_snapshot(),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.set_index(index),
|
||||
MusicQueue::OpenHome(q) => q.set_index(index),
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
current_index: Option<usize>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.replace_queue(items, current_index),
|
||||
MusicQueue::OpenHome(q) => q.replace_queue(items, current_index),
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.sync_queue(items),
|
||||
MusicQueue::OpenHome(q) => q.sync_queue(items),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.get_item(index),
|
||||
MusicQueue::OpenHome(q) => q.get_item(index),
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.replace_item(index, item),
|
||||
MusicQueue::OpenHome(q) => q.replace_item(index, item),
|
||||
}
|
||||
}
|
||||
|
||||
fn enqueue_items(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
mode: EnqueueMode,
|
||||
) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.enqueue_items(items, mode),
|
||||
MusicQueue::OpenHome(q) => q.enqueue_items(items, mode),
|
||||
}
|
||||
}
|
||||
|
||||
// Optimized helpers
|
||||
fn clear_queue(&mut self) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.clear_queue(),
|
||||
MusicQueue::OpenHome(q) => q.clear_queue(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> Result<bool, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.is_empty(),
|
||||
MusicQueue::OpenHome(q) => q.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
fn upcoming_len(&self) -> Result<usize, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.upcoming_len(),
|
||||
MusicQueue::OpenHome(q) => q.upcoming_len(),
|
||||
}
|
||||
}
|
||||
|
||||
fn upcoming_items(&self) -> Result<Vec<PlaybackItem>, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.upcoming_items(),
|
||||
MusicQueue::OpenHome(q) => q.upcoming_items(),
|
||||
}
|
||||
}
|
||||
|
||||
fn peek_current(&self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.peek_current(),
|
||||
MusicQueue::OpenHome(q) => q.peek_current(),
|
||||
}
|
||||
}
|
||||
|
||||
fn dequeue_next(&mut self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.dequeue_next(),
|
||||
MusicQueue::OpenHome(q) => q.dequeue_next(),
|
||||
}
|
||||
}
|
||||
|
||||
fn append_or_init_index(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||
match self {
|
||||
MusicQueue::Internal(q) => q.append_or_init_index(items),
|
||||
MusicQueue::OpenHome(q) => q.append_or_init_index(items),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl QueueFromRendererInfo for MusicQueue {
|
||||
fn from_renderer_info(renderer: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||
MusicQueue::from_renderer_info(renderer)
|
||||
}
|
||||
|
||||
fn to_backend(self) -> MusicQueue {
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -1,859 +0,0 @@
|
||||
use std::usize;
|
||||
|
||||
use quick_xml::escape::escape;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::errors::ControlPointError;
|
||||
use crate::upnp_clients::{
|
||||
OPENHOME_PLAYLIST_HEAD_ID, OhInfoClient, OhPlaylistClient, OhProductClient, OhTrack,
|
||||
OhTrackEntry,
|
||||
};
|
||||
// use crate::openhome_playlist::{OpenHomePlaylistSnapshot, OpenHomePlaylistTrack};
|
||||
use crate::queue::{
|
||||
EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueFromRendererInfo, QueueSnapshot,
|
||||
};
|
||||
use crate::{DeviceId, DeviceIdentity, RendererInfo};
|
||||
|
||||
/// Local mirror of an OpenHome playlist for a single renderer.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OpenHomeQueue {
|
||||
renderer_id: DeviceId,
|
||||
playlist_client: OhPlaylistClient,
|
||||
info_client: Option<OhInfoClient>,
|
||||
product_client: Option<OhProductClient>,
|
||||
// current_index: Option<usize>,
|
||||
}
|
||||
|
||||
impl OpenHomeQueue {
|
||||
pub fn new(
|
||||
renderer_id: DeviceId,
|
||||
playlist: OhPlaylistClient,
|
||||
info_client: Option<OhInfoClient>,
|
||||
product_client: Option<OhProductClient>,
|
||||
) -> Self {
|
||||
Self {
|
||||
renderer_id,
|
||||
playlist_client: playlist,
|
||||
info_client,
|
||||
product_client,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_renderer_info(info: &RendererInfo) -> Result<OpenHomeQueue, ControlPointError> {
|
||||
let playlist_client = OhPlaylistClient::from_renderer_info(info)?;
|
||||
let info_client = OhInfoClient::from_renderer_info(&info).ok();
|
||||
let product_client = OhProductClient::from_renderer_info(&info).ok();
|
||||
|
||||
Ok(OpenHomeQueue::new(
|
||||
info.id(),
|
||||
playlist_client,
|
||||
info_client,
|
||||
product_client,
|
||||
))
|
||||
}
|
||||
|
||||
fn ensure_playlist_source_selected(&self) -> Result<(), ControlPointError> {
|
||||
if let Some(product) = &self.product_client {
|
||||
product.ensure_playlist_source_selected()
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn playback_item_from_entry(&self, entry: &OhTrackEntry) -> PlaybackItem {
|
||||
let metadata = entry.metadata();
|
||||
let didl_id = entry
|
||||
.didl_id()
|
||||
.unwrap_or_else(|| format!("openhome:{}", entry.id));
|
||||
PlaybackItem {
|
||||
media_server_id: DeviceId(format!("openhome:{}", self.renderer_id.0)),
|
||||
backend_id: entry.id as usize,
|
||||
didl_id,
|
||||
uri: entry.uri().to_string(),
|
||||
// OpenHome tracks don't provide protocolInfo, use generic default
|
||||
protocol_info: "http-get:*:audio/*:*".to_string(),
|
||||
metadata,
|
||||
}
|
||||
}
|
||||
|
||||
fn item_with_openhome_id(&self, mut item: PlaybackItem, track_id: u32) -> PlaybackItem {
|
||||
item.didl_id = format!("openhome:{}", track_id);
|
||||
item.media_server_id = DeviceId(format!("openhome:{}", self.renderer_id.0));
|
||||
item
|
||||
}
|
||||
|
||||
fn add_playback_item(
|
||||
&mut self,
|
||||
item: PlaybackItem,
|
||||
after_id: u32,
|
||||
) -> Result<u32, ControlPointError> {
|
||||
self.ensure_playlist_source_selected()?;
|
||||
let metadata_xml = build_metadata_xml(&item);
|
||||
let new_id = self
|
||||
.playlist_client
|
||||
.insert(after_id, &item.uri, &metadata_xml)?;
|
||||
Ok(new_id)
|
||||
}
|
||||
|
||||
/// CASE 1: Replace queue while preserving the currently playing item as first.
|
||||
/// The currently playing item is NOT in the new playlist, so we keep it as the first
|
||||
/// item and append the entire new playlist after it.
|
||||
fn replace_queue_preserve_current(
|
||||
&mut self,
|
||||
new_items: Vec<PlaybackItem>,
|
||||
playing_id: usize,
|
||||
) -> Result<(), ControlPointError> {
|
||||
// Get current track IDs from OpenHome
|
||||
let current_track_ids = self.track_ids()?;
|
||||
|
||||
// Delete everything except the currently playing item
|
||||
// Using delete_id_if_exists() to handle cases where another control point
|
||||
// may have already modified the playlist
|
||||
for &track_id in current_track_ids.iter().rev() {
|
||||
if track_id as usize != playing_id {
|
||||
self.playlist_client.delete_id_if_exists(track_id)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Insert new items after the currently playing track
|
||||
let mut previous_id = playing_id as u32;
|
||||
for item in new_items {
|
||||
let metadata = build_metadata_xml(&item);
|
||||
let new_id = self
|
||||
.playlist_client
|
||||
.insert(previous_id, &item.uri, &metadata)?;
|
||||
previous_id = new_id;
|
||||
}
|
||||
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
"Gentle sync completed: preserved playing track as first item (not in new playlist)"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Helper: Delete items marked for deletion in reverse order with logging.
|
||||
fn delete_marked_items(
|
||||
&mut self,
|
||||
old_ids: &[u32],
|
||||
keep_flags: &[bool],
|
||||
position_label: &str,
|
||||
) -> Result<(), ControlPointError> {
|
||||
for (idx, &track_id) in old_ids.iter().enumerate().rev() {
|
||||
if !keep_flags[idx] {
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
track_id,
|
||||
position = position_label,
|
||||
"RENDERER OP: DeleteId({})",
|
||||
track_id
|
||||
);
|
||||
self.playlist_client.delete_id_if_exists(track_id)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Helper: Rebuild a playlist section (before or after pivot) using LCS results.
|
||||
fn rebuild_playlist_section(
|
||||
&mut self,
|
||||
new_items: &[PlaybackItem],
|
||||
keep_new_flags: &[bool],
|
||||
old_ids: &[u32],
|
||||
keep_old_flags: &[bool],
|
||||
mut previous_id: u32,
|
||||
position_label: &str,
|
||||
) -> Result<u32, ControlPointError> {
|
||||
// Collect IDs of kept items (in order)
|
||||
let remaining_ids: Vec<u32> = old_ids
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, &id)| if keep_old_flags[idx] { Some(id) } else { None })
|
||||
.collect();
|
||||
|
||||
let mut remaining_idx = 0;
|
||||
|
||||
// Rebuild section
|
||||
for (idx, item) in new_items.iter().enumerate() {
|
||||
if keep_new_flags[idx] {
|
||||
let existing_id = remaining_ids[remaining_idx];
|
||||
remaining_idx += 1;
|
||||
previous_id = existing_id;
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
track_id = existing_id,
|
||||
position = position_label,
|
||||
"KEPT existing track ID {}",
|
||||
existing_id
|
||||
);
|
||||
} else {
|
||||
let metadata = build_metadata_xml(item);
|
||||
let new_id = self
|
||||
.playlist_client
|
||||
.insert(previous_id, &item.uri, &metadata)?;
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
after_id = previous_id,
|
||||
new_id,
|
||||
position = position_label,
|
||||
"RENDERER OP: Insert(after={}) -> new_id={}",
|
||||
previous_id,
|
||||
new_id
|
||||
);
|
||||
previous_id = new_id;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(previous_id)
|
||||
}
|
||||
|
||||
/// CASE 2: Replace queue with double-LCS (before and after the pivot).
|
||||
/// The currently playing item IS in the new playlist, so we use it as a pivot
|
||||
/// and apply LCS separately to the portions before and after it.
|
||||
fn replace_queue_with_pivot(
|
||||
&mut self,
|
||||
new_items: Vec<PlaybackItem>,
|
||||
pivot_idx_new: usize,
|
||||
pivot_id: usize,
|
||||
) -> Result<(), ControlPointError> {
|
||||
// Get current state from OpenHome
|
||||
let snapshot = self.queue_snapshot()?;
|
||||
let current_track_ids = self.track_ids()?;
|
||||
|
||||
// Find the pivot index in our current state
|
||||
let pivot_idx = current_track_ids
|
||||
.iter()
|
||||
.position(|&id| id as usize == pivot_id)
|
||||
.ok_or_else(|| {
|
||||
ControlPointError::OpenHomeError(format!(
|
||||
"Pivot track ID {} not found in playlist",
|
||||
pivot_id
|
||||
))
|
||||
})?;
|
||||
|
||||
// Split current data at the pivot
|
||||
let old_before: Vec<PlaybackItem> = snapshot.items[..pivot_idx].to_vec();
|
||||
let old_after: Vec<PlaybackItem> = snapshot.items[pivot_idx + 1..].to_vec();
|
||||
let old_ids_before: Vec<u32> = current_track_ids[..pivot_idx].to_vec();
|
||||
let old_ids_after: Vec<u32> = current_track_ids[pivot_idx + 1..].to_vec();
|
||||
|
||||
let new_before = &new_items[..pivot_idx_new];
|
||||
let new_after = &new_items[pivot_idx_new + 1..];
|
||||
|
||||
// LCS on the AFTER part (using fresh data from OpenHome)
|
||||
let (keep_old_after, keep_new_after) = lcs_flags(&old_after, new_after);
|
||||
|
||||
// LCS on the BEFORE part (using fresh data from OpenHome)
|
||||
let (keep_old_before, keep_new_before) = lcs_flags(&old_before, new_before);
|
||||
|
||||
// Delete items marked for deletion in AFTER part (reverse order)
|
||||
self.delete_marked_items(&old_ids_after, &keep_old_after, "AFTER pivot")?;
|
||||
|
||||
// Delete items marked for deletion in BEFORE part (reverse order)
|
||||
self.delete_marked_items(&old_ids_before, &keep_old_before, "BEFORE pivot")?;
|
||||
|
||||
// Rebuild the playlist: [BEFORE, PIVOT, AFTER]
|
||||
// Rebuild BEFORE part (we don't need the returned previous_id)
|
||||
self.rebuild_playlist_section(
|
||||
new_before,
|
||||
&keep_new_before,
|
||||
&old_ids_before,
|
||||
&keep_old_before,
|
||||
OPENHOME_PLAYLIST_HEAD_ID,
|
||||
"BEFORE pivot",
|
||||
)?;
|
||||
|
||||
// PIVOT keeps its ID and position - it's the anchor point
|
||||
let previous_id = pivot_id as u32;
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
pivot_id,
|
||||
pivot_idx_new,
|
||||
"PIVOT preserved with ID {} at index {}",
|
||||
pivot_id,
|
||||
pivot_idx_new
|
||||
);
|
||||
|
||||
// Rebuild AFTER part
|
||||
self.rebuild_playlist_section(
|
||||
new_after,
|
||||
&keep_new_after,
|
||||
&old_ids_after,
|
||||
&keep_old_after,
|
||||
previous_id,
|
||||
"AFTER pivot",
|
||||
)?;
|
||||
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
pivot_idx = pivot_idx_new,
|
||||
pivot_id,
|
||||
"Gentle sync completed: double-LCS with pivot (playing track preserved)"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Standard LCS-based replacement (used when no currently playing item).
|
||||
fn replace_queue_standard_lcs(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
_current_index: Option<usize>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
// Get current state from OpenHome
|
||||
let snapshot = self.queue_snapshot()?;
|
||||
let current_track_ids = self.track_ids()?;
|
||||
|
||||
let (keep_current, keep_desired) = lcs_flags(&snapshot.items, &items);
|
||||
|
||||
let items_to_keep = keep_current.iter().filter(|&&k| k).count();
|
||||
let items_to_delete = keep_current.iter().filter(|&&k| !k).count();
|
||||
let items_to_add = keep_desired.iter().filter(|&&k| !k).count();
|
||||
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
keep = items_to_keep,
|
||||
delete = items_to_delete,
|
||||
add = items_to_add,
|
||||
"LCS computed: minimizing OpenHome playlist operations"
|
||||
);
|
||||
|
||||
// If we're replacing everything (keep=0), use delete_all() instead of
|
||||
// individual delete_id() calls. This is much more robust for live playlists
|
||||
// where track IDs can become invalid between refresh and deletion.
|
||||
if items_to_keep == 0 && items_to_delete > 0 {
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
"Using delete_all() for complete replacement (more robust for live playlists)"
|
||||
);
|
||||
self.playlist_client.delete_all()?;
|
||||
} else {
|
||||
// Selective deletion when keeping some items
|
||||
for idx in (0..current_track_ids.len()).rev() {
|
||||
if !keep_current[idx] {
|
||||
let track_id = current_track_ids[idx];
|
||||
// Use delete_id_if_exists() to handle cases where another control point
|
||||
// may have already modified the playlist
|
||||
self.playlist_client.delete_id_if_exists(track_id)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild by inserting new items
|
||||
let remaining_ids: Vec<u32> = current_track_ids
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, &id)| {
|
||||
if keep_current.get(idx).copied().unwrap_or(false) {
|
||||
Some(id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut remaining_idx = 0usize;
|
||||
let mut previous_id = OPENHOME_PLAYLIST_HEAD_ID;
|
||||
|
||||
for (idx, item) in items.into_iter().enumerate() {
|
||||
if keep_desired[idx] {
|
||||
if remaining_idx >= remaining_ids.len() {
|
||||
return Err(ControlPointError::OpenHomeError(format!(
|
||||
"OpenHome playlist refresh bookkeeping mismatch (kept entries underflow)"
|
||||
)));
|
||||
}
|
||||
let existing_id = remaining_ids[remaining_idx];
|
||||
remaining_idx += 1;
|
||||
previous_id = existing_id;
|
||||
} else {
|
||||
let metadata = build_metadata_xml(&item);
|
||||
let new_id = self
|
||||
.playlist_client
|
||||
.insert(previous_id, &item.uri, &metadata)?;
|
||||
previous_id = new_id;
|
||||
}
|
||||
}
|
||||
|
||||
if remaining_idx != remaining_ids.len() {
|
||||
return Err(ControlPointError::OpenHomeError(format!(
|
||||
"OpenHome playlist refresh bookkeeping mismatch (kept entries overflow)"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn build_metadata_xml(item: &PlaybackItem) -> String {
|
||||
let title = item
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.title.as_deref())
|
||||
.unwrap_or("Unknown");
|
||||
let escaped_title = escape(title);
|
||||
let escaped_uri = escape(item.uri.as_str());
|
||||
let escaped_id = escape(item.didl_id.as_str());
|
||||
|
||||
let mut xml = String::from(
|
||||
r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">"#,
|
||||
);
|
||||
xml.push_str(&format!(
|
||||
r#"<item id="{}" parentID="-1" restricted="1">"#,
|
||||
escaped_id
|
||||
));
|
||||
xml.push_str(&format!("<dc:title>{}</dc:title>", escaped_title));
|
||||
|
||||
if let Some(meta) = &item.metadata {
|
||||
if let Some(artist) = meta.artist.as_deref() {
|
||||
let escaped = escape(artist);
|
||||
xml.push_str(&format!("<upnp:artist>{}</upnp:artist>", escaped));
|
||||
xml.push_str(&format!("<dc:creator>{}</dc:creator>", escaped));
|
||||
}
|
||||
if let Some(album) = meta.album.as_deref() {
|
||||
let escaped = escape(album);
|
||||
xml.push_str(&format!("<upnp:album>{}</upnp:album>", escaped));
|
||||
}
|
||||
if let Some(genre) = meta.genre.as_deref() {
|
||||
let escaped = escape(genre);
|
||||
xml.push_str(&format!("<upnp:genre>{}</upnp:genre>", escaped));
|
||||
}
|
||||
if let Some(uri) = meta.album_art_uri.as_deref() {
|
||||
let escaped = escape(uri);
|
||||
xml.push_str(&format!("<upnp:albumArtURI>{}</upnp:albumArtURI>", escaped));
|
||||
}
|
||||
if let Some(date) = meta.date.as_deref() {
|
||||
let escaped = escape(date);
|
||||
xml.push_str(&format!("<dc:date>{}</dc:date>", escaped));
|
||||
}
|
||||
if let Some(track_no) = meta.track_number.as_deref() {
|
||||
let escaped = escape(track_no);
|
||||
xml.push_str(&format!(
|
||||
"<upnp:originalTrackNumber>{}</upnp:originalTrackNumber>",
|
||||
escaped
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let escaped_protocol_info = escape(item.protocol_info.as_str());
|
||||
xml.push_str(&format!(
|
||||
r#"<res protocolInfo="{}">{}</res>"#,
|
||||
escaped_protocol_info, escaped_uri
|
||||
));
|
||||
xml.push_str(r#"<upnp:class>object.item.audioItem.musicTrack</upnp:class></item></DIDL-Lite>"#);
|
||||
xml
|
||||
}
|
||||
|
||||
fn lcs_flags(current: &[PlaybackItem], desired: &[PlaybackItem]) -> (Vec<bool>, Vec<bool>) {
|
||||
let m = current.len();
|
||||
let n = desired.len();
|
||||
let mut dp = vec![vec![0u32; n + 1]; m + 1];
|
||||
|
||||
for i in 0..m {
|
||||
for j in 0..n {
|
||||
if current[i].uri == desired[j].uri {
|
||||
dp[i + 1][j + 1] = dp[i][j] + 1;
|
||||
} else {
|
||||
dp[i + 1][j + 1] = dp[i + 1][j].max(dp[i][j + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut keep_current = vec![false; m];
|
||||
let mut keep_desired = vec![false; n];
|
||||
let (mut i, mut j) = (m, n);
|
||||
|
||||
while i > 0 && j > 0 {
|
||||
if current[i - 1].uri == desired[j - 1].uri {
|
||||
keep_current[i - 1] = true;
|
||||
keep_desired[j - 1] = true;
|
||||
i -= 1;
|
||||
j -= 1;
|
||||
} else if dp[i - 1][j] >= dp[i][j - 1] {
|
||||
i -= 1;
|
||||
} else {
|
||||
j -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
(keep_current, keep_desired)
|
||||
}
|
||||
|
||||
impl QueueBackend for OpenHomeQueue {
|
||||
fn len(&self) -> Result<usize, ControlPointError> {
|
||||
Ok(self.track_ids()?.len())
|
||||
}
|
||||
|
||||
/// Return the list of OpenHome track IDs in order.
|
||||
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
|
||||
self.ensure_playlist_source_selected()?;
|
||||
self.playlist_client.id_array()
|
||||
}
|
||||
|
||||
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
|
||||
self.track_ids()?
|
||||
.iter()
|
||||
.position(|&tid| tid == id)
|
||||
.ok_or_else(|| {
|
||||
ControlPointError::QueueError(format!("Item {} id is not present in the queue", id))
|
||||
})
|
||||
}
|
||||
|
||||
fn position_to_id(&self, index: usize) -> Result<u32, ControlPointError> {
|
||||
let idxs = self.track_ids()?;
|
||||
|
||||
if index < idxs.len() {
|
||||
Ok(idxs[index])
|
||||
} else {
|
||||
Err(ControlPointError::QueueError(format!(
|
||||
"Index out of bound {} >= {}",
|
||||
index,
|
||||
idxs.len()
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
|
||||
let id = self.playlist_client.id()?;
|
||||
// OpenHome returns 0 when no track is selected/playing
|
||||
if id == 0 { Ok(None) } else { Ok(Some(id)) }
|
||||
}
|
||||
|
||||
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
|
||||
if let Some(id) = self.current_track()? {
|
||||
return Ok(Some(self.id_to_position(id)?));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
|
||||
self.ensure_playlist_source_selected()?;
|
||||
let entries = self.playlist_client.read_all_tracks()?;
|
||||
let mut items = Vec::with_capacity(entries.len());
|
||||
|
||||
for entry in &entries {
|
||||
items.push(self.playback_item_from_entry(entry));
|
||||
}
|
||||
|
||||
// Get the currently playing track ID from the renderer (may be None if no track is playing)
|
||||
let current_id = self.playlist_client.id().ok();
|
||||
|
||||
// Find the index of the current track in the playlist
|
||||
let current_index = current_id.and_then(|id| {
|
||||
items
|
||||
.iter()
|
||||
.position(|entry_id| entry_id.backend_id == id as usize)
|
||||
});
|
||||
|
||||
Ok(QueueSnapshot {
|
||||
items: items,
|
||||
current_index: current_index,
|
||||
})
|
||||
}
|
||||
|
||||
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
|
||||
if let Some(index) = index {
|
||||
let track_id = self.position_to_id(index)?;
|
||||
self.ensure_playlist_source_selected()?;
|
||||
self.playlist_client.seek_id(track_id)?;
|
||||
} else {
|
||||
self.ensure_playlist_source_selected()?;
|
||||
self.playlist_client.stop()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
current_index: Option<usize>,
|
||||
) -> Result<(), ControlPointError> {
|
||||
if let Some(ci) = current_index {
|
||||
if ci >= items.len() {
|
||||
return Err(ControlPointError::QueueError(format!(
|
||||
"Invalid Current index parameter {} >= {}",
|
||||
ci,
|
||||
items.len()
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
self.ensure_playlist_source_selected()?;
|
||||
self.playlist_client.delete_all()?;
|
||||
|
||||
if items.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut previous_id = OPENHOME_PLAYLIST_HEAD_ID;
|
||||
|
||||
for item in items {
|
||||
let metadata = build_metadata_xml(&item);
|
||||
let new_id = self
|
||||
.playlist_client
|
||||
.insert(previous_id, &item.uri, &metadata)?;
|
||||
previous_id = new_id;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||
self.ensure_playlist_source_selected()?;
|
||||
if items.is_empty() {
|
||||
self.playlist_client.delete_all()?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Synchronize local state with the actual OpenHome playlist before computing
|
||||
// differences. Without this, any drift between our cache and the renderer
|
||||
// (e.g., manual edits from another control point) would keep the stale items.
|
||||
let snapshot = self.queue_snapshot()?;
|
||||
let playing_info = snapshot.current_index.and_then(|idx| {
|
||||
Some((
|
||||
idx,
|
||||
snapshot.items[idx].backend_id,
|
||||
snapshot.items[idx].uri.clone(),
|
||||
))
|
||||
});
|
||||
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
actual_items = snapshot.items.len(),
|
||||
playing_info_detected = playing_info.is_some(),
|
||||
"OpenHome playlist state"
|
||||
);
|
||||
|
||||
if let Some((playing_idx, playing_id, playing_uri)) = playing_info {
|
||||
// Find if the currently playing item is in the new playlist (by URI)
|
||||
let new_playing_idx = items.iter().position(|item| item.uri == playing_uri);
|
||||
|
||||
if let Some(pivot_idx) = new_playing_idx {
|
||||
// CASE 2: Currently playing item IS in the new playlist
|
||||
// Use gentle double-LCS strategy: preserve the pivot and sync before/after separately
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
playing_idx,
|
||||
pivot_idx,
|
||||
"Gentle sync: currently playing item found in new playlist at index {}",
|
||||
pivot_idx
|
||||
);
|
||||
|
||||
self.replace_queue_with_pivot(items, pivot_idx, playing_id)?;
|
||||
} else {
|
||||
// CASE 1: Currently playing item NOT in the new playlist
|
||||
// Keep it as first item and append the new playlist after it
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
playing_idx,
|
||||
"Gentle sync: currently playing item not in new playlist, preserving as first item"
|
||||
);
|
||||
|
||||
self.replace_queue_preserve_current(items, playing_id)?;
|
||||
}
|
||||
} else {
|
||||
// No currently playing item or can't determine it - use standard LCS
|
||||
debug!(
|
||||
renderer = self.renderer_id.0.as_str(),
|
||||
"No currently playing item, using standard LCS sync"
|
||||
);
|
||||
self.replace_queue_standard_lcs(items, Some(0))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
|
||||
let snapshot = self.queue_snapshot()?;
|
||||
|
||||
if index < snapshot.items.len() {
|
||||
return Ok(Some(snapshot.items[index].clone()));
|
||||
}
|
||||
|
||||
Err(ControlPointError::QueueError(format!(
|
||||
"get_item index out of bound {} >= {}",
|
||||
index,
|
||||
snapshot.items.len()
|
||||
)))
|
||||
}
|
||||
|
||||
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
|
||||
let metadata = build_metadata_xml(&item);
|
||||
|
||||
let ids = self.track_ids()?;
|
||||
|
||||
if index >= ids.len() {
|
||||
return Err(ControlPointError::QueueError(format!(
|
||||
"get_item index out of bound {} >= {}",
|
||||
index,
|
||||
ids.len()
|
||||
)));
|
||||
}
|
||||
|
||||
self.ensure_playlist_source_selected()?;
|
||||
|
||||
let track_id = ids[index];
|
||||
let before_id = if index == 0 {
|
||||
OPENHOME_PLAYLIST_HEAD_ID
|
||||
} else {
|
||||
ids[index - 1]
|
||||
};
|
||||
|
||||
let ci = self.current_index()?;
|
||||
// Use delete_id_if_exists() to handle cases where another control point
|
||||
// may have already modified the playlist
|
||||
self.playlist_client.delete_id(track_id)?;
|
||||
let new_id = self
|
||||
.playlist_client
|
||||
.insert(before_id, &item.uri, &metadata)?;
|
||||
|
||||
if ci == Some(index) {
|
||||
self.playlist_client.seek_id(new_id)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Override enqueue_items to add items directly to the OpenHome playlist.
|
||||
fn enqueue_items(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
mode: EnqueueMode,
|
||||
) -> Result<(), ControlPointError> {
|
||||
if items.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match mode {
|
||||
EnqueueMode::AppendToEnd => {
|
||||
let ids = self.track_ids()?;
|
||||
let mut after_id = if ids.len() > 0 {
|
||||
ids[ids.len() - 1]
|
||||
} else {
|
||||
OPENHOME_PLAYLIST_HEAD_ID
|
||||
};
|
||||
// Append to the end of the OpenHome playlist
|
||||
for item in items {
|
||||
after_id = self.add_playback_item(item, after_id)?;
|
||||
}
|
||||
}
|
||||
EnqueueMode::InsertAfterCurrent => {
|
||||
if let Some(mut after_id) = self.current_track()? {
|
||||
for item in items {
|
||||
after_id = self.add_playback_item(item, after_id)?;
|
||||
}
|
||||
} else {
|
||||
self.enqueue_items(items, EnqueueMode::AppendToEnd)?;
|
||||
}
|
||||
}
|
||||
EnqueueMode::ReplaceAll => {
|
||||
// Replace the entire playlist
|
||||
self.replace_queue(items, None)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Optimized helpers to avoid unnecessary network calls
|
||||
|
||||
/// Optimized clear_queue: use delete_all() directly instead of replace_queue.
|
||||
fn clear_queue(&mut self) -> Result<(), ControlPointError> {
|
||||
self.ensure_playlist_source_selected()?;
|
||||
self.playlist_client.delete_all()
|
||||
}
|
||||
|
||||
/// Optimized is_empty: only fetch track IDs, not the full playlist.
|
||||
fn is_empty(&self) -> Result<bool, ControlPointError> {
|
||||
Ok(self.track_ids()?.is_empty())
|
||||
}
|
||||
|
||||
/// Optimized upcoming_len: calculate from len() and current_index() without fetching items.
|
||||
fn upcoming_len(&self) -> Result<usize, ControlPointError> {
|
||||
let len = self.len()?;
|
||||
match self.current_index()? {
|
||||
None => Ok(len),
|
||||
Some(idx) => Ok(len.saturating_sub(idx + 1)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Optimized peek_current: use primitives instead of full snapshot.
|
||||
fn peek_current(&self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
||||
let len = self.len()?;
|
||||
if len == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let current_idx = self.current_index()?;
|
||||
let resolved_index = match current_idx {
|
||||
Some(idx) if idx < len => Some(idx),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let item_index = resolved_index.unwrap_or(0);
|
||||
let item = match self.get_item(item_index)? {
|
||||
Some(item) => item,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let remaining = match resolved_index {
|
||||
Some(idx) => len.saturating_sub(idx + 1),
|
||||
None => len,
|
||||
};
|
||||
|
||||
Ok(Some((item, remaining)))
|
||||
}
|
||||
|
||||
/// Optimized dequeue_next: use primitives instead of full snapshot.
|
||||
fn dequeue_next(&mut self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
||||
let len = self.len()?;
|
||||
if len == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let current_idx = self.current_index()?;
|
||||
let next_index = match current_idx {
|
||||
None => 0,
|
||||
Some(idx) => {
|
||||
let candidate = idx + 1;
|
||||
if candidate >= len {
|
||||
return Ok(None);
|
||||
}
|
||||
candidate
|
||||
}
|
||||
};
|
||||
|
||||
let Some(item) = self.get_item(next_index)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let remaining = len.saturating_sub(next_index + 1);
|
||||
self.set_index(Some(next_index))?;
|
||||
Ok(Some((item, remaining)))
|
||||
}
|
||||
|
||||
/// Optimized append_or_init_index: use enqueue_items which is already optimized.
|
||||
fn append_or_init_index(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||
let was_empty = self.is_empty()?;
|
||||
|
||||
// Use the already optimized enqueue_items(AppendToEnd)
|
||||
self.enqueue_items(items, EnqueueMode::AppendToEnd)?;
|
||||
|
||||
// If the queue was empty before, set index to 0
|
||||
if was_empty && !self.is_empty()? {
|
||||
self.set_index(Some(0))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl QueueFromRendererInfo for OpenHomeQueue {
|
||||
fn from_renderer_info(renderer: &RendererInfo) -> Result<Self, ControlPointError> {
|
||||
OpenHomeQueue::from_renderer_info(renderer)
|
||||
}
|
||||
|
||||
fn to_backend(self) -> MusicQueue {
|
||||
MusicQueue::OpenHome(self)
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
use crate::{DeviceId, model::TrackMetadata};
|
||||
|
||||
/// Canonical representation of a track in a renderer queue.
|
||||
///
|
||||
/// This type is the bridge between:
|
||||
/// - the UPnP MediaServer (DIDL-Lite items),
|
||||
/// - the ControlPoint runtime,
|
||||
/// - and the different queue backends (internal / OpenHome).
|
||||
///
|
||||
/// It is intentionally DIDL-centric: every item in a queue comes from
|
||||
/// a UPnP ContentDirectory and carries its MediaServer identity.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PlaybackItem {
|
||||
/// Identifier of the UPnP MediaServer that owns this content.
|
||||
///
|
||||
/// Typically this is the UDN of the MediaServer device, or an
|
||||
/// equivalent logical identifier.
|
||||
pub media_server_id: DeviceId,
|
||||
|
||||
/// Internal ID of the item in the queue.
|
||||
/// This ID only has meaning when returning a snapshot.
|
||||
/// In an internal queue, it can have any value; the position in the vector is what matters.
|
||||
/// In principle, it can be set to usize::MAX.
|
||||
pub backend_id: usize,
|
||||
|
||||
/// DIDL-Lite `id` attribute of the `item` in the ContentDirectory.
|
||||
///
|
||||
/// This, combined with `media_server_id`, is the logical identity
|
||||
/// of the track across refreshes of the MediaServer state.
|
||||
pub didl_id: String,
|
||||
|
||||
/// Main resource URI to be used for playback.
|
||||
///
|
||||
/// This is usually the first `<res>` element (or a selected one)
|
||||
/// from the DIDL-Lite item.
|
||||
pub uri: String,
|
||||
|
||||
/// UPnP protocolInfo string for the resource (e.g., "http-get:*:audio/flac:*").
|
||||
///
|
||||
/// This string describes the protocol, network, MIME type, and additional
|
||||
/// info about the media resource. It's required for proper UPnP/OpenHome
|
||||
/// renderer compatibility.
|
||||
pub protocol_info: String,
|
||||
|
||||
/// Optional rich metadata for the track (title, artist, album, cover,
|
||||
/// duration, …).
|
||||
///
|
||||
/// The exact structure is defined in `TrackMetadata` and may
|
||||
/// aggregate information from DIDL, tags, or additional sources.
|
||||
pub metadata: Option<TrackMetadata>,
|
||||
}
|
||||
|
||||
impl PlaybackItem {
|
||||
/// Returns a stable, backend-agnostic logical identifier for this item.
|
||||
///
|
||||
/// By default this is the concatenation of the MediaServer identifier
|
||||
/// and the DIDL `id`. Backends and higher-level logic should use this
|
||||
/// when they need to match items across queue rebuilds.
|
||||
pub fn unique_id(&self) -> String {
|
||||
// ADAPTE si MediaServerId n'implémente pas Display : utilise
|
||||
// un champ string interne ou une méthode as_str().
|
||||
format!("{}::{}", self.media_server_id.0, self.didl_id)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Logical snapshot of a renderer queue.
|
||||
///
|
||||
/// This is the canonical view used by the ControlPoint and the REST/API
|
||||
/// layer. It is independent of how the queue is actually stored (local
|
||||
/// in-memory queue, OpenHome playlist, …).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct QueueSnapshot {
|
||||
/// All items currently in the queue, in play order.
|
||||
pub items: Vec<PlaybackItem>,
|
||||
/// Index (0-based) of the current item in `items`, or `None` if
|
||||
/// no item is currently selected.
|
||||
pub current_index: Option<usize>,
|
||||
}
|
||||
|
||||
impl QueueSnapshot {
|
||||
/// Returns the number of items in the snapshot.
|
||||
pub fn len(&self) -> usize {
|
||||
self.items.len()
|
||||
}
|
||||
|
||||
/// Returns `true` if the snapshot contains no items.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.items.is_empty()
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,97 @@
|
||||
//! - This identity is used by the sync helpers to preserve the current
|
||||
//! track across queue rebuilds when the MediaServer content changes.
|
||||
|
||||
use crate::{PlaybackItem, QueueSnapshot, errors::ControlPointError};
|
||||
use anyhow::Result;
|
||||
|
||||
// ADAPTE ces imports aux modules existants dans pmocontrol.
|
||||
// Exemple probable :
|
||||
// use crate::model::MediaServerId;
|
||||
// use crate::model::TrackMetadata;
|
||||
use crate::media_server::ServerId as MediaServerId;
|
||||
use crate::model::TrackMetadata;
|
||||
|
||||
/// Canonical representation of a track in a renderer queue.
|
||||
///
|
||||
/// This type is the bridge between:
|
||||
/// - the UPnP MediaServer (DIDL-Lite items),
|
||||
/// - the ControlPoint runtime,
|
||||
/// - and the different queue backends (internal / OpenHome).
|
||||
///
|
||||
/// It is intentionally DIDL-centric: every item in a queue comes from
|
||||
/// a UPnP ContentDirectory and carries its MediaServer identity.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PlaybackItem {
|
||||
/// Identifier of the UPnP MediaServer that owns this content.
|
||||
///
|
||||
/// Typically this is the UDN of the MediaServer device, or an
|
||||
/// equivalent logical identifier.
|
||||
pub media_server_id: MediaServerId,
|
||||
|
||||
/// DIDL-Lite `id` attribute of the `item` in the ContentDirectory.
|
||||
///
|
||||
/// This, combined with `media_server_id`, is the logical identity
|
||||
/// of the track across refreshes of the MediaServer state.
|
||||
pub didl_id: String,
|
||||
|
||||
/// Main resource URI to be used for playback.
|
||||
///
|
||||
/// This is usually the first `<res>` element (or a selected one)
|
||||
/// from the DIDL-Lite item.
|
||||
pub uri: String,
|
||||
|
||||
/// UPnP protocolInfo string for the resource (e.g., "http-get:*:audio/flac:*").
|
||||
///
|
||||
/// This string describes the protocol, network, MIME type, and additional
|
||||
/// info about the media resource. It's required for proper UPnP/OpenHome
|
||||
/// renderer compatibility.
|
||||
pub protocol_info: String,
|
||||
|
||||
/// Optional rich metadata for the track (title, artist, album, cover,
|
||||
/// duration, …).
|
||||
///
|
||||
/// The exact structure is defined in `TrackMetadata` and may
|
||||
/// aggregate information from DIDL, tags, or additional sources.
|
||||
pub metadata: Option<TrackMetadata>,
|
||||
}
|
||||
|
||||
impl PlaybackItem {
|
||||
/// Returns a stable, backend-agnostic logical identifier for this item.
|
||||
///
|
||||
/// By default this is the concatenation of the MediaServer identifier
|
||||
/// and the DIDL `id`. Backends and higher-level logic should use this
|
||||
/// when they need to match items across queue rebuilds.
|
||||
pub fn unique_id(&self) -> String {
|
||||
// ADAPTE si MediaServerId n'implémente pas Display : utilise
|
||||
// un champ string interne ou une méthode as_str().
|
||||
format!("{}::{}", self.media_server_id.0, self.didl_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Logical snapshot of a renderer queue.
|
||||
///
|
||||
/// This is the canonical view used by the ControlPoint and the REST/API
|
||||
/// layer. It is independent of how the queue is actually stored (local
|
||||
/// in-memory queue, OpenHome playlist, …).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct QueueSnapshot {
|
||||
/// All items currently in the queue, in play order.
|
||||
pub items: Vec<PlaybackItem>,
|
||||
/// Index (0-based) of the current item in `items`, or `None` if
|
||||
/// no item is currently selected.
|
||||
pub current_index: Option<usize>,
|
||||
}
|
||||
|
||||
impl QueueSnapshot {
|
||||
/// Returns the number of items in the snapshot.
|
||||
pub fn len(&self) -> usize {
|
||||
self.items.len()
|
||||
}
|
||||
|
||||
/// Returns `true` if the snapshot contains no items.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.items.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// High-level enqueue mode.
|
||||
///
|
||||
@@ -68,32 +158,14 @@ pub trait QueueBackend {
|
||||
// BACKEND PRIMITIVES (must be implemented)
|
||||
// =====================================================================
|
||||
|
||||
/// Returns the length of this queue.
|
||||
fn len(&self) -> Result<usize, ControlPointError>;
|
||||
|
||||
/// Lists all the items in this queue.
|
||||
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError>;
|
||||
|
||||
/// Converts a track ID to its position in the queue.
|
||||
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError>;
|
||||
|
||||
/// Converts a track ID to its position in the queue.
|
||||
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError>;
|
||||
|
||||
/// Return the current playing track identifier
|
||||
fn current_track(&self) -> Result<Option<u32>, ControlPointError>;
|
||||
|
||||
/// Returns the current playing track index in the queue
|
||||
fn current_index(&self) -> Result<Option<usize>, ControlPointError>;
|
||||
|
||||
/// Returns the full snapshot (items + current index) of this queue.
|
||||
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError>;
|
||||
fn queue_snapshot(&self) -> Result<QueueSnapshot>;
|
||||
|
||||
/// Sets the current index for this queue.
|
||||
///
|
||||
/// This method only updates the queue structure (pointer to the current
|
||||
/// item). It MUST NOT start playback.
|
||||
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError>;
|
||||
fn set_index(&mut self, index: Option<usize>) -> Result<()>;
|
||||
|
||||
/// Replaces the entire queue with a new list of items and a new
|
||||
/// current index.
|
||||
@@ -101,46 +173,60 @@ pub trait QueueBackend {
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
current_index: Option<usize>,
|
||||
) -> Result<(), ControlPointError>;
|
||||
|
||||
/// Updates the queue while adjusting so that the new current_index
|
||||
/// corresponds to the old current index track.
|
||||
/// If the old current index track is absent from the new queue,
|
||||
/// it is kept as the first item and the new items are appended after it.
|
||||
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError>;
|
||||
) -> Result<()>;
|
||||
|
||||
/// Returns the item at `index`, if it exists.
|
||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError>;
|
||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>>;
|
||||
|
||||
/// Replaces the item at `index` with `item`.
|
||||
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError>;
|
||||
|
||||
/// Enqueues items according to the selected `EnqueueMode`.
|
||||
///
|
||||
/// This method only manipulates the queue structure; it does not
|
||||
/// start playback.
|
||||
fn enqueue_items(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
mode: EnqueueMode,
|
||||
) -> Result<(), ControlPointError>;
|
||||
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<()>;
|
||||
|
||||
// =====================================================================
|
||||
// DEFAULT HELPERS (backend-agnostic logic)
|
||||
// =====================================================================
|
||||
|
||||
/// Clears the queue.
|
||||
fn clear_queue(&mut self) -> Result<(), ControlPointError> {
|
||||
fn clear_queue(&mut self) -> Result<()> {
|
||||
self.replace_queue(Vec::new(), None)
|
||||
}
|
||||
|
||||
/// Alias for `clear_queue`, semantic name for “empty before rebuild”.
|
||||
fn empty_queue(&mut self) -> Result<()> {
|
||||
self.clear_queue()
|
||||
}
|
||||
|
||||
/// Returns the current index, if any.
|
||||
fn current_index(&self) -> Result<Option<usize>> {
|
||||
Ok(self.queue_snapshot()?.current_index)
|
||||
}
|
||||
|
||||
/// Returns the number of items in the queue.
|
||||
fn len(&self) -> Result<usize> {
|
||||
Ok(self.queue_snapshot()?.len())
|
||||
}
|
||||
|
||||
/// Returns `true` if the queue is empty.
|
||||
fn is_empty(&self) -> Result<bool, ControlPointError> {
|
||||
fn is_empty(&self) -> Result<bool> {
|
||||
Ok(self.queue_snapshot()?.is_empty())
|
||||
}
|
||||
|
||||
/// Returns a full snapshot of the queue.
|
||||
fn full_snapshot(&self) -> Result<QueueSnapshot> {
|
||||
self.queue_snapshot()
|
||||
}
|
||||
|
||||
/// Returns an iterator over all items in the queue.
|
||||
///
|
||||
/// The default implementation:
|
||||
/// - takes a snapshot,
|
||||
/// - returns a boxed iterator owning the underlying `Vec`.
|
||||
fn iter_items(&self) -> Result<Box<dyn Iterator<Item = PlaybackItem>>> {
|
||||
let snapshot = self.queue_snapshot()?;
|
||||
Ok(Box::new(snapshot.items.into_iter()))
|
||||
}
|
||||
|
||||
/// Returns the list of items that come strictly after the current index.
|
||||
fn upcoming_items(&self) -> Result<Vec<PlaybackItem>, ControlPointError> {
|
||||
fn upcoming_items(&self) -> Result<Vec<PlaybackItem>> {
|
||||
let snapshot = self.queue_snapshot()?;
|
||||
let items = match snapshot.current_index {
|
||||
None => snapshot.items,
|
||||
@@ -150,18 +236,13 @@ pub trait QueueBackend {
|
||||
}
|
||||
|
||||
/// Returns how many items remain in the queue after the current index.
|
||||
fn upcoming_len(&self) -> Result<usize, ControlPointError> {
|
||||
let snapshot = self.queue_snapshot()?;
|
||||
let len = snapshot.items.len();
|
||||
match snapshot.current_index {
|
||||
None => Ok(len),
|
||||
Some(idx) => Ok(len.saturating_sub(idx + 1)),
|
||||
}
|
||||
fn upcoming_len(&self) -> Result<usize> {
|
||||
Ok(self.upcoming_items()?.len())
|
||||
}
|
||||
|
||||
/// Returns the current item (or the first pending item if no index is set)
|
||||
/// along with the count of remaining items.
|
||||
fn peek_current(&self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
||||
fn peek_current(&self) -> Result<Option<(PlaybackItem, usize)>> {
|
||||
let snapshot = self.queue_snapshot()?;
|
||||
let QueueSnapshot {
|
||||
items,
|
||||
@@ -193,7 +274,7 @@ pub trait QueueBackend {
|
||||
|
||||
/// Advances the queue to the next item (respecting the current index) and
|
||||
/// returns it with the number of remaining items.
|
||||
fn dequeue_next(&mut self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
|
||||
fn dequeue_next(&mut self) -> Result<Option<(PlaybackItem, usize)>> {
|
||||
let snapshot = self.queue_snapshot()?;
|
||||
let QueueSnapshot {
|
||||
items,
|
||||
@@ -225,8 +306,38 @@ pub trait QueueBackend {
|
||||
Ok(Some((item, remaining)))
|
||||
}
|
||||
|
||||
/// Enqueues items according to the selected `EnqueueMode`.
|
||||
///
|
||||
/// This method only manipulates the queue structure; it does not
|
||||
/// start playback.
|
||||
fn enqueue_items(&mut self, items: Vec<PlaybackItem>, mode: EnqueueMode) -> Result<()> {
|
||||
let mut snapshot = self.queue_snapshot()?;
|
||||
|
||||
match mode {
|
||||
EnqueueMode::AppendToEnd => {
|
||||
snapshot.items.extend(items);
|
||||
}
|
||||
EnqueueMode::InsertAfterCurrent => {
|
||||
let insert_pos = snapshot
|
||||
.current_index
|
||||
.map(|i| (i + 1).min(snapshot.items.len()))
|
||||
.unwrap_or(0);
|
||||
|
||||
for (offset, it) in items.into_iter().enumerate() {
|
||||
snapshot.items.insert(insert_pos + offset, it);
|
||||
}
|
||||
}
|
||||
EnqueueMode::ReplaceAll => {
|
||||
snapshot.items = items;
|
||||
snapshot.current_index = None;
|
||||
}
|
||||
}
|
||||
|
||||
self.replace_queue(snapshot.items, snapshot.current_index)
|
||||
}
|
||||
|
||||
/// Replaces the queue with `items` and sets a default index.
|
||||
fn replace_all(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||
fn replace_all(&mut self, items: Vec<PlaybackItem>) -> Result<()> {
|
||||
if items.is_empty() {
|
||||
self.replace_queue(Vec::new(), None)
|
||||
} else {
|
||||
@@ -236,7 +347,7 @@ pub trait QueueBackend {
|
||||
|
||||
/// Appends items and, if the queue was previously empty, initializes
|
||||
/// the current index to `0`.
|
||||
fn append_or_init_index(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
|
||||
fn append_or_init_index(&mut self, items: Vec<PlaybackItem>) -> Result<()> {
|
||||
let was_empty = self.is_empty()?;
|
||||
let mut snapshot = self.queue_snapshot()?;
|
||||
snapshot.items.extend(items);
|
||||
@@ -251,7 +362,7 @@ pub trait QueueBackend {
|
||||
}
|
||||
|
||||
/// Computes the “next” index.
|
||||
fn next_index(&self) -> Result<Option<usize>, ControlPointError> {
|
||||
fn next_index(&self) -> Result<Option<usize>> {
|
||||
let len = self.len()?;
|
||||
if len == 0 {
|
||||
return Ok(None);
|
||||
@@ -265,7 +376,7 @@ pub trait QueueBackend {
|
||||
}
|
||||
|
||||
/// Computes the “previous” index.
|
||||
fn previous_index(&self) -> Result<Option<usize>, ControlPointError> {
|
||||
fn previous_index(&self) -> Result<Option<usize>> {
|
||||
match self.current_index()? {
|
||||
None => Ok(None),
|
||||
Some(0) => Ok(None),
|
||||
@@ -274,7 +385,7 @@ pub trait QueueBackend {
|
||||
}
|
||||
|
||||
/// Advances the current index to the next item, if any.
|
||||
fn advance(&mut self) -> Result<bool, ControlPointError> {
|
||||
fn advance(&mut self) -> Result<bool> {
|
||||
if let Some(next) = self.next_index()? {
|
||||
self.set_index(Some(next))?;
|
||||
Ok(true)
|
||||
@@ -284,7 +395,7 @@ pub trait QueueBackend {
|
||||
}
|
||||
|
||||
/// Rewinds the current index to the previous item, if any.
|
||||
fn rewind(&mut self) -> Result<bool, ControlPointError> {
|
||||
fn rewind(&mut self) -> Result<bool> {
|
||||
if let Some(prev) = self.previous_index()? {
|
||||
self.set_index(Some(prev))?;
|
||||
Ok(true)
|
||||
@@ -293,20 +404,44 @@ pub trait QueueBackend {
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience helper to update an item "in place" at the given index.
|
||||
/// Convenience helper to update an item “in place” at the given index.
|
||||
fn update_item(
|
||||
&mut self,
|
||||
index: usize,
|
||||
update: impl FnOnce(PlaybackItem) -> PlaybackItem,
|
||||
) -> Result<(), ControlPointError> {
|
||||
) -> Result<()> {
|
||||
if let Some(item) = self.get_item(index)? {
|
||||
let new_item = update(item);
|
||||
self.replace_item(index, new_item)
|
||||
} else {
|
||||
Err(ControlPointError::QueueError(format!(
|
||||
"Queue index {} out of range",
|
||||
index
|
||||
)))
|
||||
anyhow::bail!("Queue index {} out of range", index);
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronizes the queue with a new list of items coming from an
|
||||
/// external MediaServer, trying to preserve the current track.
|
||||
fn sync_from_external_preserve_current(&mut self, new_items: Vec<PlaybackItem>) -> Result<()> {
|
||||
let snapshot = self.queue_snapshot()?;
|
||||
let current = snapshot
|
||||
.current_index
|
||||
.and_then(|i| snapshot.items.get(i).cloned());
|
||||
|
||||
let Some(current) = current else {
|
||||
return self.replace_all(new_items);
|
||||
};
|
||||
|
||||
let current_uid = current.unique_id();
|
||||
|
||||
if let Some(new_idx) = new_items
|
||||
.iter()
|
||||
.position(|it| it.unique_id() == current_uid)
|
||||
{
|
||||
self.replace_queue(new_items, Some(new_idx))
|
||||
} else {
|
||||
let mut items = Vec::with_capacity(new_items.len() + 1);
|
||||
items.push(current);
|
||||
items.extend(new_items);
|
||||
self.replace_queue(items, Some(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
116
pmocontrol/src/queue_interne.rs
Normal file
116
pmocontrol/src/queue_interne.rs
Normal file
@@ -0,0 +1,116 @@
|
||||
//! Internal (local) queue implementation for PMOControl.
|
||||
//!
|
||||
//! This module provides a concrete implementation of the generic
|
||||
//! `QueueBackend` trait for queues that are fully managed inside the
|
||||
//! ControlPoint, without delegating playlist management to a remote
|
||||
//! backend (like OpenHome).
|
||||
//!
|
||||
//! In this design, each queue instance is associated to exactly one
|
||||
//! renderer. The queue does not need to know the renderer identifier:
|
||||
//! it is "bound" to the renderer by construction, and will be stored
|
||||
//! directly in the runtime (inside a higher-level `MusicQueue` enum).
|
||||
//!
|
||||
//! This internal queue:
|
||||
//! - owns its list of `PlaybackItem`s,
|
||||
//! - maintains a `current_index`,
|
||||
//! - never starts playback (transport control is handled elsewhere).
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::queue_backend::{PlaybackItem, QueueBackend, QueueSnapshot};
|
||||
|
||||
/// Internal/local queue implementation.
|
||||
///
|
||||
/// This is the simplest possible queue backend:
|
||||
/// - a `Vec<PlaybackItem>`
|
||||
/// - plus an optional `current_index`.
|
||||
///
|
||||
/// It does not talk to any remote service. All operations are pure
|
||||
/// structural mutations on in-memory data.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct InternalQueue {
|
||||
items: Vec<PlaybackItem>,
|
||||
current_index: Option<usize>,
|
||||
}
|
||||
|
||||
impl InternalQueue {
|
||||
/// Creates an empty internal queue.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
items: Vec::new(),
|
||||
current_index: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an internal queue from an initial list of items.
|
||||
///
|
||||
/// If `set_current_to_first` is `true` and the list is non-empty,
|
||||
/// the current index is set to `Some(0)`. Otherwise, it is `None`.
|
||||
pub fn from_items(items: Vec<PlaybackItem>, set_current_to_first: bool) -> Self {
|
||||
let current_index = if set_current_to_first && !items.is_empty() {
|
||||
Some(0)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Self {
|
||||
items,
|
||||
current_index,
|
||||
}
|
||||
}
|
||||
|
||||
/// Exposes a read-only view of the underlying items.
|
||||
pub fn items(&self) -> &[PlaybackItem] {
|
||||
&self.items
|
||||
}
|
||||
|
||||
/// Exposes the current index (read-only).
|
||||
pub fn current_index(&self) -> Option<usize> {
|
||||
self.current_index
|
||||
}
|
||||
}
|
||||
|
||||
impl QueueBackend for InternalQueue {
|
||||
fn queue_snapshot(&self) -> Result<QueueSnapshot> {
|
||||
Ok(QueueSnapshot {
|
||||
items: self.items.clone(),
|
||||
current_index: self.current_index,
|
||||
})
|
||||
}
|
||||
|
||||
fn set_index(&mut self, index: Option<usize>) -> Result<()> {
|
||||
match index {
|
||||
None => {
|
||||
self.current_index = None;
|
||||
}
|
||||
Some(i) => {
|
||||
if i < self.items.len() {
|
||||
self.current_index = Some(i);
|
||||
} else {
|
||||
self.current_index = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_queue(
|
||||
&mut self,
|
||||
items: Vec<PlaybackItem>,
|
||||
current_index: Option<usize>,
|
||||
) -> Result<()> {
|
||||
self.items = items;
|
||||
self.current_index = current_index.filter(|&i| i < self.items.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>> {
|
||||
Ok(self.items.get(index).cloned())
|
||||
}
|
||||
|
||||
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<()> {
|
||||
if index < self.items.len() {
|
||||
self.items[index] = item;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,302 +1,214 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use crate::errors::ControlPointError;
|
||||
use crate::events::{MediaServerEventBus, RendererEventBus};
|
||||
use crate::media_server::MusicServer;
|
||||
use crate::model::RendererInfo;
|
||||
use crate::music_renderer::MusicRenderer;
|
||||
use crate::{
|
||||
DeviceId, DeviceIdentity, DeviceOnline, MediaServerEvent, RendererEvent, UpnpMediaServer,
|
||||
};
|
||||
use crate::avtransport_client::AvTransportClient;
|
||||
use crate::connection_manager_client::ConnectionManagerClient;
|
||||
use crate::media_server::{MediaServerInfo, ServerId};
|
||||
use crate::model::{RendererId, RendererInfo};
|
||||
use crate::rendering_control_client::RenderingControlClient;
|
||||
use tracing::debug;
|
||||
|
||||
const DEFAULT_MAX_AGE: u32 = 1800;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DeviceItem {
|
||||
music_renderer: Option<Arc<MusicRenderer>>,
|
||||
music_server: Option<Arc<MusicServer>>,
|
||||
#[derive(Clone, Debug)]
|
||||
enum DeviceKey {
|
||||
Renderer(RendererId),
|
||||
Server(ServerId),
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DeviceRegistry {
|
||||
devices: HashMap<DeviceId, DeviceItem>,
|
||||
udn_index: HashMap<String, DeviceId>,
|
||||
renderer_bus: RendererEventBus,
|
||||
server_bus: MediaServerEventBus,
|
||||
renderers: HashMap<RendererId, RendererInfo>,
|
||||
servers: HashMap<ServerId, MediaServerInfo>,
|
||||
udn_index: HashMap<String, DeviceKey>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// Read-only view / trait for registry access.
|
||||
///
|
||||
/// Pour l’instant, on ne rajoute pas AVTransport ici, on se contente
|
||||
/// d’ajouter les helpers dans `impl DeviceRegistry`.
|
||||
pub trait DeviceRegistryRead {
|
||||
fn list_renderers(&self) -> Vec<RendererInfo>;
|
||||
fn list_servers(&self) -> Vec<MediaServerInfo>;
|
||||
|
||||
fn get_renderer(&self, id: &RendererId) -> Option<RendererInfo>;
|
||||
fn get_server(&self, id: &ServerId) -> Option<MediaServerInfo>;
|
||||
}
|
||||
|
||||
impl DeviceRegistryRead for DeviceRegistry {
|
||||
fn list_renderers(&self) -> Vec<RendererInfo> {
|
||||
self.renderers.values().cloned().collect()
|
||||
}
|
||||
|
||||
fn list_servers(&self) -> Vec<MediaServerInfo> {
|
||||
self.servers.values().cloned().collect()
|
||||
}
|
||||
|
||||
fn get_renderer(&self, id: &RendererId) -> Option<RendererInfo> {
|
||||
self.renderers.get(id).cloned()
|
||||
}
|
||||
|
||||
fn get_server(&self, id: &ServerId) -> Option<MediaServerInfo> {
|
||||
self.servers.get(id).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum DeviceUpdate {
|
||||
OfflineById(DeviceId),
|
||||
OfflineByUdn(String),
|
||||
}
|
||||
RendererOnline(RendererInfo),
|
||||
RendererOfflineById(RendererId),
|
||||
RendererOfflineByUdn(String),
|
||||
|
||||
impl DeviceItem {
|
||||
pub fn as_music_renderer(&self) -> Result<Arc<MusicRenderer>, ControlPointError> {
|
||||
self.music_renderer
|
||||
.clone() // Clone l'Option<Arc<...>>
|
||||
.ok_or_else(|| ControlPointError::IsNotAMediaRender(format!("{:#?}", self)))
|
||||
}
|
||||
|
||||
pub fn is_a_music_renderer(&self) -> bool {
|
||||
self.music_renderer.is_some()
|
||||
}
|
||||
|
||||
pub fn as_music_server(&self) -> Result<Arc<MusicServer>, ControlPointError> {
|
||||
self.music_server
|
||||
.clone() // Clone l'Option<Arc<...>>
|
||||
.ok_or_else(|| ControlPointError::IsNotAMediaRender(format!("{:#?}", self)))
|
||||
}
|
||||
|
||||
pub fn is_a_music_server(&self) -> bool {
|
||||
self.music_server.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl DeviceOnline for DeviceItem {
|
||||
fn is_online(&self) -> bool {
|
||||
self.music_renderer
|
||||
.as_ref()
|
||||
.map_or(false, |r| r.is_online())
|
||||
|| self.music_server.as_ref().map_or(false, |s| s.is_online())
|
||||
}
|
||||
|
||||
fn last_seen(&self) -> SystemTime {
|
||||
let renderer_time = self.music_renderer.as_ref().map(|r| r.last_seen());
|
||||
let server_time = self.music_server.as_ref().map(|s| s.last_seen());
|
||||
|
||||
match (renderer_time, server_time) {
|
||||
(Some(r), Some(s)) => r.max(s), // Le plus récent
|
||||
(Some(r), None) => r,
|
||||
(None, Some(s)) => s,
|
||||
(None, None) => unreachable!("DeviceEntry must have at least one component"),
|
||||
}
|
||||
}
|
||||
|
||||
fn has_been_seen_now(&self, max_age: u32) {
|
||||
if let Some(r) = &self.music_renderer {
|
||||
r.has_been_seen_now(max_age);
|
||||
}
|
||||
if let Some(s) = &self.music_server {
|
||||
s.has_been_seen_now(max_age);
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_as_offline(&self) {
|
||||
if let Some(r) = &self.music_renderer {
|
||||
r.mark_as_offline();
|
||||
}
|
||||
if let Some(s) = &self.music_server {
|
||||
s.mark_as_offline();
|
||||
}
|
||||
}
|
||||
|
||||
fn max_age(&self) -> u32 {
|
||||
let renderer_max_age = self.music_renderer.as_ref().map(|r| r.max_age());
|
||||
let server_max_age = self.music_server.as_ref().map(|s| s.max_age());
|
||||
|
||||
match (renderer_max_age, server_max_age) {
|
||||
(Some(r), Some(s)) => r.max(s), // Le plus récent
|
||||
(Some(r), None) => r,
|
||||
(None, Some(s)) => s,
|
||||
(None, None) => unreachable!("DeviceEntry must have at least one component"),
|
||||
}
|
||||
}
|
||||
ServerOnline(MediaServerInfo),
|
||||
ServerOfflineById(ServerId),
|
||||
ServerOfflineByUdn(String),
|
||||
}
|
||||
|
||||
impl DeviceRegistry {
|
||||
pub fn new(renderer_bus: &RendererEventBus, server_bus: &MediaServerEventBus) -> Self {
|
||||
Self {
|
||||
devices: HashMap::new(),
|
||||
udn_index: HashMap::new(),
|
||||
renderer_bus: renderer_bus.clone(),
|
||||
server_bus: server_bus.clone(),
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn apply_update(&mut self, update: DeviceUpdate) {
|
||||
match update {
|
||||
DeviceUpdate::RendererOnline(info) => {
|
||||
let udn = info.udn.to_ascii_lowercase();
|
||||
let id = info.id.clone();
|
||||
let mut info = info;
|
||||
|
||||
info.online = true;
|
||||
info.last_seen = SystemTime::now();
|
||||
|
||||
self.renderers.insert(id.clone(), info);
|
||||
self.udn_index.insert(udn, DeviceKey::Renderer(id));
|
||||
}
|
||||
DeviceUpdate::RendererOfflineById(id) => {
|
||||
if let Some(info) = self.renderers.get_mut(&id) {
|
||||
info.online = false;
|
||||
info.last_seen = SystemTime::now();
|
||||
}
|
||||
}
|
||||
DeviceUpdate::RendererOfflineByUdn(udn) => {
|
||||
let lookup = udn.to_ascii_lowercase();
|
||||
if let Some(DeviceKey::Renderer(id)) = self.udn_index.get(&lookup) {
|
||||
if let Some(info) = self.renderers.get_mut(id) {
|
||||
info.online = false;
|
||||
info.last_seen = SystemTime::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
DeviceUpdate::ServerOnline(info) => {
|
||||
let udn = info.udn.to_ascii_lowercase();
|
||||
let id = info.id.clone();
|
||||
let mut info = info;
|
||||
|
||||
info.online = true;
|
||||
info.last_seen = SystemTime::now();
|
||||
|
||||
self.servers.insert(id.clone(), info);
|
||||
self.udn_index.insert(udn, DeviceKey::Server(id));
|
||||
}
|
||||
DeviceUpdate::ServerOfflineById(id) => {
|
||||
if let Some(info) = self.servers.get_mut(&id) {
|
||||
info.online = false;
|
||||
info.last_seen = SystemTime::now();
|
||||
}
|
||||
}
|
||||
DeviceUpdate::ServerOfflineByUdn(udn) => {
|
||||
let lookup = udn.to_ascii_lowercase();
|
||||
if let Some(DeviceKey::Server(id)) = self.udn_index.get(&lookup) {
|
||||
if let Some(info) = self.servers.get_mut(id) {
|
||||
info.online = false;
|
||||
info.last_seen = SystemTime::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_renderers(&self) -> Result<Vec<Arc<MusicRenderer>>, ControlPointError> {
|
||||
self.devices
|
||||
.iter()
|
||||
.filter(|(_, item)| item.is_a_music_renderer())
|
||||
.map(|(_, item)| item.as_music_renderer())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn list_servers(&self) -> Result<Vec<Arc<MusicServer>>, ControlPointError> {
|
||||
self.devices
|
||||
.iter()
|
||||
.filter(|(_, item)| item.is_a_music_server())
|
||||
.map(|(_, item)| item.as_music_server())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn get_renderer(&self, id: &DeviceId) -> Option<Arc<MusicRenderer>> {
|
||||
self.devices.get(id)?.as_music_renderer().ok()
|
||||
}
|
||||
|
||||
pub fn get_server(&self, id: &DeviceId) -> Option<Arc<MusicServer>> {
|
||||
self.devices.get(id)?.as_music_server().ok()
|
||||
}
|
||||
|
||||
pub fn get_device_by_udn(&self, udn: &str) -> Option<DeviceItem> {
|
||||
let lookup = udn.to_ascii_lowercase();
|
||||
self.udn_index
|
||||
.get(&lookup)
|
||||
.and_then(|id| self.devices.get(id).cloned())
|
||||
}
|
||||
|
||||
/// Helper: get a renderer by UDN (case-insensitive, via udn_index).
|
||||
pub fn get_renderer_by_udn(&self, udn: &str) -> Option<Arc<MusicRenderer>> {
|
||||
self.get_device_by_udn(udn)
|
||||
.and_then(|item| item.as_music_renderer().ok())
|
||||
pub fn get_renderer_by_udn(&self, udn: &str) -> Option<RendererInfo> {
|
||||
let lookup = udn.to_ascii_lowercase();
|
||||
match self.udn_index.get(&lookup) {
|
||||
Some(DeviceKey::Renderer(id)) => self.renderers.get(id).cloned(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper: get a server by UDN (case-insensitive, via udn_index).
|
||||
pub fn get_server_by_udn(&self, udn: &str) -> Option<Arc<MusicServer>> {
|
||||
self.get_device_by_udn(udn)
|
||||
.and_then(|item| item.as_music_server().ok())
|
||||
}
|
||||
|
||||
pub fn push_renderer(&mut self, info: &RendererInfo, max_age: u32) {
|
||||
let device_id = info.id();
|
||||
|
||||
if let Some(entry) = self.devices.get_mut(&device_id) {
|
||||
if let Some(renderer) = &entry.music_renderer {
|
||||
let was_online = renderer.is_online();
|
||||
renderer.has_been_seen_now(max_age);
|
||||
|
||||
if !was_online {
|
||||
self.renderer_bus.broadcast(RendererEvent::Online {
|
||||
id: device_id.clone(),
|
||||
info: info.basic_info(),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Entry existe mais pas de renderer -> on l'ajoute
|
||||
if let Ok(new_renderer) = MusicRenderer::from_renderer_info(info) {
|
||||
entry.music_renderer = Some(Arc::new(new_renderer));
|
||||
self.udn_index
|
||||
.insert(info.udn().to_ascii_lowercase(), device_id.clone());
|
||||
|
||||
// Broadcast sur le bon bus
|
||||
self.renderer_bus.broadcast(RendererEvent::Online {
|
||||
id: device_id.clone(),
|
||||
info: info.basic_info(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Entry n'existe pas -> on crée
|
||||
if let Ok(new_renderer) = MusicRenderer::from_renderer_info(info) {
|
||||
let new_entry = DeviceItem {
|
||||
music_renderer: Some(Arc::new(new_renderer)),
|
||||
music_server: None,
|
||||
};
|
||||
|
||||
self.devices.insert(device_id.clone(), new_entry);
|
||||
self.udn_index
|
||||
.insert(info.udn().to_ascii_lowercase(), device_id.clone());
|
||||
|
||||
// Broadcast sur le bon bus
|
||||
self.renderer_bus.broadcast(RendererEvent::Online {
|
||||
id: device_id,
|
||||
info: info.basic_info(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_server(&mut self, info: &UpnpMediaServer, max_age: u32) {
|
||||
let device_id = info.id();
|
||||
|
||||
if let Some(entry) = self.devices.get_mut(&device_id) {
|
||||
if let Some(server) = &entry.music_server {
|
||||
let was_online = server.is_online();
|
||||
server.has_been_seen_now(max_age);
|
||||
|
||||
if !was_online {
|
||||
self.server_bus.broadcast(MediaServerEvent::Online {
|
||||
server_id: device_id.clone(),
|
||||
info: info.basic_info(),
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
// Entry existe mais pas de renderer -> on l'ajoute
|
||||
if let Ok(new_server) = MusicServer::from_server_info(info) {
|
||||
entry.music_server = Some(Arc::new(new_server));
|
||||
self.udn_index
|
||||
.insert(info.udn().to_ascii_lowercase(), device_id.clone());
|
||||
|
||||
// Broadcast sur le bon bus
|
||||
self.server_bus.broadcast(MediaServerEvent::Online {
|
||||
server_id: device_id.clone(),
|
||||
info: info.basic_info(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Entry n'existe pas -> on crée
|
||||
if let Ok(new_server) = MusicServer::from_server_info(info) {
|
||||
let new_entry = DeviceItem {
|
||||
music_renderer: None,
|
||||
music_server: Some(Arc::new(new_server)),
|
||||
};
|
||||
|
||||
self.devices.insert(device_id.clone(), new_entry);
|
||||
self.udn_index
|
||||
.insert(info.udn().to_ascii_lowercase(), device_id.clone());
|
||||
|
||||
// Broadcast sur le bon bus
|
||||
self.server_bus.broadcast(MediaServerEvent::Online {
|
||||
server_id: device_id,
|
||||
info: info.basic_info(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn device_says_byebye(&mut self, udn: &str) {
|
||||
pub fn get_server_by_udn(&self, udn: &str) -> Option<MediaServerInfo> {
|
||||
let lookup = udn.to_ascii_lowercase();
|
||||
|
||||
if let Some(id) = self.udn_index.get(&lookup) {
|
||||
if let Some(device) = self.devices.get(id) {
|
||||
device.mark_as_offline();
|
||||
|
||||
// Broadcast sur le bon bus
|
||||
if device.is_a_music_renderer() {
|
||||
self.renderer_bus
|
||||
.broadcast(RendererEvent::Offline { id: id.clone() });
|
||||
}
|
||||
if device.is_a_music_server() {
|
||||
self.server_bus.broadcast(MediaServerEvent::Offline {
|
||||
server_id: id.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
match self.udn_index.get(&lookup) {
|
||||
Some(DeviceKey::Server(id)) => self.servers.get(id).cloned(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn check_timeouts(&mut self) {
|
||||
let now = SystemTime::now();
|
||||
/// Construct an AvTransportClient for a given renderer id, if possible.
|
||||
///
|
||||
/// Returns:
|
||||
/// - Some(client) if the renderer exists AND has avtransport_* fields set
|
||||
/// - None if renderer not found or no AVTransport service.
|
||||
pub fn avtransport_client_for_renderer(&self, id: &RendererId) -> Option<AvTransportClient> {
|
||||
let info = self.renderers.get(id)?;
|
||||
|
||||
for (id, device) in &self.devices {
|
||||
if let Ok(elapsed) = now.duration_since(device.last_seen()) {
|
||||
if elapsed.as_secs() > device.max_age() as u64 {
|
||||
device.mark_as_offline();
|
||||
let service_type = info.avtransport_service_type.as_ref()?;
|
||||
let control_url = info.avtransport_control_url.as_ref()?;
|
||||
|
||||
// Broadcast sur le bon bus
|
||||
if device.is_a_music_renderer() {
|
||||
self.renderer_bus
|
||||
.broadcast(RendererEvent::Offline { id: id.clone() });
|
||||
}
|
||||
if device.is_a_music_server() {
|
||||
self.server_bus.broadcast(MediaServerEvent::Offline {
|
||||
server_id: id.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(AvTransportClient::new(
|
||||
control_url.clone(),
|
||||
service_type.clone(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Construct an AvTransportClient for a given UDN, if possible.
|
||||
pub fn avtransport_client_for_udn(&self, udn: &str) -> Option<AvTransportClient> {
|
||||
let info = self.get_renderer_by_udn(udn)?;
|
||||
|
||||
let service_type = info.avtransport_service_type?;
|
||||
let control_url = info.avtransport_control_url?;
|
||||
|
||||
Some(AvTransportClient::new(control_url, service_type))
|
||||
}
|
||||
|
||||
/// Construct a RenderingControlClient for a given renderer id, if possible.
|
||||
pub fn rendering_control_client_for_renderer(
|
||||
&self,
|
||||
id: &RendererId,
|
||||
) -> Option<RenderingControlClient> {
|
||||
let info = self.renderers.get(id)?;
|
||||
|
||||
let service_type = info.rendering_control_service_type.as_ref()?;
|
||||
let control_url = info.rendering_control_control_url.as_ref()?;
|
||||
|
||||
Some(RenderingControlClient::new(
|
||||
control_url.clone(),
|
||||
service_type.clone(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Construct a ConnectionManagerClient for a given renderer id, if possible.
|
||||
pub fn connection_manager_client_for_renderer(
|
||||
&self,
|
||||
id: &RendererId,
|
||||
) -> Option<ConnectionManagerClient> {
|
||||
let info = self.renderers.get(id)?;
|
||||
|
||||
let service_type = info.connection_manager_service_type.as_ref()?;
|
||||
let control_url = info.connection_manager_control_url.as_ref()?;
|
||||
|
||||
Some(ConnectionManagerClient::new(
|
||||
control_url.clone(),
|
||||
service_type.clone(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn mark_renderer_supports_set_next(&mut self, id: &RendererId) {
|
||||
if let Some(info) = self.renderers.get_mut(id) {
|
||||
if !info.capabilities.has_avtransport_set_next {
|
||||
info.capabilities.has_avtransport_set_next = true;
|
||||
debug!(
|
||||
renderer = id.0.as_str(),
|
||||
"Renderer now marked as supporting AVTransport.SetNextAVTransportURI"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
231
pmocontrol/src/rendering_control_client.rs
Normal file
231
pmocontrol/src/rendering_control_client.rs
Normal file
@@ -0,0 +1,231 @@
|
||||
use crate::soap_client::{SoapCallResult, invoke_upnp_action};
|
||||
use anyhow::{Result, anyhow};
|
||||
use pmoupnp::soap::SoapEnvelope;
|
||||
use xmltree::{Element, XMLNode};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RenderingControlClient {
|
||||
pub control_url: String,
|
||||
pub service_type: String,
|
||||
}
|
||||
|
||||
impl RenderingControlClient {
|
||||
pub fn new(control_url: String, service_type: String) -> Self {
|
||||
Self {
|
||||
control_url,
|
||||
service_type,
|
||||
}
|
||||
}
|
||||
|
||||
/// RenderingControl:1 — GetVolume
|
||||
pub fn get_volume(&self, instance_id: u32, channel: &str) -> Result<u16> {
|
||||
let instance_id_str = instance_id.to_string();
|
||||
let args = [
|
||||
("InstanceID", instance_id_str.as_str()),
|
||||
("Channel", channel),
|
||||
];
|
||||
|
||||
let call_result =
|
||||
invoke_upnp_action(&self.control_url, &self.service_type, "GetVolume", &args)?;
|
||||
|
||||
ensure_success("GetVolume", &call_result)?;
|
||||
|
||||
let envelope = call_result
|
||||
.envelope
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("Missing SOAP envelope in GetVolume response"))?;
|
||||
|
||||
if let Some(err) = parse_upnp_error(envelope) {
|
||||
return Err(anyhow!(
|
||||
"GetVolume returned UPnP error {}: {} (HTTP status {})",
|
||||
err.error_code,
|
||||
err.error_description,
|
||||
call_result.status
|
||||
));
|
||||
}
|
||||
|
||||
let response = find_child_with_suffix(&envelope.body.content, "GetVolumeResponse")
|
||||
.ok_or_else(|| anyhow!("Missing GetVolumeResponse element in SOAP body"))?;
|
||||
|
||||
let text = extract_child_text(response, "CurrentVolume")?;
|
||||
let volume = text
|
||||
.parse::<u16>()
|
||||
.map_err(|_| anyhow!("Invalid CurrentVolume value: {}", text))?;
|
||||
|
||||
Ok(volume)
|
||||
}
|
||||
|
||||
/// RenderingControl:1 — SetVolume
|
||||
pub fn set_volume(&self, instance_id: u32, channel: &str, volume: u16) -> Result<()> {
|
||||
let instance_id_str = instance_id.to_string();
|
||||
let volume_str = volume.to_string();
|
||||
let args = [
|
||||
("InstanceID", instance_id_str.as_str()),
|
||||
("Channel", channel),
|
||||
("DesiredVolume", volume_str.as_str()),
|
||||
];
|
||||
|
||||
let call_result =
|
||||
invoke_upnp_action(&self.control_url, &self.service_type, "SetVolume", &args)?;
|
||||
|
||||
handle_action_response("SetVolume", &call_result)
|
||||
}
|
||||
|
||||
/// RenderingControl:1 — GetMute
|
||||
pub fn get_mute(&self, instance_id: u32, channel: &str) -> Result<bool> {
|
||||
let instance_id_str = instance_id.to_string();
|
||||
let args = [
|
||||
("InstanceID", instance_id_str.as_str()),
|
||||
("Channel", channel),
|
||||
];
|
||||
|
||||
let call_result =
|
||||
invoke_upnp_action(&self.control_url, &self.service_type, "GetMute", &args)?;
|
||||
|
||||
ensure_success("GetMute", &call_result)?;
|
||||
|
||||
let envelope = call_result
|
||||
.envelope
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("Missing SOAP envelope in GetMute response"))?;
|
||||
|
||||
if let Some(err) = parse_upnp_error(envelope) {
|
||||
return Err(anyhow!(
|
||||
"GetMute returned UPnP error {}: {} (HTTP status {})",
|
||||
err.error_code,
|
||||
err.error_description,
|
||||
call_result.status
|
||||
));
|
||||
}
|
||||
|
||||
let response = find_child_with_suffix(&envelope.body.content, "GetMuteResponse")
|
||||
.ok_or_else(|| anyhow!("Missing GetMuteResponse element in SOAP body"))?;
|
||||
|
||||
let text = extract_child_text(response, "CurrentMute")?;
|
||||
let mute = match text.as_str() {
|
||||
"0" => false,
|
||||
"1" => true,
|
||||
_ => {
|
||||
return Err(anyhow!(
|
||||
"Invalid CurrentMute value: {} (expected 0 or 1)",
|
||||
text
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(mute)
|
||||
}
|
||||
|
||||
/// RenderingControl:1 — SetMute
|
||||
pub fn set_mute(&self, instance_id: u32, channel: &str, mute: bool) -> Result<()> {
|
||||
let instance_id_str = instance_id.to_string();
|
||||
let mute_str = if mute { "1" } else { "0" };
|
||||
let args = [
|
||||
("InstanceID", instance_id_str.as_str()),
|
||||
("Channel", channel),
|
||||
("DesiredMute", mute_str),
|
||||
];
|
||||
|
||||
let call_result =
|
||||
invoke_upnp_action(&self.control_url, &self.service_type, "SetMute", &args)?;
|
||||
|
||||
handle_action_response("SetMute", &call_result)
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_success(action: &str, call_result: &SoapCallResult) -> Result<()> {
|
||||
if call_result.status.is_success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(env) = &call_result.envelope {
|
||||
if let Some(err) = parse_upnp_error(env) {
|
||||
return Err(anyhow!(
|
||||
"{action} failed with UPnP error {}: {} (HTTP status {})",
|
||||
err.error_code,
|
||||
err.error_description,
|
||||
call_result.status
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!(
|
||||
"{action} failed with HTTP status {} and body: {}",
|
||||
call_result.status,
|
||||
call_result.raw_body
|
||||
))
|
||||
}
|
||||
|
||||
fn handle_action_response(action: &str, call_result: &SoapCallResult) -> Result<()> {
|
||||
ensure_success(action, call_result)?;
|
||||
|
||||
if let Some(env) = &call_result.envelope {
|
||||
if let Some(err) = parse_upnp_error(env) {
|
||||
return Err(anyhow!(
|
||||
"{action} returned UPnP error {}: {} (HTTP status {})",
|
||||
err.error_code,
|
||||
err.error_description,
|
||||
call_result.status
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct UpnpError {
|
||||
pub error_code: u32,
|
||||
pub error_description: String,
|
||||
}
|
||||
|
||||
fn parse_upnp_error(envelope: &SoapEnvelope) -> Option<UpnpError> {
|
||||
let fault = find_child_with_suffix(&envelope.body.content, "Fault")?;
|
||||
let detail = find_child_with_suffix(fault, "detail")?;
|
||||
let upnp_error = find_child_with_suffix(detail, "UPnPError")?;
|
||||
|
||||
let error_code_elem = upnp_error.children.iter().find_map(|node| match node {
|
||||
XMLNode::Element(elem) if elem.name.ends_with("errorCode") => Some(elem),
|
||||
_ => None,
|
||||
})?;
|
||||
|
||||
let binding = error_code_elem.get_text()?;
|
||||
let error_code_text = binding.trim();
|
||||
let error_code = error_code_text.parse::<u32>().ok()?;
|
||||
|
||||
let error_description = upnp_error
|
||||
.children
|
||||
.iter()
|
||||
.find_map(|node| match node {
|
||||
XMLNode::Element(elem) if elem.name.ends_with("errorDescription") => {
|
||||
elem.get_text().map(|t| t.trim().to_string())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| String::from(""));
|
||||
|
||||
Some(UpnpError {
|
||||
error_code,
|
||||
error_description,
|
||||
})
|
||||
}
|
||||
|
||||
fn find_child_with_suffix<'a>(parent: &'a Element, suffix: &str) -> Option<&'a Element> {
|
||||
parent.children.iter().find_map(|node| match node {
|
||||
XMLNode::Element(elem) if elem.name.ends_with(suffix) => Some(elem),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_child_text(parent: &Element, suffix: &str) -> Result<String> {
|
||||
let child = find_child_with_suffix(parent, suffix)
|
||||
.ok_or_else(|| anyhow!("Missing {suffix} element in response"))?;
|
||||
|
||||
let text = child
|
||||
.get_text()
|
||||
.map(|t| t.trim().to_string())
|
||||
.filter(|t| !t.is_empty())
|
||||
.ok_or_else(|| anyhow!("{suffix} element missing text in response"))?;
|
||||
|
||||
Ok(text)
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use pmoupnp::soap::{SoapEnvelope, build_soap_request, parse_soap_envelope};
|
||||
use pmoupnp::soap::{build_soap_request, parse_soap_envelope, SoapEnvelope};
|
||||
use tracing::{debug, trace, warn};
|
||||
use ureq::Agent;
|
||||
|
||||
use crate::errors::ControlPointError;
|
||||
|
||||
/// Result of a SOAP call:
|
||||
/// - HTTP status code
|
||||
/// - raw XML body (always)
|
||||
@@ -36,7 +34,7 @@ pub fn invoke_upnp_action(
|
||||
service_type: &str,
|
||||
action: &str,
|
||||
args: &[(&str, &str)],
|
||||
) -> Result<SoapCallResult, ControlPointError> {
|
||||
) -> Result<SoapCallResult> {
|
||||
invoke_upnp_action_with_timeout(control_url, service_type, action, args, None)
|
||||
}
|
||||
|
||||
@@ -46,10 +44,9 @@ pub fn invoke_upnp_action_with_timeout(
|
||||
action: &str,
|
||||
args: &[(&str, &str)],
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<SoapCallResult, ControlPointError> {
|
||||
let body_xml = build_soap_body(action, service_type, args)
|
||||
.context("Failed to build SOAP request body")
|
||||
.map_err(|e| ControlPointError::SoapAction(format!("{}", e)))?;
|
||||
) -> Result<SoapCallResult> {
|
||||
let body_xml =
|
||||
build_soap_body(action, service_type, args).context("Failed to build SOAP request body")?;
|
||||
|
||||
let arg_log = summarize_args_for_log(args);
|
||||
debug!(
|
||||
@@ -80,8 +77,7 @@ pub fn invoke_upnp_action_with_timeout(
|
||||
.header("Content-Type", r#"text/xml; charset="utf-8""#)
|
||||
.header("SOAPAction", &soap_action_header)
|
||||
.send(body_xml)
|
||||
.with_context(|| format!("HTTP error when sending SOAP request to {}", control_url))
|
||||
.map_err(|e| ControlPointError::SoapAction(format!("{}", e)))?;
|
||||
.with_context(|| format!("HTTP error when sending SOAP request to {}", control_url))?;
|
||||
|
||||
let status = response.status();
|
||||
debug!(status = status.as_u16(), "SOAP response received");
|
||||
@@ -93,8 +89,7 @@ pub fn invoke_upnp_action_with_timeout(
|
||||
let raw_body = response
|
||||
.body_mut()
|
||||
.read_to_string()
|
||||
.context("Failed to read SOAP response body")
|
||||
.map_err(|e| ControlPointError::SoapAction(format!("{}", e)))?;
|
||||
.context("Failed to read SOAP response body")?;
|
||||
|
||||
// 6. Try to parse SOAP envelope; non-fatal on failure
|
||||
let parsed_envelope = parse_soap_envelope(raw_body.as_bytes()).ok();
|
||||
@@ -208,7 +203,7 @@ pub fn parse_upnp_error(envelope: &SoapEnvelope) -> Option<UpnpError> {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn find_child_with_suffix<'a>(
|
||||
fn find_child_with_suffix<'a>(
|
||||
parent: &'a xmltree::Element,
|
||||
suffix: &str,
|
||||
) -> Option<&'a xmltree::Element> {
|
||||
@@ -218,235 +213,6 @@ pub fn find_child_with_suffix<'a>(
|
||||
})
|
||||
}
|
||||
|
||||
pub fn find_child_with_local_name<'a>(
|
||||
parent: &'a xmltree::Element,
|
||||
local: &str,
|
||||
) -> Option<&'a xmltree::Element> {
|
||||
parent.children.iter().find_map(|node| {
|
||||
if let xmltree::XMLNode::Element(elem) = node {
|
||||
if elem.name == local || elem.name.ends_with(&format!(":{}", local)) {
|
||||
return Some(elem);
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
/// Ensures the SOAP call succeeded, returns error if HTTP status or UPnP error occurred
|
||||
pub fn ensure_success(action: &str, call_result: &SoapCallResult) -> Result<(), ControlPointError> {
|
||||
if call_result.status.is_success() {
|
||||
if let Some(env) = &call_result.envelope {
|
||||
if let Some(err) = parse_upnp_error(env) {
|
||||
return Err(ControlPointError::SoapUpnpParseError(
|
||||
action.to_string(),
|
||||
err.error_code,
|
||||
err.error_description,
|
||||
call_result.status.as_u16() as u32,
|
||||
));
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(env) = &call_result.envelope {
|
||||
if let Some(err) = parse_upnp_error(env) {
|
||||
return Err(ControlPointError::SoapUpnpParseError(
|
||||
action.to_string(),
|
||||
err.error_code,
|
||||
err.error_description,
|
||||
call_result.status.as_u16() as u32,
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(ControlPointError::SoapActionWrongBody(
|
||||
action.to_string(),
|
||||
call_result.status.as_u16() as u32,
|
||||
call_result.raw_body.clone(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Ensures success and returns the SOAP envelope (for openhome_client compatibility)
|
||||
pub fn ensure_success_with_envelope<'a>(
|
||||
action: &str,
|
||||
call_result: &'a SoapCallResult,
|
||||
) -> Result<&'a SoapEnvelope, ControlPointError> {
|
||||
if !call_result.status.is_success() {
|
||||
if let Some(env) = &call_result.envelope {
|
||||
if let Some(err) = parse_upnp_error(env) {
|
||||
return Err(ControlPointError::SoapUpnpParseError(
|
||||
action.to_string(),
|
||||
err.error_code,
|
||||
err.error_description,
|
||||
call_result.status.as_u16() as u32,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return Err(ControlPointError::SoapActionWrongBody(
|
||||
action.to_string(),
|
||||
call_result.status.as_u16() as u32,
|
||||
call_result.raw_body.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
let envelope = call_result
|
||||
.envelope
|
||||
.as_ref()
|
||||
.ok_or_else(|| ControlPointError::SoapNoEnvelop(action.to_string()))?;
|
||||
|
||||
if let Some(err) = parse_upnp_error(envelope) {
|
||||
return Err(ControlPointError::SoapUpnpParseError(
|
||||
action.to_string(),
|
||||
err.error_code,
|
||||
err.error_description,
|
||||
call_result.status.as_u16() as u32,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(envelope)
|
||||
}
|
||||
|
||||
/// Handles action response, ensuring success
|
||||
pub fn handle_action_response(
|
||||
action: &str,
|
||||
call_result: &SoapCallResult,
|
||||
) -> Result<(), ControlPointError> {
|
||||
ensure_success(action, call_result)
|
||||
}
|
||||
|
||||
pub fn extract_child_text(parent: &xmltree::Element, suffix: &str) -> Result<String, ControlPointError> {
|
||||
let child = find_child_with_suffix(parent, suffix)
|
||||
.ok_or_else(|| ControlPointError::UpnpMissingReturnValue(suffix.to_string()))?;
|
||||
|
||||
let text = child
|
||||
.get_text()
|
||||
.map(|t| t.trim().to_string())
|
||||
.filter(|t| !t.is_empty())
|
||||
.ok_or_else(|| ControlPointError::UpnpMissingReturnValue(suffix.to_string()))?;
|
||||
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
pub fn extract_child_text_allow_empty(parent: &xmltree::Element, suffix: &str) -> Result<String, ControlPointError> {
|
||||
let child = find_child_with_suffix(parent, suffix)
|
||||
.ok_or_else(|| ControlPointError::UpnpMissingReturnValue(suffix.to_string()))?;
|
||||
|
||||
let text = child
|
||||
.get_text()
|
||||
.map(|t| t.trim().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
pub fn extract_child_text_optional(
|
||||
parent: &xmltree::Element,
|
||||
suffix: &str,
|
||||
) -> Result<Option<String>> {
|
||||
if let Some(child) = find_child_with_suffix(parent, suffix) {
|
||||
let text = child
|
||||
.get_text()
|
||||
.map(|t| t.trim().to_string())
|
||||
.unwrap_or_default();
|
||||
Ok(Some(text))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extract_child_text_any(
|
||||
parent: &xmltree::Element,
|
||||
suffixes: &[&str],
|
||||
) -> Result<String, ControlPointError> {
|
||||
for suffix in suffixes {
|
||||
if let Ok(text) = extract_child_text(parent, suffix) {
|
||||
return Ok(text);
|
||||
}
|
||||
}
|
||||
Err(ControlPointError::UpnpMissingReturnValue(
|
||||
suffixes.join(" or "),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn extract_child_text_local(parent: &xmltree::Element, local: &str) -> Result<String, ControlPointError> {
|
||||
let child = find_child_with_local_name(parent, local)
|
||||
.ok_or_else(|| ControlPointError::SoapAction(format!("Missing {local} element in response")))?;
|
||||
let text = child
|
||||
.get_text()
|
||||
.map(|t| t.trim().to_string())
|
||||
.ok_or_else(|| ControlPointError::SoapAction(format!("{local} element missing text in response")))?;
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
pub fn extract_child_text_optional_local(
|
||||
parent: &xmltree::Element,
|
||||
local: &str,
|
||||
) -> Result<Option<String>, ControlPointError> {
|
||||
if let Some(child) = find_child_with_local_name(parent, local) {
|
||||
let text = child
|
||||
.get_text()
|
||||
.map(|t| t.trim().to_string())
|
||||
.unwrap_or_default();
|
||||
Ok(Some(text))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_visible_flag(value: &str) -> bool {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.eq_ignore_ascii_case("true") {
|
||||
return true;
|
||||
}
|
||||
if trimmed.eq_ignore_ascii_case("false") {
|
||||
return false;
|
||||
}
|
||||
trimmed == "1"
|
||||
}
|
||||
|
||||
pub fn parse_bool(value: &str) -> bool {
|
||||
value.trim() == "1"
|
||||
}
|
||||
|
||||
|
||||
pub fn decode_base64(input: &str) -> Result<Vec<u8>, ControlPointError> {
|
||||
fn value(byte: u8) -> Option<u8> {
|
||||
match byte {
|
||||
b'A'..=b'Z' => Some(byte - b'A'),
|
||||
b'a'..=b'z' => Some(byte - b'a' + 26),
|
||||
b'0'..=b'9' => Some(byte - b'0' + 52),
|
||||
b'+' => Some(62),
|
||||
b'/' => Some(63),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
let mut output = Vec::new();
|
||||
let mut buffer: u32 = 0;
|
||||
let mut bits_collected: u8 = 0;
|
||||
|
||||
for byte in input.bytes() {
|
||||
if byte == b'=' {
|
||||
break;
|
||||
}
|
||||
if byte == b'\r' || byte == b'\n' || byte == b' ' || byte == b'\t' {
|
||||
continue;
|
||||
}
|
||||
let val =
|
||||
value(byte).ok_or_else(|| ControlPointError::ParsingError(format!("Invalid base64 character '{}'", byte as char)))?;
|
||||
buffer = (buffer << 6) | (val as u32);
|
||||
bits_collected += 6;
|
||||
if bits_collected >= 8 {
|
||||
bits_collected -= 8;
|
||||
let out = (buffer >> bits_collected) & 0xFF;
|
||||
output.push(out as u8);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::build_soap_body;
|
||||
|
||||
@@ -32,9 +32,6 @@ use serde::Serialize;
|
||||
#[cfg(feature = "pmoserver")]
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{DeviceIdentity, DeviceOnline};
|
||||
use tracing::error;
|
||||
|
||||
// ============================================================================
|
||||
// PAYLOADS SSE
|
||||
// ============================================================================
|
||||
@@ -171,38 +168,6 @@ pub async fn renderer_events_sse(
|
||||
});
|
||||
|
||||
let stream = stream! {
|
||||
// INITIAL SNAPSHOT: Send Online events for all currently discovered renderers
|
||||
// This ensures clients see devices that were discovered before they connected
|
||||
let initial_renderers = {
|
||||
let registry = control_point.registry();
|
||||
let reg = registry.read().unwrap();
|
||||
match reg.list_renderers() {
|
||||
Ok(renderers) => renderers,
|
||||
Err(e) => {
|
||||
error!("Failed to list renderers: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for renderer in initial_renderers {
|
||||
if renderer.is_online() {
|
||||
let timestamp = chrono::Utc::now();
|
||||
let payload = RendererEventPayload::Online {
|
||||
renderer_id: renderer.id().0,
|
||||
friendly_name: renderer.friendly_name().to_string(),
|
||||
model_name: renderer.model_name().to_string(),
|
||||
manufacturer: renderer.manufacturer().to_string(),
|
||||
timestamp,
|
||||
};
|
||||
|
||||
if let Ok(json) = serde_json::to_string(&payload) {
|
||||
yield Ok::<_, axum::Error>(Event::default().event("renderer").data(json));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Then stream future events
|
||||
while let Some(event) = rx_tokio.recv().await {
|
||||
let timestamp = chrono::Utc::now();
|
||||
|
||||
@@ -265,9 +230,9 @@ pub async fn renderer_events_sse(
|
||||
RendererEvent::Online { id, info } => {
|
||||
RendererEventPayload::Online {
|
||||
renderer_id: id.0,
|
||||
friendly_name: info.friendly_name.clone(),
|
||||
model_name: info.model_name.clone(),
|
||||
manufacturer: info.manufacturer.clone(),
|
||||
friendly_name: info.friendly_name,
|
||||
model_name: info.model_name,
|
||||
manufacturer: info.manufacturer,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
@@ -320,38 +285,6 @@ pub async fn media_server_events_sse(
|
||||
});
|
||||
|
||||
let stream = stream! {
|
||||
// INITIAL SNAPSHOT: Send Online events for all currently discovered media servers
|
||||
// This ensures clients see servers that were discovered before they connected
|
||||
let initial_servers = {
|
||||
let registry = control_point.registry();
|
||||
let reg = registry.read().unwrap();
|
||||
match reg.list_servers() {
|
||||
Ok(servers) => servers,
|
||||
Err(e) => {
|
||||
error!("Failed to list servers: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for server in initial_servers {
|
||||
if server.is_online() {
|
||||
let timestamp = chrono::Utc::now();
|
||||
let payload = MediaServerEventPayload::Online {
|
||||
server_id: server.id().0,
|
||||
friendly_name: server.friendly_name().to_string(),
|
||||
model_name: server.model_name().to_string(),
|
||||
manufacturer: server.manufacturer().to_string(),
|
||||
timestamp,
|
||||
};
|
||||
|
||||
if let Ok(json) = serde_json::to_string(&payload) {
|
||||
yield Ok::<_, axum::Error>(Event::default().event("media_server").data(json));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Then stream future events
|
||||
while let Some(event) = rx_tokio.recv().await {
|
||||
let timestamp = chrono::Utc::now();
|
||||
|
||||
@@ -373,9 +306,9 @@ pub async fn media_server_events_sse(
|
||||
MediaServerEvent::Online { server_id, info } => {
|
||||
MediaServerEventPayload::Online {
|
||||
server_id: server_id.0,
|
||||
friendly_name: info.friendly_name.clone(),
|
||||
model_name: info.model_name.clone(),
|
||||
manufacturer: info.manufacturer.clone(),
|
||||
friendly_name: info.friendly_name,
|
||||
model_name: info.model_name,
|
||||
manufacturer: info.manufacturer,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
@@ -437,67 +370,6 @@ pub async fn all_events_sse(State(control_point): State<Arc<ControlPoint>>) -> i
|
||||
});
|
||||
|
||||
let stream = stream! {
|
||||
// INITIAL SNAPSHOT: Send Online events for all currently discovered devices
|
||||
// This ensures clients see devices that were discovered before they connected
|
||||
let (initial_renderers, initial_servers) = {
|
||||
let registry = control_point.registry();
|
||||
let reg = registry.read().unwrap();
|
||||
let renderers = match reg.list_renderers() {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
error!("Failed to list renderers: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
let servers = match reg.list_servers() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!("Failed to list servers: {}", e);
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
(renderers, servers)
|
||||
};
|
||||
|
||||
// Send renderer Online events
|
||||
for renderer in initial_renderers {
|
||||
if renderer.is_online() {
|
||||
let timestamp = chrono::Utc::now();
|
||||
let renderer_payload = RendererEventPayload::Online {
|
||||
renderer_id: renderer.id().0,
|
||||
friendly_name: renderer.friendly_name().to_string(),
|
||||
model_name: renderer.model_name().to_string(),
|
||||
manufacturer: renderer.manufacturer().to_string(),
|
||||
timestamp,
|
||||
};
|
||||
let payload = UnifiedEventPayload::Renderer(renderer_payload);
|
||||
|
||||
if let Ok(json) = serde_json::to_string(&payload) {
|
||||
yield Ok::<_, axum::Error>(Event::default().event("control").data(json));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send server Online events
|
||||
for server in initial_servers {
|
||||
if server.is_online() {
|
||||
let timestamp = chrono::Utc::now();
|
||||
let server_payload = MediaServerEventPayload::Online {
|
||||
server_id: server.id().0,
|
||||
friendly_name: server.friendly_name().to_string(),
|
||||
model_name: server.model_name().to_string(),
|
||||
manufacturer: server.manufacturer().to_string(),
|
||||
timestamp,
|
||||
};
|
||||
let payload = UnifiedEventPayload::MediaServer(server_payload);
|
||||
|
||||
if let Ok(json) = serde_json::to_string(&payload) {
|
||||
yield Ok::<_, axum::Error>(Event::default().event("control").data(json));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Then stream future events
|
||||
loop {
|
||||
tokio::select! {
|
||||
Some(event) = renderer_rx_tokio.recv() => {
|
||||
@@ -562,9 +434,9 @@ pub async fn all_events_sse(State(control_point): State<Arc<ControlPoint>>) -> i
|
||||
RendererEvent::Online { id, info } => {
|
||||
RendererEventPayload::Online {
|
||||
renderer_id: id.0,
|
||||
friendly_name: info.friendly_name.clone(),
|
||||
model_name: info.model_name.clone(),
|
||||
manufacturer: info.manufacturer.clone(),
|
||||
friendly_name: info.friendly_name,
|
||||
model_name: info.model_name,
|
||||
manufacturer: info.manufacturer,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
@@ -603,9 +475,9 @@ pub async fn all_events_sse(State(control_point): State<Arc<ControlPoint>>) -> i
|
||||
MediaServerEvent::Online { server_id, info } => {
|
||||
MediaServerEventPayload::Online {
|
||||
server_id: server_id.0,
|
||||
friendly_name: info.friendly_name.clone(),
|
||||
model_name: info.model_name.clone(),
|
||||
manufacturer: info.manufacturer.clone(),
|
||||
friendly_name: info.friendly_name,
|
||||
model_name: info.model_name,
|
||||
manufacturer: info.manufacturer,
|
||||
timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
use crate::{
|
||||
errors::ControlPointError,
|
||||
soap_client::{
|
||||
ensure_success, extract_child_text, extract_child_text_allow_empty, find_child_with_suffix,
|
||||
invoke_upnp_action, parse_upnp_error,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConnectionManagerClient {
|
||||
pub control_url: String,
|
||||
pub service_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProtocolInfo {
|
||||
/// Liste brute des protocolInfo "source" (séparés par virgule dans UPnP)
|
||||
pub source: Vec<String>,
|
||||
/// Liste brute des protocolInfo "sink"
|
||||
pub sink: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConnectionInfo {
|
||||
pub rcs_id: i32,
|
||||
pub av_transport_id: i32,
|
||||
pub protocol_info: String,
|
||||
pub peer_connection_manager: String,
|
||||
pub peer_connection_id: i32,
|
||||
pub direction: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
impl ConnectionManagerClient {
|
||||
pub fn new(control_url: String, service_type: String) -> Self {
|
||||
Self {
|
||||
control_url,
|
||||
service_type,
|
||||
}
|
||||
}
|
||||
|
||||
/// GetProtocolInfo
|
||||
pub fn get_protocol_info(&self) -> Result<ProtocolInfo, ControlPointError> {
|
||||
let call_result = invoke_upnp_action(
|
||||
&self.control_url,
|
||||
&self.service_type,
|
||||
"GetProtocolInfo",
|
||||
&[],
|
||||
)?;
|
||||
|
||||
ensure_success("GetProtocolInfo", &call_result)?;
|
||||
|
||||
let envelope = call_result.envelope.as_ref().ok_or_else(|| {
|
||||
ControlPointError::UpnpError(format!(
|
||||
"Missing SOAP envelope in GetProtocolInfo response"
|
||||
))
|
||||
})?;
|
||||
|
||||
if let Some(err) = parse_upnp_error(envelope) {
|
||||
return Err(ControlPointError::UpnpError(format!(
|
||||
"GetProtocolInfo returned UPnP error {}: {} (HTTP status {})",
|
||||
err.error_code, err.error_description, call_result.status
|
||||
)));
|
||||
}
|
||||
|
||||
let response = find_child_with_suffix(&envelope.body.content, "GetProtocolInfoResponse")
|
||||
.ok_or_else(|| {
|
||||
ControlPointError::UpnpError(format!(
|
||||
"Missing GetProtocolInfoResponse element in SOAP body"
|
||||
))
|
||||
})?;
|
||||
|
||||
let source_text = extract_child_text_allow_empty(response, "Source")?;
|
||||
let sink_text = extract_child_text_allow_empty(response, "Sink")?;
|
||||
|
||||
Ok(ProtocolInfo {
|
||||
source: split_list(&source_text),
|
||||
sink: split_list(&sink_text),
|
||||
})
|
||||
}
|
||||
|
||||
/// GetCurrentConnectionIDs
|
||||
pub fn get_current_connection_ids(&self) -> Result<Vec<i32>, ControlPointError> {
|
||||
let call_result = invoke_upnp_action(
|
||||
&self.control_url,
|
||||
&self.service_type,
|
||||
"GetCurrentConnectionIDs",
|
||||
&[],
|
||||
)?;
|
||||
|
||||
ensure_success("GetCurrentConnectionIDs", &call_result)?;
|
||||
|
||||
let envelope = call_result.envelope.as_ref().ok_or_else(|| {
|
||||
ControlPointError::UpnpError(format!(
|
||||
"Missing SOAP envelope in GetCurrentConnectionIDs response"
|
||||
))
|
||||
})?;
|
||||
|
||||
if let Some(err) = parse_upnp_error(envelope) {
|
||||
return Err(ControlPointError::UpnpError(format!(
|
||||
"GetCurrentConnectionIDs returned UPnP error {}: {} (HTTP status {})",
|
||||
err.error_code, err.error_description, call_result.status
|
||||
)));
|
||||
}
|
||||
|
||||
let response =
|
||||
find_child_with_suffix(&envelope.body.content, "GetCurrentConnectionIDsResponse")
|
||||
.ok_or_else(|| {
|
||||
ControlPointError::UpnpError(format!(
|
||||
"Missing GetCurrentConnectionIDsResponse element in SOAP body"
|
||||
))
|
||||
})?;
|
||||
|
||||
let ids_text = extract_child_text_allow_empty(response, "ConnectionIDs")?;
|
||||
let trimmed = ids_text.trim();
|
||||
|
||||
if trimmed.is_empty() || trimmed == "0" {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut ids = Vec::new();
|
||||
for part in trimmed.split(',') {
|
||||
let value = part.trim();
|
||||
if value.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let parsed = value.parse::<i32>().map_err(|_| {
|
||||
ControlPointError::UpnpError(format!("Invalid ConnectionID value: {}", value))
|
||||
})?;
|
||||
ids.push(parsed);
|
||||
}
|
||||
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// GetCurrentConnectionInfo
|
||||
pub fn get_current_connection_info(
|
||||
&self,
|
||||
connection_id: i32,
|
||||
) -> Result<ConnectionInfo, ControlPointError> {
|
||||
let connection_id_str = connection_id.to_string();
|
||||
let args = [("ConnectionID", connection_id_str.as_str())];
|
||||
|
||||
let call_result = invoke_upnp_action(
|
||||
&self.control_url,
|
||||
&self.service_type,
|
||||
"GetCurrentConnectionInfo",
|
||||
&args,
|
||||
)?;
|
||||
|
||||
ensure_success("GetCurrentConnectionInfo", &call_result)?;
|
||||
|
||||
let envelope = call_result.envelope.as_ref().ok_or_else(|| {
|
||||
ControlPointError::UpnpError(format!(
|
||||
"Missing SOAP envelope in GetCurrentConnectionInfo response"
|
||||
))
|
||||
})?;
|
||||
|
||||
if let Some(err) = parse_upnp_error(envelope) {
|
||||
return Err(ControlPointError::UpnpError(format!(
|
||||
"GetCurrentConnectionInfo returned UPnP error {}: {} (HTTP status {})",
|
||||
err.error_code, err.error_description, call_result.status
|
||||
)));
|
||||
}
|
||||
|
||||
let response =
|
||||
find_child_with_suffix(&envelope.body.content, "GetCurrentConnectionInfoResponse")
|
||||
.ok_or_else(|| {
|
||||
ControlPointError::UpnpError(format!(
|
||||
"Missing GetCurrentConnectionInfoResponse element in SOAP body"
|
||||
))
|
||||
})?;
|
||||
|
||||
let rcs_id = extract_child_text(response, "RcsID")?
|
||||
.parse::<i32>()
|
||||
.map_err(|_| {
|
||||
ControlPointError::UpnpError(format!("Invalid RcsID value in response"))
|
||||
})?;
|
||||
|
||||
let av_transport_id = extract_child_text(response, "AVTransportID")?
|
||||
.parse::<i32>()
|
||||
.map_err(|_| {
|
||||
ControlPointError::UpnpError(format!("Invalid AVTransportID value in response"))
|
||||
})?;
|
||||
|
||||
let protocol_info = extract_child_text_allow_empty(response, "ProtocolInfo")?;
|
||||
let peer_connection_manager =
|
||||
extract_child_text_allow_empty(response, "PeerConnectionManager")?;
|
||||
|
||||
let peer_connection_id = extract_child_text(response, "PeerConnectionID")?
|
||||
.parse::<i32>()
|
||||
.map_err(|_| {
|
||||
ControlPointError::UpnpError(format!("Invalid PeerConnectionID value in response"))
|
||||
})?;
|
||||
|
||||
let direction = extract_child_text(response, "Direction")?;
|
||||
let status = extract_child_text(response, "Status")?;
|
||||
|
||||
Ok(ConnectionInfo {
|
||||
rcs_id,
|
||||
av_transport_id,
|
||||
protocol_info,
|
||||
peer_connection_manager,
|
||||
peer_connection_id,
|
||||
direction,
|
||||
status,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn split_list(value: &str) -> Vec<String> {
|
||||
value
|
||||
.split(',')
|
||||
.filter_map(|part| {
|
||||
let trimmed = part.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
mod openhome_client;
|
||||
mod avtransport_client;
|
||||
mod rendering_control_client;
|
||||
mod connection_manager_client;
|
||||
|
||||
|
||||
pub use crate::upnp_clients::avtransport_client::{AvTransportClient, PositionInfo};
|
||||
pub use crate::upnp_clients::rendering_control_client::RenderingControlClient;
|
||||
pub use crate::upnp_clients::connection_manager_client::{ConnectionInfo, ConnectionManagerClient, ProtocolInfo};
|
||||
pub use crate::upnp_clients::openhome_client::{
|
||||
OPENHOME_PLAYLIST_HEAD_ID,
|
||||
OhTrackEntry,OhTrack,
|
||||
OhInfoClient, OhPlaylistClient, OhProductClient, OhRadioClient, OhTimeClient, OhVolumeClient,
|
||||
};
|
||||
|
||||
/// Resolve a possibly relative controlURL against the description URL.
|
||||
///
|
||||
/// - If `control_url` is already absolute (starts with http:// or https://), it is returned as-is.
|
||||
/// - Otherwise, it is resolved against the scheme://host:port of `description_url`.
|
||||
pub fn resolve_control_url(description_url: &str, control_url: &str) -> String {
|
||||
if control_url.starts_with("http://") || control_url.starts_with("https://") {
|
||||
return control_url.to_string();
|
||||
}
|
||||
|
||||
// Extract "scheme://host[:port]" from description_url
|
||||
if let Some((scheme, rest)) = description_url.split_once("://") {
|
||||
if let Some(pos) = rest.find('/') {
|
||||
let authority = &rest[..pos];
|
||||
let base = format!("{}://{}", scheme, authority);
|
||||
|
||||
if control_url.starts_with('/') {
|
||||
return format!("{}{}", base, control_url);
|
||||
} else {
|
||||
return format!("{}/{}", base, control_url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: just return the raw control_url if we cannot parse
|
||||
control_url.to_string()
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
use crate::{
|
||||
errors::ControlPointError,
|
||||
soap_client::{
|
||||
ensure_success, extract_child_text, find_child_with_suffix, handle_action_response,
|
||||
invoke_upnp_action, parse_upnp_error,
|
||||
},
|
||||
};
|
||||
use tracing::debug;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RenderingControlClient {
|
||||
pub control_url: String,
|
||||
pub service_type: String,
|
||||
}
|
||||
|
||||
impl RenderingControlClient {
|
||||
pub fn new(control_url: String, service_type: String) -> Self {
|
||||
Self {
|
||||
control_url,
|
||||
service_type,
|
||||
}
|
||||
}
|
||||
|
||||
/// RenderingControl:1 — GetVolume
|
||||
pub fn get_volume(&self, instance_id: u32, channel: &str) -> Result<u16, ControlPointError> {
|
||||
debug!("GetVolume: {}", instance_id);
|
||||
let instance_id_str = instance_id.to_string();
|
||||
let args = [
|
||||
("InstanceID", instance_id_str.as_str()),
|
||||
("Channel", channel),
|
||||
];
|
||||
|
||||
let call_result =
|
||||
invoke_upnp_action(&self.control_url, &self.service_type, "GetVolume", &args)?;
|
||||
|
||||
ensure_success("GetVolume", &call_result)?;
|
||||
|
||||
let envelope = call_result.envelope.as_ref().ok_or_else(|| {
|
||||
ControlPointError::UpnpError(format!("Missing SOAP envelope in GetVolume response"))
|
||||
})?;
|
||||
|
||||
if let Some(err) = parse_upnp_error(envelope) {
|
||||
return Err(ControlPointError::UpnpError(format!(
|
||||
"GetVolume returned UPnP error {}: {} (HTTP status {})",
|
||||
err.error_code, err.error_description, call_result.status
|
||||
)));
|
||||
}
|
||||
|
||||
let response = find_child_with_suffix(&envelope.body.content, "GetVolumeResponse")
|
||||
.ok_or_else(|| {
|
||||
ControlPointError::UpnpError(format!(
|
||||
"Missing GetVolumeResponse element in SOAP body"
|
||||
))
|
||||
})?;
|
||||
|
||||
let text = extract_child_text(response, "CurrentVolume")?;
|
||||
let volume = text.parse::<u16>().map_err(|_| {
|
||||
ControlPointError::UpnpError(format!("Invalid CurrentVolume value: {}", text))
|
||||
})?;
|
||||
|
||||
Ok(volume)
|
||||
}
|
||||
|
||||
/// RenderingControl:1 — SetVolume
|
||||
pub fn set_volume(
|
||||
&self,
|
||||
instance_id: u32,
|
||||
channel: &str,
|
||||
volume: u16,
|
||||
) -> Result<(), ControlPointError> {
|
||||
let instance_id_str = instance_id.to_string();
|
||||
let volume_str = volume.to_string();
|
||||
let args = [
|
||||
("InstanceID", instance_id_str.as_str()),
|
||||
("Channel", channel),
|
||||
("DesiredVolume", volume_str.as_str()),
|
||||
];
|
||||
|
||||
let call_result =
|
||||
invoke_upnp_action(&self.control_url, &self.service_type, "SetVolume", &args)?;
|
||||
|
||||
handle_action_response("SetVolume", &call_result)
|
||||
}
|
||||
|
||||
/// RenderingControl:1 — GetMute
|
||||
pub fn get_mute(&self, instance_id: u32, channel: &str) -> Result<bool, ControlPointError> {
|
||||
let instance_id_str = instance_id.to_string();
|
||||
let args = [
|
||||
("InstanceID", instance_id_str.as_str()),
|
||||
("Channel", channel),
|
||||
];
|
||||
|
||||
let call_result =
|
||||
invoke_upnp_action(&self.control_url, &self.service_type, "GetMute", &args)?;
|
||||
|
||||
ensure_success("GetMute", &call_result)?;
|
||||
|
||||
let envelope = call_result.envelope.as_ref().ok_or_else(|| {
|
||||
ControlPointError::UpnpError(format!("Missing SOAP envelope in GetMute response"))
|
||||
})?;
|
||||
|
||||
if let Some(err) = parse_upnp_error(envelope) {
|
||||
return Err(ControlPointError::UpnpError(format!(
|
||||
"GetMute returned UPnP error {}: {} (HTTP status {})",
|
||||
err.error_code, err.error_description, call_result.status
|
||||
)));
|
||||
}
|
||||
|
||||
let response = find_child_with_suffix(&envelope.body.content, "GetMuteResponse")
|
||||
.ok_or_else(|| {
|
||||
ControlPointError::UpnpError(format!(
|
||||
"Missing GetMuteResponse element in SOAP body"
|
||||
))
|
||||
})?;
|
||||
|
||||
let text = extract_child_text(response, "CurrentMute")?;
|
||||
let mute = match text.as_str() {
|
||||
"0" => false,
|
||||
"1" => true,
|
||||
_ => {
|
||||
return Err(ControlPointError::UpnpError(format!(
|
||||
"Invalid CurrentMute value: {} (expected 0 or 1)",
|
||||
text
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(mute)
|
||||
}
|
||||
|
||||
/// RenderingControl:1 — SetMute
|
||||
pub fn set_mute(
|
||||
&self,
|
||||
instance_id: u32,
|
||||
channel: &str,
|
||||
mute: bool,
|
||||
) -> Result<(), ControlPointError> {
|
||||
let instance_id_str = instance_id.to_string();
|
||||
let mute_str = if mute { "1" } else { "0" };
|
||||
let args = [
|
||||
("InstanceID", instance_id_str.as_str()),
|
||||
("Channel", channel),
|
||||
("DesiredMute", mute_str),
|
||||
];
|
||||
|
||||
let call_result =
|
||||
invoke_upnp_action(&self.control_url, &self.service_type, "SetMute", &args)?;
|
||||
|
||||
handle_action_response("SetMute", &call_result)
|
||||
}
|
||||
}
|
||||
317
pmocontrol/src/upnp_renderer.rs
Normal file
317
pmocontrol/src/upnp_renderer.rs
Normal file
@@ -0,0 +1,317 @@
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
|
||||
use crate::capabilities::{PlaybackPositionInfo, PlaybackStatus};
|
||||
use crate::connection_manager_client::{ConnectionInfo, ConnectionManagerClient, ProtocolInfo};
|
||||
use crate::music_renderer::op_not_supported;
|
||||
use crate::rendering_control_client::RenderingControlClient;
|
||||
use crate::{
|
||||
AvTransportClient, DeviceRegistry, PlaybackPosition, PlaybackState, PositionInfo, RendererId,
|
||||
RendererInfo, TransportControl, VolumeControl,
|
||||
};
|
||||
|
||||
/// High-level handle representing a renderer and its optional AVTransport client.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct UpnpRenderer {
|
||||
pub info: RendererInfo,
|
||||
registry: Arc<RwLock<DeviceRegistry>>,
|
||||
avtransport: Option<AvTransportClient>,
|
||||
rendering_control: Option<RenderingControlClient>,
|
||||
connection_manager: Option<ConnectionManagerClient>,
|
||||
}
|
||||
|
||||
impl UpnpRenderer {
|
||||
pub fn id(&self) -> &RendererId {
|
||||
&self.info.id
|
||||
}
|
||||
|
||||
pub fn friendly_name(&self) -> &str {
|
||||
&self.info.friendly_name
|
||||
}
|
||||
|
||||
pub fn has_avtransport(&self) -> bool {
|
||||
self.avtransport.is_some()
|
||||
}
|
||||
|
||||
pub fn has_rendering_control(&self) -> bool {
|
||||
self.rendering_control.is_some()
|
||||
}
|
||||
|
||||
pub fn has_connection_manager(&self) -> bool {
|
||||
self.connection_manager.is_some()
|
||||
}
|
||||
|
||||
/// Returns true if this renderer is known to support SetNextAVTransportURI.
|
||||
pub fn supports_set_next(&self) -> bool {
|
||||
self.info.capabilities.supports_set_next()
|
||||
}
|
||||
|
||||
pub fn avtransport(&self) -> Result<&AvTransportClient> {
|
||||
self.avtransport
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("Renderer has no AVTransport service"))
|
||||
}
|
||||
|
||||
pub fn rendering_control(&self) -> Result<&RenderingControlClient> {
|
||||
self.rendering_control
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("Renderer has no RenderingControl service"))
|
||||
}
|
||||
|
||||
pub fn connection_manager(&self) -> Result<&ConnectionManagerClient> {
|
||||
self.connection_manager
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("Renderer has no ConnectionManager service"))
|
||||
}
|
||||
|
||||
pub fn play_uri(&self, uri: &str, meta: &str) -> Result<()> {
|
||||
let avt = self.avtransport()?;
|
||||
avt.set_av_transport_uri(uri, meta)?;
|
||||
avt.play(0, "1")
|
||||
}
|
||||
|
||||
/// Best-effort attempt to configure the next URI via AVTransport SetNextAVTransportURI.
|
||||
pub fn set_next_uri(&self, next_uri: &str, next_meta: &str) -> Result<()> {
|
||||
if !self.info.capabilities.has_avtransport {
|
||||
return Err(op_not_supported("SetNextAVTransportURI", "AVTransport"));
|
||||
}
|
||||
|
||||
let client = self.avtransport()?;
|
||||
let result = client.set_next_av_transport_uri(next_uri, next_meta);
|
||||
|
||||
if result.is_ok() {
|
||||
let mut reg = self.registry.write().unwrap();
|
||||
reg.mark_renderer_supports_set_next(&self.info.id);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub fn pause(&self) -> Result<()> {
|
||||
let avt = self.avtransport()?;
|
||||
avt.pause(0)
|
||||
}
|
||||
|
||||
pub fn stop(&self) -> Result<()> {
|
||||
let avt = self.avtransport()?;
|
||||
avt.stop(0)
|
||||
}
|
||||
|
||||
pub fn seek_rel_time(&self, hhmmss: &str) -> Result<()> {
|
||||
let avt = self.avtransport()?;
|
||||
avt.seek(0, "REL_TIME", hhmmss)
|
||||
}
|
||||
|
||||
pub fn get_master_volume(&self) -> Result<u16> {
|
||||
let rc = self.rendering_control()?;
|
||||
rc.get_volume(0, "Master")
|
||||
}
|
||||
|
||||
pub fn set_master_volume(&self, volume: u16) -> Result<()> {
|
||||
let rc = self.rendering_control()?;
|
||||
rc.set_volume(0, "Master", volume)
|
||||
}
|
||||
|
||||
pub fn get_master_mute(&self) -> Result<bool> {
|
||||
let rc = self.rendering_control()?;
|
||||
rc.get_mute(0, "Master")
|
||||
}
|
||||
|
||||
pub fn set_master_mute(&self, mute: bool) -> Result<()> {
|
||||
let rc = self.rendering_control()?;
|
||||
rc.set_mute(0, "Master", mute)
|
||||
}
|
||||
|
||||
pub fn protocol_info(&self) -> Result<ProtocolInfo> {
|
||||
let cm = self.connection_manager()?;
|
||||
cm.get_protocol_info()
|
||||
}
|
||||
|
||||
pub fn connection_ids(&self) -> Result<Vec<i32>> {
|
||||
let cm = self.connection_manager()?;
|
||||
cm.get_current_connection_ids()
|
||||
}
|
||||
|
||||
pub fn connection_info(&self, connection_id: i32) -> Result<ConnectionInfo> {
|
||||
let cm = self.connection_manager()?;
|
||||
cm.get_current_connection_info(connection_id)
|
||||
}
|
||||
|
||||
pub fn from_registry(info: RendererInfo, registry: &Arc<RwLock<DeviceRegistry>>) -> Self {
|
||||
let (avtransport, rendering_control, connection_manager) = {
|
||||
let reg = registry.read().unwrap();
|
||||
(
|
||||
reg.avtransport_client_for_renderer(&info.id),
|
||||
reg.rendering_control_client_for_renderer(&info.id),
|
||||
reg.connection_manager_client_for_renderer(&info.id),
|
||||
)
|
||||
};
|
||||
Self {
|
||||
info,
|
||||
registry: Arc::clone(registry),
|
||||
avtransport,
|
||||
rendering_control,
|
||||
connection_manager,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::{RendererCapabilities, RendererProtocol};
|
||||
use crate::registry::{DeviceRegistry, DeviceUpdate};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::SystemTime;
|
||||
|
||||
fn renderer_info(id_suffix: &str, with_avtransport: bool) -> RendererInfo {
|
||||
RendererInfo {
|
||||
id: RendererId(format!("renderer-{id_suffix}")),
|
||||
udn: format!("uuid:renderer-{id_suffix}"),
|
||||
friendly_name: format!("Renderer {id_suffix}"),
|
||||
model_name: "Model".into(),
|
||||
manufacturer: "Manufacturer".into(),
|
||||
protocol: RendererProtocol::UpnpAvOnly,
|
||||
capabilities: RendererCapabilities {
|
||||
has_avtransport: with_avtransport,
|
||||
..RendererCapabilities::default()
|
||||
},
|
||||
location: "http://127.0.0.1/device.xml".into(),
|
||||
server_header: "TestServer/1.0".into(),
|
||||
online: true,
|
||||
last_seen: SystemTime::now(),
|
||||
max_age: 1800,
|
||||
avtransport_service_type: with_avtransport
|
||||
.then(|| "urn:schemas-upnp-org:service:AVTransport:1".into()),
|
||||
avtransport_control_url: with_avtransport
|
||||
.then(|| "http://127.0.0.1/avtransport".into()),
|
||||
rendering_control_service_type: None,
|
||||
rendering_control_control_url: None,
|
||||
connection_manager_service_type: None,
|
||||
connection_manager_control_url: None,
|
||||
oh_playlist_service_type: None,
|
||||
oh_playlist_control_url: None,
|
||||
oh_playlist_event_sub_url: None,
|
||||
oh_info_service_type: None,
|
||||
oh_info_control_url: None,
|
||||
oh_info_event_sub_url: None,
|
||||
oh_time_service_type: None,
|
||||
oh_time_control_url: None,
|
||||
oh_time_event_sub_url: None,
|
||||
oh_volume_service_type: None,
|
||||
oh_volume_control_url: None,
|
||||
oh_radio_service_type: None,
|
||||
oh_radio_control_url: None,
|
||||
oh_product_service_type: None,
|
||||
oh_product_control_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn registry_with_renderer(info: RendererInfo) -> Arc<RwLock<DeviceRegistry>> {
|
||||
let mut registry = DeviceRegistry::new();
|
||||
registry.apply_update(DeviceUpdate::RendererOnline(info));
|
||||
Arc::new(RwLock::new(registry))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renderer_without_avtransport() {
|
||||
let info = renderer_info("no-avt", false);
|
||||
let registry = registry_with_renderer(info.clone());
|
||||
let renderer = UpnpRenderer::from_registry(info, ®istry);
|
||||
|
||||
assert_eq!(renderer.has_avtransport(), false);
|
||||
assert_eq!(renderer.id().0, "renderer-no-avt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renderer_with_avtransport() {
|
||||
let info = renderer_info("with-avt", true);
|
||||
let registry = registry_with_renderer(info.clone());
|
||||
let renderer = UpnpRenderer::from_registry(info, ®istry);
|
||||
|
||||
assert!(renderer.has_avtransport());
|
||||
assert_eq!(renderer.friendly_name(), "Renderer with-avt");
|
||||
}
|
||||
}
|
||||
|
||||
/// Implémentation UPnP AV de `TransportControl` pour [`UpnpRenderer`].
|
||||
///
|
||||
/// Cette impl se base sur AVTransport (InstanceID = 0).
|
||||
impl TransportControl for UpnpRenderer {
|
||||
fn play_uri(&self, uri: &str, meta: &str) -> Result<()> {
|
||||
let avt = self.avtransport()?;
|
||||
avt.set_av_transport_uri(uri, meta)?;
|
||||
avt.play(0, "1")
|
||||
}
|
||||
|
||||
fn play(&self) -> Result<()> {
|
||||
let avt = self.avtransport()?;
|
||||
avt.play(0, "1")
|
||||
}
|
||||
|
||||
fn pause(&self) -> Result<()> {
|
||||
let avt = self.avtransport()?;
|
||||
avt.pause(0)
|
||||
}
|
||||
|
||||
fn stop(&self) -> Result<()> {
|
||||
let avt = self.avtransport()?;
|
||||
avt.stop(0)
|
||||
}
|
||||
|
||||
fn seek_rel_time(&self, hhmmss: &str) -> Result<()> {
|
||||
let avt = self.avtransport()?;
|
||||
avt.seek(0, "REL_TIME", hhmmss)
|
||||
}
|
||||
}
|
||||
|
||||
/// Implémentation UPnP RenderingControl de `VolumeControl` pour [`UpnpRenderer`].
|
||||
///
|
||||
/// Cette impl se base sur le channel "Master" (InstanceID = 0).
|
||||
impl VolumeControl for UpnpRenderer {
|
||||
fn volume(&self) -> Result<u16> {
|
||||
self.get_master_volume()
|
||||
}
|
||||
|
||||
fn set_volume(&self, v: u16) -> Result<()> {
|
||||
self.set_master_volume(v)
|
||||
}
|
||||
|
||||
fn mute(&self) -> Result<bool> {
|
||||
self.get_master_mute()
|
||||
}
|
||||
|
||||
fn set_mute(&self, m: bool) -> Result<()> {
|
||||
self.set_master_mute(m)
|
||||
}
|
||||
}
|
||||
|
||||
/// Implémentation UPnP AV de `PlaybackStatus` pour [`UpnpRenderer`].
|
||||
///
|
||||
/// Utilise AVTransport::GetTransportInfo(InstanceID=0).
|
||||
impl PlaybackStatus for UpnpRenderer {
|
||||
fn playback_state(&self) -> Result<PlaybackState> {
|
||||
let avt = self.avtransport()?;
|
||||
let info = avt.get_transport_info(0)?;
|
||||
Ok(PlaybackState::from_upnp_state(
|
||||
&info.current_transport_state,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl PlaybackPosition for UpnpRenderer {
|
||||
fn playback_position(&self) -> Result<PlaybackPositionInfo> {
|
||||
let avt = self.avtransport()?;
|
||||
let raw: PositionInfo = avt.get_position_info(0)?;
|
||||
|
||||
Ok(PlaybackPositionInfo {
|
||||
track: Some(raw.track),
|
||||
rel_time: raw.rel_time,
|
||||
abs_time: raw.abs_time,
|
||||
track_duration: raw.track_duration,
|
||||
track_metadata: raw.track_metadata,
|
||||
track_uri: raw.track_uri,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -15,12 +15,12 @@ webp = "0.3"
|
||||
reqwest = { version = "0.12", features = ["blocking"] }
|
||||
|
||||
# Utilitaires
|
||||
anyhow = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
anyhow = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# Async
|
||||
tokio = { workspace = true }
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
|
||||
# Singleton
|
||||
once_cell = "1.20"
|
||||
@@ -32,7 +32,7 @@ async-trait = { version = "0.1", optional = true }
|
||||
axum = { version = "0.8", optional = true }
|
||||
utoipa = { version = "5.3", features = ["axum_extras"], optional = true }
|
||||
|
||||
tracing = { workspace = true }
|
||||
tracing = "0.1.41"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
[package]
|
||||
name = "pmocovers"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
# Cache générique
|
||||
pmocache = { path = "../pmocache" }
|
||||
|
||||
# Gestion d'images
|
||||
image = "0.25"
|
||||
webp = "0.3"
|
||||
|
||||
# HTTP client
|
||||
reqwest = { version = "0.12", features = ["blocking"] }
|
||||
|
||||
# Utilitaires
|
||||
anyhow = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# Async
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
|
||||
# Singleton
|
||||
once_cell = "1.20"
|
||||
|
||||
# Serveur HTTP (optionnel pour l'extension)
|
||||
pmoserver = { path = "../pmoserver", optional = true }
|
||||
pmoconfig = { path = "../pmoconfig", optional = true }
|
||||
async-trait = { version = "0.1", optional = true }
|
||||
axum = { version = "0.8", optional = true }
|
||||
utoipa = { version = "5.3", features = ["axum_extras"], optional = true }
|
||||
|
||||
tracing = "0.1.41"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[features]
|
||||
default = ["pmoserver"]
|
||||
pmoconfig = ["dep:pmoconfig", "pmocache/pmoconfig"]
|
||||
pmoserver = [
|
||||
"pmoconfig",
|
||||
"dep:pmoserver",
|
||||
"dep:axum",
|
||||
"dep:utoipa",
|
||||
"pmocache/openapi",
|
||||
"pmocache/pmoserver",
|
||||
"dep:async-trait",
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user