moosicbox_upnp/
api.rs

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}