1use std::collections::HashMap;
2
3use actix_web::{
4 dev::{ServiceFactory, ServiceRequest},
5 error::{ErrorBadRequest, ErrorFailedDependency, ErrorInternalServerError},
6 route,
7 web::{self, Json},
8 Result, Scope,
9};
10use futures::TryStreamExt;
11use serde::Deserialize;
12
13use crate::{
14 cache::get_device_and_service_from_url, devices, get_device_and_service, get_media_info,
15 get_position_info, get_transport_info, get_volume, models::UpnpDevice, pause, play,
16 scan_devices, seek, set_volume, subscribe_events, ActionError, MediaInfo, PositionInfo,
17 ScanError, TransportInfo, UpnpDeviceScannerError,
18};
19
20pub fn bind_services<
21 T: ServiceFactory<ServiceRequest, Config = (), Error = actix_web::Error, InitError = ()>,
22>(
23 scope: Scope<T>,
24) -> Scope<T> {
25 scope
26 .service(scan_devices_endpoint)
27 .service(get_transport_info_endpoint)
28 .service(get_media_info_endpoint)
29 .service(get_position_info_endpoint)
30 .service(get_volume_endpoint)
31 .service(set_volume_endpoint)
32 .service(subscribe_endpoint)
33 .service(pause_endpoint)
34 .service(play_endpoint)
35 .service(seek_endpoint)
36}
37
38#[cfg(feature = "openapi")]
39#[derive(utoipa::OpenApi)]
40#[openapi(
41 tags((name = "UPnP")),
42 paths(
43 scan_devices_endpoint,
44 get_transport_info_endpoint,
45 get_media_info_endpoint,
46 get_position_info_endpoint,
47 get_volume_endpoint,
48 set_volume_endpoint,
49 subscribe_endpoint,
50 pause_endpoint,
51 play_endpoint,
52 seek_endpoint,
53 ),
54 components(schemas(
55 MediaInfo,
56 PositionInfo,
57 TransportInfo,
58 crate::TrackMetadata,
59 crate::TrackMetadataItem,
60 crate::TrackMetadataItemResource,
61 UpnpDevice,
62 crate::models::UpnpService,
63 ))
64)]
65pub struct Api;
66
67impl From<ActionError> for actix_web::Error {
68 fn from(e: ActionError) -> Self {
69 match &e {
70 ActionError::Rupnp(rupnp_err) => {
71 ErrorFailedDependency(format!("UPnP error: {rupnp_err:?}"))
72 }
73 ActionError::MissingProperty(_property) => ErrorInternalServerError(e.to_string()),
74 ActionError::Roxml(roxmltree_err) => {
75 ErrorFailedDependency(format!("roxmltree error: {roxmltree_err:?}"))
76 }
77 }
78 }
79}
80
81impl From<ScanError> for actix_web::Error {
82 fn from(e: ScanError) -> Self {
83 ErrorFailedDependency(e.to_string())
84 }
85}
86
87impl From<UpnpDeviceScannerError> for actix_web::Error {
88 fn from(e: UpnpDeviceScannerError) -> Self {
89 ErrorFailedDependency(e.to_string())
90 }
91}
92
93#[cfg_attr(
94 feature = "openapi", utoipa::path(
95 tags = ["UPnP"],
96 post,
97 path = "/scan-devices",
98 description = "Scan the network for UPnP devices",
99 params(),
100 responses(
101 (
102 status = 200,
103 description = "List of UPnP devices",
104 body = Vec<UpnpDevice>,
105 )
106 )
107 )
108)]
109#[route("/scan-devices", method = "GET")]
110pub async fn scan_devices_endpoint() -> Result<Json<Vec<UpnpDevice>>> {
111 scan_devices().await?;
112 Ok(Json(devices().await))
113}
114
115#[derive(Deserialize)]
116#[serde(rename_all = "camelCase")]
117pub struct GetTransportInfoQuery {
118 device_udn: Option<String>,
119 device_url: Option<String>,
120 instance_id: u32,
121}
122
123#[cfg_attr(
124 feature = "openapi", utoipa::path(
125 tags = ["UPnP"],
126 get,
127 path = "/transport-info",
128 description = "Get the current UPnP transport info",
129 params(
130 ("deviceUdn" = Option<String>, Query, description = "UPnP device UDN to get transport info from"),
131 ("deviceUrl" = Option<String>, Query, description = "UPnP device URL to get transport info from"),
132 ("instanceId" = u32, Query, description = "UPnP instance ID to get transport info from"),
133 ),
134 responses(
135 (
136 status = 200,
137 description = "The current UPnP transport info",
138 body = TransportInfo,
139 )
140 )
141 )
142)]
143#[route("/transport-info", method = "GET")]
144pub async fn get_transport_info_endpoint(
145 query: web::Query<GetTransportInfoQuery>,
146) -> Result<Json<TransportInfo>> {
147 let (device, service) = if let Some(udn) = &query.device_udn {
148 get_device_and_service(udn, "urn:upnp-org:serviceId:AVTransport")?
149 } else if let Some(url) = &query.device_url {
150 get_device_and_service_from_url(url, "urn:upnp-org:serviceId:AVTransport")?
151 } else {
152 return Err(ErrorBadRequest("Must pass device_udn or device_url"));
153 };
154 Ok(Json(
155 get_transport_info(&service, device.url(), query.instance_id).await?,
156 ))
157}
158
159#[derive(Deserialize)]
160#[serde(rename_all = "camelCase")]
161pub struct GetMediaInfoQuery {
162 device_udn: Option<String>,
163 device_url: Option<String>,
164 instance_id: u32,
165}
166
167#[cfg_attr(
168 feature = "openapi", utoipa::path(
169 tags = ["UPnP"],
170 get,
171 path = "/media-info",
172 description = "Get the current UPnP media info",
173 params(
174 ("deviceUdn" = Option<String>, Query, description = "UPnP device UDN to get media info from"),
175 ("deviceUrl" = Option<String>, Query, description = "UPnP device URL to get media info from"),
176 ("instanceId" = u32, Query, description = "UPnP instance ID to get media info from"),
177 ),
178 responses(
179 (
180 status = 200,
181 description = "The current UPnP media info",
182 body = MediaInfo,
183 )
184 )
185 )
186)]
187#[route("/media-info", method = "GET")]
188pub async fn get_media_info_endpoint(
189 query: web::Query<GetMediaInfoQuery>,
190) -> Result<Json<MediaInfo>> {
191 let (device, service) = if let Some(udn) = &query.device_udn {
192 get_device_and_service(udn, "urn:upnp-org:serviceId:AVTransport")?
193 } else if let Some(url) = &query.device_url {
194 get_device_and_service_from_url(url, "urn:upnp-org:serviceId:AVTransport")?
195 } else {
196 return Err(ErrorBadRequest("Must pass device_udn or device_url"));
197 };
198 Ok(Json(
199 get_media_info(&service, device.url(), query.instance_id).await?,
200 ))
201}
202
203#[derive(Deserialize)]
204#[serde(rename_all = "camelCase")]
205pub struct GetPositionInfoQuery {
206 device_udn: Option<String>,
207 device_url: Option<String>,
208 instance_id: u32,
209}
210
211#[cfg_attr(
212 feature = "openapi", utoipa::path(
213 tags = ["UPnP"],
214 get,
215 path = "/position-info",
216 description = "Get the current UPnP position info",
217 params(
218 ("deviceUdn" = Option<String>, Query, description = "UPnP device UDN to get position info from"),
219 ("deviceUrl" = Option<String>, Query, description = "UPnP device URL to get position info from"),
220 ("instanceId" = u32, Query, description = "UPnP instance ID to get position info from"),
221 ),
222 responses(
223 (
224 status = 200,
225 description = "The current UPnP position info",
226 body = PositionInfo,
227 )
228 )
229 )
230)]
231#[route("/position-info", method = "GET")]
232pub async fn get_position_info_endpoint(
233 query: web::Query<GetPositionInfoQuery>,
234) -> Result<Json<PositionInfo>> {
235 let (device, service) = if let Some(udn) = &query.device_udn {
236 get_device_and_service(udn, "urn:upnp-org:serviceId:AVTransport")?
237 } else if let Some(url) = &query.device_url {
238 get_device_and_service_from_url(url, "urn:upnp-org:serviceId:AVTransport")?
239 } else {
240 return Err(ErrorBadRequest("Must pass device_udn or device_url"));
241 };
242 Ok(Json(
243 get_position_info(&service, device.url(), query.instance_id).await?,
244 ))
245}
246
247#[derive(Deserialize)]
248#[serde(rename_all = "camelCase")]
249pub struct GetVolumeQuery {
250 channel: Option<String>,
251 device_udn: Option<String>,
252 device_url: Option<String>,
253 instance_id: u32,
254}
255
256#[cfg_attr(
257 feature = "openapi", utoipa::path(
258 tags = ["UPnP"],
259 get,
260 path = "/volume",
261 description = "Get the current UPnP volume info for a device",
262 params(
263 ("channel" = Option<String>, Query, description = "UPnP device channel to get volume info from"),
264 ("deviceUdn" = Option<String>, Query, description = "UPnP device UDN to get volume info from"),
265 ("deviceUrl" = Option<String>, Query, description = "UPnP device URL to get volume info from"),
266 ("instanceId" = u32, Query, description = "UPnP instance ID to get volume info from"),
267 ),
268 responses(
269 (
270 status = 200,
271 description = "The current UPnP volume info",
272 body = HashMap<String, String>,
273 )
274 )
275 )
276)]
277#[route("/volume", method = "GET")]
278pub async fn get_volume_endpoint(
279 query: web::Query<GetVolumeQuery>,
280) -> Result<Json<HashMap<String, String>>> {
281 let (device, service) = if let Some(udn) = &query.device_udn {
282 get_device_and_service(udn, "urn:upnp-org:serviceId:RenderingControl")?
283 } else if let Some(url) = &query.device_url {
284 get_device_and_service_from_url(url, "urn:upnp-org:serviceId:RenderingControl")?
285 } else {
286 return Err(ErrorBadRequest("Must pass device_udn or device_url"));
287 };
288 Ok(Json(
289 get_volume(
290 &service,
291 device.url(),
292 query.instance_id,
293 query.channel.as_deref().unwrap_or("Master"),
294 )
295 .await?,
296 ))
297}
298
299#[derive(Deserialize)]
300#[serde(rename_all = "camelCase")]
301pub struct SetVolumeQuery {
302 channel: Option<String>,
303 device_udn: Option<String>,
304 device_url: Option<String>,
305 instance_id: u32,
306 value: u8,
307}
308
309#[cfg_attr(
310 feature = "openapi", utoipa::path(
311 tags = ["UPnP"],
312 post,
313 path = "/volume",
314 description = "Set the current UPnP volume for a device",
315 params(
316 ("channel" = Option<String>, Query, description = "UPnP device channel to get volume info from"),
317 ("deviceUdn" = Option<String>, Query, description = "UPnP device UDN to get volume info from"),
318 ("deviceUrl" = Option<String>, Query, description = "UPnP device URL to get volume info from"),
319 ("instanceId" = u32, Query, description = "UPnP instance ID to get volume info from"),
320 ("value" = u8, Query, description = "Integer to set the device volume to"),
321 ),
322 responses(
323 (
324 status = 200,
325 description = "The set volume action response",
326 body = HashMap<String, String>,
327 )
328 )
329 )
330)]
331#[route("/volume", method = "POST")]
332pub async fn set_volume_endpoint(
333 query: web::Query<SetVolumeQuery>,
334) -> Result<Json<HashMap<String, String>>> {
335 let (device, service) = if let Some(udn) = &query.device_udn {
336 get_device_and_service(udn, "urn:upnp-org:serviceId:RenderingControl")?
337 } else if let Some(url) = &query.device_url {
338 get_device_and_service_from_url(url, "urn:upnp-org:serviceId:RenderingControl")?
339 } else {
340 return Err(ErrorBadRequest("Must pass device_udn or device_url"));
341 };
342 Ok(Json(
343 set_volume(
344 &service,
345 device.url(),
346 query.instance_id,
347 query.channel.as_deref().unwrap_or("Master"),
348 query.value,
349 )
350 .await?,
351 ))
352}
353
354#[derive(Deserialize)]
355#[serde(rename_all = "camelCase")]
356pub struct SubscribeQuery {
357 device_udn: Option<String>,
358 device_url: Option<String>,
359 service_id: String,
360}
361
362#[cfg_attr(
363 feature = "openapi", utoipa::path(
364 tags = ["UPnP"],
365 post,
366 path = "/subscribe",
367 description = "Subscribe to the specified device's service",
368 params(
369 ("deviceUdn" = Option<String>, Query, description = "UPnP device UDN to subscribe to"),
370 ("deviceUrl" = Option<String>, Query, description = "UPnP device URL to subscribe to"),
371 ("serviceId" = String, Query, description = "UPnP device service ID to subscribe to"),
372 ),
373 responses(
374 (
375 status = 200,
376 description = "The subscribe SID",
377 body = String,
378 )
379 )
380 )
381)]
382#[route("/subscribe", method = "POST")]
383pub async fn subscribe_endpoint(query: web::Query<SubscribeQuery>) -> Result<Json<String>> {
384 let (device, service) = if let Some(udn) = &query.device_udn {
385 get_device_and_service(udn, &query.service_id)?
386 } else if let Some(url) = &query.device_url {
387 get_device_and_service_from_url(url, &query.service_id)?
388 } else {
389 return Err(ErrorBadRequest("Must pass device_udn or device_url"));
390 };
391 let (sid, mut stream) = subscribe_events(&service, device.url()).await?;
392
393 moosicbox_task::spawn(&format!("upnp: api subscribe {sid}"), {
394 let sid = sid.clone();
395 async move {
396 while let Ok(Some(event)) = stream.try_next().await {
397 log::info!("Received subscription event for sid={sid}: {event:?}");
398 }
399 log::info!("Stream ended for sid={sid}");
400 }
401 });
402
403 Ok(Json(sid))
404}
405
406#[derive(Deserialize)]
407#[serde(rename_all = "camelCase")]
408pub struct PauseQuery {
409 device_udn: Option<String>,
410 device_url: Option<String>,
411 instance_id: u32,
412}
413
414#[cfg_attr(
415 feature = "openapi", utoipa::path(
416 tags = ["UPnP"],
417 post,
418 path = "/pause",
419 description = "Pause the specified device's AVTransport",
420 params(
421 ("deviceUdn" = Option<String>, Query, description = "UPnP device UDN to pause"),
422 ("deviceUrl" = Option<String>, Query, description = "UPnP device URL to pause"),
423 ("instanceId" = u32, Query, description = "UPnP instance ID to pause"),
424 ),
425 responses(
426 (
427 status = 200,
428 description = "The pause action response",
429 body = String,
430 )
431 )
432 )
433)]
434#[route("/pause", method = "POST")]
435pub async fn pause_endpoint(
436 query: web::Query<PauseQuery>,
437) -> Result<Json<HashMap<String, String>>> {
438 let (device, service) = if let Some(udn) = &query.device_udn {
439 get_device_and_service(udn, "urn:upnp-org:serviceId:AVTransport")?
440 } else if let Some(url) = &query.device_url {
441 get_device_and_service_from_url(url, "urn:upnp-org:serviceId:AVTransport")?
442 } else {
443 return Err(ErrorBadRequest("Must pass device_udn or device_url"));
444 };
445 Ok(Json(
446 pause(&service, device.url(), query.instance_id).await?,
447 ))
448}
449
450#[derive(Deserialize)]
451#[serde(rename_all = "camelCase")]
452pub struct PlayQuery {
453 speed: Option<f64>,
454 device_udn: Option<String>,
455 device_url: Option<String>,
456 instance_id: u32,
457}
458
459#[cfg_attr(
460 feature = "openapi", utoipa::path(
461 tags = ["UPnP"],
462 post,
463 path = "/play",
464 description = "Play the specified device's AVTransport",
465 params(
466 ("speed" = Option<f64>, Query, description = "Speed to play the playback at"),
467 ("deviceUdn" = Option<String>, Query, description = "UPnP device UDN to play"),
468 ("deviceUrl" = Option<String>, Query, description = "UPnP device URL to play"),
469 ("instanceId" = u32, Query, description = "UPnP instance ID to play"),
470 ),
471 responses(
472 (
473 status = 200,
474 description = "The play action response",
475 body = String,
476 )
477 )
478 )
479)]
480#[route("/play", method = "POST")]
481pub async fn play_endpoint(query: web::Query<PlayQuery>) -> Result<Json<HashMap<String, String>>> {
482 let (device, service) = if let Some(udn) = &query.device_udn {
483 get_device_and_service(udn, "urn:upnp-org:serviceId:AVTransport")?
484 } else if let Some(url) = &query.device_url {
485 get_device_and_service_from_url(url, "urn:upnp-org:serviceId:AVTransport")?
486 } else {
487 return Err(ErrorBadRequest("Must pass device_udn or device_url"));
488 };
489 Ok(Json(
490 play(
491 &service,
492 device.url(),
493 query.instance_id,
494 query.speed.unwrap_or(1.0),
495 )
496 .await?,
497 ))
498}
499
500#[derive(Deserialize)]
501#[serde(rename_all = "camelCase")]
502pub struct SeekQuery {
503 position: f64,
504 device_udn: Option<String>,
505 device_url: Option<String>,
506 instance_id: u32,
507 unit: Option<String>,
508}
509
510#[cfg_attr(
511 feature = "openapi", utoipa::path(
512 tags = ["UPnP"],
513 post,
514 path = "/seek",
515 description = "Seek the specified device's AVTransport",
516 params(
517 ("position" = f64, Query, description = "Seek position to seek the playback to"),
518 ("deviceUdn" = Option<String>, Query, description = "UPnP device UDN to seek"),
519 ("deviceUrl" = Option<String>, Query, description = "UPnP device URL to seek"),
520 ("instanceId" = u32, Query, description = "UPnP instance ID to seek"),
521 ("unit" = Option<String>, Query, description = "Seek unit"),
522 ),
523 responses(
524 (
525 status = 200,
526 description = "The seek action response",
527 body = String,
528 )
529 )
530 )
531)]
532#[route("/seek", method = "POST")]
533pub async fn seek_endpoint(query: web::Query<SeekQuery>) -> Result<Json<HashMap<String, String>>> {
534 let (device, service) = if let Some(udn) = &query.device_udn {
535 get_device_and_service(udn, "urn:upnp-org:serviceId:AVTransport")?
536 } else if let Some(url) = &query.device_url {
537 get_device_and_service_from_url(url, "urn:upnp-org:serviceId:AVTransport")?
538 } else {
539 return Err(ErrorBadRequest("Must pass device_udn or device_url"));
540 };
541 Ok(Json(
542 seek(
543 &service,
544 device.url(),
545 query.instance_id,
546 query.unit.as_deref().unwrap_or("ABS_TIME"),
547 query.position as u32,
548 )
549 .await?,
550 ))
551}