26 Commits

Author SHA1 Message Date
f5d055be6f Actualiser version.txt
All checks were successful
Build and Push Docker Image / build (push) Successful in 28m30s
2026-01-04 00:08:21 +01:00
e42a7b1ef2 Merge pull request 'Refactoring complet de PMO contrôle.' (#37) from push-wkzrllnusrys into main
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
Reviewed-on: #37
2026-01-04 00:07:42 +01:00
5320545d56 Debugging de la nouvelle implémentation. 2026-01-03 23:59:45 +01:00
9c90e706f1 Enorme refactoring de PMO control step 2 2026-01-03 08:00:08 +01:00
a4301140d8 Migration vers une forme unifiée des cargos 2025-12-30 16:49:29 +01:00
64450185c5 énorme refactoring de PMOcontrol step 1 2025-12-30 16:49:29 +01:00
000d378789 Merge pull request 'push-mvtuzlstzutl' (#35) from push-mvtuzlstzutl into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 29m38s
Reviewed-on: #35
2025-12-28 16:31:41 +01:00
02a44e9e75 Debug playlist lecture 2025-12-28 16:31:02 +01:00
2f77913caa Enrichissement de la source Qobuz 2025-12-28 15:00:06 +01:00
2353cd736d Merge pull request 'Debug dans pmoaudiocache pour les piste non complètement téléchargées' (#34) from push-wzwlxvklsnuw into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 28m25s
Reviewed-on: #34
2025-12-28 14:20:57 +01:00
572f6e4301 Debug dans pmoaudiocache pour les piste non complètement téléchargées 2025-12-28 14:20:14 +01:00
5cb8cc2f31 Merge pull request 'Debug qobuz, pas encore parfait mais presque.' (#33) from push-ryzlqtmkoyxw into main
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
Reviewed-on: #33
2025-12-28 13:53:53 +01:00
2b69350162 Debug qobuz pas encore parfait mais mieux 2025-12-28 13:52:31 +01:00
0c64ed1a9a Autorise la configuration des noms UPNP. 2025-12-28 12:37:38 +01:00
876b1e0147 update de qobuz pour utiliser les sources lazy 2025-12-28 12:37:10 +01:00
b915481595 Merge pull request 'Fin provisoire du débugage de PMO contrôle' (#32) from push-nlwpnmvzzmvs into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 28m17s
Reviewed-on: #32
2025-12-28 09:45:43 +01:00
9fe4819640 Fin provisoire du débugage de PMO contrôle 2025-12-28 09:36:55 +01:00
7976ce096e Merge pull request 'push-wmwyloupzyyo' (#31) from push-wmwyloupzyyo into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 29m40s
Reviewed-on: #31
2025-12-27 22:58:58 +01:00
0cbd0c9b30 Remise au propre des abstractions de PMOcontrol. 2025-12-27 22:42:13 +01:00
0abe66f7fb On corrige le bug des serveur de musique autre que le PMO serveur. 2025-12-27 22:06:20 +01:00
ed46ee8109 Merge pull request 'push-xwqwmkpwtxqk' (#30) from push-xwqwmkpwtxqk into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 28m32s
Reviewed-on: #30
2025-12-27 18:06:34 +01:00
dec48a3130 Lenteur détection des serveurs. 2025-12-27 18:05:03 +01:00
46625c20ec Petit débugage de la user interface. 2025-12-27 17:02:29 +01:00
125b5bbc2f Simplification du code OpenHome environ tous les workarounds. 2025-12-27 17:02:29 +01:00
0a50ca38ac Merge pull request 'Amélioration de l'interface utilisateur.' (#29) from push-lxpmrvpuzxkw into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 28m27s
Reviewed-on: #29
2025-12-27 15:28:05 +01:00
40cce9f894 Rends les items de la queue de lecture cliquables. 2025-12-27 15:12:57 +01:00
152 changed files with 13051 additions and 11418 deletions

3
.claude-env Normal file
View File

@@ -0,0 +1,3 @@
# 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"

31
.claude/CLAUDE.md Normal file
View File

@@ -0,0 +1,31 @@
# 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
View File

@@ -1,15 +1,37 @@
{
"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
}
"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,
}

48
Cargo.lock generated
View File

@@ -306,6 +306,17 @@ 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"
@@ -3631,7 +3642,7 @@ dependencies = [
"pmoflac",
"pmometadata",
"pmoserver",
"quick-xml 0.37.5",
"quick-xml",
"rusqlite",
"serde",
"serde_json",
@@ -3710,7 +3721,7 @@ dependencies = [
"pmodidl",
"pmoserver",
"pmoupnp",
"quick-xml 0.38.4",
"quick-xml",
"ratatui",
"rust_cast",
"rustls",
@@ -3758,7 +3769,7 @@ dependencies = [
"bevy_reflect",
"bevy_reflect_derive",
"pmoutils",
"quick-xml 0.38.4",
"quick-xml",
"serde",
"utoipa",
"utoipa-swagger-ui",
@@ -3779,7 +3790,7 @@ dependencies = [
"opus",
"pmometadata",
"tempfile",
"thiserror 1.0.69",
"thiserror 2.0.17",
"tokio",
"tracing",
]
@@ -3793,7 +3804,7 @@ dependencies = [
"once_cell",
"pmodidl",
"pmoupnp",
"quick-xml 0.38.4",
"quick-xml",
]
[[package]]
@@ -3816,10 +3827,10 @@ dependencies = [
"pmosource",
"pmoupnp",
"pmoutils",
"quick-xml 0.38.4",
"quick-xml",
"serde",
"serde_json",
"thiserror 1.0.69",
"thiserror 2.0.17",
"tokio",
"tokio-util",
"tracing",
@@ -3898,7 +3909,7 @@ dependencies = [
"rusqlite",
"serde",
"serde_json",
"thiserror 1.0.69",
"thiserror 2.0.17",
"tokio",
"tokio-stream",
"tracing",
@@ -3935,7 +3946,7 @@ dependencies = [
"serde_yaml",
"sha1",
"tempfile",
"thiserror 1.0.69",
"thiserror 2.0.17",
"tokio",
"tokio-test",
"tracing",
@@ -3987,7 +3998,7 @@ dependencies = [
"pmoupnp",
"serde",
"serde_json",
"thiserror 1.0.69",
"thiserror 2.0.17",
"tokio",
"tokio-stream",
"tracing",
@@ -3999,6 +4010,8 @@ name = "pmoupnp"
version = "0.1.0"
dependencies = [
"anyhow",
"async-recursion",
"async-trait",
"axum 0.8.7",
"base64 0.22.1",
"bevy_reflect",
@@ -4017,10 +4030,11 @@ dependencies = [
"pmoplaylist",
"pmoserver",
"pmoutils",
"quick-xml 0.37.5",
"quick-xml",
"reqwest",
"serde",
"serde_json",
"serde_yaml",
"socket2 0.5.10",
"thiserror 2.0.17",
"tokio",
@@ -4039,7 +4053,7 @@ dependencies = [
"get_if_addrs",
"netstat2",
"os_info",
"quick-xml 0.38.4",
"quick-xml",
"sysinfo",
"users",
"xmltree 0.10.3",
@@ -4323,16 +4337,6 @@ 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"

View File

@@ -21,3 +21,35 @@ 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"

View File

@@ -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.source="https://gargoton.petite-maison-orange.fr/eric/pmomusic"
LABEL org.opencontainers.image.url="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

View File

@@ -16,8 +16,8 @@ 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"
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "time", "signal"] }
tracing = { workspace = true }
tracing-subscriber = "0.3.20"
axum = "0.8.4"
serde_json = "1.0.145"

View File

@@ -0,0 +1,25 @@
[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"

15
pmoapp/Cargo.toml.backup Normal file
View File

@@ -0,0 +1,15 @@
[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"]

View File

@@ -56,6 +56,10 @@ 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'}`)

View File

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

View File

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

View File

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

View File

@@ -18,7 +18,7 @@ pmoplaylist = { path = "../pmoplaylist", optional = true }
pmocache = { path = "../pmocache", optional = true }
# Async runtime
tokio = { version = "1.0", features = ["full"] }
tokio = { workspace = true }
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 = { version = "1.0", features = ["derive"], optional = true }
serde_json = { version = "1.0", optional = true }
serde = { workspace = true, optional = true }
serde_json = { workspace = true, optional = true }
[features]
default = []

View File

@@ -8,9 +8,9 @@ default = []
simd = []
[dependencies]
tokio = { version = "1.42", features = ["full"] }
tokio = { workspace = true }
tokio-util = { version = "0.7", features = ["io"] }
async-trait = "0.1"
async-trait = { workspace = true }
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 = "0.1"
tracing = { workspace = true }
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 = { version = "0.3", features = ["env-filter"] }
tracing-subscriber = { workspace = true }

29
pmoaudio/Cargo.toml.backup Executable file
View File

@@ -0,0 +1,29 @@
[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"] }

View File

@@ -16,7 +16,7 @@ pmometadata = { path = "../pmometadata" }
# Base de données
rusqlite = { version = "0.37", features = ["bundled"] }
chrono = "0.4"
chrono = { workspace = true }
# Métadonnées audio
lofty = "0.22"
@@ -26,15 +26,15 @@ 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.37", features = ["serialize"] }
anyhow = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
quick-xml = { workspace = true }
paste = "1.0"
async-trait = "0.1"
async-trait = { workspace = true }
# Async
tokio = { version = "1.0", features = ["full"] }
tokio = { workspace = true }
# 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 = "0.1.41"
tracing = { workspace = true }
[dev-dependencies]
tracing-subscriber = "0.3"

View File

@@ -0,0 +1,57 @@
[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"]

View File

@@ -1,5 +1,5 @@
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use pmometadata::{MetadataError, MetadataResult, TrackMetadata};
use serde_json::{Number, Value};

View File

@@ -17,21 +17,21 @@ 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"
anyhow = { workspace = true }
async-trait = { workspace = true }
chrono = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
bytes = "1.6"
paste = "1.0"
pmoflac = { path = "../pmoflac" }
# Async
tokio = { version = "1.0", features = ["full"] }
tokio = { workspace = true }
tokio-util = { version = "0.7", features = ["io"] }
# Logging
tracing = "0.1"
tracing = { workspace = true }
# Feature pour OpenAPI
utoipa = { version = "5.3", optional = true }

View File

@@ -0,0 +1,53 @@
[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"]

View File

@@ -124,6 +124,24 @@ 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() {
@@ -256,6 +274,8 @@ 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;
@@ -284,15 +304,24 @@ async fn stream_file_progressive(
let stream = ReaderStream::new(file);
let body = Body::from_stream(stream);
(
StatusCode::OK,
[
("content-type", content_type),
("transfer-encoding", "chunked"),
],
body,
)
.into_response()
// 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()
}
/// Sert un fichier complet déjà téléchargé

View File

@@ -7,21 +7,21 @@ edition = "2021"
[dependencies]
pmoutils ={ path = "../pmoutils" }
serde = { version = "1.0", features = ["derive"] }
serde_yaml = "0.9.33"
serde_json = "1.0"
serde = { workspace = true }
serde_yaml = { workspace = true }
serde_json = { workspace = true }
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"
uuid = { workspace = true }
tracing = { workspace = true }
aes-gcm = "0.10"
sha2 = "0.10"
base64 = "0.22"
# Async
tokio = { version = "1.0", features = ["full"], optional = true }
tokio = { workspace = true, optional = true }
# Serveur HTTP (pour l'API REST)
axum = { version = "0.8", optional = true }

View File

@@ -0,0 +1,32 @@
[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"]

View File

@@ -1,5 +1,10 @@
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

View File

@@ -6,13 +6,13 @@ edition = "2024"
[dependencies]
pmoupnp = { path = "../pmoupnp" }
pmodidl = { path = "../pmodidl" }
quick-xml = "0.38.4"
thiserror = "2.0.17"
quick-xml = { workspace = true }
thiserror = { workspace = true }
ureq = "3.1.4"
tracing = "0.1.41"
tracing = { workspace = true }
tracing-subscriber = "0.3"
tracing-log = "0.1"
anyhow = "1.0"
anyhow = { workspace = true }
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 }
serde = { version = "1.0", features = ["derive"], optional = true }
serde_json = { version = "1.0", optional = true }
tokio = { version = "1", features = ["sync", "rt"], optional = true }
tokio = { workspace = true, 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,11 +38,9 @@ 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"]
pmoserver = ["dep:pmoserver", "dep:utoipa", "dep:axum", "dep:tokio", "dep:tokio-util", "dep:async-trait", "dep:tokio-stream", "dep:async-stream", "dep:chrono"]

View File

@@ -0,0 +1,48 @@
[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"]

View File

@@ -3,7 +3,7 @@ use std::thread;
use std::time::Duration;
use pmocontrol::{
ControlPoint, DeviceRegistryRead, MusicRenderer, PlaybackPosition, PlaybackPositionInfo,
ControlPoint, DeviceRegistryRead, MusicRendererBackend, PlaybackPosition, PlaybackPositionInfo,
PlaybackState, PlaybackStatus, TransportControl, VolumeControl,
};
@@ -38,10 +38,10 @@ fn main() -> io::Result<()> {
);
}
let arylic_renderers: Vec<MusicRenderer> = renderers
let arylic_renderers: Vec<MusicRendererBackend> = renderers
.iter()
.filter(|info| info.capabilities.has_arylic_tcp)
.filter_map(|info| MusicRenderer::from_registry_info(info.clone(), &registry))
.filter_map(|info| MusicRendererBackend::from_renderer_info(info.clone(), &registry))
.collect();
if arylic_renderers.is_empty() {

View File

@@ -0,0 +1,120 @@
//! 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!("═══════════════════════════════════════════════════════");
}

View File

@@ -0,0 +1,284 @@
//! 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);
}

View File

@@ -3,7 +3,7 @@ use std::thread;
use std::time::Duration;
use pmocontrol::RendererProtocol;
use pmocontrol::{ControlPoint, DeviceRegistryRead, MediaServerInfo, RendererInfo};
use pmocontrol::{ControlPoint, DeviceRegistryRead, UpnpMediaServer, 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<MediaServerInfo> = reg.list_servers();
let servers: Vec<UpnpMediaServer> = 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::Hybrid => "Hybrid",
RendererProtocol::OpenHomeHybrid => "Hybrid",
};
println!(

View File

@@ -19,8 +19,8 @@ use crossterm::terminal::{
};
use pmocontrol::model::TrackMetadata;
use pmocontrol::{
ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, MediaServerInfo,
MusicServer, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, UpnpMediaServer,
UpnpMediaServer, 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<MediaServerInfo> = reg
let list: Vec<UpnpMediaServer> = 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<MediaServerInfo>,
servers: Vec<UpnpMediaServer>,
server_index: usize,
server_info: Option<MediaServerInfo>,
music_server: Option<MusicServer>,
server_info: Option<UpnpMediaServer>,
music_server: Option<UpnpMediaServer>,
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<MediaServerInfo>,
servers: Vec<UpnpMediaServer>,
) -> Self {
Self {
control_point,
@@ -192,7 +192,7 @@ impl App {
}
}
fn renderer_id(&self) -> Option<pmocontrol::model::RendererId> {
fn renderer_id(&self) -> Option<pmocontrol::model::ServiceId> {
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 MusicServer::from_info(&info, Duration::from_secs(DEFAULT_TIMEOUT_SECS)) {
match UpnpMediaServer::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::RendererId) {
fn refresh_queue_snapshot(&mut self, renderer_id: &pmocontrol::model::ServiceId) {
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::RendererId,
renderer_id: &pmocontrol::model::ServiceId,
) -> 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::MusicRenderer> {
fn get_renderer(&self) -> Result<pmocontrol::MusicRendererBackend> {
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: &MusicServer,
server: &UpnpMediaServer,
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: &MusicServer, entry: &MediaEntry) -> Option<PlaybackItem> {
fn playback_item_from_entry(server: &UpnpMediaServer, entry: &MediaEntry) -> Option<PlaybackItem> {
let resource = entry.resources.iter().find(|res| res.is_audio())?;
let metadata = TrackMetadata {
title: Some(entry.title.clone()),

View File

@@ -3,7 +3,7 @@ use std::thread;
use std::time::Duration;
use pmocontrol::{
ControlPoint, DeviceRegistryRead, MusicRenderer, PlaybackPosition, PlaybackPositionInfo,
ControlPoint, DeviceRegistryRead, MusicRendererBackend, PlaybackPosition, PlaybackPositionInfo,
PlaybackState, PlaybackStatus, VolumeControl,
};
@@ -34,10 +34,10 @@ fn main() -> io::Result<()> {
);
}
let linkplay_renderers: Vec<MusicRenderer> = renderers
let linkplay_renderers: Vec<MusicRendererBackend> = renderers
.iter()
.filter(|info| info.capabilities.has_linkplay_http)
.filter_map(|info| MusicRenderer::from_registry_info(info.clone(), &registry))
.filter_map(|info| MusicRendererBackend::from_renderer_info(info.clone(), &registry))
.collect();
if linkplay_renderers.is_empty() {

View File

@@ -10,8 +10,8 @@ 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,
ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, UpnpMediaServer,
MusicRendererBackend, UpnpMediaServer, 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 = MusicRenderer::from_registry_info(renderer.clone(), &registry)
let renderer_instance = MusicRendererBackend::from_renderer_info(renderer.clone(), &registry)
.expect("Selected renderer is not usable by MusicRenderer façade");
let supports_set_next = renderer_instance
.as_upnp()
@@ -82,7 +82,7 @@ fn main() -> Result<()> {
let timeout = Duration::from_secs(config.timeout_secs);
let server =
MusicServer::from_info(&server_info, timeout).context("Failed to init MusicServer")?;
UpnpMediaServer::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<MediaServerInfo>) -> Option<MediaServerInfo> {
let mut candidates: Vec<MediaServerInfo> = servers
fn pick_pmomusic_server(servers: Vec<UpnpMediaServer>) -> Option<UpnpMediaServer> {
let mut candidates: Vec<UpnpMediaServer> = servers
.into_iter()
.filter(|info| info.has_content_directory)
.filter(|info| info.content_directory_control_url.is_some())
@@ -381,7 +381,7 @@ fn pick_pmomusic_server(servers: Vec<MediaServerInfo>) -> Option<MediaServerInfo
Some(candidates.remove(0))
}
fn is_pmomusic_server(info: &MediaServerInfo) -> bool {
fn is_pmomusic_server(info: &UpnpMediaServer) -> 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: &MusicServer) -> Result<Option<MediaEntry>> {
fn find_live_playlist_container(server: &UpnpMediaServer) -> 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: &MusicServer) -> Result<Option<MediaEntr
/// Collect playable items from a specific container.
/// Retries with fewer items if the initial browse times out.
fn collect_playable_items_from_container(
server: &MusicServer,
server: &UpnpMediaServer,
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: &MusicServer, entry: &MediaEntry) -> Option<PlaybackItem> {
fn playback_item_from_entry(server: &UpnpMediaServer, 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;

View File

@@ -4,7 +4,7 @@ use std::time::{Duration, Instant};
use anyhow::Result;
use crossbeam_channel::RecvTimeoutError;
use pmocontrol::{ControlPoint, MediaServerEvent, MediaServerInfo, ServerId};
use pmocontrol::{ControlPoint, MediaServerEvent, UpnpMediaServer, 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, MediaServerInfo> = servers
let mut cache: HashMap<ServerId, UpnpMediaServer> = 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, MediaServerInfo>,
cache: &mut HashMap<ServerId, UpnpMediaServer>,
event: &MediaServerEvent,
) {
match event {
@@ -101,7 +101,7 @@ fn print_event(
fn describe_server(
cp: &ControlPoint,
cache: &mut HashMap<ServerId, MediaServerInfo>,
cache: &mut HashMap<ServerId, UpnpMediaServer>,
id: &ServerId,
) -> String {
if let Some(info) = cache.get(id) {

View File

@@ -5,7 +5,7 @@ use std::time::{Duration, Instant};
use anyhow::{Context, Result, anyhow};
use pmocontrol::{
DeviceDescriptionProvider, DiscoveredEndpoint, HttpXmlDescriptionProvider, MusicRenderer,
DeviceDescriptionProvider, DiscoveredEndpoint, HttpXmlDescriptionProvider, MusicRendererBackend,
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 MusicRenderer::OpenHome(oh) = renderer {
if let MusicRendererBackend::OpenHome(oh) = renderer {
if seen.insert(oh.id().0.clone()) {
openhome_infos.push(oh.info.clone());
}

View File

@@ -1,539 +0,0 @@
//! End-to-end queue demo that prefers the PMOMusic media server and exercises
//! the ControlPoint playback queue API.
use std::collections::VecDeque;
use std::env;
use std::process;
use std::thread;
use std::time::Duration;
use anyhow::{Context, Result};
use pmocontrol::model::TrackMetadata;
use pmocontrol::{
ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, 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(), &registry)
.expect("Selected renderer is not usable by MusicRenderer façade");
let supports_set_next = renderer_instance
.as_upnp()
.map(|upnp| upnp.supports_set_next())
.unwrap_or(false);
println!(
"Renderer \"{}\": AVTransport present = {}, SetNextAVTransportURI supported = {}",
renderer.friendly_name, renderer.capabilities.has_avtransport, supports_set_next
);
let timeout = Duration::from_secs(config.timeout_secs);
let server =
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);
}

View File

@@ -17,7 +17,7 @@ use pmocontrol::PlaybackPosition;
use pmocontrol::model::RendererInfo;
use pmocontrol::openhome_client::{OhInfoClient, OhPlaylistClient, OhTimeClient};
use pmocontrol::{
ControlPoint, MusicRenderer, PlaybackState, PlaybackStatus, RendererCapabilities,
ControlPoint, MusicRendererBackend, 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<MusicRenderer> = cp.list_music_renderers();
let mut renderers: Vec<MusicRendererBackend> = 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: &MusicRenderer) {
fn print_backend(prefix: &str, renderer: &MusicRendererBackend) {
let backend = match renderer {
MusicRenderer::Upnp(_) => "UpnpRenderer (UPnP AV / DLNA)",
MusicRenderer::LinkPlay(_) => "LinkPlayRenderer (LinkPlay HTTP)",
MusicRenderer::ArylicTcp(_) => "ArylicTcpRenderer (ARylic TCP Protocol)",
MusicRenderer::HybridUpnpArylic { .. } => {
MusicRendererBackend::Upnp(_) => "UpnpRenderer (UPnP AV / DLNA)",
MusicRendererBackend::LinkPlay(_) => "LinkPlayRenderer (LinkPlay HTTP)",
MusicRendererBackend::ArylicTcp(_) => "ArylicTcpRenderer (ARylic TCP Protocol)",
MusicRendererBackend::HybridUpnpArylic { .. } => {
"Hybrid UpnpArylicRenderer (UPnP AV / DLNA + ARylic TCP Protocol)"
}
MusicRenderer::OpenHome(_) => "OpenHomeRenderer (native OpenHome stack)",
MusicRendererBackend::OpenHome(_) => "OpenHomeRenderer (native OpenHome stack)",
};
println!("{prefix}Backend : {backend}");
}
fn dump_renderer_state(renderer: &MusicRenderer, label: &str) -> Result<()> {
fn dump_renderer_state(renderer: &MusicRendererBackend, label: &str) -> Result<()> {
println!("\n[{label}]");
if let Ok(state) = renderer.playback_state() {
@@ -344,7 +344,7 @@ fn dump_renderer_state(renderer: &MusicRenderer, label: &str) -> Result<()> {
Ok(())
}
fn progress_monitor(renderer: &MusicRenderer, label: &str, iterations: usize, interval_secs: u64) {
fn progress_monitor(renderer: &MusicRendererBackend, 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: &MusicRenderer, label: &str, iterations: usize, in
}
}
fn volume_demo(renderer: &MusicRenderer) -> Result<()> {
fn volume_demo(renderer: &MusicRendererBackend) -> Result<()> {
// Try to get current volume
let original = renderer.volume()?;
println!(" Current music volume : {}", original);

View File

@@ -0,0 +1,409 @@
# 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>``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 !**

View File

@@ -0,0 +1,296 @@
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 dattente de réponse pour une commande TCP Arylic.
enum ResponseMode<'a> {
/// On nattend 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 labsence 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)))?)
}

View File

@@ -1,659 +0,0 @@
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 dattente de réponse pour une commande TCP Arylic.
enum ResponseMode<'a> {
/// On nattend 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 labsence 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(|_| ())
}

View File

@@ -1,111 +0,0 @@
// 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<()>;
}

View File

@@ -1,285 +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 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
);
}
}

View File

@@ -1,294 +0,0 @@
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

View File

@@ -1,85 +0,0 @@
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),
}
}
}

View File

@@ -1,971 +0,0 @@
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 &current_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(())
}
}

View File

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

View File

@@ -1,228 +0,0 @@
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 dun endpoint découvert.
/// Limplémentation pourra, plus tard, faire un HTTP GET sur `location`
/// et parser la description pour remplir RendererInfo / MediaServerInfo.
pub trait DeviceDescriptionProvider: Send + Sync {
/// Construit un RendererInfo pour cet endpoint, ou None sil
/// ne correspond pas à un renderer audio intéressant.
fn build_renderer_info(&self, endpoint: &DiscoveredEndpoint) -> Option<RendererInfo>;
/// Construit un MediaServerInfo pour cet endpoint, ou None sil
/// ne correspond pas à un media server (ou pas intéressant).
fn build_server_info(&self, 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
}
}

View File

@@ -0,0 +1,66 @@
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
)))
}
}

View File

@@ -0,0 +1,287 @@
//! 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
);
}
}

View File

@@ -0,0 +1,48 @@
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
}
}
}

View File

@@ -0,0 +1,8 @@
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;

View File

@@ -0,0 +1,84 @@
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
}
}

View File

@@ -1,16 +1,16 @@
use std::io::BufReader;
use std::time::{Duration, SystemTime};
use std::time::Duration;
use quick_xml::{events::Event, Error as XmlError, Reader};
use quick_xml::{Error as XmlError, Reader, events::Event};
use thiserror::Error;
use tracing::{debug, warn};
use tracing::{debug};
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 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 ureq::Agent;
@@ -31,8 +31,11 @@ pub enum DescriptionError {
/// Parsed device description, plus (optionally) AVTransport endpoint.
#[derive(Debug, Default)]
struct ParsedDeviceDescription {
udn: Option<String>,
pub struct ParsedDeviceDescription {
timeout_secs: u64,
udn: String,
location: String,
server_header: String,
device_type: Option<String>,
friendly_name: Option<String>,
manufacturer: Option<String>,
@@ -74,47 +77,23 @@ 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.
fn fetch_and_parse(
&self,
endpoint: &DiscoveredEndpoint,
) -> Result<ParsedDeviceDescription, DescriptionError> {
debug!(
"Fetching description for {} at {}",
endpoint.udn, 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);
let config = Agent::config_builder()
.timeout_global(Some(std::time::Duration::from_secs(self.timeout_secs)))
.timeout_global(Some(std::time::Duration::from_secs(timeout_secs)))
.build();
let agent: Agent = config.into();
let response = agent.get(&endpoint.location).call()?;
let response = agent.get(location).call()?;
// response: http::Response<ureq::Body>
let (_parts, body) = response.into_parts();
@@ -124,14 +103,16 @@ impl HttpXmlDescriptionProvider {
let mut reader = Reader::from_reader(BufReader::new(body_reader));
reader.config_mut().trim_text(true);
debug!(
"Parsing description XML for {} at {}",
endpoint.udn, endpoint.location
);
debug!("Parsing description XML for {} at {}", udn, 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;
@@ -187,7 +168,7 @@ impl HttpXmlDescriptionProvider {
parsed.avtransport_control_url = Some(ctrl.clone());
debug!(
"Found AVTransport service for {}: type={} controlURL={}",
endpoint.udn, st, ctrl
udn, st, ctrl
);
}
}
@@ -202,7 +183,7 @@ impl HttpXmlDescriptionProvider {
Some(ctrl.clone());
debug!(
"Found RenderingControl service for {}: type={} controlURL={}",
endpoint.udn, st, ctrl
udn, st, ctrl
);
}
}
@@ -217,7 +198,7 @@ impl HttpXmlDescriptionProvider {
Some(ctrl.clone());
debug!(
"Found ConnectionManager service for {}: type={} controlURL={}",
endpoint.udn, st, ctrl
udn, st, ctrl
);
}
}
@@ -232,7 +213,7 @@ impl HttpXmlDescriptionProvider {
Some(ctrl.clone());
debug!(
"Found ContentDirectory service for {}: type={} controlURL={}",
endpoint.udn, st, ctrl
udn, st, ctrl
);
}
}
@@ -247,7 +228,7 @@ impl HttpXmlDescriptionProvider {
}
debug!(
"Found OpenHome Playlist for {}: type={} controlURL={}",
endpoint.udn, st, ctrl
udn, st, ctrl
);
}
}
@@ -262,7 +243,7 @@ impl HttpXmlDescriptionProvider {
}
debug!(
"Found OpenHome Info for {}: type={} controlURL={}",
endpoint.udn, st, ctrl
udn, st, ctrl
);
}
}
@@ -277,7 +258,7 @@ impl HttpXmlDescriptionProvider {
}
debug!(
"Found OpenHome Time for {}: type={} controlURL={}",
endpoint.udn, st, ctrl
udn, st, ctrl
);
}
}
@@ -288,7 +269,7 @@ impl HttpXmlDescriptionProvider {
parsed.oh_volume_control_url = Some(ctrl.clone());
debug!(
"Found OpenHome Volume for {}: type={} controlURL={}",
endpoint.udn, st, ctrl
udn, st, ctrl
);
}
}
@@ -299,7 +280,7 @@ impl HttpXmlDescriptionProvider {
parsed.oh_radio_control_url = Some(ctrl.clone());
debug!(
"Found OpenHome Radio for {}: type={} controlURL={}",
endpoint.udn, st, ctrl
udn, st, ctrl
);
}
}
@@ -310,7 +291,7 @@ impl HttpXmlDescriptionProvider {
parsed.oh_product_control_url = Some(ctrl.clone());
debug!(
"Found OpenHome Product for {}: type={} controlURL={}",
endpoint.udn, st, ctrl
udn, st, ctrl
);
}
}
@@ -334,7 +315,7 @@ impl HttpXmlDescriptionProvider {
match tag.as_str() {
"UDN" => {
parsed.udn = Some(text);
parsed.udn = text;
}
"deviceType" => {
parsed.device_type = Some(text);
@@ -373,188 +354,274 @@ impl HttpXmlDescriptionProvider {
parsed.require_fields()
}
fn build_renderer(
pub fn build_renderer(
&self,
endpoint: &DiscoveredEndpoint,
parsed: &ParsedDeviceDescription,
) -> Option<RendererInfo> {
let device_type = parsed.device_type.as_ref()?.to_ascii_lowercase();
let device_type = self.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 {}: {}",
endpoint.udn, device_type
self.udn, device_type
);
return None;
}
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)),
) {
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))) {
caps.has_linkplay_http = true;
}
if detect_arylic_tcp(
&endpoint.location,
Duration::from_secs(self.timeout_secs.max(1)),
) {
if detect_arylic_tcp(&self.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 {
id: RendererId(udn.clone()),
Some(RendererInfo::make(
DeviceId(udn.clone()),
udn,
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(),
self.friendly_name.clone().unwrap_or_default(),
self.model_name.clone().unwrap_or_default(),
self.manufacturer.clone().unwrap_or_default(),
protocol,
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
caps,
self.location.clone(),
self.server_header.clone(),
self.avtransport_service_type.clone(),
self
.avtransport_control_url
.as_ref()
.map(|ctrl| resolve_control_url(&endpoint.location, ctrl)),
rendering_control_service_type: parsed.rendering_control_service_type.clone(),
rendering_control_control_url: parsed
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
self.rendering_control_service_type.clone(),
self
.rendering_control_control_url
.as_ref()
.map(|ctrl| resolve_control_url(&endpoint.location, ctrl)),
connection_manager_service_type: parsed.connection_manager_service_type.clone(),
connection_manager_control_url: parsed
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
self.connection_manager_service_type.clone(),
self
.connection_manager_control_url
.as_ref()
.map(|ctrl| resolve_control_url(&endpoint.location, ctrl)),
oh_playlist_service_type: parsed.oh_playlist_service_type.clone(),
oh_playlist_control_url: parsed
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
self.oh_playlist_service_type.clone(),
self
.oh_playlist_control_url
.as_ref()
.map(|ctrl| resolve_control_url(&endpoint.location, ctrl)),
oh_playlist_event_sub_url: parsed
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
self
.oh_playlist_event_sub_url
.as_ref()
.map(|url| resolve_control_url(&endpoint.location, url)),
oh_info_service_type: parsed.oh_info_service_type.clone(),
oh_info_control_url: parsed
.map(|url| resolve_control_url(&self.location, url)),
self.oh_info_service_type.clone(),
self
.oh_info_control_url
.as_ref()
.map(|ctrl| resolve_control_url(&endpoint.location, ctrl)),
oh_info_event_sub_url: parsed
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
self
.oh_info_event_sub_url
.as_ref()
.map(|url| resolve_control_url(&endpoint.location, url)),
oh_time_service_type: parsed.oh_time_service_type.clone(),
oh_time_control_url: parsed
.map(|url| resolve_control_url(&self.location, url)),
self.oh_time_service_type.clone(),
self
.oh_time_control_url
.as_ref()
.map(|ctrl| resolve_control_url(&endpoint.location, ctrl)),
oh_time_event_sub_url: parsed
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
self
.oh_time_event_sub_url
.as_ref()
.map(|url| resolve_control_url(&endpoint.location, url)),
oh_volume_service_type: parsed.oh_volume_service_type.clone(),
oh_volume_control_url: parsed
.map(|url| resolve_control_url(&self.location, url)),
self.oh_volume_service_type.clone(),
self
.oh_volume_control_url
.as_ref()
.map(|ctrl| resolve_control_url(&endpoint.location, ctrl)),
oh_radio_service_type: parsed.oh_radio_service_type.clone(),
oh_radio_control_url: parsed
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
self.oh_radio_service_type.clone(),
self
.oh_radio_control_url
.as_ref()
.map(|ctrl| resolve_control_url(&endpoint.location, ctrl)),
oh_product_service_type: parsed.oh_product_service_type.clone(),
oh_product_control_url: parsed
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
self.oh_product_service_type.clone(),
self
.oh_product_control_url
.as_ref()
.map(|ctrl| resolve_control_url(&endpoint.location, ctrl)),
})
.map(|ctrl| resolve_control_url(&self.location, ctrl)),
))
}
fn build_server(
pub fn build_server(
&self,
endpoint: &DiscoveredEndpoint,
parsed: &ParsedDeviceDescription,
) -> Option<MediaServerInfo> {
let device_type = parsed.device_type.as_ref()?.to_ascii_lowercase();
) -> Option<UpnpMediaServer> {
let device_type = self.device_type.as_ref()?.to_ascii_lowercase();
if !device_type.contains("urn:schemas-upnp-org:device:mediaserver:") {
return None;
}
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| {
let udn = self.udn.to_ascii_lowercase();
let has_content_directory = self.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 = parsed
let content_directory_control_url = self
.content_directory_control_url
.as_ref()
.map(|ctrl| resolve_control_url(&endpoint.location, ctrl));
.map(|ctrl| resolve_control_url(&self.location, ctrl));
Some(MediaServerInfo {
id: ServerId(udn.clone()),
Some(UpnpMediaServer::new(
DeviceId(udn.clone()),
udn,
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,
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(),
has_content_directory,
content_directory_service_type: parsed.content_directory_service_type.clone(),
self.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 parsed = self.fetch_and_parse(endpoint)?;
let service_type = match &parsed.avtransport_service_type {
let service_type = match &self.avtransport_service_type {
Some(st) => st.clone(),
None => return Ok(None),
};
let raw_control = match &parsed.avtransport_control_url {
let raw_control = match &self.avtransport_control_url {
Some(ctrl) => ctrl.clone(),
None => return Ok(None),
};
let control_url = resolve_control_url(&endpoint.location, &raw_control);
let control_url = resolve_control_url(&self.location, &raw_control);
debug!(
"AVTransport client for {}: service_type={} control_url={}",
endpoint.udn, service_type, control_url
&self.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 ---
@@ -604,98 +671,30 @@ fn detect_renderer_protocol(caps: &RendererCapabilities) -> RendererProtocol {
|| caps.has_oh_radio;
match (has_upnp_av, has_openhome) {
(true, true) => RendererProtocol::Hybrid,
(true, true) => RendererProtocol::OpenHomeHybrid,
(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);
if control_url.starts_with('/') {
return format!("{}{}", base, control_url);
} else {
return format!("{}/{}", base, control_url);
}
/// 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
}
}
// 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
}
}
}
}
}

70
pmocontrol/src/errors.rs Normal file
View File

@@ -0,0 +1,70 @@
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())
}
}

View File

@@ -0,0 +1,29 @@
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(),
}
}
}

View File

@@ -1,29 +1,21 @@
mod events;
mod media_server_events;
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 arylic_client;
pub mod control_point;
pub mod discovery;
pub mod linkplay;
pub mod errors;
pub mod identity;
pub mod linkplay_client;
pub mod linkplay_utils;
pub mod media_server;
pub mod model;
pub mod music_renderer;
pub mod 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 online;
pub mod queue;
pub mod registry;
pub mod rendering_control_client;
pub mod soap_client;
pub mod upnp_renderer;
pub mod upnp_clients;
// pmoserver extension (optional)
#[cfg(feature = "pmoserver")]
@@ -33,36 +25,27 @@ pub mod pmoserver_ext;
#[cfg(feature = "pmoserver")]
pub mod sse;
use std::time::Duration;
#[cfg(feature = "pmoserver")]
pub use pmoserver_ext::ControlPointExt;
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 control_point::ControlPoint;
pub use media_server::{MediaBrowser, MediaEntry, MediaResource, UpnpMediaServer};
pub use queue::{EnqueueMode, PlaybackItem, QueueSnapshot};
pub use discovery::{DeviceDescriptionProvider, DiscoveredEndpoint, DiscoveryManager};
pub use model::{
MediaServerEvent, RendererCapabilities, RendererEvent, RendererId, RendererInfo,
MediaServerEvent, PlaybackSource, RendererCapabilities, RendererEvent, RendererInfo,
RendererProtocol,
};
pub use provider::HttpXmlDescriptionProvider;
pub use registry::{DeviceRegistry, DeviceRegistryRead, DeviceUpdate};
pub use registry::{DeviceRegistry, 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);

View File

@@ -1,476 +0,0 @@
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)
}

View File

@@ -0,0 +1,172 @@
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,
})
}

View File

View File

@@ -1,33 +1,293 @@
use std::time::{Duration, SystemTime};
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
use anyhow::{Result, anyhow};
use pmodidl::{self, DIDLLite};
use pmodidl::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};
/// Unique identifier for a media server registered by the control point.
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
pub struct ServerId(pub String);
use crate::{DEFAULT_HTTP_TIMEOUT, DeviceId, DeviceIdentity};
/// Snapshot of a media server discovered through UPnP SSDP.
#[derive(Clone, Debug)]
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>,
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
}
}
/// Simplified view over a DIDL-Lite resource entry.
@@ -42,15 +302,45 @@ 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
lower
.split(':')
.nth(2)
.map(|mime| mime.starts_with("audio/"))
.unwrap_or(false)
// 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
}
}
@@ -74,16 +364,21 @@ pub struct MediaEntry {
/// Backend-agnostic media browsing contract.
pub trait MediaBrowser {
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 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 search(
&self,
container_id: &str,
query: &str,
start: u32,
count: u32,
) -> Result<Vec<MediaEntry>>;
) -> Result<Vec<MediaEntry>, ControlPointError>;
}
/// Façade over every supported media server backend.
@@ -93,40 +388,107 @@ pub enum MusicServer {
}
impl MusicServer {
pub fn from_info(info: &MediaServerInfo, timeout: Duration) -> Result<Self> {
Ok(MusicServer::Upnp(UpnpMediaServer::new(
info.clone(),
timeout,
)))
pub fn from_server_info(info: &UpnpMediaServer) -> Result<MusicServer, ControlPointError> {
Ok(MusicServer::Upnp(info.clone()))
}
pub fn id(&self) -> &ServerId {
pub fn has_content_directory(&self) -> bool {
match self {
MusicServer::Upnp(upnp) => upnp.id(),
}
}
pub fn info(&self) -> &MediaServerInfo {
match self {
MusicServer::Upnp(upnp) => upnp.info(),
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(),
}
}
fn udn(&self) -> &str {
match self {
MusicServer::Upnp(u) => u.udn(),
}
}
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>> {
fn browse_root(&self) -> Result<Vec<MediaEntry>, ControlPointError> {
match self {
MusicServer::Upnp(upnp) => upnp.browse_root(),
}
}
fn browse_children(&self, object_id: &str, start: u32, count: u32) -> Result<Vec<MediaEntry>> {
fn browse_children(
&self,
object_id: &str,
start: u32,
count: u32,
) -> Result<Vec<MediaEntry>, ControlPointError> {
match self {
MusicServer::Upnp(upnp) => upnp.browse_children(object_id, start, count),
}
}
fn browse_object(&self, object_id: &str) -> Result<MediaEntry> {
fn browse_object(&self, object_id: &str) -> Result<MediaEntry, ControlPointError> {
match self {
MusicServer::Upnp(upnp) => upnp.browse_object(object_id),
}
@@ -138,181 +500,113 @@ impl MediaBrowser for MusicServer {
query: &str,
start: u32,
count: u32,
) -> Result<Vec<MediaEntry>> {
) -> Result<Vec<MediaEntry>, ControlPointError> {
match self {
MusicServer::Upnp(upnp) => upnp.search(container_id, query, start, count),
}
}
}
/// 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 }
/// 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;
}
pub fn id(&self) -> &ServerId {
&self.info.id
// 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 info(&self) -> &MediaServerInfo {
&self.info
// 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;
}
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 resource = resource.unwrap();
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)
}
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(),
};
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();
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"
);
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))
}
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),
})
}
impl MediaBrowser for UpnpMediaServer {
fn browse_root(&self) -> Result<Vec<MediaEntry>> {
fn browse_root(&self) -> Result<Vec<MediaEntry>, ControlPointError> {
self.browse_with_flag("0", "BrowseDirectChildren", 0, 0)
}
fn browse_children(&self, object_id: &str, start: u32, count: u32) -> Result<Vec<MediaEntry>> {
fn browse_children(
&self,
object_id: &str,
start: u32,
count: u32,
) -> Result<Vec<MediaEntry>, ControlPointError> {
self.browse_with_flag(object_id, "BrowseDirectChildren", start, count)
}
fn browse_object(&self, object_id: &str) -> Result<MediaEntry> {
fn browse_object(&self, object_id: &str) -> Result<MediaEntry, ControlPointError> {
let entries = self.browse_with_flag(object_id, "BrowseMetadata", 0, 1)?;
entries
.into_iter()
.next()
.ok_or_else(|| anyhow!("Object {} was not returned by the server", object_id))
entries.into_iter().next().ok_or_else(|| {
ControlPointError::MediaServerError(format!(
"Object {} was not returned by the server",
object_id
))
})
}
fn search(
@@ -321,19 +615,24 @@ impl MediaBrowser for UpnpMediaServer {
query: &str,
start: u32,
count: u32,
) -> Result<Vec<MediaEntry>> {
) -> Result<Vec<MediaEntry>, ControlPointError> {
self.search_impl(container_id, query, start, count)
}
}
fn map_didl_entries(xml: &str) -> Result<Vec<MediaEntry>> {
fn map_didl_entries(xml: &str) -> Result<Vec<MediaEntry>, ControlPointError> {
let trimmed = xml.trim();
if trimmed.is_empty() {
return Ok(Vec::new());
}
let didl: DIDLLite = pmodidl::parse_metadata::<DIDLLite>(trimmed)
.map_err(|err| anyhow!("Failed to parse DIDL-Lite payload: {}", err))?
.map_err(|err| {
ControlPointError::MediaServerError(format!(
"Failed to parse DIDL-Lite payload: {}",
err
))
})?
.data;
let mut entries = Vec::new();
@@ -392,11 +691,23 @@ fn map_didl_entries(xml: &str) -> Result<Vec<MediaEntry>> {
Ok(entries)
}
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))?;
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
))
})?;
let payload = result_elem
.get_text()
@@ -457,14 +768,6 @@ 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,

