Skip to main content

switchy_upnp/
lib.rs

1//! UPnP/DLNA device discovery and control library.
2//!
3//! This crate provides functionality for discovering and controlling UPnP/DLNA devices
4//! on the local network. It supports device scanning, media playback control, volume
5//! management, and event subscriptions for `UPnP` `AVTransport` and `RenderingControl` services.
6//!
7//! # Features
8//!
9//! * `api` - Actix-web API endpoints for `UPnP` operations
10//! * `listener` - Event listener service for monitoring `UPnP` device state changes
11//! * `player` - `UPnP` player implementation for media playback
12//! * `openapi` - OpenAPI/utoipa schema support
13//! * `simulator` - Simulated `UPnP` devices for testing
14//!
15//! # Examples
16//!
17//! Scanning for `UPnP` devices on the network:
18//!
19//! ```rust,no_run
20//! # use switchy_upnp::{scan_devices, devices};
21//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
22//! // Scan the network for UPnP devices
23//! scan_devices().await?;
24//!
25//! // Get the list of discovered devices
26//! let upnp_devices = devices().await;
27//! for device in upnp_devices {
28//!     println!("Found device: {} ({})", device.name, device.udn);
29//! }
30//! # Ok(())
31//! # }
32//! ```
33//!
34//! Controlling playback on a `UPnP` device:
35//!
36//! ```rust,no_run
37//! # use switchy_upnp::{get_device_and_service, play, pause, set_volume};
38//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
39//! # let device_udn = "uuid:device-id";
40//! # let service_id = "urn:upnp-org:serviceId:AVTransport";
41//! // Get device and AVTransport service
42//! let (device, service) = get_device_and_service(device_udn, service_id)?;
43//! let url = device.url();
44//!
45//! // Start playback
46//! play(&service, url, 0, 1.0).await?;
47//!
48//! // Pause playback
49//! pause(&service, url, 0).await?;
50//!
51//! // Set volume to 50%
52//! # let rendering_control_service = service;
53//! set_volume(&rendering_control_service, url, 0, "Master", 50).await?;
54//! # Ok(())
55//! # }
56//! ```
57
58#![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    //! Internal cache for storing discovered `UPnP` devices and their services.
86    //!
87    //! This module maintains mappings of devices by both URL and UDN (Unique Device Name)
88    //! to enable fast lookups during `UPnP` operations.
89
90    use std::{
91        collections::BTreeMap,
92        sync::{LazyLock, RwLock},
93    };
94
95    use rupnp::{Device, Service};
96
97    use crate::ScanError;
98
99    /// Mapping of a device and its associated services.
100    #[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    /// Retrieves a cached device by its URL.
113    ///
114    /// # Errors
115    ///
116    /// * If no device with the specified URL is found in the cache
117    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    /// Retrieves a cached device by its UDN.
130    ///
131    /// # Errors
132    ///
133    /// * If no device with the specified UDN is found in the cache
134    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    /// Inserts a device into the cache, indexed by both URL and UDN.
147    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    /// Retrieves a cached service by device UDN and service ID.
165    ///
166    /// # Errors
167    ///
168    /// * If no device with the specified UDN is found in the cache
169    /// * If no service with the specified service ID is found on the device
170    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    /// Retrieves a cached device and service by device UDN and service ID.
187    ///
188    /// # Errors
189    ///
190    /// * If no device with the specified UDN is found in the cache
191    /// * If no service with the specified service ID is found on the device
192    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    /// Retrieves a cached device and service by device URL and service ID.
218    ///
219    /// # Errors
220    ///
221    /// * If no device with the specified URL is found in the cache
222    /// * If no service with the specified service ID is found on the device
223    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    /// Inserts a service into the cache for a specific device.
249    ///
250    /// The service is added to both the URL-indexed and UDN-indexed device mappings.
251    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
275/// Retrieves a cached `UPnP` device by its unique device name (UDN).
276///
277/// # Errors
278///
279/// * If a `Device` is not found with the given `udn`
280pub fn get_device(udn: &str) -> Result<Device, ScanError> {
281    cache::get_device(udn)
282}
283
284/// Retrieves a cached `UPnP` service by device UDN and service ID.
285///
286/// # Errors
287///
288/// * If a `Service` is not found with the given `device_udn` and `service_id`
289pub fn get_service(device_udn: &str, service_id: &str) -> Result<Service, ScanError> {
290    cache::get_service(device_udn, service_id)
291}
292
293/// Retrieves a cached `UPnP` device and service by device UDN and service ID.
294///
295/// # Errors
296///
297/// * If a `Device` or `Service` is not found with the given `device_udn` and `service_id`
298pub 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
305/// Retrieves a cached `UPnP` device by its URL.
306///
307/// # Errors
308///
309/// * If a `Device` is not found with the given `url`
310pub fn get_device_from_url(url: &str) -> Result<Device, ScanError> {
311    cache::get_device_from_url(url)
312}
313
314/// Retrieves a cached `UPnP` device and service by device URL and service ID.
315///
316/// # Errors
317///
318/// * If a `Device` or `Service` is not found with the given `device_url` and `service_id`
319pub 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/// Errors that can occur when executing `UPnP` actions.
327#[derive(Debug, Error)]
328pub enum ActionError {
329    /// Error parsing XML response from `UPnP` device.
330    #[error(transparent)]
331    Roxml(#[from] roxmltree::Error),
332    /// Error from the underlying `UPnP` library.
333    #[error(transparent)]
334    Rupnp(#[from] rupnp::Error),
335    /// Required property missing from `UPnP` action response.
336    #[error("Missing property \"{0}\"")]
337    MissingProperty(String),
338}
339
340/// Errors that can occur when scanning for `UPnP` devices and services.
341#[derive(Debug, Error)]
342pub enum ScanError {
343    /// `RenderingControl` service not found on the device.
344    #[error("Failed to find `RenderingControl` service")]
345    RenderingControlNotFound,
346    /// `MediaRenderer` service not found on the device.
347    #[error("Failed to find MediaRenderer service")]
348    MediaRendererNotFound,
349    /// `UPnP` device with the specified UDN not found in cache.
350    #[error("Failed to find UPnP Device device_udn={device_udn}")]
351    DeviceUdnNotFound {
352        /// The device UDN that was not found.
353        device_udn: String,
354    },
355    /// `UPnP` device with the specified URL not found in cache.
356    #[error("Failed to find UPnP Device device_url={device_url}")]
357    DeviceUrlNotFound {
358        /// The device URL that was not found.
359        device_url: String,
360    },
361    /// `UPnP` service with the specified service ID not found on the device.
362    #[error("Failed to find UPnP Service service_id={service_id}")]
363    ServiceIdNotFound {
364        /// The service ID that was not found.
365        service_id: String,
366    },
367    /// Error from the underlying `UPnP` library.
368    #[error(transparent)]
369    Rupnp(#[from] rupnp::Error),
370}
371
372/// Converts a duration string in the format "HH:MM:SS" to seconds.
373///
374/// # Panics
375///
376/// * If the duration str is an invalid format
377#[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/// Converts a duration in seconds to a string in the format "HH:MM:SS".
389#[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/// Sets the AV transport URI for a `UPnP` device with metadata.
405///
406/// # Errors
407///
408/// * If the action failed to execute
409///
410/// # Examples
411///
412/// ```rust,no_run
413/// # use switchy_upnp::{get_device_and_service, set_av_transport_uri};
414/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
415/// # let device_udn = "uuid:device-id";
416/// let (device, service) =
417///     get_device_and_service(device_udn, "urn:upnp-org:serviceId:AVTransport")?;
418/// set_av_transport_uri(
419///     &service,
420///     device.url(),
421///     0,
422///     "https://example.com/track.flac",
423///     "flac",
424///     Some("Track Title"),
425///     Some("Creator"),
426///     Some("Artist"),
427///     Some("Album"),
428///     Some(1),
429///     Some(211),
430///     None,
431/// )
432/// .await?;
433/// # Ok(())
434/// # }
435/// ```
436#[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    // Remove extraneous whitespace
457    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/// Parsed track metadata from `UPnP` `DIDL-Lite` XML.
536#[derive(Debug, Clone, Serialize)]
537#[serde(rename_all = "camelCase")]
538#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
539pub struct TrackMetadata {
540    /// List of track metadata items parsed from the XML.
541    items: Vec<TrackMetadataItem>,
542}
543
544/// A single track metadata item from `UPnP` `DIDL-Lite` XML.
545#[derive(Debug, Clone, Serialize)]
546#[serde(rename_all = "camelCase")]
547#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
548pub struct TrackMetadataItem {
549    /// `UPnP` class of the item (e.g., "object.item.audioItem.musicTrack").
550    upnp_class: Option<String>,
551    /// Artist name from `UPnP` metadata.
552    upnp_artist: Option<String>,
553    /// Album name from `UPnP` metadata.
554    upnp_album: Option<String>,
555    /// Original track number from `UPnP` metadata.
556    upnp_original_track_number: Option<String>,
557    /// Track title from Dublin Core metadata.
558    dc_title: Option<String>,
559    /// Creator name from Dublin Core metadata.
560    dc_creator: Option<String>,
561    /// Resource information for the track.
562    res: TrackMetadataItemResource,
563}
564
565/// Resource information for a track metadata item.
566#[derive(Debug, Clone, Serialize)]
567#[serde(rename_all = "camelCase")]
568#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
569pub struct TrackMetadataItemResource {
570    /// Duration of the track in seconds.
571    duration: Option<u32>,
572    /// Protocol information describing the resource format.
573    protocol_info: Option<String>,
574    /// URI of the media resource.
575    source: String,
576}
577
578// "<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/\">
579//     <item id=\"0\" parentID=\"-1\" restricted=\"false\">
580//         <upnp:class>object.item.audioItem.musicTrack</upnp:class>
581//         <dc:title>Friday</dc:title>
582//         <dc:creator>Rebecca Black</dc:creator>
583//         <upnp:artist>Rebecca Black</upnp:artist>
584//         <upnp:album>Friday</upnp:album>
585//         <upnp:originalTrackNumber>1</upnp:originalTrackNumber>
586//         <res duration=\"00:03:31\" protocolInfo=\"http-get:*:audio/flac:*\">http://192.168.254.137:8001/track?trackId=12911&amp;source=LIBRARY</res>
587//     </item>
588// </DIDL-Lite>"
589fn 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/// `UPnP` `AVTransport` service transport information.
652#[derive(Debug, Clone, Serialize)]
653#[serde(rename_all = "camelCase")]
654#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
655pub struct TransportInfo {
656    /// Current transport status (e.g., "OK", "`ERROR_OCCURRED`").
657    pub current_transport_status: String,
658    /// Current transport state (e.g., "PLAYING", "`PAUSED_PLAYBACK`", "STOPPED").
659    pub current_transport_state: String,
660    /// Current playback speed (typically "1" for normal speed).
661    pub current_speed: String,
662}
663
664/// Retrieves transport information from a `UPnP` `AVTransport` service.
665///
666/// # Errors
667///
668/// * If the action failed to execute
669/// * If the transport info is missing the required properties
670pub 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/// `UPnP` `AVTransport` service position information.
702#[derive(Debug, Clone, Serialize)]
703#[serde(rename_all = "camelCase")]
704#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
705pub struct PositionInfo {
706    /// Current track number in the playlist (1-based).
707    pub track: u32,
708    /// Relative playback position in seconds within the current track.
709    pub rel_time: u32,
710    /// Absolute playback position in seconds across the entire playlist.
711    pub abs_time: u32,
712    /// URI of the current track.
713    pub track_uri: String,
714    /// Metadata for the current track.
715    pub track_metadata: TrackMetadata,
716    /// Relative counter value.
717    pub rel_count: u32,
718    /// Absolute counter value.
719    pub abs_count: u32,
720    /// Total duration of the current track in seconds.
721    pub track_duration: u32,
722}
723
724/// Retrieves position information from a `UPnP` `AVTransport` service.
725///
726/// # Errors
727///
728/// * If the action failed to execute
729/// * If the position info is missing the required properties
730pub 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
782/// Seeks to a specific position in the current media on a `UPnP` `AVTransport` service.
783///
784/// # Errors
785///
786/// * If the action failed to execute
787pub 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
814/// Retrieves the volume from a `UPnP` `RenderingControl` service.
815///
816/// # Errors
817///
818/// * If the action failed to execute
819pub 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
836/// Sets the volume on a `UPnP` `RenderingControl` service.
837///
838/// # Errors
839///
840/// * If the action failed to execute
841pub 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/// `UPnP` `AVTransport` service media information.
859#[derive(Debug, Clone, Serialize)]
860#[serde(rename_all = "camelCase")]
861#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
862pub struct MediaInfo {
863    /// Total duration of the media in seconds.
864    media_duration: u32,
865    /// Recording medium type (e.g., "`NOT_IMPLEMENTED`").
866    record_medium: String,
867    /// Write status of the media (e.g., "`NOT_IMPLEMENTED`").
868    write_status: String,
869    /// Metadata for the current media URI.
870    current_uri_metadata: TrackMetadata,
871    /// Number of tracks in the current playlist.
872    nr_tracks: u32,
873    /// Playback medium type (e.g., "NETWORK", "NONE").
874    play_medium: String,
875    /// URI of the current media.
876    current_uri: String,
877}
878
879/// Retrieves media information from a `UPnP` `AVTransport` service.
880///
881/// # Errors
882///
883/// * If the action failed to execute
884/// * If the media info is missing the required properties
885pub 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
931/// Subscribes to events from a `UPnP` service.
932///
933/// # Errors
934///
935/// * If the subscription failed to execute
936pub 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
951/// Starts playback on a `UPnP` `AVTransport` service.
952///
953/// # Errors
954///
955/// * If the action failed to execute
956pub 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
973/// Pauses playback on a `UPnP` `AVTransport` service.
974///
975/// # Errors
976///
977/// * If the action failed to execute
978pub 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
994/// Stops playback on a `UPnP` `AVTransport` service.
995///
996/// # Errors
997///
998/// * If the action failed to execute
999pub 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
1015/// Scans and retrieves information about a `UPnP` service.
1016///
1017/// # Errors
1018///
1019/// * If failed to scan for `UPnP` services
1020pub 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/// Scans a `UPnP` device and its sub-devices, returning information about all discovered devices.
1051///
1052/// # Errors
1053///
1054/// * If failed to scan for `UPnP` devices
1055#[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            // FIXME: should somehow insert sub-devices into the cache
1124            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
1146/// Scans the network for `UPnP` devices and caches them.
1147///
1148/// # Errors
1149///
1150/// * If failed to scan for `UPnP` devices
1151pub async fn scan_devices() -> Result<(), UpnpDeviceScannerError> {
1152    UPNP_DEVICE_SCANNER.lock().await.scan().await
1153}
1154
1155/// Returns the list of cached `UPnP` devices from the last scan.
1156#[must_use]
1157pub async fn devices() -> Vec<UpnpDevice> {
1158    UPNP_DEVICE_SCANNER.lock().await.devices.clone()
1159}
1160
1161/// Scanner for discovering `UPnP` devices on the network.
1162#[derive(Default)]
1163pub struct UpnpDeviceScanner {
1164    scanning: bool,
1165    /// List of discovered `UPnP` devices from the most recent scan.
1166    pub devices: Vec<UpnpDevice>,
1167}
1168
1169/// Errors that can occur when scanning for `UPnP` devices.
1170#[allow(dead_code)]
1171#[allow(clippy::enum_variant_names)]
1172#[derive(Debug, Error)]
1173pub enum UpnpDeviceScannerError {
1174    /// No audio outputs are available.
1175    #[error("No outputs available")]
1176    NoOutputs,
1177    /// Error from the underlying `UPnP` library.
1178    #[error(transparent)]
1179    Rupnp(#[from] rupnp::Error),
1180    /// Error scanning for `UPnP` devices or services.
1181    #[error(transparent)]
1182    Scan(#[from] ScanError),
1183}
1184
1185impl UpnpDeviceScanner {
1186    /// Creates a new `UPnP` device scanner.
1187    #[must_use]
1188    pub fn new() -> Self {
1189        Self::default()
1190    }
1191
1192    /// Scans the network for `UPnP` devices and populates the device list.
1193    ///
1194    /// This method discovers devices on the local network, caches them, and stores
1195    /// their information in the scanner's device list. If devices have already been
1196    /// scanned or a scan is in progress, this method returns immediately without
1197    /// performing another scan.
1198    ///
1199    /// # Errors
1200    ///
1201    /// * If failed to scan for `UPnP` devices
1202    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 &amp; Title</dc:title>
1399                <res>http://example.com/track?id=1&amp;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        // UPnP specs don't guarantee case consistency, test that we handle different cases
1439        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>"#; // Missing closing tags
1462
1463        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        // Tests various duration formats in metadata res elements
1474        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        // 1:30:45 = 1*3600 + 30*60 + 45 = 3600 + 1800 + 45 = 5445 seconds
1483        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        // Tests URL with query parameters and special characters (common in streaming APIs)
1489        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&amp;source=LIBRARY&amp;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        // Test boundary between time units
1506        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        // Test with leading zeros
1510        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        // Test boundary between time units
1518        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        // Test large values beyond 24 hours
1522        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            // Should return Ok immediately without actually scanning
1614            let result = scanner.scan().await;
1615            assert!(result.is_ok());
1616            // Devices should still be empty since scan was skipped
1617            assert!(scanner.devices.is_empty());
1618            // Scanning flag should remain true (it wasn't reset because scan was skipped)
1619            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            // Should return Ok immediately without rescanning
1637            let result = scanner.scan().await;
1638            assert!(result.is_ok());
1639            // Devices should still contain the original device
1640            assert_eq!(scanner.devices.len(), 1);
1641            assert_eq!(scanner.devices[0].udn, "uuid:test-123");
1642            // Scanning flag should remain false (scan was skipped)
1643            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            // Tests the scan path when no devices are found (simulator returns empty stream)
1656            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            // After scan completes, scanning flag should be reset to false
1663            assert!(!scanner.scanning);
1664            // Devices list should still be empty (no devices discovered in simulator mode)
1665            assert!(scanner.devices.is_empty());
1666        }
1667    }
1668}