1#![cfg_attr(feature = "fail-on-warnings", deny(warnings))]
59#![warn(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)]
60#![allow(clippy::multiple_crate_versions, clippy::struct_field_names)]
61
62#[cfg(feature = "api")]
63pub mod api;
64
65pub mod models;
66
67mod scanner;
68
69use async_recursion::async_recursion;
70use futures::prelude::*;
71use itertools::Itertools;
72use models::{UpnpDevice, UpnpService};
73pub use rupnp::{Device, DeviceSpec, Service, http::Uri, ssdp::SearchTarget};
74use scanner::UpnpScanner;
75use serde::Serialize;
76use std::{
77 collections::BTreeMap,
78 sync::{Arc, LazyLock},
79 time::Duration,
80};
81use switchy_async::sync::Mutex;
82use thiserror::Error;
83
84mod cache {
85 use std::{
91 collections::BTreeMap,
92 sync::{LazyLock, RwLock},
93 };
94
95 use rupnp::{Device, Service};
96
97 use crate::ScanError;
98
99 #[derive(Debug, Clone)]
101 struct DeviceMapping {
102 device: Device,
103 services: BTreeMap<String, Service>,
104 }
105
106 static DEVICE_URL_MAPPINGS: LazyLock<RwLock<BTreeMap<String, DeviceMapping>>> =
107 LazyLock::new(|| RwLock::new(BTreeMap::new()));
108
109 static DEVICE_MAPPINGS: LazyLock<RwLock<BTreeMap<String, DeviceMapping>>> =
110 LazyLock::new(|| RwLock::new(BTreeMap::new()));
111
112 pub fn get_device_from_url(url: &str) -> Result<Device, ScanError> {
118 Ok(DEVICE_MAPPINGS
119 .read()
120 .unwrap()
121 .get(url)
122 .ok_or_else(|| ScanError::DeviceUrlNotFound {
123 device_url: url.to_string(),
124 })?
125 .device
126 .clone())
127 }
128
129 pub fn get_device(udn: &str) -> Result<Device, ScanError> {
135 Ok(DEVICE_MAPPINGS
136 .read()
137 .unwrap()
138 .get(udn)
139 .ok_or_else(|| ScanError::DeviceUdnNotFound {
140 device_udn: udn.to_string(),
141 })?
142 .device
143 .clone())
144 }
145
146 pub fn insert_device(device: Device) {
148 DEVICE_URL_MAPPINGS.write().unwrap().insert(
149 device.url().to_string(),
150 DeviceMapping {
151 device: device.clone(),
152 services: BTreeMap::new(),
153 },
154 );
155 DEVICE_MAPPINGS.write().unwrap().insert(
156 device.udn().to_owned(),
157 DeviceMapping {
158 device,
159 services: BTreeMap::new(),
160 },
161 );
162 }
163
164 pub fn get_service(device_udn: &str, service_id: &str) -> Result<Service, ScanError> {
171 Ok(DEVICE_MAPPINGS
172 .read()
173 .unwrap()
174 .get(device_udn)
175 .ok_or_else(|| ScanError::DeviceUdnNotFound {
176 device_udn: device_udn.to_string(),
177 })?
178 .services
179 .get(service_id)
180 .ok_or_else(|| ScanError::ServiceIdNotFound {
181 service_id: service_id.to_string(),
182 })?
183 .clone())
184 }
185
186 pub fn get_device_and_service(
193 device_udn: &str,
194 service_id: &str,
195 ) -> Result<(Device, Service), ScanError> {
196 let devices = DEVICE_MAPPINGS.read().unwrap();
197 let device = devices
198 .get(device_udn)
199 .ok_or_else(|| ScanError::DeviceUdnNotFound {
200 device_udn: device_udn.to_string(),
201 })?;
202 let resp = (
203 device.device.clone(),
204 device
205 .services
206 .get(service_id)
207 .ok_or_else(|| ScanError::ServiceIdNotFound {
208 service_id: service_id.to_string(),
209 })?
210 .clone(),
211 );
212 drop(devices);
213
214 Ok(resp)
215 }
216
217 pub fn get_device_and_service_from_url(
224 device_url: &str,
225 service_id: &str,
226 ) -> Result<(Device, Service), ScanError> {
227 let devices = DEVICE_URL_MAPPINGS.read().unwrap();
228 let device = devices
229 .get(device_url)
230 .ok_or_else(|| ScanError::DeviceUrlNotFound {
231 device_url: device_url.to_string(),
232 })?;
233 let resp = (
234 device.device.clone(),
235 device
236 .services
237 .get(service_id)
238 .ok_or_else(|| ScanError::ServiceIdNotFound {
239 service_id: service_id.to_string(),
240 })?
241 .clone(),
242 );
243 drop(devices);
244
245 Ok(resp)
246 }
247
248 pub fn insert_service(device: &Device, service: &Service) {
252 if let Some(device_mapping) = DEVICE_URL_MAPPINGS
253 .write()
254 .as_mut()
255 .unwrap()
256 .get_mut(device.url().to_string().as_str())
257 {
258 device_mapping
259 .services
260 .insert(service.service_id().to_owned(), service.clone());
261 }
262 if let Some(device_mapping) = DEVICE_MAPPINGS
263 .write()
264 .as_mut()
265 .unwrap()
266 .get_mut(device.udn())
267 {
268 device_mapping
269 .services
270 .insert(service.service_id().to_owned(), service.clone());
271 }
272 }
273}
274
275pub fn get_device(udn: &str) -> Result<Device, ScanError> {
281 cache::get_device(udn)
282}
283
284pub fn get_service(device_udn: &str, service_id: &str) -> Result<Service, ScanError> {
290 cache::get_service(device_udn, service_id)
291}
292
293pub fn get_device_and_service(
299 device_udn: &str,
300 service_id: &str,
301) -> Result<(Device, Service), ScanError> {
302 cache::get_device_and_service(device_udn, service_id)
303}
304
305pub fn get_device_from_url(url: &str) -> Result<Device, ScanError> {
311 cache::get_device_from_url(url)
312}
313
314pub fn get_device_and_service_from_url(
320 device_url: &str,
321 service_id: &str,
322) -> Result<(Device, Service), ScanError> {
323 cache::get_device_and_service_from_url(device_url, service_id)
324}
325
326#[derive(Debug, Error)]
328pub enum ActionError {
329 #[error(transparent)]
331 Roxml(#[from] roxmltree::Error),
332 #[error(transparent)]
334 Rupnp(#[from] rupnp::Error),
335 #[error("Missing property \"{0}\"")]
337 MissingProperty(String),
338}
339
340#[derive(Debug, Error)]
342pub enum ScanError {
343 #[error("Failed to find `RenderingControl` service")]
345 RenderingControlNotFound,
346 #[error("Failed to find MediaRenderer service")]
348 MediaRendererNotFound,
349 #[error("Failed to find UPnP Device device_udn={device_udn}")]
351 DeviceUdnNotFound {
352 device_udn: String,
354 },
355 #[error("Failed to find UPnP Device device_url={device_url}")]
357 DeviceUrlNotFound {
358 device_url: String,
360 },
361 #[error("Failed to find UPnP Service service_id={service_id}")]
363 ServiceIdNotFound {
364 service_id: String,
366 },
367 #[error(transparent)]
369 Rupnp(#[from] rupnp::Error),
370}
371
372#[must_use]
378pub fn str_to_duration(duration: &str) -> u32 {
379 let time_components = duration
380 .split(':')
381 .map(str::parse)
382 .collect::<Result<Vec<u32>, std::num::ParseIntError>>()
383 .expect("Failed to parse time...");
384
385 time_components[0] * 60 * 60 + time_components[1] * 60 + time_components[2]
386}
387
388#[must_use]
390pub fn duration_to_string(duration: u32) -> String {
391 format!(
392 "{:0>2}:{:0>2}:{:0>2}",
393 (duration / 60) / 60,
394 (duration / 60) % 60,
395 duration % 60
396 )
397}
398
399static DIDL_LITE_NS: &str = "urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/";
400static UPNP_NS: &str = "urn:schemas-upnp-org:metadata-1-0/upnp/";
401static DC_NS: &str = "http://purl.org/dc/elements/1.1/";
402static SEC_NS: &str = "http://www.sec.co.kr/";
403
404#[allow(clippy::too_many_arguments)]
437pub async fn set_av_transport_uri(
438 service: &Service,
439 device_url: &Uri,
440 instance_id: u32,
441 transport_uri: &str,
442 format: &str,
443 title: Option<&str>,
444 creator: Option<&str>,
445 artist: Option<&str>,
446 album: Option<&str>,
447 original_track_number: Option<u32>,
448 duration: Option<u32>,
449 size: Option<u64>,
450) -> Result<BTreeMap<String, String>, ActionError> {
451 static BRACKET_WHITESPACE: LazyLock<regex::Regex> =
452 LazyLock::new(|| regex::Regex::new(r">\s+<").expect("Invalid Regex"));
453 static BETWEEN_WHITESPACE: LazyLock<regex::Regex> =
454 LazyLock::new(|| regex::Regex::new(r"\s{2,}").expect("Invalid Regex"));
455
456 fn compress_xml(xml: &str) -> String {
458 BETWEEN_WHITESPACE
459 .replace_all(
460 BRACKET_WHITESPACE.replace_all(xml.trim(), "><").as_ref(),
461 " ",
462 )
463 .to_string()
464 .replace(['\r', '\n'], "")
465 .replace("\" >", "\">")
466 }
467
468 fn escape_xml(xml: &str) -> String {
469 xml::escape::escape_str_attribute(xml).to_string()
470 }
471
472 let headers = "*";
473
474 let transport_uri = xml::escape::escape_str_attribute(transport_uri);
475
476 let metadata = format!(
477 r#"
478 <DIDL-Lite
479 xmlns="{DIDL_LITE_NS}"
480 xmlns:dc="{DC_NS}"
481 xmlns:sec="{SEC_NS}"
482 xmlns:upnp="{UPNP_NS}">
483 <item id="0" parentID="-1" restricted="false">
484 <upnp:class>object.item.audioItem.musicTrack</upnp:class>
485 {title}
486 {creator}
487 {artist}
488 {album}
489 {original_track_number}
490 <res{duration}{size} protocolInfo="http-get:*:audio/{format}:{headers}">{transport_uri}</res>
491 </item>
492 </DIDL-Lite>
493 "#,
494 title = title
495 .map(xml::escape::escape_str_attribute)
496 .map_or_else(String::new, |x| format!("<dc:title>{x}</dc:title>")),
497 creator = creator
498 .map(xml::escape::escape_str_attribute)
499 .map_or_else(String::new, |x| format!("<dc:creator>{x}</dc:creator>")),
500 artist = artist
501 .map(xml::escape::escape_str_attribute)
502 .map_or_else(String::new, |x| format!("<upnp:artist>{x}</upnp:artist>")),
503 album = album
504 .map(xml::escape::escape_str_attribute)
505 .map_or_else(String::new, |x| format!("<upnp:album>{x}</upnp:album>")),
506 original_track_number = original_track_number.map_or_else(String::new, |x| format!(
507 "<upnp:originalTrackNumber>{x}</upnp:originalTrackNumber>"
508 )),
509 duration = duration.map_or_else(String::new, |x| format!(
510 " duration=\"{}\"",
511 duration_to_string(x)
512 )),
513 size = size.map_or_else(String::new, |x| format!(" size=\"{x}\"")),
514 );
515
516 let metadata = escape_xml(&compress_xml(&metadata));
517
518 let args = format!(
519 r"
520 <InstanceID>{instance_id}</InstanceID>
521 <CurrentURI>{transport_uri}</CurrentURI>
522 <CurrentURIMetaData>{metadata}</CurrentURIMetaData>
523 "
524 );
525 let args = compress_xml(&args);
526 log::debug!("set_av_transport_uri args={args}");
527
528 Ok(service
529 .action(device_url, "SetAVTransportURI", &args)
530 .await?
531 .into_iter()
532 .collect())
533}
534
535#[derive(Debug, Clone, Serialize)]
537#[serde(rename_all = "camelCase")]
538#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
539pub struct TrackMetadata {
540 items: Vec<TrackMetadataItem>,
542}
543
544#[derive(Debug, Clone, Serialize)]
546#[serde(rename_all = "camelCase")]
547#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
548pub struct TrackMetadataItem {
549 upnp_class: Option<String>,
551 upnp_artist: Option<String>,
553 upnp_album: Option<String>,
555 upnp_original_track_number: Option<String>,
557 dc_title: Option<String>,
559 dc_creator: Option<String>,
561 res: TrackMetadataItemResource,
563}
564
565#[derive(Debug, Clone, Serialize)]
567#[serde(rename_all = "camelCase")]
568#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
569pub struct TrackMetadataItemResource {
570 duration: Option<u32>,
572 protocol_info: Option<String>,
574 source: String,
576}
577
578fn parse_track_metadata(track_metadata: &str) -> Result<TrackMetadata, ActionError> {
590 let doc = roxmltree::Document::parse(track_metadata)?;
591
592 let items = doc
593 .descendants()
594 .filter(|x| x.tag_name().name().to_lowercase() == "item")
595 .map(|x| {
596 let upnp_class = x.descendants().find(|x| {
597 x.tag_name().namespace().is_some_and(|n| n == UPNP_NS)
598 && x.tag_name().name().to_lowercase() == "class"
599 });
600 let upnp_artist = x.descendants().find(|x| {
601 x.tag_name().namespace().is_some_and(|n| n == UPNP_NS)
602 && x.tag_name().name().to_lowercase() == "artist"
603 });
604 let upnp_album = x.descendants().find(|x| {
605 x.tag_name().namespace().is_some_and(|n| n == UPNP_NS)
606 && x.tag_name().name().to_lowercase() == "album"
607 });
608 let upnp_original_track_number = x.descendants().find(|x| {
609 x.tag_name().namespace().is_some_and(|n| n == UPNP_NS)
610 && x.tag_name().name().to_lowercase() == "originaltracknumber"
611 });
612 let dc_title = x.descendants().find(|x| {
613 x.tag_name().namespace().is_some_and(|n| n == DC_NS)
614 && x.tag_name().name().to_lowercase() == "title"
615 });
616 let dc_creator = x.descendants().find(|x| {
617 x.tag_name().namespace().is_some_and(|n| n == DC_NS)
618 && x.tag_name().name().to_lowercase() == "creator"
619 });
620 let res = x
621 .descendants()
622 .find(|x| {
623 x.tag_name().namespace().is_some_and(|n| n == DIDL_LITE_NS)
624 && x.tag_name().name().to_lowercase() == "res"
625 })
626 .ok_or_else(|| ActionError::MissingProperty("Missing res".into()))?;
627 Ok(TrackMetadataItem {
628 upnp_class: upnp_class.and_then(|x| x.text()).map(ToOwned::to_owned),
629 upnp_artist: upnp_artist.and_then(|x| x.text()).map(ToOwned::to_owned),
630 upnp_album: upnp_album.and_then(|x| x.text()).map(ToOwned::to_owned),
631 upnp_original_track_number: upnp_original_track_number
632 .and_then(|x| x.text())
633 .map(ToOwned::to_owned),
634 dc_title: dc_title.and_then(|x| x.text()).map(ToOwned::to_owned),
635 dc_creator: dc_creator.and_then(|x| x.text()).map(ToOwned::to_owned),
636 res: TrackMetadataItemResource {
637 duration: res.attribute("duration").map(str_to_duration),
638 protocol_info: res.attribute("protocolInfo").map(ToOwned::to_owned),
639 source: res
640 .text()
641 .ok_or_else(|| ActionError::MissingProperty("Missing res value".into()))?
642 .to_owned(),
643 },
644 })
645 })
646 .collect::<Result<Vec<_>, ActionError>>();
647
648 Ok(TrackMetadata { items: items? })
649}
650
651#[derive(Debug, Clone, Serialize)]
653#[serde(rename_all = "camelCase")]
654#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
655pub struct TransportInfo {
656 pub current_transport_status: String,
658 pub current_transport_state: String,
660 pub current_speed: String,
662}
663
664pub async fn get_transport_info(
671 service: &Service,
672 url: &Uri,
673 instance_id: u32,
674) -> Result<TransportInfo, ActionError> {
675 let map = service
676 .action(
677 url,
678 "GetTransportInfo",
679 &format!("<InstanceID>{instance_id}</InstanceID>"),
680 )
681 .await?;
682
683 Ok(TransportInfo {
684 current_transport_status: map
685 .get("CurrentTransportStatus")
686 .ok_or(ActionError::MissingProperty(
687 "CurrentTransportStatus".into(),
688 ))?
689 .clone(),
690 current_transport_state: map
691 .get("CurrentTransportState")
692 .ok_or(ActionError::MissingProperty("CurrentTransportState".into()))?
693 .clone(),
694 current_speed: map
695 .get("CurrentSpeed")
696 .ok_or(ActionError::MissingProperty("TrackURI".into()))?
697 .clone(),
698 })
699}
700
701#[derive(Debug, Clone, Serialize)]
703#[serde(rename_all = "camelCase")]
704#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
705pub struct PositionInfo {
706 pub track: u32,
708 pub rel_time: u32,
710 pub abs_time: u32,
712 pub track_uri: String,
714 pub track_metadata: TrackMetadata,
716 pub rel_count: u32,
718 pub abs_count: u32,
720 pub track_duration: u32,
722}
723
724pub async fn get_position_info(
731 service: &Service,
732 url: &Uri,
733 instance_id: u32,
734) -> Result<PositionInfo, ActionError> {
735 let map = service
736 .action(
737 url,
738 "GetPositionInfo",
739 &format!("<InstanceID>{instance_id}</InstanceID>"),
740 )
741 .await?;
742
743 Ok(PositionInfo {
744 abs_time: str_to_duration(
745 map.get("AbsTime")
746 .ok_or(ActionError::MissingProperty("AbsTime".into()))?,
747 ),
748 rel_time: str_to_duration(
749 map.get("RelTime")
750 .ok_or(ActionError::MissingProperty("RelTime".into()))?,
751 ),
752 track_duration: str_to_duration(
753 map.get("TrackDuration")
754 .ok_or(ActionError::MissingProperty("TrackDuration".into()))?,
755 ),
756 abs_count: map
757 .get("AbsCount")
758 .ok_or(ActionError::MissingProperty("AbsCount".into()))?
759 .parse::<u32>()
760 .map_err(|e| ActionError::MissingProperty(format!("AbsCount (\"{e:?}\")")))?,
761 rel_count: map
762 .get("RelCount")
763 .ok_or(ActionError::MissingProperty("RelCount".into()))?
764 .parse::<u32>()
765 .map_err(|e| ActionError::MissingProperty(format!("RelCount (\"{e:?}\")")))?,
766 track: map
767 .get("Track")
768 .ok_or(ActionError::MissingProperty("Track".into()))?
769 .parse::<u32>()
770 .map_err(|e| ActionError::MissingProperty(format!("Track (\"{e:?}\")")))?,
771 track_uri: map
772 .get("TrackURI")
773 .ok_or(ActionError::MissingProperty("TrackURI".into()))?
774 .clone(),
775 track_metadata: parse_track_metadata(
776 map.get("TrackMetaData")
777 .ok_or(ActionError::MissingProperty("TrackMetaData".into()))?,
778 )?,
779 })
780}
781
782pub async fn seek(
788 service: &Service,
789 url: &Uri,
790 instance_id: u32,
791 unit: &str,
792 target: u32,
793) -> Result<BTreeMap<String, String>, ActionError> {
794 let target_str = duration_to_string(target);
795 log::trace!("seek: seeking to target={target_str} instance_id={instance_id} unit={unit}");
796
797 Ok(service
798 .action(
799 url,
800 "Seek",
801 &format!(
802 r"
803 <InstanceID>{instance_id}</InstanceID>
804 <Unit>{unit}</Unit>
805 <Target>{target_str}</Target>
806 "
807 ),
808 )
809 .await?
810 .into_iter()
811 .collect())
812}
813
814pub async fn get_volume(
820 service: &Service,
821 url: &Uri,
822 instance_id: u32,
823 channel: &str,
824) -> Result<BTreeMap<String, String>, ActionError> {
825 Ok(service
826 .action(
827 url,
828 "GetVolume",
829 &format!("<InstanceID>{instance_id}</InstanceID><Channel>{channel}</Channel>"),
830 )
831 .await?
832 .into_iter()
833 .collect())
834}
835
836pub async fn set_volume(
842 service: &Service,
843 url: &Uri,
844 instance_id: u32,
845 channel: &str,
846 volume: u8,
847) -> Result<BTreeMap<String, String>, ActionError> {
848 Ok(service
849 .action(
850 url,
851 "SetVolume",
852 &format!("<InstanceID>{instance_id}</InstanceID><Channel>{channel}</Channel><DesiredVolume>{volume}</DesiredVolume>"),
853 )
854 .await?.into_iter()
855 .collect())
856}
857
858#[derive(Debug, Clone, Serialize)]
860#[serde(rename_all = "camelCase")]
861#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
862pub struct MediaInfo {
863 media_duration: u32,
865 record_medium: String,
867 write_status: String,
869 current_uri_metadata: TrackMetadata,
871 nr_tracks: u32,
873 play_medium: String,
875 current_uri: String,
877}
878
879pub async fn get_media_info(
886 service: &Service,
887 url: &Uri,
888 instance_id: u32,
889) -> Result<MediaInfo, ActionError> {
890 let map = service
891 .action(
892 url,
893 "GetMediaInfo",
894 &format!("<InstanceID>{instance_id}</InstanceID>"),
895 )
896 .await?;
897
898 Ok(MediaInfo {
899 media_duration: str_to_duration(
900 map.get("MediaDuration")
901 .ok_or(ActionError::MissingProperty("MediaDuration".into()))?,
902 ),
903 record_medium: map
904 .get("RecordMedium")
905 .ok_or(ActionError::MissingProperty("MediaDuration".into()))?
906 .clone(),
907 write_status: map
908 .get("WriteStatus")
909 .ok_or(ActionError::MissingProperty("WriteStatus".into()))?
910 .clone(),
911 current_uri_metadata: parse_track_metadata(
912 map.get("CurrentURIMetaData")
913 .ok_or(ActionError::MissingProperty("CurrentURIMetaData".into()))?,
914 )?,
915 nr_tracks: map
916 .get("NrTracks")
917 .ok_or(ActionError::MissingProperty("NrTracks".into()))?
918 .parse::<u32>()
919 .map_err(|e| ActionError::MissingProperty(format!("NrTracks (\"{e:?}\")")))?,
920 play_medium: map
921 .get("PlayMedium")
922 .ok_or(ActionError::MissingProperty("PlayMedium".into()))?
923 .clone(),
924 current_uri: map
925 .get("CurrentURI")
926 .ok_or(ActionError::MissingProperty("CurrentURI".into()))?
927 .clone(),
928 })
929}
930
931pub async fn subscribe_events(
937 service: &Service,
938 url: &Uri,
939) -> Result<
940 (
941 String,
942 impl Stream<Item = Result<BTreeMap<String, String>, rupnp::Error>> + use<>,
943 ),
944 ScanError,
945> {
946 let (url, stream) = service.subscribe(url, 300).await?;
947
948 Ok((url, stream.map(|x| x.map(|x| x.into_iter().collect()))))
949}
950
951pub async fn play(
957 service: &Service,
958 url: &Uri,
959 instance_id: u32,
960 speed: f64,
961) -> Result<BTreeMap<String, String>, ActionError> {
962 Ok(service
963 .action(
964 url,
965 "Play",
966 &format!("<InstanceID>{instance_id}</InstanceID><Speed>{speed}</Speed>"),
967 )
968 .await?
969 .into_iter()
970 .collect())
971}
972
973pub async fn pause(
979 service: &Service,
980 url: &Uri,
981 instance_id: u32,
982) -> Result<BTreeMap<String, String>, ActionError> {
983 Ok(service
984 .action(
985 url,
986 "Pause",
987 &format!("<InstanceID>{instance_id}</InstanceID>"),
988 )
989 .await?
990 .into_iter()
991 .collect())
992}
993
994pub async fn stop(
1000 service: &Service,
1001 url: &Uri,
1002 instance_id: u32,
1003) -> Result<BTreeMap<String, String>, ActionError> {
1004 Ok(service
1005 .action(
1006 url,
1007 "Stop",
1008 &format!("<InstanceID>{instance_id}</InstanceID>"),
1009 )
1010 .await?
1011 .into_iter()
1012 .collect())
1013}
1014
1015pub async fn scan_service(
1021 url: Option<&Uri>,
1022 service: &Service,
1023 path: Option<&str>,
1024) -> Result<UpnpService, ScanError> {
1025 let path = path.unwrap_or_default();
1026
1027 log::debug!(
1028 "\n\
1029 {path}Scanning service:\n\t\
1030 {path}service_type={}\n\t\
1031 {path}service_id={}\n\t\
1032 ",
1033 service.service_type(),
1034 service.service_id(),
1035 );
1036
1037 log::trace!(
1038 "service '{}' scpd={}",
1039 service.service_id(),
1040 if let Some(url) = url {
1041 format!("{:?}", service.scpd(url).await.ok())
1042 } else {
1043 "N/A".to_string()
1044 }
1045 );
1046
1047 Ok(service.into())
1048}
1049
1050#[async_recursion]
1056pub async fn scan_device(
1057 device: Option<Device>,
1058 spec: &DeviceSpec,
1059 path: Option<&str>,
1060) -> Result<Vec<UpnpDevice>, ScanError> {
1061 let path = path.unwrap_or_default();
1062
1063 log::debug!(
1064 "\n\
1065 {path}Scanning device: {}\n\t\
1066 {path}url={:?}\n\t\
1067 {path}manufacturer={}\n\t\
1068 {path}manufacturer_url={}\n\t\
1069 {path}model_name={}\n\t\
1070 {path}model_description={}\n\t\
1071 {path}model_number={}\n\t\
1072 {path}model_url={}\n\t\
1073 {path}serial_number={}\n\t\
1074 {path}udn={}\n\t\
1075 {path}upc={}\
1076 ",
1077 spec.friendly_name(),
1078 device.as_ref().map(rupnp::Device::url),
1079 spec.manufacturer(),
1080 spec.manufacturer_url().unwrap_or("N/A"),
1081 spec.model_name(),
1082 spec.model_description().unwrap_or("N/A"),
1083 spec.model_number().unwrap_or("N/A"),
1084 spec.model_url().unwrap_or("N/A"),
1085 spec.serial_number().unwrap_or("N/A"),
1086 spec.udn(),
1087 spec.upc().unwrap_or("N/A"),
1088 );
1089
1090 let upnp_device: UpnpDevice = spec.into();
1091 let mut upnp_services = vec![];
1092
1093 let services = spec.services();
1094
1095 if services.is_empty() {
1096 log::debug!("no services for {}", spec.friendly_name());
1097 } else {
1098 let path = format!("{path}\t");
1099 for service in services {
1100 if let Some(device) = &device {
1101 cache::insert_service(device, service);
1102 }
1103 upnp_services.push(
1104 scan_service(
1105 device.as_ref().map(rupnp::Device::url),
1106 service,
1107 Some(&path),
1108 )
1109 .await?,
1110 );
1111 }
1112 }
1113
1114 let mut upnp_devices = vec![upnp_device.with_services(upnp_services)];
1115
1116 let sub_devices = spec.devices();
1117
1118 if sub_devices.is_empty() {
1119 log::debug!("no sub-devices for {}", spec.friendly_name());
1120 } else {
1121 let path = format!("{path}\t");
1122 for sub in sub_devices {
1123 upnp_devices.extend_from_slice(&scan_device(None, sub, Some(&path)).await?);
1125 }
1126 }
1127
1128 Ok(upnp_devices)
1129}
1130
1131static UPNP_DEVICE_SCANNER: LazyLock<Arc<Mutex<UpnpDeviceScanner>>> =
1132 LazyLock::new(|| Arc::new(Mutex::new(UpnpDeviceScanner::new())));
1133
1134static SCANNER: LazyLock<Box<dyn UpnpScanner>> = LazyLock::new(|| {
1135 #[cfg(feature = "simulator")]
1136 {
1137 Box::new(scanner::simulator::SimulatorScanner)
1138 }
1139
1140 #[cfg(not(feature = "simulator"))]
1141 {
1142 Box::new(scanner::RupnpScanner)
1143 }
1144});
1145
1146pub async fn scan_devices() -> Result<(), UpnpDeviceScannerError> {
1152 UPNP_DEVICE_SCANNER.lock().await.scan().await
1153}
1154
1155#[must_use]
1157pub async fn devices() -> Vec<UpnpDevice> {
1158 UPNP_DEVICE_SCANNER.lock().await.devices.clone()
1159}
1160
1161#[derive(Default)]
1163pub struct UpnpDeviceScanner {
1164 scanning: bool,
1165 pub devices: Vec<UpnpDevice>,
1167}
1168
1169#[allow(dead_code)]
1171#[allow(clippy::enum_variant_names)]
1172#[derive(Debug, Error)]
1173pub enum UpnpDeviceScannerError {
1174 #[error("No outputs available")]
1176 NoOutputs,
1177 #[error(transparent)]
1179 Rupnp(#[from] rupnp::Error),
1180 #[error(transparent)]
1182 Scan(#[from] ScanError),
1183}
1184
1185impl UpnpDeviceScanner {
1186 #[must_use]
1188 pub fn new() -> Self {
1189 Self::default()
1190 }
1191
1192 pub async fn scan(&mut self) -> Result<(), UpnpDeviceScannerError> {
1203 if self.scanning || !self.devices.is_empty() {
1204 return Ok(());
1205 }
1206
1207 self.scanning = true;
1208
1209 let search_target = SearchTarget::RootDevice;
1210 let devices = SCANNER
1211 .discover(&search_target, Duration::from_secs(3))
1212 .await?;
1213 pin_utils::pin_mut!(devices);
1214
1215 let mut upnp_devices = vec![];
1216
1217 loop {
1218 match devices.try_next().await {
1219 Ok(Some(device)) => {
1220 cache::insert_device(device.clone());
1221 let spec: &DeviceSpec = &device;
1222 upnp_devices
1223 .extend_from_slice(&scan_device(Some(device.clone()), spec, None).await?);
1224 }
1225 Ok(None) => {
1226 break;
1227 }
1228 Err(e) => {
1229 log::error!("Received error device response: {e:?}");
1230 }
1231 }
1232 }
1233
1234 if upnp_devices.is_empty() {
1235 log::debug!("No `UPnP` devices discovered");
1236 }
1237
1238 self.devices = upnp_devices
1239 .into_iter()
1240 .unique_by(|x| x.udn.clone())
1241 .collect::<Vec<_>>();
1242
1243 self.scanning = false;
1244
1245 Ok(())
1246 }
1247}
1248
1249#[cfg(test)]
1250mod tests {
1251 use super::*;
1252
1253 #[test_log::test]
1254 fn test_str_to_duration_valid_formats() {
1255 assert_eq!(str_to_duration("00:00:00"), 0);
1256 assert_eq!(str_to_duration("00:00:01"), 1);
1257 assert_eq!(str_to_duration("00:01:00"), 60);
1258 assert_eq!(str_to_duration("01:00:00"), 3600);
1259 assert_eq!(str_to_duration("00:03:31"), 211);
1260 assert_eq!(str_to_duration("01:30:45"), 5445);
1261 assert_eq!(str_to_duration("10:15:20"), 36_920);
1262 }
1263
1264 #[test_log::test]
1265 fn test_duration_to_string_various_durations() {
1266 assert_eq!(duration_to_string(0), "00:00:00");
1267 assert_eq!(duration_to_string(1), "00:00:01");
1268 assert_eq!(duration_to_string(60), "00:01:00");
1269 assert_eq!(duration_to_string(3600), "01:00:00");
1270 assert_eq!(duration_to_string(211), "00:03:31");
1271 assert_eq!(duration_to_string(5445), "01:30:45");
1272 assert_eq!(duration_to_string(36_920), "10:15:20");
1273 }
1274
1275 #[test_log::test]
1276 fn test_duration_roundtrip_conversion() {
1277 let test_cases = vec![
1278 "00:00:00", "00:00:30", "00:05:45", "01:23:45", "10:00:00", "23:59:59",
1279 ];
1280
1281 for original in test_cases {
1282 let seconds = str_to_duration(original);
1283 let converted = duration_to_string(seconds);
1284 assert_eq!(
1285 original, converted,
1286 "Roundtrip conversion failed for {original}"
1287 );
1288 }
1289 }
1290
1291 #[test_log::test]
1292 fn test_parse_track_metadata_complete() {
1293 let xml = r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:sec="http://www.sec.co.kr/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">
1294 <item id="0" parentID="-1" restricted="false">
1295 <upnp:class>object.item.audioItem.musicTrack</upnp:class>
1296 <dc:title>Friday</dc:title>
1297 <dc:creator>Rebecca Black</dc:creator>
1298 <upnp:artist>Rebecca Black</upnp:artist>
1299 <upnp:album>Friday</upnp:album>
1300 <upnp:originalTrackNumber>1</upnp:originalTrackNumber>
1301 <res duration="00:03:31" protocolInfo="http-get:*:audio/flac:*">http://192.168.1.1:8001/track?trackId=123</res>
1302 </item>
1303 </DIDL-Lite>"#;
1304
1305 let result = parse_track_metadata(xml).expect("Failed to parse metadata");
1306 assert_eq!(result.items.len(), 1);
1307
1308 let item = &result.items[0];
1309 assert_eq!(
1310 item.upnp_class.as_deref(),
1311 Some("object.item.audioItem.musicTrack")
1312 );
1313 assert_eq!(item.dc_title.as_deref(), Some("Friday"));
1314 assert_eq!(item.dc_creator.as_deref(), Some("Rebecca Black"));
1315 assert_eq!(item.upnp_artist.as_deref(), Some("Rebecca Black"));
1316 assert_eq!(item.upnp_album.as_deref(), Some("Friday"));
1317 assert_eq!(item.upnp_original_track_number.as_deref(), Some("1"));
1318 assert_eq!(item.res.duration, Some(211));
1319 assert_eq!(
1320 item.res.protocol_info.as_deref(),
1321 Some("http-get:*:audio/flac:*")
1322 );
1323 assert_eq!(item.res.source, "http://192.168.1.1:8001/track?trackId=123");
1324 }
1325
1326 #[test_log::test]
1327 fn test_parse_track_metadata_minimal() {
1328 let xml = r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/">
1329 <item id="0" parentID="-1" restricted="false">
1330 <res>http://example.com/track.mp3</res>
1331 </item>
1332 </DIDL-Lite>"#;
1333
1334 let result = parse_track_metadata(xml).expect("Failed to parse minimal metadata");
1335 assert_eq!(result.items.len(), 1);
1336
1337 let item = &result.items[0];
1338 assert!(item.upnp_class.is_none());
1339 assert!(item.dc_title.is_none());
1340 assert!(item.dc_creator.is_none());
1341 assert!(item.upnp_artist.is_none());
1342 assert!(item.upnp_album.is_none());
1343 assert!(item.upnp_original_track_number.is_none());
1344 assert!(item.res.duration.is_none());
1345 assert!(item.res.protocol_info.is_none());
1346 assert_eq!(item.res.source, "http://example.com/track.mp3");
1347 }
1348
1349 #[test_log::test]
1350 fn test_parse_track_metadata_multiple_items() {
1351 let xml = r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/">
1352 <item id="0" parentID="-1" restricted="false">
1353 <dc:title>Track 1</dc:title>
1354 <res>http://example.com/track1.mp3</res>
1355 </item>
1356 <item id="1" parentID="-1" restricted="false">
1357 <dc:title>Track 2</dc:title>
1358 <res>http://example.com/track2.mp3</res>
1359 </item>
1360 </DIDL-Lite>"#;
1361
1362 let result = parse_track_metadata(xml).expect("Failed to parse multiple items");
1363 assert_eq!(result.items.len(), 2);
1364 assert_eq!(result.items[0].dc_title.as_deref(), Some("Track 1"));
1365 assert_eq!(result.items[1].dc_title.as_deref(), Some("Track 2"));
1366 }
1367
1368 #[test_log::test]
1369 fn test_parse_track_metadata_missing_res_element() {
1370 let xml = r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/">
1371 <item id="0" parentID="-1" restricted="false">
1372 </item>
1373 </DIDL-Lite>"#;
1374
1375 let result = parse_track_metadata(xml);
1376 assert!(
1377 result.is_err(),
1378 "Expected error when res element is missing"
1379 );
1380 match result {
1381 Err(ActionError::MissingProperty(_)) => {}
1382 _ => panic!("Expected MissingProperty error"),
1383 }
1384 }
1385
1386 #[test_log::test]
1387 fn test_parse_track_metadata_empty_document() {
1388 let xml = r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"></DIDL-Lite>"#;
1389
1390 let result = parse_track_metadata(xml).expect("Failed to parse empty document");
1391 assert_eq!(result.items.len(), 0);
1392 }
1393
1394 #[test_log::test]
1395 fn test_parse_track_metadata_with_xml_escaping() {
1396 let xml = r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/">
1397 <item id="0" parentID="-1" restricted="false">
1398 <dc:title>Track & Title</dc:title>
1399 <res>http://example.com/track?id=1&format=mp3</res>
1400 </item>
1401 </DIDL-Lite>"#;
1402
1403 let result = parse_track_metadata(xml).expect("Failed to parse escaped XML");
1404 assert_eq!(result.items.len(), 1);
1405 assert_eq!(result.items[0].dc_title.as_deref(), Some("Track & Title"));
1406 assert_eq!(
1407 result.items[0].res.source,
1408 "http://example.com/track?id=1&format=mp3"
1409 );
1410 }
1411
1412 #[test_log::test]
1413 fn test_parse_track_metadata_res_element_without_text_content() {
1414 let xml = r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/">
1415 <item id="0" parentID="-1" restricted="false">
1416 <res protocolInfo="http-get:*:audio/mp3:*"></res>
1417 </item>
1418 </DIDL-Lite>"#;
1419
1420 let result = parse_track_metadata(xml);
1421 assert!(
1422 result.is_err(),
1423 "Expected error when res element has no text content"
1424 );
1425 match result {
1426 Err(ActionError::MissingProperty(msg)) => {
1427 assert!(
1428 msg.contains("res"),
1429 "Error message should mention res: {msg}"
1430 );
1431 }
1432 _ => panic!("Expected MissingProperty error"),
1433 }
1434 }
1435
1436 #[test_log::test]
1437 fn test_parse_track_metadata_case_insensitive_tag_names() {
1438 let xml = r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" xmlns:dc="http://purl.org/dc/elements/1.1/">
1440 <ITEM id="0" parentID="-1" restricted="false">
1441 <upnp:CLASS>object.item.audioItem.musicTrack</upnp:CLASS>
1442 <dc:TITLE>Uppercase Tags</dc:TITLE>
1443 <RES>http://example.com/track.mp3</RES>
1444 </ITEM>
1445 </DIDL-Lite>"#;
1446
1447 let result = parse_track_metadata(xml).expect("Failed to parse uppercase tags");
1448 assert_eq!(result.items.len(), 1);
1449 assert_eq!(
1450 result.items[0].upnp_class.as_deref(),
1451 Some("object.item.audioItem.musicTrack")
1452 );
1453 assert_eq!(result.items[0].dc_title.as_deref(), Some("Uppercase Tags"));
1454 }
1455
1456 #[test_log::test]
1457 fn test_parse_track_metadata_invalid_xml() {
1458 let xml = r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/">
1459 <item id="0" parentID="-1" restricted="false">
1460 <res>http://example.com/track.mp3
1461 </item>"#; let result = parse_track_metadata(xml);
1464 assert!(result.is_err(), "Expected error when XML is invalid");
1465 assert!(
1466 matches!(result, Err(ActionError::Roxml(_))),
1467 "Expected Roxml error variant"
1468 );
1469 }
1470
1471 #[test_log::test]
1472 fn test_parse_track_metadata_duration_parsing() {
1473 let xml = r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/">
1475 <item id="0" parentID="-1" restricted="false">
1476 <res duration="01:30:45">http://example.com/long_track.mp3</res>
1477 </item>
1478 </DIDL-Lite>"#;
1479
1480 let result = parse_track_metadata(xml).expect("Failed to parse metadata with duration");
1481 assert_eq!(result.items.len(), 1);
1482 assert_eq!(result.items[0].res.duration, Some(5445));
1484 }
1485
1486 #[test_log::test]
1487 fn test_parse_track_metadata_with_special_characters_in_url() {
1488 let xml = r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/">
1490 <item id="0" parentID="-1" restricted="false">
1491 <res>http://192.168.1.100:8001/track?trackId=12911&source=LIBRARY&quality=HIGH</res>
1492 </item>
1493 </DIDL-Lite>"#;
1494
1495 let result = parse_track_metadata(xml).expect("Failed to parse metadata with special URL");
1496 assert_eq!(result.items.len(), 1);
1497 assert_eq!(
1498 result.items[0].res.source,
1499 "http://192.168.1.100:8001/track?trackId=12911&source=LIBRARY&quality=HIGH"
1500 );
1501 }
1502
1503 #[test_log::test]
1504 fn test_str_to_duration_boundary_values() {
1505 assert_eq!(str_to_duration("00:00:59"), 59);
1507 assert_eq!(str_to_duration("00:59:59"), 3599);
1508 assert_eq!(str_to_duration("23:59:59"), 86399);
1509 assert_eq!(str_to_duration("00:00:09"), 9);
1511 assert_eq!(str_to_duration("00:09:00"), 540);
1512 assert_eq!(str_to_duration("09:00:00"), 32400);
1513 }
1514
1515 #[test_log::test]
1516 fn test_duration_to_string_boundary_values() {
1517 assert_eq!(duration_to_string(59), "00:00:59");
1519 assert_eq!(duration_to_string(3599), "00:59:59");
1520 assert_eq!(duration_to_string(86399), "23:59:59");
1521 assert_eq!(duration_to_string(90000), "25:00:00");
1523 assert_eq!(duration_to_string(360_000), "100:00:00");
1524 }
1525
1526 mod cache_tests {
1527 use super::*;
1528
1529 #[test_log::test]
1530 fn test_cache_device_not_found_by_udn() {
1531 let result = cache::get_device("uuid:nonexistent-device");
1532 assert!(result.is_err());
1533 match result {
1534 Err(ScanError::DeviceUdnNotFound { device_udn }) => {
1535 assert_eq!(device_udn, "uuid:nonexistent-device");
1536 }
1537 _ => panic!("Expected DeviceUdnNotFound error"),
1538 }
1539 }
1540
1541 #[test_log::test]
1542 fn test_cache_device_not_found_by_url() {
1543 let result = cache::get_device_from_url("http://192.168.1.100:1234/device");
1544 assert!(result.is_err());
1545 match result {
1546 Err(ScanError::DeviceUrlNotFound { device_url }) => {
1547 assert_eq!(device_url, "http://192.168.1.100:1234/device");
1548 }
1549 _ => panic!("Expected DeviceUrlNotFound error"),
1550 }
1551 }
1552
1553 #[test_log::test]
1554 fn test_cache_service_not_found() {
1555 let result = cache::get_service(
1556 "uuid:nonexistent-device",
1557 "urn:upnp-org:serviceId:AVTransport",
1558 );
1559 assert!(result.is_err());
1560 match result {
1561 Err(ScanError::DeviceUdnNotFound { device_udn }) => {
1562 assert_eq!(device_udn, "uuid:nonexistent-device");
1563 }
1564 _ => panic!("Expected DeviceUdnNotFound error"),
1565 }
1566 }
1567
1568 #[test_log::test]
1569 fn test_public_get_device_not_found() {
1570 let result = get_device("uuid:nonexistent");
1571 assert!(result.is_err());
1572 }
1573
1574 #[test_log::test]
1575 fn test_public_get_service_not_found() {
1576 let result = get_service("uuid:nonexistent", "urn:upnp-org:serviceId:AVTransport");
1577 assert!(result.is_err());
1578 }
1579
1580 #[test_log::test]
1581 fn test_public_get_device_and_service_not_found() {
1582 let result =
1583 get_device_and_service("uuid:nonexistent", "urn:upnp-org:serviceId:AVTransport");
1584 assert!(result.is_err());
1585 }
1586
1587 #[test_log::test]
1588 fn test_public_get_device_from_url_not_found() {
1589 let result = get_device_from_url("http://192.168.1.100:1234/device");
1590 assert!(result.is_err());
1591 }
1592
1593 #[test_log::test]
1594 fn test_public_get_device_and_service_from_url_not_found() {
1595 let result = get_device_and_service_from_url(
1596 "http://192.168.1.100:1234/device",
1597 "urn:upnp-org:serviceId:AVTransport",
1598 );
1599 assert!(result.is_err());
1600 }
1601 }
1602
1603 mod upnp_device_scanner_tests {
1604 use super::*;
1605
1606 #[test_log::test(switchy_async::test)]
1607 async fn test_scanner_returns_early_when_already_scanning() {
1608 let mut scanner = UpnpDeviceScanner {
1609 scanning: true,
1610 devices: vec![],
1611 };
1612
1613 let result = scanner.scan().await;
1615 assert!(result.is_ok());
1616 assert!(scanner.devices.is_empty());
1618 assert!(scanner.scanning);
1620 }
1621
1622 #[test_log::test(switchy_async::test)]
1623 async fn test_scanner_returns_early_when_devices_already_found() {
1624 let existing_device = UpnpDevice {
1625 name: "Test Device".to_string(),
1626 udn: "uuid:test-123".to_string(),
1627 volume: None,
1628 services: vec![],
1629 };
1630
1631 let mut scanner = UpnpDeviceScanner {
1632 scanning: false,
1633 devices: vec![existing_device.clone()],
1634 };
1635
1636 let result = scanner.scan().await;
1638 assert!(result.is_ok());
1639 assert_eq!(scanner.devices.len(), 1);
1641 assert_eq!(scanner.devices[0].udn, "uuid:test-123");
1642 assert!(!scanner.scanning);
1644 }
1645
1646 #[test_log::test]
1647 fn test_scanner_new_creates_default_state() {
1648 let scanner = UpnpDeviceScanner::new();
1649 assert!(!scanner.scanning);
1650 assert!(scanner.devices.is_empty());
1651 }
1652
1653 #[test_log::test(switchy_async::test)]
1654 async fn test_scanner_scan_completes_with_empty_device_list() {
1655 let mut scanner = UpnpDeviceScanner::new();
1657 assert!(!scanner.scanning);
1658 assert!(scanner.devices.is_empty());
1659
1660 let result = scanner.scan().await;
1661 assert!(result.is_ok());
1662 assert!(!scanner.scanning);
1664 assert!(scanner.devices.is_empty());
1666 }
1667 }
1668}