View File

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

View File

@@ -1,9 +1,87 @@
use crate::capabilities::{PlaybackPositionInfo, PlaybackState};
use crate::control_point::PlaylistBinding;
use crate::media_server::{MediaServerInfo, ServerId};
use crate::music_renderer::{PlaybackPositionInfo, PlaylistBinding};
use crate::{DeviceId, DeviceIdentity};
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct RendererId(pub String);
/// 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)]
pub struct TrackMetadata {
@@ -17,11 +95,11 @@ pub struct TrackMetadata {
pub creator: Option<String>,
}
#[derive(Clone, Debug)]
#[derive(Clone, Debug, Copy)]
pub enum RendererProtocol {
UpnpAvOnly,
OpenHomeOnly,
Hybrid,
OpenHomeHybrid,
ChromecastOnly,
}
@@ -47,6 +125,74 @@ 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
}
@@ -54,98 +200,264 @@ impl RendererCapabilities {
#[derive(Clone, Debug)]
pub struct RendererInfo {
pub id: RendererId,
pub udn: String,
pub friendly_name: String,
pub model_name: String,
pub manufacturer: String,
id: DeviceId,
udn: String,
friendly_name: String,
model_name: String,
manufacturer: String,
pub protocol: RendererProtocol,
pub capabilities: RendererCapabilities,
protocol: RendererProtocol,
capabilities: RendererCapabilities,
pub location: String,
pub server_header: String,
pub online: bool,
pub last_seen: std::time::SystemTime,
pub max_age: u32,
location: String,
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>,
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
}
}
#[derive(Clone, Debug)]
pub enum RendererEvent {
StateChanged {
id: RendererId,
id: DeviceId,
state: PlaybackState,
},
PositionChanged {
id: RendererId,
id: DeviceId,
position: PlaybackPositionInfo,
},
VolumeChanged {
id: RendererId,
id: DeviceId,
volume: u16,
},
MuteChanged {
id: RendererId,
id: DeviceId,
mute: bool,
},
MetadataChanged {
id: RendererId,
id: DeviceId,
metadata: TrackMetadata,
},
QueueUpdated {
id: RendererId,
id: DeviceId,
queue_length: usize,
},
BindingChanged {
id: RendererId,
id: DeviceId,
binding: Option<PlaylistBinding>,
},
Online {
id: RendererId,
info: RendererInfo,
id: DeviceId,
info: DeviceBasicInfo,
},
Offline {
id: RendererId,
id: DeviceId,
},
}
#[derive(Clone, Debug)]
pub enum MediaServerEvent {
GlobalUpdated {
server_id: ServerId,
server_id: DeviceId,
system_update_id: Option<u32>,
},
ContainersUpdated {
server_id: ServerId,
server_id: DeviceId,
container_ids: Vec<String>,
},
Online {
server_id: ServerId,
info: MediaServerInfo,
server_id: DeviceId,
info: DeviceBasicInfo,
},
Offline {
server_id: ServerId,
server_id: DeviceId,
},
}

View File

@@ -1,521 +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, 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 dArylic : 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(),
}
}
}

View File

@@ -0,0 +1,341 @@
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,
})
}

View File

@@ -0,0 +1,68 @@
// 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>;
}

View File

@@ -14,16 +14,19 @@
use std::sync::{Arc, Mutex, Once};
use std::thread::JoinHandle;
use anyhow::{Result, anyhow};
use tracing::debug;
use crate::capabilities::{
PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl,
use crate::DeviceIdentity;
use crate::music_renderer::capabilities::{
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, TransportControl,
VolumeControl,
};
use crate::chromecast_discovery::{extract_host_from_location, extract_port_from_location};
use crate::model::{RendererId, RendererInfo, RendererProtocol};
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 rust_cast::{
CastDevice, ChannelMessage,
@@ -46,7 +49,6 @@ 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>>,
@@ -58,7 +60,6 @@ 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()
@@ -76,23 +77,31 @@ 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>> {
fn connect_to_device<'a>(host: &'a str, port: u16) -> Result<CastDevice<'a>, ControlPointError> {
// 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| anyhow!("Failed to connect to Chromecast: {}", e))?;
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect to Chromecast: {}", e)))?;
device.connection
device
.connection
.connect(DEFAULT_DESTINATION_ID.to_string())
.map_err(|e| anyhow!("Failed to connect channel: {}", e))?;
.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)))?;
Ok(device)
}
@@ -107,62 +116,46 @@ fn map_player_state(player_state: &CastPlayerState) -> PlaybackState {
}
}
impl ChromecastRenderer {
/// Creates a new ChromecastRenderer from RendererInfo.
pub fn from_renderer_info(info: RendererInfo) -> Result<Self> {
impl RendererFromMediaRendererInfo for ChromecastRenderer {
fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
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(|| anyhow!("Invalid Chromecast location: {}", info.location))?;
let host = extract_host_from_location(info.location())
.ok_or_else(|| ControlPointError::ChromecastError(format!("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,
})
}
/// Returns the renderer ID.
pub fn id(&self) -> &RendererId {
&self.info.id
fn to_backend(self) -> MusicRendererBackend {
MusicRendererBackend::Chromecast(self)
}
/// 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<()> {
fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
debug!("ChromecastRenderer: play_uri({})", uri);
// Signal any existing play thread to stop
@@ -178,8 +171,7 @@ 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(())) => {
@@ -219,7 +211,10 @@ 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);
@@ -243,11 +238,10 @@ 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);
}
@@ -308,63 +302,87 @@ impl TransportControl for ChromecastRenderer {
Ok(())
}
fn play(&self) -> Result<()> {
fn play(&self) -> Result<(), ControlPointError> {
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| anyhow!("Failed to get receiver status: {}", e))?;
let status = device
.receiver
.get_status()
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?;
let app = status.applications.first()
.ok_or_else(|| anyhow!("No active app found"))?;
let app = status
.applications
.first()
.ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?;
// Connect to the app
device.connection.connect(app.transport_id.as_str())
.map_err(|e| anyhow!("Failed to connect to app: {}", e))?;
device
.connection
.connect(app.transport_id.as_str())
.map_err(|e| ControlPointError::ChromecastError(format!("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| anyhow!("Failed to get media status: {}", 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_entry = media_status.entries.first()
.ok_or_else(|| anyhow!("No media session found"))?;
let media_entry = media_status
.entries
.first()
.ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?;
// Send play command
device.media.play(app.transport_id.as_str(), media_entry.media_session_id)
.map_err(|e| anyhow!("Failed to play: {}", e))?;
device
.media
.play(app.transport_id.as_str(), media_entry.media_session_id)
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to play: {}", e)))?;
Ok(())
}
fn pause(&self) -> Result<()> {
fn pause(&self) -> Result<(), ControlPointError> {
debug!("ChromecastRenderer: pause()");
let device = connect_to_device(&self.host, self.port)?;
let status = device.receiver.get_status()
.map_err(|e| anyhow!("Failed to get receiver status: {}", e))?;
let status = device
.receiver
.get_status()
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?;
let app = status.applications.first()
.ok_or_else(|| anyhow!("No active app found"))?;
let app = status
.applications
.first()
.ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?;
device.connection.connect(app.transport_id.as_str())
.map_err(|e| anyhow!("Failed to connect to app: {}", e))?;
device
.connection
.connect(app.transport_id.as_str())
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect to app: {}", 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_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_entry = media_status.entries.first()
.ok_or_else(|| anyhow!("No media session found"))?;
let media_entry = media_status
.entries
.first()
.ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?;
device.media.pause(app.transport_id.as_str(), media_entry.media_session_id)
.map_err(|e| anyhow!("Failed to pause: {}", e))?;
device
.media
.pause(app.transport_id.as_str(), media_entry.media_session_id)
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to pause: {}", e)))?;
Ok(())
}
fn stop(&self) -> Result<()> {
fn stop(&self) -> Result<(), ControlPointError> {
debug!("ChromecastRenderer: stop()");
// Signal the play thread to stop
@@ -379,81 +397,94 @@ 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| anyhow!("Failed to get receiver status: {}", e))?;
let status = device
.receiver
.get_status()
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?;
let app = status.applications.first()
.ok_or_else(|| anyhow!("No active app found"))?;
let app = status
.applications
.first()
.ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?;
device.connection.connect(app.transport_id.as_str())
.map_err(|e| anyhow!("Failed to connect to app: {}", e))?;
device
.connection
.connect(app.transport_id.as_str())
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect to app: {}", 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_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_entry = media_status.entries.first()
.ok_or_else(|| anyhow!("No media session found"))?;
let media_entry = media_status
.entries
.first()
.ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?;
device.media.stop(app.transport_id.as_str(), media_entry.media_session_id)
.map_err(|e| anyhow!("Failed to stop: {}", e))?;
device
.media
.stop(app.transport_id.as_str(), media_entry.media_session_id)
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to stop: {}", e)))?;
Ok(())
}
fn seek_rel_time(&self, hhmmss: &str) -> Result<()> {
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
debug!("ChromecastRenderer: seek_rel_time({})", hhmmss);
// 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 total_seconds = parse_hhmmss_strict(hhmmss)? as f32;
let device = connect_to_device(&self.host, self.port)?;
let status = device.receiver.get_status()
.map_err(|e| anyhow!("Failed to get receiver status: {}", e))?;
let status = device
.receiver
.get_status()
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?;
let app = status.applications.first()
.ok_or_else(|| anyhow!("No active app found"))?;
let app = status
.applications
.first()
.ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?;
device.connection.connect(app.transport_id.as_str())
.map_err(|e| anyhow!("Failed to connect to app: {}", e))?;
device
.connection
.connect(app.transport_id.as_str())
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to connect to app: {}", 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_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_entry = media_status.entries.first()
.ok_or_else(|| anyhow!("No media session found"))?;
let media_entry = media_status
.entries
.first()
.ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?;
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))?;
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)))?;
Ok(())
}
}
impl PlaybackStatus for ChromecastRenderer {
fn playback_state(&self) -> Result<PlaybackState> {
fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
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| anyhow!("Failed to get receiver status: {}", e))?;
let status = device
.receiver
.get_status()
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?;
// If no app is running, return NoMedia
let app = match status.applications.first() {
@@ -462,12 +493,16 @@ impl PlaybackStatus for ChromecastRenderer {
};
// Connect to the app
device.connection.connect(app.transport_id.as_str())
.map_err(|e| anyhow!("Failed to connect to app: {}", e))?;
device
.connection
.connect(app.transport_id.as_str())
.map_err(|e| ControlPointError::ChromecastError(format!("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| anyhow!("Failed to get media status: {}", 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)))?;
// If no media entry, return NoMedia
let media_entry = match media_status.entries.first() {
@@ -480,37 +515,49 @@ impl PlaybackStatus for ChromecastRenderer {
}
impl PlaybackPosition for ChromecastRenderer {
fn playback_position(&self) -> Result<PlaybackPositionInfo> {
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
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| anyhow!("Failed to get receiver status: {}", e))?;
let status = device
.receiver
.get_status()
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?;
let app = status.applications.first()
.ok_or_else(|| anyhow!("No active app found"))?;
let app = status
.applications
.first()
.ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?;
// Connect to the app
device.connection.connect(app.transport_id.as_str())
.map_err(|e| anyhow!("Failed to connect to app: {}", e))?;
device
.connection
.connect(app.transport_id.as_str())
.map_err(|e| ControlPointError::ChromecastError(format!("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| anyhow!("Failed to get media status: {}", 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_entry = media_status.entries.first()
.ok_or_else(|| anyhow!("No media session found"))?;
let media_entry = media_status
.entries
.first()
.ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?;
// Extract position information
let rel_time = media_entry.current_time
.map(|time| format_time_hhmmss(time as f64));
let rel_time = media_entry
.current_time
.map(|time| format_hhmmss_f64(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_time_hhmmss(dur as f64));
.map(|dur| format_hhmmss_f64(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),
@@ -584,21 +631,14 @@ 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> {
fn volume(&self) -> Result<u16, ControlPointError> {
let device = connect_to_device(&self.host, self.port)?;
let status = device.receiver.get_status()
.map_err(|e| anyhow!("Failed to get receiver status: {}", e))?;
let status = device
.receiver
.get_status()
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?;
if let Some(level) = status.volume.level {
Ok((level * 100.0) as u16)
@@ -607,34 +647,40 @@ impl VolumeControl for ChromecastRenderer {
}
}
fn set_volume(&self, volume: u16) -> Result<()> {
fn set_volume(&self, volume: u16) -> Result<(), ControlPointError> {
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| anyhow!("Failed to set volume: {}", e))?;
device
.receiver
.set_volume(level)
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to set volume: {}", e)))?;
Ok(())
}
fn mute(&self) -> Result<bool> {
fn mute(&self) -> Result<bool, ControlPointError> {
let device = connect_to_device(&self.host, self.port)?;
let status = device.receiver.get_status()
.map_err(|e| anyhow!("Failed to get receiver status: {}", e))?;
let status = device
.receiver
.get_status()
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e)))?;
Ok(status.volume.muted.unwrap_or(false))
}
fn set_mute(&self, mute: bool) -> Result<()> {
fn set_mute(&self, mute: bool) -> Result<(), ControlPointError> {
debug!("ChromecastRenderer: set_mute({})", mute);
let device = connect_to_device(&self.host, self.port)?;
device.receiver.set_volume(mute)
.map_err(|e| anyhow!("Failed to set mute: {}", e))?;
device
.receiver
.set_volume(mute)
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to set mute: {}", e)))?;
Ok(())
}

View File

@@ -0,0 +1,134 @@
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())
}
}

View File

@@ -0,0 +1,53 @@
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)))
}
}

View File

@@ -0,0 +1,965 @@
//! 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(),
}
}
}

View File

@@ -0,0 +1,174 @@
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);
}
}

View File

@@ -0,0 +1,332 @@
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()),
}
}

View File

@@ -0,0 +1,218 @@
//! 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);
}
}

View File

@@ -0,0 +1,221 @@
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,
})
}
}

55
pmocontrol/src/online.rs Normal file
View File

@@ -0,0 +1,55 @@
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;
}
}

View File

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

View File

@@ -1,187 +0,0 @@
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);
}
}

View File

@@ -1,31 +0,0 @@
/// 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>,
}

View File

@@ -1,410 +0,0 @@
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),
}
}

View File

@@ -4,25 +4,22 @@
//! et naviguer dans les serveurs de médias.
#[cfg(feature = "pmoserver")]
use crate::control_point::{
ControlPoint, OpenHomeAccessError, OPENHOME_SNAPSHOT_CACHE_TTL,
};
use crate::control_point::ControlPoint;
#[cfg(feature = "pmoserver")]
use crate::media_server::{MediaBrowser, MediaEntry, MusicServer, ServerId};
use crate::media_server::{MediaBrowser, playback_item_from_entry};
#[cfg(feature = "pmoserver")]
use crate::model::{RendererCapabilities, RendererId, RendererProtocol, TrackMetadata};
use crate::model::{RendererCapabilities, RendererProtocol};
#[cfg(feature = "pmoserver")]
use crate::openapi::{
AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry, ErrorResponse,
FullRendererSnapshot, MediaServerSummary, OpenHomePlaylistAddRequest, OpenHomePlaylistSnapshot,
PlayContentRequest, QueueItem, QueueSnapshot, RendererCapabilitiesSummary,
RendererProtocolSummary, RendererState, RendererSummary, SeekQueueRequest, SuccessResponse,
VolumeSetRequest,
FullRendererSnapshot, MediaServerSummary, PlayContentRequest, QueueSnapshot,
RendererCapabilitiesSummary, RendererProtocolSummary, RendererState, RendererSummary,
SeekQueueRequest, SuccessResponse, VolumeSetRequest,
};
#[cfg(feature = "pmoserver")]
use crate::queue_backend::PlaybackItem;
use crate::queue::PlaybackItem;
#[cfg(feature = "pmoserver")]
use crate::{PlaybackPosition, PlaybackStatus, TransportControl, VolumeControl};
use crate::{DeviceId, DeviceIdentity, DeviceOnline};
#[cfg(feature = "pmoserver")]
use async_trait::async_trait;
@@ -47,8 +44,6 @@ use utoipa::OpenApi;
#[cfg(feature = "pmoserver")]
const BROWSE_PAGE_SIZE: u32 = 100;
#[cfg(feature = "pmoserver")]
const MEDIA_SERVER_SOAP_TIMEOUT: Duration = Duration::from_secs(15);
#[cfg(feature = "pmoserver")]
const BROWSE_REQUEST_TIMEOUT: Duration = Duration::from_secs(20);
// Timeouts for simple commands (play/pause/stop)
@@ -103,12 +98,12 @@ async fn list_renderers(State(state): State<ControlPointState>) -> Json<Vec<Rend
.map(|r| {
let info = r.info();
RendererSummary {
id: info.id.0.clone(),
friendly_name: info.friendly_name.clone(),
model_name: info.model_name.clone(),
protocol: protocol_summary(&info.protocol),
capabilities: capability_summary(&info.capabilities),
online: info.online,
id: r.id().0.clone(),
friendly_name: r.friendly_name().to_string(),
model_name: r.model_name().to_string(),
protocol: protocol_summary(&info.protocol()),
capabilities: capability_summary(&info.capabilities()),
online: r.is_online(),
}
})
.collect();
@@ -134,7 +129,7 @@ async fn get_renderer_state(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
) -> Result<Json<RendererState>, (StatusCode, Json<ErrorResponse>)> {
let rid = RendererId(renderer_id.clone());
let rid = DeviceId(renderer_id.clone());
let snapshot = state
.control_point
.renderer_full_snapshot(&rid)
@@ -160,7 +155,7 @@ async fn get_renderer_full_snapshot(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
) -> Result<Json<FullRendererSnapshot>, (StatusCode, Json<ErrorResponse>)> {
let rid = RendererId(renderer_id.clone());
let rid = DeviceId(renderer_id.clone());
let snapshot = state
.control_point
.renderer_full_snapshot(&rid)
@@ -191,7 +186,7 @@ async fn get_renderer_queue(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
) -> Result<Json<QueueSnapshot>, (StatusCode, Json<ErrorResponse>)> {
let rid = RendererId(renderer_id.clone());
let rid = DeviceId(renderer_id.clone());
let snapshot = state
.control_point
.renderer_full_snapshot(&rid)
@@ -218,7 +213,7 @@ async fn get_renderer_binding(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
) -> Result<Json<Option<AttachedPlaylistInfo>>, (StatusCode, Json<ErrorResponse>)> {
let rid = RendererId(renderer_id.clone());
let rid = DeviceId(renderer_id.clone());
let snapshot = state
.control_point
.renderer_full_snapshot(&rid)
@@ -250,7 +245,7 @@ async fn play_renderer(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = RendererId(renderer_id.clone());
let rid = DeviceId(renderer_id.clone());
let renderer = state
.control_point
.music_renderer_by_id(&rid)
@@ -326,7 +321,7 @@ async fn pause_renderer(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = RendererId(renderer_id.clone());
let rid = DeviceId(renderer_id.clone());
let renderer = state
.control_point
.music_renderer_by_id(&rid)
@@ -402,7 +397,7 @@ async fn stop_renderer(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = RendererId(renderer_id.clone());
let rid = DeviceId(renderer_id.clone());
state
.control_point
.music_renderer_by_id(&rid)
@@ -479,7 +474,7 @@ async fn resume_renderer(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = RendererId(renderer_id.clone());
let rid = DeviceId(renderer_id.clone());
state
.control_point
.music_renderer_by_id(&rid)
@@ -560,7 +555,7 @@ async fn next_renderer(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = RendererId(renderer_id.clone());
let rid = DeviceId(renderer_id.clone());
state
.control_point
.music_renderer_by_id(&rid)
@@ -641,7 +636,7 @@ async fn seek_queue_index(
Path(renderer_id): Path<String>,
Json(payload): Json<SeekQueueRequest>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = RendererId(renderer_id.clone());
let rid = DeviceId(renderer_id.clone());
state
.control_point
.music_renderer_by_id(&rid)
@@ -657,9 +652,8 @@ async fn seek_queue_index(
let control_point = Arc::clone(&state.control_point);
let rid_for_task = rid.clone();
let index = payload.index;
let seek_task = tokio::task::spawn_blocking(move || {
control_point.play_queue_index(&rid_for_task, index)
});
let seek_task =
tokio::task::spawn_blocking(move || control_point.play_queue_index(&rid_for_task, index));
time::timeout(TRANSPORT_COMMAND_TIMEOUT, seek_task)
.await
@@ -727,7 +721,7 @@ async fn set_renderer_volume(
Path(renderer_id): Path<String>,
Json(req): Json<VolumeSetRequest>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = RendererId(renderer_id.clone());
let rid = DeviceId(renderer_id.clone());
let renderer = state
.control_point
@@ -805,7 +799,7 @@ async fn volume_up_renderer(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = RendererId(renderer_id.clone());
let rid = DeviceId(renderer_id.clone());
let renderer = state
.control_point
@@ -890,7 +884,7 @@ async fn volume_down_renderer(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = RendererId(renderer_id.clone());
let rid = DeviceId(renderer_id.clone());
let renderer = state
.control_point
@@ -975,7 +969,7 @@ async fn toggle_mute_renderer(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = RendererId(renderer_id.clone());
let rid = DeviceId(renderer_id.clone());
let renderer = state
.control_point
@@ -1062,8 +1056,8 @@ async fn attach_playlist_binding(
Path(renderer_id): Path<String>,
Json(req): Json<AttachPlaylistRequest>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = RendererId(renderer_id.clone());
let sid = ServerId(req.server_id.clone());
let rid = DeviceId(renderer_id.clone());
let sid = DeviceId(req.server_id.clone());
let container_id = req.container_id.clone();
let control_point = Arc::clone(&state.control_point);
@@ -1144,7 +1138,7 @@ async fn detach_playlist_binding(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
) -> Json<SuccessResponse> {
let rid = RendererId(renderer_id.clone());
let rid = DeviceId(renderer_id.clone());
state.control_point.detach_queue_playlist(&rid);
@@ -1158,297 +1152,6 @@ async fn detach_playlist_binding(
})
}
// ============================================================================
// HANDLERS - OPENHOME PLAYLIST
// ============================================================================
/// GET /control/renderers/{renderer_id}/oh/playlist - Snapshot de la playlist OH
#[cfg(feature = "pmoserver")]
#[utoipa::path(
get,
path = "/renderers/{renderer_id}/oh/playlist",
params(
("renderer_id" = String, Path, description = "ID unique du renderer")
),
responses(
(status = 200, description = "Playlist OpenHome", body = OpenHomePlaylistSnapshot),
(status = 404, description = "Renderer non trouvé ou sans service OH", body = ErrorResponse)
),
tag = "control"
)]
async fn get_openhome_playlist(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
) -> Result<Json<OpenHomePlaylistSnapshot>, (StatusCode, Json<ErrorResponse>)> {
let rid = RendererId(renderer_id.clone());
let control_point = Arc::clone(&state.control_point);
let rid_for_task = rid.clone();
let fetch_task = tokio::task::spawn_blocking(move || {
control_point.get_cached_openhome_playlist_snapshot(
&rid_for_task,
OPENHOME_SNAPSHOT_CACHE_TTL,
)
});
let snapshot = fetch_task
.await
.map_err(|e| {
warn!(
renderer = renderer_id.as_str(),
error = %e,
"Join error while fetching OpenHome playlist"
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Internal task error: {}", e),
}),
)
})?
.map_err(|e| {
warn!(
renderer = renderer_id.as_str(),
error = %e,
"Failed to read OpenHome playlist"
);
map_openhome_error(&rid, e, "read OpenHome playlist")
})?;
Ok(Json(snapshot))
}
/// POST /control/renderers/{renderer_id}/oh/playlist/clear - Vide la playlist OH
#[cfg(feature = "pmoserver")]
#[utoipa::path(
post,
path = "/renderers/{renderer_id}/oh/playlist/clear",
params(
("renderer_id" = String, Path, description = "ID unique du renderer")
),
responses(
(status = 200, description = "Playlist vidée", body = SuccessResponse),
(status = 404, description = "Renderer non trouvé ou sans service OH", body = ErrorResponse)
),
tag = "control"
)]
async fn clear_openhome_playlist(
State(state): State<ControlPointState>,
Path(renderer_id): Path<String>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = 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
// ============================================================================
@@ -1474,8 +1177,8 @@ async fn play_content(
Path(renderer_id): Path<String>,
Json(req): Json<PlayContentRequest>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = RendererId(renderer_id.clone());
let sid = ServerId(req.server_id.clone());
let rid = DeviceId(renderer_id.clone());
let sid = DeviceId(req.server_id.clone());
let object_id = req.object_id.clone();
let object_id_for_log = object_id.clone();
@@ -1603,8 +1306,8 @@ async fn add_to_queue(
Path(renderer_id): Path<String>,
Json(req): Json<PlayContentRequest>,
) -> Result<Json<SuccessResponse>, (StatusCode, Json<ErrorResponse>)> {
let rid = RendererId(renderer_id.clone());
let sid = ServerId(req.server_id.clone());
let rid = DeviceId(renderer_id.clone());
let sid = DeviceId(req.server_id.clone());
let object_id = req.object_id.clone();
let object_id_for_log = object_id.clone();
@@ -1704,15 +1407,15 @@ async fn add_to_queue(
tag = "control"
)]
async fn list_servers(State(state): State<ControlPointState>) -> Json<Vec<MediaServerSummary>> {
let servers = state.control_point.list_media_servers();
let servers = state.control_point.list_media_servers().unwrap_or_default();
let summaries: Vec<MediaServerSummary> = servers
.into_iter()
.map(|s| MediaServerSummary {
id: s.id.0,
friendly_name: s.friendly_name,
model_name: s.model_name,
online: s.online,
id: s.id().0.clone(),
friendly_name: s.friendly_name().to_string(),
model_name: s.model_name().to_string(),
online: s.is_online(),
})
.collect();
@@ -1739,9 +1442,9 @@ async fn browse_container(
State(state): State<ControlPointState>,
Path((server_id, container_id)): Path<(String, String)>,
) -> Result<Json<BrowseResponse>, (StatusCode, Json<ErrorResponse>)> {
let sid = ServerId(server_id.clone());
let sid = DeviceId(server_id.clone());
let server_info = state.control_point.media_server(&sid).ok_or_else(|| {
let server = state.control_point.media_server(&sid).ok_or_else(|| {
(
StatusCode::NOT_FOUND,
Json(ErrorResponse {
@@ -1750,7 +1453,7 @@ async fn browse_container(
)
})?;
if !server_info.online {
if !server.is_online() {
return Err((
StatusCode::SERVICE_UNAVAILABLE,
Json(ErrorResponse {
@@ -1759,7 +1462,7 @@ async fn browse_container(
));
}
if !server_info.has_content_directory {
if !server.has_content_directory() {
return Err((
StatusCode::NOT_IMPLEMENTED,
Json(ErrorResponse {
@@ -1768,21 +1471,11 @@ 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 || {
music_server.browse_children(&container_id_clone, 0, BROWSE_PAGE_SIZE)
server_clone.browse_children(&container_id_clone, 0, BROWSE_PAGE_SIZE)
});
let entries = time::timeout(BROWSE_REQUEST_TIMEOUT, browse_task)
@@ -1866,32 +1559,6 @@ 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.
@@ -1899,82 +1566,61 @@ fn map_openhome_error(
#[cfg(feature = "pmoserver")]
fn fetch_playback_items(
control_point: &ControlPoint,
server_id: &ServerId,
server_id: &DeviceId,
object_id: &str,
) -> anyhow::Result<Vec<PlaybackItem>> {
// Get server info from registry
let server_info = control_point
// Get server from registry
let server = control_point
.media_server(server_id)
.ok_or_else(|| anyhow::anyhow!("Server {} not found", server_id.0))?;
if !server_info.online {
if !server.is_online() {
return Err(anyhow::anyhow!("Server {} is offline", server_id.0));
}
if !server_info.has_content_directory {
if !server.has_content_directory() {
return Err(anyhow::anyhow!(
"Server {} does not support ContentDirectory",
server_id.0
));
}
// Create MusicServer
let music_server = MusicServer::from_info(&server_info, MEDIA_SERVER_SOAP_TIMEOUT)?;
// Browse the object to get entries
let entries = music_server.browse_children(object_id, 0, BROWSE_PAGE_SIZE)?;
let entries = server.browse_children(object_id, 0, BROWSE_PAGE_SIZE)?;
debug!(
server_id = server_id.0.as_str(),
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"
);
// Convert to PlaybackItem
let items: Vec<PlaybackItem> = entries
.iter()
.filter_map(|entry| playback_item_from_entry(&music_server, entry))
.filter_map(|entry| playback_item_from_entry(server.clone(), entry))
.collect();
if items.is_empty() && !entries.is_empty() {
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;
}
// 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")]
fn protocol_summary(protocol: &RendererProtocol) -> RendererProtocolSummary {
match protocol {
RendererProtocol::UpnpAvOnly => RendererProtocolSummary::Upnp,
RendererProtocol::OpenHomeOnly => RendererProtocolSummary::Openhome,
RendererProtocol::Hybrid => RendererProtocolSummary::Hybrid,
RendererProtocol::OpenHomeHybrid => RendererProtocolSummary::Hybrid,
RendererProtocol::ChromecastOnly => RendererProtocolSummary::Chromecast,
}
}
@@ -2024,7 +1670,10 @@ pub fn create_api_router(state: ControlPointState, control_point: Arc<ControlPoi
.route("/renderers/{renderer_id}/resume", post(resume_renderer))
.route("/renderers/{renderer_id}/next", post(next_renderer))
// Queue control
.route("/renderers/{renderer_id}/queue/seek", post(seek_queue_index))
.route(
"/renderers/{renderer_id}/queue/seek",
post(seek_queue_index),
)
// Volume control
.route(
"/renderers/{renderer_id}/volume/set",
@@ -2051,23 +1700,6 @@ pub fn create_api_router(state: ControlPointState, control_point: Arc<ControlPoi
"/renderers/{renderer_id}/binding/detach",
post(detach_playlist_binding),
)
// OpenHome playlist
.route(
"/renderers/{renderer_id}/oh/playlist",
get(get_openhome_playlist),
)
.route(
"/renderers/{renderer_id}/oh/playlist/clear",
post(clear_openhome_playlist),
)
.route(
"/renderers/{renderer_id}/oh/playlist/add",
post(add_openhome_playlist_item),
)
.route(
"/renderers/{renderer_id}/oh/playlist/play/{track_id}",
post(play_openhome_track),
)
// Queue content
.route("/renderers/{renderer_id}/queue/play", post(play_content))
.route("/renderers/{renderer_id}/queue/add", post(add_to_queue))

View File

@@ -28,97 +28,7 @@
//! - This identity is used by the sync helpers to preserve the current
//! track across queue rebuilds when the MediaServer content changes.
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()
}
}
use crate::{PlaybackItem, QueueSnapshot, errors::ControlPointError};
/// High-level enqueue mode.
///
@@ -158,14 +68,32 @@ 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>;
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError>;
/// 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<()>;
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError>;
/// Replaces the entire queue with a new list of items and a new
/// current index.
@@ -173,60 +101,46 @@ pub trait QueueBackend {
&mut self,
items: Vec<PlaybackItem>,
current_index: Option<usize>,
) -> Result<()>;
) -> 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>;
/// Returns the item at `index`, if it exists.
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>>;
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError>;
/// Replaces the item at `index` with `item`.
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<()>;
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>;
// =====================================================================
// DEFAULT HELPERS (backend-agnostic logic)
// =====================================================================
/// Clears the queue.
fn clear_queue(&mut self) -> Result<()> {
fn clear_queue(&mut self) -> Result<(), ControlPointError> {
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> {
fn is_empty(&self) -> Result<bool, ControlPointError> {
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>> {
fn upcoming_items(&self) -> Result<Vec<PlaybackItem>, ControlPointError> {
let snapshot = self.queue_snapshot()?;
let items = match snapshot.current_index {
None => snapshot.items,
@@ -236,13 +150,18 @@ pub trait QueueBackend {
}
/// Returns how many items remain in the queue after the current index.
fn upcoming_len(&self) -> Result<usize> {
Ok(self.upcoming_items()?.len())
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)),
}
}
/// 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)>> {
fn peek_current(&self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
let snapshot = self.queue_snapshot()?;
let QueueSnapshot {
items,
@@ -274,7 +193,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)>> {
fn dequeue_next(&mut self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
let snapshot = self.queue_snapshot()?;
let QueueSnapshot {
items,
@@ -306,38 +225,8 @@ 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<()> {
fn replace_all(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
if items.is_empty() {
self.replace_queue(Vec::new(), None)
} else {
@@ -347,7 +236,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<()> {
fn append_or_init_index(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
let was_empty = self.is_empty()?;
let mut snapshot = self.queue_snapshot()?;
snapshot.items.extend(items);
@@ -362,7 +251,7 @@ pub trait QueueBackend {
}
/// Computes the “next” index.
fn next_index(&self) -> Result<Option<usize>> {
fn next_index(&self) -> Result<Option<usize>, ControlPointError> {
let len = self.len()?;
if len == 0 {
return Ok(None);
@@ -376,7 +265,7 @@ pub trait QueueBackend {
}
/// Computes the “previous” index.
fn previous_index(&self) -> Result<Option<usize>> {
fn previous_index(&self) -> Result<Option<usize>, ControlPointError> {
match self.current_index()? {
None => Ok(None),
Some(0) => Ok(None),
@@ -385,7 +274,7 @@ pub trait QueueBackend {
}
/// Advances the current index to the next item, if any.
fn advance(&mut self) -> Result<bool> {
fn advance(&mut self) -> Result<bool, ControlPointError> {
if let Some(next) = self.next_index()? {
self.set_index(Some(next))?;
Ok(true)
@@ -395,7 +284,7 @@ pub trait QueueBackend {
}
/// Rewinds the current index to the previous item, if any.
fn rewind(&mut self) -> Result<bool> {
fn rewind(&mut self) -> Result<bool, ControlPointError> {
if let Some(prev) = self.previous_index()? {
self.set_index(Some(prev))?;
Ok(true)
@@ -404,44 +293,20 @@ 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<()> {
) -> Result<(), ControlPointError> {
if let Some(item) = self.get_item(index)? {
let new_item = update(item);
self.replace_item(index, new_item)
} else {
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))
Err(ControlPointError::QueueError(format!(
"Queue index {} out of range",
index
)))
}
}
}

View File

@@ -0,0 +1,328 @@
//! 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)
}
}

View File

@@ -0,0 +1,43 @@
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)))
}
}

View File

@@ -0,0 +1,189 @@
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
}
}

View File

@@ -0,0 +1,859 @@
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)
}
}

View File

@@ -0,0 +1,91 @@
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()
}
}

View File

@@ -1,116 +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 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(())
}
}

View File

@@ -1,214 +1,302 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::SystemTime;
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;
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,
};
#[derive(Clone, Debug)]
enum DeviceKey {
Renderer(RendererId),
Server(ServerId),
const DEFAULT_MAX_AGE: u32 = 1800;
#[derive(Debug, Clone)]
pub struct DeviceItem {
music_renderer: Option<Arc<MusicRenderer>>,
music_server: Option<Arc<MusicServer>>,
}
#[derive(Debug, Default)]
pub struct DeviceRegistry {
renderers: HashMap<RendererId, RendererInfo>,
servers: HashMap<ServerId, MediaServerInfo>,
udn_index: HashMap<String, DeviceKey>,
devices: HashMap<DeviceId, DeviceItem>,
udn_index: HashMap<String, DeviceId>,
renderer_bus: RendererEventBus,
server_bus: MediaServerEventBus,
}
/// Read-only view / trait for registry access.
///
/// Pour linstant, on ne rajoute pas AVTransport ici, on se contente
/// dajouter 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)]
#[derive(Debug, Clone)]
pub enum DeviceUpdate {
RendererOnline(RendererInfo),
RendererOfflineById(RendererId),
RendererOfflineByUdn(String),
OfflineById(DeviceId),
OfflineByUdn(String),
}
ServerOnline(MediaServerInfo),
ServerOfflineById(ServerId),
ServerOfflineByUdn(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"),
}
}
}
impl DeviceRegistry {
pub fn new() -> Self {
Self::default()
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 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;
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()
}
info.online = true;
info.last_seen = SystemTime::now();
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()
}
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;
pub fn get_renderer(&self, id: &DeviceId) -> Option<Arc<MusicRenderer>> {
self.devices.get(id)?.as_music_renderer().ok()
}
info.online = true;
info.last_seen = SystemTime::now();
pub fn get_server(&self, id: &DeviceId) -> Option<Arc<MusicServer>> {
self.devices.get(id)?.as_music_server().ok()
}
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 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<RendererInfo> {
let lookup = udn.to_ascii_lowercase();
match self.udn_index.get(&lookup) {
Some(DeviceKey::Renderer(id)) => self.renderers.get(id).cloned(),
_ => None,
}
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())
}
/// Helper: get a server by UDN (case-insensitive, via udn_index).
pub fn get_server_by_udn(&self, udn: &str) -> Option<MediaServerInfo> {
let lookup = udn.to_ascii_lowercase();
match self.udn_index.get(&lookup) {
Some(DeviceKey::Server(id)) => self.servers.get(id).cloned(),
_ => None,
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(),
});
}
}
}
/// 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)?;
pub fn push_server(&mut self, info: &UpnpMediaServer, max_age: u32) {
let device_id = info.id();
let service_type = info.avtransport_service_type.as_ref()?;
let control_url = info.avtransport_control_url.as_ref()?;
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);
Some(AvTransportClient::new(
control_url.clone(),
service_type.clone(),
))
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(),
});
}
}
}
/// 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)?;
pub fn device_says_byebye(&mut self, udn: &str) {
let lookup = udn.to_ascii_lowercase();
let service_type = info.avtransport_service_type?;
let control_url = info.avtransport_control_url?;
if let Some(id) = self.udn_index.get(&lookup) {
if let Some(device) = self.devices.get(id) {
device.mark_as_offline();
Some(AvTransportClient::new(control_url, service_type))
// 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(),
});
}
}
}
}
/// 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)?;
pub fn check_timeouts(&mut self) {
let now = SystemTime::now();
let service_type = info.rendering_control_service_type.as_ref()?;
let control_url = info.rendering_control_control_url.as_ref()?;
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();
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"
);
// 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(),
});
}
}
}
}
}

View File

@@ -1,231 +0,0 @@
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)
}

View File

@@ -1,10 +1,12 @@
use std::time::Duration;
use anyhow::{Context, Result};
use pmoupnp::soap::{build_soap_request, parse_soap_envelope, SoapEnvelope};
use pmoupnp::soap::{SoapEnvelope, build_soap_request, parse_soap_envelope};
use tracing::{debug, trace, warn};
use ureq::Agent;
use crate::errors::ControlPointError;
/// Result of a SOAP call:
/// - HTTP status code
/// - raw XML body (always)
@@ -34,7 +36,7 @@ pub fn invoke_upnp_action(
service_type: &str,
action: &str,
args: &[(&str, &str)],
) -> Result<SoapCallResult> {
) -> Result<SoapCallResult, ControlPointError> {
invoke_upnp_action_with_timeout(control_url, service_type, action, args, None)
}
@@ -44,9 +46,10 @@ pub fn invoke_upnp_action_with_timeout(
action: &str,
args: &[(&str, &str)],
timeout: Option<Duration>,
) -> Result<SoapCallResult> {
let body_xml =
build_soap_body(action, service_type, args).context("Failed to build SOAP request body")?;
) -> 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)))?;
let arg_log = summarize_args_for_log(args);
debug!(
@@ -77,7 +80,8 @@ 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))?;
.with_context(|| format!("HTTP error when sending SOAP request to {}", control_url))
.map_err(|e| ControlPointError::SoapAction(format!("{}", e)))?;
let status = response.status();
debug!(status = status.as_u16(), "SOAP response received");
@@ -89,7 +93,8 @@ pub fn invoke_upnp_action_with_timeout(
let raw_body = response
.body_mut()
.read_to_string()
.context("Failed to read SOAP response body")?;
.context("Failed to read SOAP response body")
.map_err(|e| ControlPointError::SoapAction(format!("{}", e)))?;
// 6. Try to parse SOAP envelope; non-fatal on failure
let parsed_envelope = parse_soap_envelope(raw_body.as_bytes()).ok();
@@ -203,7 +208,7 @@ pub fn parse_upnp_error(envelope: &SoapEnvelope) -> Option<UpnpError> {
})
}
fn find_child_with_suffix<'a>(
pub fn find_child_with_suffix<'a>(
parent: &'a xmltree::Element,
suffix: &str,
) -> Option<&'a xmltree::Element> {
@@ -213,6 +218,235 @@ 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;

View File

@@ -32,6 +32,9 @@ use serde::Serialize;
#[cfg(feature = "pmoserver")]
use std::sync::Arc;
use crate::{DeviceIdentity, DeviceOnline};
use tracing::error;
// ============================================================================
// PAYLOADS SSE
// ============================================================================
@@ -168,6 +171,38 @@ 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();
@@ -230,9 +265,9 @@ pub async fn renderer_events_sse(
RendererEvent::Online { id, info } => {
RendererEventPayload::Online {
renderer_id: id.0,
friendly_name: info.friendly_name,
model_name: info.model_name,
manufacturer: info.manufacturer,
friendly_name: info.friendly_name.clone(),
model_name: info.model_name.clone(),
manufacturer: info.manufacturer.clone(),
timestamp,
}
}
@@ -285,6 +320,38 @@ 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();
@@ -306,9 +373,9 @@ pub async fn media_server_events_sse(
MediaServerEvent::Online { server_id, info } => {
MediaServerEventPayload::Online {
server_id: server_id.0,
friendly_name: info.friendly_name,
model_name: info.model_name,
manufacturer: info.manufacturer,
friendly_name: info.friendly_name.clone(),
model_name: info.model_name.clone(),
manufacturer: info.manufacturer.clone(),
timestamp,
}
}
@@ -370,6 +437,67 @@ 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() => {
@@ -434,9 +562,9 @@ pub async fn all_events_sse(State(control_point): State<Arc<ControlPoint>>) -> i
RendererEvent::Online { id, info } => {
RendererEventPayload::Online {
renderer_id: id.0,
friendly_name: info.friendly_name,
model_name: info.model_name,
manufacturer: info.manufacturer,
friendly_name: info.friendly_name.clone(),
model_name: info.model_name.clone(),
manufacturer: info.manufacturer.clone(),
timestamp,
}
}
@@ -475,9 +603,9 @@ pub async fn all_events_sse(State(control_point): State<Arc<ControlPoint>>) -> i
MediaServerEvent::Online { server_id, info } => {
MediaServerEventPayload::Online {
server_id: server_id.0,
friendly_name: info.friendly_name,
model_name: info.model_name,
manufacturer: info.manufacturer,
friendly_name: info.friendly_name.clone(),
model_name: info.model_name.clone(),
manufacturer: info.manufacturer.clone(),
timestamp,
}
}

View File

@@ -2,8 +2,13 @@
use std::time::Duration;
use crate::soap_client::{SoapCallResult, invoke_upnp_action, invoke_upnp_action_with_timeout};
use anyhow::{Result, anyhow};
use crate::{
errors::ControlPointError,
soap_client::{
extract_child_text, handle_action_response, invoke_upnp_action,
invoke_upnp_action_with_timeout,
},
};
use pmoupnp::soap::SoapEnvelope;
use xmltree::{Element, XMLNode};
@@ -31,7 +36,7 @@ impl AvTransportClient {
}
/// AVTransport:1 — GetTransportInfo
pub fn get_transport_info(&self, instance_id: u32) -> Result<TransportInfo> {
pub fn get_transport_info(&self, instance_id: u32) -> Result<TransportInfo, ControlPointError> {
let instance_id_str = instance_id.to_string();
let args = [("InstanceID", instance_id_str.as_str())];
@@ -43,16 +48,17 @@ impl AvTransportClient {
)?;
if !call_result.status.is_success() {
return Err(anyhow!(
return Err(ControlPointError::UpnpError(format!(
"GetTransportInfo failed with HTTP status {}",
call_result.status
));
)));
}
let envelope = call_result
.envelope
.as_ref()
.ok_or_else(|| anyhow!("Missing SOAP envelope in GetTransportInfo response"))?;
let envelope = call_result.envelope.as_ref().ok_or_else(|| {
ControlPointError::UpnpError(format!(
"Missing SOAP envelope in GetTransportInfo response"
))
})?;
parse_transport_info(envelope)
}
@@ -64,7 +70,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<()> {
pub fn set_av_transport_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
let args = [
("InstanceID", "0"),
("CurrentURI", uri),
@@ -82,7 +88,7 @@ impl AvTransportClient {
}
/// AVTransport:1 — Play
pub fn play(&self, instance_id: u32, speed: &str) -> Result<()> {
pub fn play(&self, instance_id: u32, speed: &str) -> Result<(), ControlPointError> {
let instance_id_str = instance_id.to_string();
let args = [("InstanceID", instance_id_str.as_str()), ("Speed", speed)];
@@ -92,7 +98,7 @@ impl AvTransportClient {
}
/// AVTransport:1 — Pause
pub fn pause(&self, instance_id: u32) -> Result<()> {
pub fn pause(&self, instance_id: u32) -> Result<(), ControlPointError> {
let instance_id_str = instance_id.to_string();
let args = [("InstanceID", instance_id_str.as_str())];
@@ -103,7 +109,7 @@ impl AvTransportClient {
}
/// AVTransport:1 — Stop
pub fn stop(&self, instance_id: u32) -> Result<()> {
pub fn stop(&self, instance_id: u32) -> Result<(), ControlPointError> {
let instance_id_str = instance_id.to_string();
let args = [("InstanceID", instance_id_str.as_str())];
@@ -113,7 +119,12 @@ impl AvTransportClient {
}
/// AVTransport:1 — Seek
pub fn seek(&self, instance_id: u32, unit: &str, target: &str) -> Result<()> {
pub fn seek(
&self,
instance_id: u32,
unit: &str,
target: &str,
) -> Result<(), ControlPointError> {
let instance_id_str = instance_id.to_string();
let args = [
("InstanceID", instance_id_str.as_str()),
@@ -131,7 +142,11 @@ 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<()> {
pub fn set_next_av_transport_uri(
&self,
next_uri: &str,
next_meta: &str,
) -> Result<(), ControlPointError> {
let args = [
("InstanceID", "0"),
("NextURI", next_uri),
@@ -150,10 +165,9 @@ 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(anyhow!(
"Renderer does not support AVTransport.SetNextAVTransportURI (UPnP error {}: {})",
upnp_error.error_code,
upnp_error.error_description
return Err(ControlPointError::upnp_operation_not_supported(
"SetNextAVTransportURI",
"AVTransport",
));
}
}
@@ -166,43 +180,13 @@ impl AvTransportClient {
}
}
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> {
fn parse_transport_info(envelope: &SoapEnvelope) -> Result<TransportInfo, ControlPointError> {
let response = find_child_with_suffix(&envelope.body.content, "GetTransportInfoResponse")
.ok_or_else(|| anyhow!("Missing GetTransportInfoResponse element in SOAP body"))?;
.ok_or_else(|| {
ControlPointError::UpnpError(format!(
"Missing GetTransportInfoResponse element in SOAP body"
))
})?;
let current_transport_state = extract_child_text(response, "CurrentTransportState")?;
let current_transport_status = extract_child_text(response, "CurrentTransportStatus")?;
@@ -289,19 +273,6 @@ 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::*;
@@ -397,7 +368,7 @@ pub struct PositionInfo {
impl AvTransportClient {
/// AVTransport:1 — GetPositionInfo
pub fn get_position_info(&self, instance_id: u32) -> Result<PositionInfo> {
pub fn get_position_info(&self, instance_id: u32) -> Result<PositionInfo, ControlPointError> {
let instance_id_str = instance_id.to_string();
let args = [("InstanceID", instance_id_str.as_str())];
@@ -409,24 +380,27 @@ impl AvTransportClient {
)?;
if !call_result.status.is_success() {
return Err(anyhow!(
return Err(ControlPointError::UpnpError(format!(
"GetPositionInfo failed with HTTP status {}",
call_result.status
));
)));
}
let envelope = call_result
.envelope
.as_ref()
.ok_or_else(|| anyhow!("Missing SOAP envelope in GetPositionInfo response"))?;
let envelope = call_result.envelope.as_ref().ok_or_else(|| {
ControlPointError::UpnpError(format!(
"Missing SOAP envelope in GetPositionInfo response"
))
})?;
parse_position_info(envelope)
}
}
fn parse_position_info(envelope: &SoapEnvelope) -> Result<PositionInfo> {
fn parse_position_info(envelope: &SoapEnvelope) -> Result<PositionInfo, ControlPointError> {
let response = find_child_with_suffix(&envelope.body.content, "GetPositionInfoResponse")
.ok_or_else(|| anyhow!("Missing GetPositionInfoResponse element"))?;
.ok_or_else(|| {
ControlPointError::UpnpError(format!("Missing GetPositionInfoResponse element"))
})?;
// Helpers allow missing text (AVTransport allows empty durations)
fn opt(parent: &Element, name: &str) -> Option<String> {

View File

@@ -0,0 +1,223 @@
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()
}

View File

@@ -0,0 +1,41 @@
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()
}

View File

@@ -1,20 +1,42 @@
use crate::errors::ControlPointError;
use crate::model::TrackMetadata;
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 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 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;
#[derive(Debug, Clone)]
pub struct OhTrackEntry {
pub id: u32,
pub uri: String,
pub metadata_xml: String,
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)]
@@ -24,10 +46,20 @@ pub struct OhInfoTrack {
}
impl OhInfoTrack {
pub fn metadata(&self) -> Option<TrackMetadata> {
self.metadata_xml
.as_deref()
.and_then(parse_track_metadata_from_didl)
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
}
}
@@ -51,12 +83,52 @@ 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 {
@@ -65,7 +137,48 @@ impl OhPlaylistClient {
}
}
pub fn read_list(&self, id_list: &[u32]) -> Result<Vec<OhTrackEntry>> {
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> {
if id_list.is_empty() {
return Ok(Vec::new());
}
@@ -80,12 +193,15 @@ impl OhPlaylistClient {
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "ReadList", &args)?;
let envelope = ensure_success("ReadList", &call_result)?;
let envelope: &pmoupnp::soap::SoapEnvelope = ensure_success("ReadList", &call_result)?;
let response = find_child_with_suffix(&envelope.body.content, "ReadListResponse")
.ok_or_else(|| anyhow!("Missing ReadListResponse element in SOAP body"))?;
.ok_or_else(|| {
ControlPointError::OpenHomeError(format!(
"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(),
@@ -96,7 +212,12 @@ impl OhPlaylistClient {
parse_track_list(&track_list_b64)
}
pub fn insert(&self, after_id: u32, uri: &str, metadata: &str) -> Result<u32> {
pub fn insert(
&self,
after_id: u32,
uri: &str,
metadata: &str,
) -> Result<u32, ControlPointError> {
let after_id_str = after_id.to_string();
let args = [
("AfterId", after_id_str.as_str()),
@@ -107,19 +228,25 @@ impl OhPlaylistClient {
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "Insert", &args)?;
let envelope = ensure_success("Insert", &call_result)?;
let envelope = ensure_success("Insert", &call_result)
.map_err(|e| ControlPointError::SoapAction(format!("{}", e)))?;
let response = find_child_with_suffix(&envelope.body.content, "InsertResponse")
.ok_or_else(|| anyhow!("Missing InsertResponse element in SOAP body"))?;
let new_id_text =
extract_child_text_any(response, &["NewId", "Value"])?;
.ok_or_else(|| {
ControlPointError::UpnpMissingReturnValue("InsertResponse".to_string())
})?;
let new_id_text = extract_child_text_any(response, &["NewId", "Value"])?;
let new_id = new_id_text
.parse::<u32>()
.map_err(|_| anyhow!("Invalid NewId value: {}", new_id_text))?;
.map_err(|_| ControlPointError::UpnpBadReturnValue("NewId".to_string(), new_id_text))?;
Ok(new_id)
}
pub fn play_id(&self, id: u32) -> Result<()> {
/// 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> {
let id_str = id.to_string();
let args = [("Value", id_str.as_str())];
@@ -129,58 +256,65 @@ impl OhPlaylistClient {
handle_action_response("SeekId", &call_result)
}
pub fn transport_state(&self) -> Result<String> {
pub fn transport_state(&self) -> Result<String, ControlPointError> {
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(|| anyhow!("Missing TransportStateResponse element in SOAP body"))?;
.ok_or_else(|| {
ControlPointError::UpnpMissingReturnValue("TransportStateResponse".to_string())
})?;
let state = extract_child_text_any(response, &["State", "Value"])?;
Ok(state)
}
pub fn id(&self) -> Result<u32> {
pub fn id(&self) -> Result<u32, ControlPointError> {
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 id_text = extract_child_text(response, "Id")?;
let id = id_text
.parse::<u32>()
.map_err(|_| anyhow!("Invalid Info.Id value: {}", id_text))?;
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)
})?;
Ok(id)
}
pub fn play(&self) -> Result<()> {
pub fn play(&self) -> Result<(), ControlPointError> {
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Play", &[])?;
handle_action_response("Play", &call_result)
}
pub fn pause(&self) -> Result<()> {
pub fn pause(&self) -> Result<(), ControlPointError> {
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Pause", &[])?;
handle_action_response("Pause", &call_result)
}
pub fn stop(&self) -> Result<()> {
pub fn stop(&self) -> Result<(), ControlPointError> {
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Stop", &[])?;
handle_action_response("Stop", &call_result)
}
pub fn next(&self) -> Result<()> {
pub fn next(&self) -> Result<(), ControlPointError> {
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Next", &[])?;
handle_action_response("Next", &call_result)
}
pub fn previous(&self) -> Result<()> {
pub fn previous(&self) -> Result<(), ControlPointError> {
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<()> {
pub fn seek_second_absolute(&self, second: u32) -> Result<(), ControlPointError> {
let second_str = second.to_string();
let args = [("Second", second_str.as_str())];
let call_result = invoke_upnp_action(
@@ -193,7 +327,7 @@ impl OhPlaylistClient {
handle_action_response("SeekSecondAbsolute", &call_result)
}
pub fn delete_id(&self, id: u32) -> Result<()> {
pub fn delete_id(&self, id: u32) -> Result<(), ControlPointError> {
let id_str = id.to_string();
let args = [("Value", id_str.as_str())];
@@ -211,7 +345,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> {
pub fn delete_id_if_exists(&self, id: u32) -> Result<bool, ControlPointError> {
match self.delete_id(id) {
Ok(()) => Ok(true),
Err(err) => {
@@ -224,7 +358,8 @@ 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(),
@@ -240,49 +375,38 @@ impl OhPlaylistClient {
}
}
pub fn delete_all(&self) -> Result<()> {
pub fn delete_all(&self) -> Result<(), ControlPointError> {
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "DeleteAll", &[])?;
handle_action_response("DeleteAll", &call_result)
}
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> {
pub fn tracks_max(&self) -> Result<u32, ControlPointError> {
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(|| anyhow!("Missing TracksMaxResponse element in SOAP body"))?;
.ok_or_else(|| {
ControlPointError::UpnpMissingReturnValue("TracksMaxResponse".to_string())
})?;
let value_text: String = extract_child_text_any(response, &["Value"])?;
let value = value_text
.parse::<u32>()
.map_err(|_| anyhow!("Invalid TracksMax value: {}", value_text))?;
let value = value_text.parse::<u32>().map_err(|_| {
ControlPointError::UpnpBadReturnValue("Playlist.Id".to_string(), value_text)
})?;
Ok(value)
}
pub fn id_array(&self) -> Result<Vec<u32>> {
pub fn id_array(&self) -> Result<Vec<u32>, ControlPointError> {
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(|| anyhow!("Missing IdArrayResponse element in SOAP body"))?;
.ok_or_else(|| ControlPointError::upnp_missing_return_value("IdArrayResponse"))?;
// 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
@@ -297,10 +421,10 @@ impl OhPlaylistClient {
let bytes = decode_base64(&array_text)?;
if bytes.len() % 4 != 0 {
return Err(anyhow!(
return Err(ControlPointError::SoapAction(format!(
"Invalid IdArray payload length {} (expected multiple of 4)",
bytes.len()
));
)));
}
let mut ids = Vec::with_capacity(bytes.len() / 4);
@@ -310,7 +434,7 @@ impl OhPlaylistClient {
Ok(ids)
}
pub fn read_all_tracks(&self) -> Result<Vec<OhTrackEntry>> {
pub fn read_all_tracks(&self) -> Result<Vec<OhTrackEntry>, ControlPointError> {
let ids = self.id_array()?;
debug!(
control_url = self.control_url.as_str(),
@@ -359,12 +483,6 @@ 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 {
@@ -373,6 +491,17 @@ 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", &[])?;
@@ -380,8 +509,7 @@ 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());
@@ -407,8 +535,7 @@ 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());
@@ -436,25 +563,41 @@ impl OhTimeClient {
}
}
pub fn position(&self) -> Result<OhTimePosition> {
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> {
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(|| anyhow!("Missing TimeResponse element in SOAP body"))?;
.ok_or_else(|| ControlPointError::UpnpMissingReturnValue("TimeResponse".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"))?;
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())
})?;
Ok(OhTimePosition {
track_count,
@@ -478,19 +621,34 @@ impl OhVolumeClient {
}
}
pub fn volume(&self) -> Result<u16> {
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> {
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(|| anyhow!("Missing VolumeResponse element in SOAP body"))?;
let value = extract_child_text_any(response, &["Volume", "Value"])?;
.ok_or_else(|| {
ControlPointError::UpnpMissingReturnValue("VolumeResponse".to_string())
})?;
let value = extract_child_text_any(response, &["Value"])?;
let parsed = value
.parse::<u32>()
.map_err(|_| anyhow!("Invalid volume value: {}", value))?;
.map_err(|_| ControlPointError::UpnpBadReturnValue("volume".to_string(), value))?;
Ok(parsed.min(u16::MAX as u32) as u16)
}
pub fn set_volume(&self, vol: u16) -> Result<()> {
pub fn set_volume(&self, vol: u16) -> Result<(), ControlPointError> {
let vol_str = vol.to_string();
let args = [("Value", vol_str.as_str())];
let call_result =
@@ -498,18 +656,20 @@ impl OhVolumeClient {
handle_action_response("SetVolume", &call_result)
}
pub fn mute(&self) -> Result<bool> {
pub fn mute(&self) -> Result<bool, ControlPointError> {
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(|| anyhow!("Missing MuteResponse element in SOAP body"))?;
let value = extract_child_text_any(response, &["Mute", "Value"])?;
parse_bool(&value)
.ok_or_else(|| ControlPointError::UpnpMissingReturnValue("MuteResponse".to_string()))?;
let value = extract_child_text_any(response, &["Value"])?;
Ok(parse_bool(&value))
}
pub fn set_mute(&self, mute: bool) -> Result<()> {
pub fn set_mute(&self, mute: bool) -> Result<(), ControlPointError> {
let mute_str = if mute { "1" } else { "0" };
let args = [("Mute", mute_str)];
let args = [("Value", mute_str)];
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "SetMute", &args)?;
handle_action_response("SetMute", &call_result)
@@ -530,7 +690,18 @@ impl OhRadioClient {
}
}
pub fn play_channel(&self, id: u32) -> Result<()> {
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> {
let id_str = id.to_string();
let args = [("Id", id_str.as_str())];
let call_result =
@@ -548,8 +719,7 @@ 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());
@@ -572,6 +742,17 @@ 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", &[])?;
@@ -582,19 +763,22 @@ impl OhProductClient {
parse_product_source_list(&xml)
}
pub fn source_index(&self) -> Result<u32> {
pub fn source_index(&self) -> Result<u32, ControlPointError> {
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(|| anyhow!("Missing SourceIndexResponse element in SOAP body"))?;
.ok_or_else(|| {
ControlPointError::UpnpMissingReturnValue("SourceIndexResponse".to_string())
})?;
let value = extract_child_text_any(response, &["Index", "Value"])?;
value
.parse::<u32>()
.map_err(|_| anyhow!("Invalid Product.SourceIndex value: {}", value))
.map_err(|_| ControlPointError::UpnpBadReturnValue("volume".to_string(), value))
}
pub fn set_source_index(&self, index: u32) -> Result<()> {
pub fn set_source_index(&self, index: u32) -> Result<(), ControlPointError> {
let value = index.to_string();
let args = [("Index", value.as_str())];
let call_result = invoke_upnp_action(
@@ -606,8 +790,10 @@ impl OhProductClient {
handle_action_response("SetSourceIndex", &call_result)
}
pub fn ensure_playlist_source_selected(&self) -> Result<()> {
let sources = self.source_xml()?;
pub fn ensure_playlist_source_selected(&self) -> Result<(), ControlPointError> {
let sources = self
.source_xml()
.map_err(|e| ControlPointError::SoapAction(format!("{}", e)))?;
// Log all available sources for diagnostics
debug!(
@@ -615,8 +801,9 @@ impl OhProductClient {
source_count = sources.len(),
"OpenHome Product sources available"
);
for (idx, source) in sources.iter().enumerate() {
debug!(
trace!(
control_url = self.control_url.as_str(),
index = idx,
name = source.name.as_str(),
@@ -635,7 +822,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"
);
anyhow!("OpenHome Product source list does not expose a Playlist entry")
ControlPointError::OpenHomeNoPlaylistEntry()
})?;
let playlist_index = playlist_index as u32;
let current_index = self.source_index()?;
@@ -646,7 +833,9 @@ 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"
@@ -708,7 +897,16 @@ pub fn parse_track_metadata_from_didl(xml: &str) -> Option<TrackMetadata> {
})
}
fn parse_track_list(payload: &str) -> Result<Vec<OhTrackEntry>> {
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> {
let trimmed = payload.trim();
if trimmed.is_empty() {
return Ok(Vec::new());
@@ -718,8 +916,11 @@ fn parse_track_list(payload: &str) -> Result<Vec<OhTrackEntry>> {
(trimmed.to_string(), false)
} else {
let bytes = decode_base64(trimmed)?;
let decoded = String::from_utf8(bytes)
.map_err(|err| anyhow!("TrackList payload not valid UTF-8 after base64 decode: {err}"))?;
let decoded = String::from_utf8(bytes).map_err(|err| {
ControlPointError::OpenHomeError(format!(
"TrackList payload not valid UTF-8 after base64 decode: {err}"
))
})?;
(decoded, true)
};
let xml_sample: String = xml.chars().take(256).collect();
@@ -731,8 +932,9 @@ fn parse_track_list(payload: &str) -> Result<Vec<OhTrackEntry>> {
);
let mut reader = std::io::Cursor::new(xml.as_bytes());
let root = Element::parse(&mut reader)
.map_err(|err| anyhow!("Failed to parse OpenHome TrackList XML: {}", err))?;
let root = Element::parse(&mut reader).map_err(|err| {
ControlPointError::OpenHomeError(format!("Failed to parse OpenHome TrackList XML: {}", err))
})?;
let mut entries = Vec::new();
for node in &root.children {
@@ -746,7 +948,7 @@ fn parse_track_list(payload: &str) -> Result<Vec<OhTrackEntry>> {
Ok(entries)
}
fn parse_track_entry(elem: &Element) -> Result<OhTrackEntry> {
fn parse_track_entry(elem: &Element) -> Result<OhTrackEntry, ControlPointError> {
let id_text = extract_child_text_local(elem, "Id")?;
// Some renderers (like upmpdcli) return a comma-separated list of IDs in the Id element
@@ -759,14 +961,14 @@ fn parse_track_entry(elem: &Element) -> Result<OhTrackEntry> {
raw_id = id_text.as_str(),
"Multi-value Id element detected - forcing fallback to individual reads"
);
return Err(anyhow!(
return Err(ControlPointError::OpenHomeError(format!(
"Renderer returned comma-separated IDs in single Entry - need individual reads"
));
)));
}
let id = id_text
.parse::<u32>()
.map_err(|_| anyhow!("Invalid OpenHome Entry Id: {}", id_text))?;
let id = id_text.parse::<u32>().map_err(|_| {
ControlPointError::OpenHomeError(format!("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();
@@ -809,181 +1011,7 @@ fn parse_product_source_list(xml: &str) -> Result<Vec<OhProductSource>> {
Ok(sources)
}
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 {
fn is_invalid_entry_id_error(err: &ControlPointError) -> bool {
let msg = format!("{err}");
msg.contains("Invalid OpenHome Entry Id") || msg.contains("comma-separated IDs")
}
@@ -998,8 +1026,7 @@ 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");
}
@@ -1008,9 +1035,7 @@ 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+");
}
@@ -1019,11 +1044,8 @@ 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=");
}
}

View File

@@ -0,0 +1,151 @@
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)
}
}

View File

@@ -1,317 +0,0 @@
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, &registry);
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, &registry);
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,
})
}
}

View File

@@ -15,12 +15,12 @@ webp = "0.3"
reqwest = { version = "0.12", features = ["blocking"] }
# Utilitaires
anyhow = "1.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
# Async
tokio = { version = "1.0", features = ["full"] }
tokio = { workspace = true }
# 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 = "0.1.41"
tracing = { workspace = true }
[dev-dependencies]
tempfile = "3"

View File

@@ -0,0 +1,51 @@
[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