diff --git a/docs/features/dlna.md b/docs/features/dlna.md index 27a692e47..52d8d2c82 100644 --- a/docs/features/dlna.md +++ b/docs/features/dlna.md @@ -45,6 +45,18 @@ Object IDs are **string prefixes**, routed by [`cds.rs`](../../src-tauri/crates/ 0/track/ Single-track BrowseMetadata payload ``` +Every id that a `BrowseDirectChildren` can return also answers `BrowseMetadata` with exactly one object: controllers read a folder's metadata before opening it, or before queueing it whole, and an empty answer shows as an empty folder. Every container carries a `childCount` (index-backed `COUNT` subqueries; an album counts only its available tracks, as its listing does). The `parentID` a `BrowseMetadata` reports: + +| Object | `parentID` | Why | +| ----------------------- | --------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `0` | `-1` | The root, per the spec. | +| `0/artists`, `0/albums` | `0` | | +| `0/artists/` | `0/artists` | | +| `0/albums/` | `0/albums` | Also listed under its artist, but its id lives under `0/albums`, which reaches it whatever the album's artist row says. | +| `0/track/` | `0/albums/` | Its album, the only container that lists it; `0` for a track without an album. | + +An unknown id, or one whose row is gone, still answers an empty DIDL rather than a fault. + Pagination via `StartingIndex` + `RequestedCount` → SQL `LIMIT/OFFSET`, capped at `MAX_PAGE_SIZE = 500`. `RequestedCount = 0` (the spec's "all") folds to the same cap so a misbehaving controller can't pull 50k tracks into one DIDL document. ## DIDL-Lite items @@ -67,6 +79,16 @@ The `transferMode.dlna.org: Streaming` and `contentFeatures.dlna.org` headers on The device UUID is `Uuid::new_v5(NAMESPACE, server_name)` so controllers see the same `uuid:` URN across launches even when the LAN IP changes — no on-disk persistence needed. +## Which address is advertised + +A desktop often carries a VPN, WSL, Hyper-V or Docker adapter next to its real LAN, frequently listed first, and an address on one of those is one a receiver cannot reach. So nothing advertises a single address picked once: + +- **SSDP** joins the multicast group on **every** IPv4 interface (one that refuses the membership is skipped; only refusing them all fails). Each NOTIFY batch goes out of each interface with that interface's own address in `LOCATION`, and an M-SEARCH reply names the address on the requester's subnet — the preferred address when none matches. +- **HTTP** — `URLBase` in `/description.xml`, and every stream and cover URL in a Browse result, is built from the local address the request arrived on (`http::LocalAddr`, recorded through axum's `ConnectInfo`). The address a controller connected to is reachable from it by construction, whichever interface that is. +- **The preferred address** — the URL shown in Settings, and the fallback above — comes from [`lan_addrs`](../../src-tauri/crates/app/src/dlna/mod.rs), which ranks every non-loopback IPv4 address: a private address on a running, physical interface first; then a non-private one; then anything point-to-point, down, or named like a VPN / VM / container adapter (`vEthernet (…)`, `docker0`, `br-…`, `virbr0`, `tailscale0`, `wg0`, …); link-local (`169.254.x.x`) last. The name match is a heuristic, so it only demotes, never drops: a host whose only address looks virtual is still served on it. Within a rank the OS order holds. + +The interface list is read once when the server starts. After a network change (new Wi-Fi, a VPN coming up), toggle the server off and on to pick it up. There is no setting to pin an interface. + ## Range streaming `/stream/` parses the `Range:` header, replies with `206 Partial Content` + `Content-Range`, and pipes the file through `tokio_util::io::ReaderStream::with_capacity(64 KiB)`. `take(length)` caps the reader so we never overshoot the window even if the controller closes early. @@ -76,7 +98,7 @@ The device UUID is `Uuid::new_v5(NAMESPACE, server_name)` so controllers see the Failures inside the worker are surfaced through `DlnaStatus.last_error` so the Settings UI can display them inline: - Bind failure (port in use, no privilege) → `"bind 0.0.0.0:1234: …"` and the server stays in the stopped state. -- SSDP socket failure → HTTP keeps serving for controllers that already know the LOCATION; only auto-discovery breaks. Surfaced as `"SSDP: …"`. +- SSDP socket failure → HTTP keeps serving for controllers that already know the LOCATION; only auto-discovery breaks. Surfaced as `"SSDP: …"` — including `"SSDP: no IPv4 network interface"` when the host has no address to advertise, rather than announcing `0.0.0.0`. ## Limitations / not implemented yet diff --git a/docs/features/mpd.md b/docs/features/mpd.md index bf78db5b1..ea46e011f 100644 --- a/docs/features/mpd.md +++ b/docs/features/mpd.md @@ -54,6 +54,8 @@ Persisted in the global `app_setting` table — the listener is process-wide, no The server binds every interface, matching [DLNA](dlna.md). Settled in #471 for two reasons. +A client connects to us, so no address is advertised on the wire; the one in `MpdStatus.bound_address` is only what Settings displays. It is the DLNA server's preferred address ([which one](dlna.md#which-address-is-advertised)): a real LAN interface ahead of a VPN, WSL, Hyper-V or Docker adapter listed before it. + **It matches what we already ship.** [`dlna/mod.rs`](../../src-tauri/crates/app/src/dlna/mod.rs) binds `0.0.0.0` and the DLNA HTTP layer has no authentication at all (UPnP has no such concept). WaveFlow therefore already exposes an opt-in, default-off, LAN-bound, zero-auth service — one that exposes strictly _more_ than MPD control does: | | DLNA | MPD | diff --git a/src-tauri/crates/app/src/dlna/cds.rs b/src-tauri/crates/app/src/dlna/cds.rs index ce037553a..ffec3c0bf 100644 --- a/src-tauri/crates/app/src/dlna/cds.rs +++ b/src-tauri/crates/app/src/dlna/cds.rs @@ -10,6 +10,17 @@ //! - `0/albums/` — Tracks for that album (items) //! - `0/track/` — Single track item (used by BrowseMetadata) //! +//! Every id above answers `BrowseMetadata` with the one object it +//! names, and every container carries a `childCount`. Controllers +//! read a folder's metadata before opening it, or before queueing it +//! whole, and treat an empty answer as an empty folder. +//! +//! An album is listed under both `0/albums` and `0/artists/`, but +//! its own metadata names `0/albums` as the parent: that is the +//! container its id lives under, and it is reachable from the root +//! whatever the album's artist row says. A track names its album, the +//! only container that lists it. +//! //! # Pagination //! //! `StartingIndex` + `RequestedCount` map directly to SQL @@ -19,14 +30,17 @@ use std::sync::Arc; use axum::{ - extract::State, + extract::{ConnectInfo, State}, http::{header, HeaderMap, StatusCode}, response::{IntoResponse, Response}, }; use serde::Deserialize; use sqlx::SqlitePool; -use crate::dlna::{description::xml_escape, http::ServerCtx}; +use crate::dlna::{ + description::xml_escape, + http::{LocalAddr, ServerCtx}, +}; /// Maximum rows we'll ever return in a single Browse call. Even when /// the controller asks for "all" (count = 0), we cap at this value to @@ -34,16 +48,77 @@ use crate::dlna::{description::xml_escape, http::ServerCtx}; /// avoid serialising 50k tracks into one DIDL document. const MAX_PAGE_SIZE: i64 = 500; +/// Titles of the two top-level folders. Shared by the root listing and +/// by each folder's own `BrowseMetadata` so the two cannot drift. +const ARTISTS_TITLE: &str = "Artists"; +const ALBUMS_TITLE: &str = "Albums"; + +/// A ContentDirectory object id, parsed once so `BrowseDirectChildren` +/// and `BrowseMetadata` agree on which ids exist. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ObjectId { + Root, + Artists, + Artist(i64), + Albums, + Album(i64), + Track(i64), +} + +impl ObjectId { + /// `None` for anything the server never emits. An empty id is the + /// root: some controllers send `` on first + /// contact. + fn parse(oid: &str) -> Option { + match oid { + "" | "0" => Some(Self::Root), + "0/artists" => Some(Self::Artists), + "0/albums" => Some(Self::Albums), + _ => { + let numbered = |prefix: &str| oid.strip_prefix(prefix)?.parse::().ok(); + numbered("0/artists/") + .map(Self::Artist) + .or_else(|| numbered("0/albums/").map(Self::Album)) + .or_else(|| numbered("0/track/").map(Self::Track)) + } + } + } + + /// The parent a container's own metadata names. A track's parent + /// depends on its album row instead, see [`track_parent_id`]. + fn container_parent(self) -> &'static str { + match self { + Self::Root => "-1", + Self::Artists | Self::Albums => "0", + Self::Artist(_) => "0/artists", + Self::Album(_) => "0/albums", + // Never asked: a track's metadata reads its album row. + Self::Track(_) => "0", + } + } +} + +/// The container a track is listed under: its album, or the root for a +/// track without one (no container lists it, and the root at least +/// exists). +fn track_parent_id(album_id: Option) -> String { + album_id.map_or_else(|| "0".to_string(), |id| format!("0/albums/{id}")) +} + /// SOAP control endpoint handler. Decodes the envelope, dispatches by /// action name, returns the appropriate SOAP response (or fault). -pub async fn handle_control(State(ctx): State>, body: String) -> Response { +pub async fn handle_control( + State(ctx): State>, + ConnectInfo(conn): ConnectInfo, + body: String, +) -> Response { let action = match parse_soap_action(&body) { Some(a) => a, None => return soap_fault(401, "Invalid Action"), }; match action { - SoapAction::Browse(req) => match browse(&ctx, &req).await { + SoapAction::Browse(req) => match browse(&ctx, &ctx.base_url_for(conn), &req).await { Ok(resp) => soap_response("Browse", &resp), Err(err) => { tracing::warn!(?err, "Browse failed"); @@ -144,7 +219,13 @@ struct BrowseResult { total_matches: i64, } -async fn browse(ctx: &ServerCtx, req: &BrowseRequest) -> Result { +/// `base` prefixes the stream and artwork URLs: the address this +/// request reached us on (see [`ServerCtx::base_url_for`]). +async fn browse( + ctx: &ServerCtx, + base: &str, + req: &BrowseRequest, +) -> Result { // Resolve effective limit + offset. RequestedCount = 0 means // "everything" per the spec — we cap at MAX_PAGE_SIZE. let count = if req.requested_count <= 0 { @@ -155,50 +236,70 @@ async fn browse(ctx: &ServerCtx, req: &BrowseRequest) -> Result() { - return browse_artist_albums(&ctx.pool, artist_id, count, offset).await; - } - } - if oid == "0/albums" { - return browse_albums(&ctx.pool, count, offset).await; - } - if let Some(id_str) = oid.strip_prefix("0/albums/") { - if let Ok(album_id) = id_str.parse::() { - return browse_album_tracks(ctx, album_id, count, offset).await; - } + match ObjectId::parse(&req.object_id) { + Some(ObjectId::Root) => browse_root(&ctx.pool).await, + Some(ObjectId::Artists) => browse_artists(&ctx.pool, count, offset).await, + Some(ObjectId::Artist(id)) => browse_artist_albums(&ctx.pool, id, count, offset).await, + Some(ObjectId::Albums) => browse_albums(&ctx.pool, count, offset).await, + Some(ObjectId::Album(id)) => browse_album_tracks(ctx, base, id, count, offset).await, + // A track has no children, and an unknown id gets an empty + // DIDL rather than a fault so controllers gracefully render + // an empty folder. + Some(ObjectId::Track(_)) | None => Ok(empty_result()), } +} - // Unknown ID — return an empty DIDL rather than a fault so - // controllers gracefully render an empty folder. - Ok(BrowseResult { +fn empty_result() -> BrowseResult { + BrowseResult { didl: didl_envelope(""), number_returned: 0, total_matches: 0, - }) + } } -fn browse_root() -> BrowseResult { - let mut body = String::new(); - body.push_str(&container_xml( - "0/artists", - "0", - "Artists", - // childCount is informational; controllers query it for the - // folder badge but won't fail if it's wrong. - None, - )); - body.push_str(&container_xml("0/albums", "0", "Albums", None)); +/// Wrap a single DIDL object, or nothing when the id named no row. +fn single_result(body: Option) -> BrowseResult { + match body { + Some(body) => BrowseResult { + didl: didl_envelope(&body), + number_returned: 1, + total_matches: 1, + }, + None => empty_result(), + } +} + +/// Children of the two top-level folders, in the order +/// `(artists, albums)`. +async fn root_counts(pool: &SqlitePool) -> Result<(i64, i64), sqlx::Error> { + let artists: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM artist") + .fetch_one(pool) + .await?; + let albums: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM album") + .fetch_one(pool) + .await?; + Ok((artists, albums)) +} + +fn artists_container(child_count: i64) -> String { + container_xml("0/artists", "0", ARTISTS_TITLE, Some(child_count)) +} + +fn albums_container(child_count: i64) -> String { + container_xml("0/albums", "0", ALBUMS_TITLE, Some(child_count)) +} + +async fn browse_root(pool: &SqlitePool) -> Result { + let (artists, albums) = root_counts(pool).await?; + Ok(root_children(artists, albums)) +} + +fn root_children(artists: i64, albums: i64) -> BrowseResult { + let mut body = artists_container(artists); + body.push_str(&albums_container(albums)); BrowseResult { didl: didl_envelope(&body), number_returned: 2, @@ -214,19 +315,26 @@ async fn browse_artists( let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM artist") .fetch_one(pool) .await?; - let rows: Vec<(i64, String)> = - sqlx::query_as("SELECT id, name FROM artist ORDER BY canonical_name LIMIT ? OFFSET ?") - .bind(count) - .bind(offset) - .fetch_all(pool) - .await?; + // The per-row counts ride `idx_album_artist`, so a page of 500 + // stays a page of index probes. + let rows: Vec<(i64, String, i64)> = sqlx::query_as( + "SELECT ar.id, ar.name, + (SELECT COUNT(*) FROM album al WHERE al.artist_id = ar.id) + FROM artist ar + ORDER BY ar.canonical_name + LIMIT ? OFFSET ?", + ) + .bind(count) + .bind(offset) + .fetch_all(pool) + .await?; let mut body = String::new(); - for (id, name) in &rows { + for (id, name, albums) in &rows { body.push_str(&container_xml( &format!("0/artists/{id}"), "0/artists", name, - None, + Some(*albums), )); } Ok(BrowseResult { @@ -246,9 +354,12 @@ async fn browse_artist_albums( .bind(artist_id) .fetch_one(pool) .await?; - let rows: Vec<(i64, String)> = sqlx::query_as( - "SELECT id, title FROM album WHERE artist_id = ? - ORDER BY year, canonical_title + let rows: Vec<(i64, String, i64)> = sqlx::query_as( + "SELECT al.id, al.title, + (SELECT COUNT(*) FROM track t WHERE t.album_id = al.id AND t.is_available = 1) + FROM album al + WHERE al.artist_id = ? + ORDER BY al.year, al.canonical_title LIMIT ? OFFSET ?", ) .bind(artist_id) @@ -258,12 +369,12 @@ async fn browse_artist_albums( .await?; let parent = format!("0/artists/{artist_id}"); let mut body = String::new(); - for (id, title) in &rows { + for (id, title, tracks) in &rows { body.push_str(&container_xml( &format!("0/albums/{id}"), &parent, title, - None, + Some(*tracks), )); } Ok(BrowseResult { @@ -281,19 +392,26 @@ async fn browse_albums( let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM album") .fetch_one(pool) .await?; - let rows: Vec<(i64, String)> = - sqlx::query_as("SELECT id, title FROM album ORDER BY canonical_title LIMIT ? OFFSET ?") - .bind(count) - .bind(offset) - .fetch_all(pool) - .await?; + // Only available tracks count, because `browse_album_tracks` lists + // only those; the per-row count rides `idx_track_album_available`. + let rows: Vec<(i64, String, i64)> = sqlx::query_as( + "SELECT al.id, al.title, + (SELECT COUNT(*) FROM track t WHERE t.album_id = al.id AND t.is_available = 1) + FROM album al + ORDER BY al.canonical_title + LIMIT ? OFFSET ?", + ) + .bind(count) + .bind(offset) + .fetch_all(pool) + .await?; let mut body = String::new(); - for (id, title) in &rows { + for (id, title, tracks) in &rows { body.push_str(&container_xml( &format!("0/albums/{id}"), "0/albums", title, - None, + Some(*tracks), )); } Ok(BrowseResult { @@ -320,10 +438,13 @@ struct TrackRow { artwork_hash: Option, artwork_format: Option, album_title: Option, + /// Names the track's parent container in its own metadata. + album_id: Option, } async fn browse_album_tracks( ctx: &ServerCtx, + base: &str, album_id: i64, count: i64, offset: i64, @@ -340,7 +461,8 @@ async fn browse_album_tracks( t.file_path, aw.hash AS artwork_hash, aw.format AS artwork_format, - al.title AS album_title + al.title AS album_title, + t.album_id FROM track t LEFT JOIN album al ON al.id = t.album_id LEFT JOIN artwork aw ON aw.id = al.artwork_id @@ -357,7 +479,7 @@ async fn browse_album_tracks( let parent = format!("0/albums/{album_id}"); let mut body = String::new(); for row in &rows { - body.push_str(&item_xml(ctx, row, &parent)); + body.push_str(&item_xml(base, row, &parent)); } Ok(BrowseResult { didl: didl_envelope(&body), @@ -366,56 +488,102 @@ async fn browse_album_tracks( }) } -async fn browse_metadata(ctx: &ServerCtx, object_id: &str) -> Result { - if object_id.is_empty() || object_id == "0" { - let body = r#"WaveFlowobject.container"#.to_string(); - return Ok(BrowseResult { - didl: didl_envelope(&body), - number_returned: 1, - total_matches: 1, - }); - } - if let Some(id_str) = object_id.strip_prefix("0/track/") { - if let Ok(track_id) = id_str.parse::() { - // Column aliases must match `TrackRow`: `ar.name AS artist_name` - // (not `t.primary_artist`) and `t.bitrate AS bitrate` (the column - // is `bitrate`, not `bit_rate` — the earlier shape would fail at - // bind time on every BrowseMetadata request). - let row: Option = sqlx::query_as( - "SELECT t.id, t.title, - ar.name AS artist_name, - t.duration_ms, t.file_size, t.bitrate, t.sample_rate, t.channels, - t.file_path, - aw.hash AS artwork_hash, - aw.format AS artwork_format, - al.title AS album_title - FROM track t - LEFT JOIN album al ON al.id = t.album_id - LEFT JOIN artwork aw ON aw.id = al.artwork_id - LEFT JOIN artist ar ON ar.id = t.primary_artist - WHERE t.id = ? AND t.is_available = 1", +async fn browse_metadata( + ctx: &ServerCtx, + base: &str, + object_id: &str, +) -> Result { + let body = match ObjectId::parse(object_id) { + Some(ObjectId::Track(track_id)) => track_metadata(ctx, base, track_id).await?, + Some(container) => container_metadata(&ctx.pool, container).await?, + None => None, + }; + Ok(single_result(body)) +} + +/// The `` an id names, or `None` when its row is gone. +async fn container_metadata( + pool: &SqlitePool, + id: ObjectId, +) -> Result, sqlx::Error> { + let parent = id.container_parent(); + Ok(match id { + ObjectId::Root => Some(format!( + r#"WaveFlowobject.container"# + )), + ObjectId::Artists => Some(artists_container(root_counts(pool).await?.0)), + ObjectId::Albums => Some(albums_container(root_counts(pool).await?.1)), + ObjectId::Artist(artist_id) => { + let row: Option<(String, i64)> = sqlx::query_as( + "SELECT ar.name, (SELECT COUNT(*) FROM album al WHERE al.artist_id = ar.id) + FROM artist ar + WHERE ar.id = ?", ) - .bind(track_id) - .fetch_optional(&ctx.pool) + .bind(artist_id) + .fetch_optional(pool) .await?; - if let Some(row) = row { - let parent = "0"; - let body = item_xml(ctx, &row, parent); - return Ok(BrowseResult { - didl: didl_envelope(&body), - number_returned: 1, - total_matches: 1, - }); - } + row.map(|(name, albums)| { + container_xml( + &format!("0/artists/{artist_id}"), + parent, + &name, + Some(albums), + ) + }) } - } - Ok(BrowseResult { - didl: didl_envelope(""), - number_returned: 0, - total_matches: 0, + ObjectId::Album(album_id) => { + let row: Option<(String, i64)> = sqlx::query_as( + "SELECT al.title, + (SELECT COUNT(*) FROM track t WHERE t.album_id = al.id AND t.is_available = 1) + FROM album al + WHERE al.id = ?", + ) + .bind(album_id) + .fetch_optional(pool) + .await?; + row.map(|(title, tracks)| { + container_xml( + &format!("0/albums/{album_id}"), + parent, + &title, + Some(tracks), + ) + }) + } + ObjectId::Track(_) => None, }) } +async fn track_metadata( + ctx: &ServerCtx, + base: &str, + track_id: i64, +) -> Result, sqlx::Error> { + // Column aliases must match `TrackRow`: `ar.name AS artist_name` + // (not `t.primary_artist`) and `t.bitrate AS bitrate` (the column + // is `bitrate`, not `bit_rate` — the earlier shape would fail at + // bind time on every BrowseMetadata request). + let row: Option = sqlx::query_as( + "SELECT t.id, t.title, + ar.name AS artist_name, + t.duration_ms, t.file_size, t.bitrate, t.sample_rate, t.channels, + t.file_path, + aw.hash AS artwork_hash, + aw.format AS artwork_format, + al.title AS album_title, + t.album_id + FROM track t + LEFT JOIN album al ON al.id = t.album_id + LEFT JOIN artwork aw ON aw.id = al.artwork_id + LEFT JOIN artist ar ON ar.id = t.primary_artist + WHERE t.id = ? AND t.is_available = 1", + ) + .bind(track_id) + .fetch_optional(&ctx.pool) + .await?; + Ok(row.map(|row| item_xml(base, &row, &track_parent_id(row.album_id)))) +} + /// Wrap inner content in the standard DIDL-Lite envelope. The four /// xmlns declarations are mandatory for AVTransport-side parsers. fn didl_envelope(inner: &str) -> String { @@ -434,9 +602,9 @@ fn container_xml(id: &str, parent: &str, title: &str, child_count: Option) ) } -fn item_xml(ctx: &ServerCtx, t: &TrackRow, parent: &str) -> String { +fn item_xml(base: &str, t: &TrackRow, parent: &str) -> String { let mime = mime_for_path(&t.file_path); - let stream_url = format!("{}/stream/{}", ctx.base_url, t.id); + let stream_url = format!("{base}/stream/{}", t.id); let duration = format_duration_hms(t.duration_ms); let size_attr = format!(r#" size="{}""#, t.file_size); let bitrate_attr = t @@ -474,7 +642,6 @@ fn item_xml(ctx: &ServerCtx, t: &TrackRow, parent: &str) -> String { let ext = t.artwork_format.as_deref().unwrap_or("jpg"); format!( r#"{base}/art/{h}.{ext}"#, - base = ctx.base_url, ) }) .unwrap_or_default(); @@ -621,9 +788,64 @@ mod tests { #[test] fn root_browse_lists_two_top_level_containers() { - let r = browse_root(); + let r = root_children(3, 7); assert_eq!(r.number_returned, 2); - assert!(r.didl.contains("0/artists")); - assert!(r.didl.contains("0/albums")); + assert!(r + .didl + .contains(r#"id="0/artists" parentID="0" restricted="1" childCount="3""#)); + assert!(r + .didl + .contains(r#"id="0/albums" parentID="0" restricted="1" childCount="7""#)); + } + + #[test] + fn object_id_parses_every_id_the_server_emits() { + assert_eq!(ObjectId::parse(""), Some(ObjectId::Root)); + assert_eq!(ObjectId::parse("0"), Some(ObjectId::Root)); + assert_eq!(ObjectId::parse("0/artists"), Some(ObjectId::Artists)); + assert_eq!(ObjectId::parse("0/artists/12"), Some(ObjectId::Artist(12))); + assert_eq!(ObjectId::parse("0/albums"), Some(ObjectId::Albums)); + assert_eq!(ObjectId::parse("0/albums/34"), Some(ObjectId::Album(34))); + assert_eq!(ObjectId::parse("0/track/56"), Some(ObjectId::Track(56))); + } + + #[test] + fn object_id_rejects_what_the_server_never_emits() { + assert_eq!(ObjectId::parse("0/artists/"), None); + assert_eq!(ObjectId::parse("0/albums/abc"), None); + assert_eq!(ObjectId::parse("0/track"), None); + assert_eq!(ObjectId::parse("0/genres"), None); + assert_eq!(ObjectId::parse("1"), None); + } + + #[test] + fn every_container_names_the_container_that_lists_it() { + assert_eq!(ObjectId::Root.container_parent(), "-1"); + assert_eq!(ObjectId::Artists.container_parent(), "0"); + assert_eq!(ObjectId::Albums.container_parent(), "0"); + assert_eq!(ObjectId::Artist(1).container_parent(), "0/artists"); + assert_eq!(ObjectId::Album(1).container_parent(), "0/albums"); + } + + #[test] + fn a_track_names_its_album_or_the_root() { + assert_eq!(track_parent_id(Some(9)), "0/albums/9"); + assert_eq!(track_parent_id(None), "0"); + } + + #[test] + fn single_result_counts_one_object_or_none() { + let one = single_result(Some(container_xml( + "0/albums/1", + "0/albums", + "A & B", + Some(4), + ))); + assert_eq!((one.number_returned, one.total_matches), (1, 1)); + assert!(one + .didl + .contains(r#"childCount="4">A & B"#)); + let none = single_result(None); + assert_eq!((none.number_returned, none.total_matches), (0, 0)); } } diff --git a/src-tauri/crates/app/src/dlna/http.rs b/src-tauri/crates/app/src/dlna/http.rs index 16b5b5bc6..f82b3dfff 100644 --- a/src-tauri/crates/app/src/dlna/http.rs +++ b/src-tauri/crates/app/src/dlna/http.rs @@ -11,19 +11,22 @@ //! shared metadata_artwork dir) //! GET /healthz — diag (always 200 "ok") +use std::net::SocketAddr; use std::path::PathBuf; use std::sync::Arc; use axum::{ body::Body, - extract::{Path, State}, + extract::{connect_info::Connected, ConnectInfo, Path, State}, http::{header, HeaderMap, HeaderValue, StatusCode}, response::{IntoResponse, Response}, routing::{get, post}, + serve::IncomingStream, Router, }; use sqlx::SqlitePool; use tokio::io::{AsyncSeekExt, SeekFrom}; +use tokio::net::TcpListener; use tokio_util::io::ReaderStream; use crate::dlna::{cds, description}; @@ -34,12 +37,47 @@ use crate::dlna::{cds, description}; #[derive(Clone)] pub struct ServerCtx { pub server_name: String, + /// The preferred address, used only when a connection's own local + /// address is unknown (see [`ServerCtx::base_url_for`]). pub base_url: String, pub pool: SqlitePool, pub profile_artwork_dir: PathBuf, pub metadata_artwork_dir: PathBuf, } +impl ServerCtx { + /// The base of every URL handed to the peer behind `conn`: the + /// address that peer reached us on. On a host with several + /// interfaces the preferred one may sit on a network the peer + /// cannot route to, while the one it connected to is reachable by + /// construction. + pub fn base_url_for(&self, conn: LocalAddr) -> String { + base_url(conn.0, &self.base_url) + } +} + +/// The local end of an accepted connection, recorded by the server +/// (`into_make_service_with_connect_info`) for [`ServerCtx::base_url_for`]. +#[derive(Debug, Clone, Copy)] +pub struct LocalAddr(pub Option); + +impl Connected> for LocalAddr { + fn connect_info(stream: IncomingStream<'_, TcpListener>) -> Self { + Self(stream.io().local_addr().ok()) + } +} + +fn base_url(local: Option, fallback: &str) -> String { + match local { + Some(addr) if !addr.ip().is_unspecified() => { + // A dual-stack socket reports an IPv4 peer as `::ffff:a.b.c.d`. + let addr = SocketAddr::new(addr.ip().to_canonical(), addr.port()); + format!("http://{addr}") + } + _ => fallback.to_string(), + } +} + pub fn router(ctx: ServerCtx) -> Router { Router::new() .route("/healthz", get(|| async { "ok" })) @@ -84,8 +122,11 @@ pub fn router(ctx: ServerCtx) -> Router { .with_state(Arc::new(ctx)) } -async fn serve_description(State(ctx): State>) -> Response { - let body = description::device_descriptor(&ctx.server_name, &ctx.base_url); +async fn serve_description( + State(ctx): State>, + ConnectInfo(conn): ConnectInfo, +) -> Response { + let body = description::device_descriptor(&ctx.server_name, &ctx.base_url_for(conn)); xml_response(body) } @@ -281,6 +322,26 @@ fn mime_for_art(name: &str) -> &'static str { mod tests { use super::*; + #[test] + fn base_url_names_the_address_the_peer_connected_to() { + let fallback = "http://192.168.1.20:8200"; + let local = |s: &str| Some(s.parse::().unwrap()); + assert_eq!( + base_url(local("172.20.0.1:8200"), fallback), + "http://172.20.0.1:8200" + ); + assert_eq!( + base_url(local("[::ffff:10.0.0.2]:8200"), fallback), + "http://10.0.0.2:8200" + ); + assert_eq!( + base_url(local("[fe80::1]:8200"), fallback), + "http://[fe80::1]:8200" + ); + assert_eq!(base_url(local("0.0.0.0:8200"), fallback), fallback); + assert_eq!(base_url(None, fallback), fallback); + } + #[test] fn parse_range_full_form() { assert_eq!(parse_range("bytes=0-99", 1000), Some((0, 99))); diff --git a/src-tauri/crates/app/src/dlna/mod.rs b/src-tauri/crates/app/src/dlna/mod.rs index 97ff20d3f..e77b2d7ed 100644 --- a/src-tauri/crates/app/src/dlna/mod.rs +++ b/src-tauri/crates/app/src/dlna/mod.rs @@ -43,6 +43,7 @@ pub mod description; pub mod http; pub mod ssdp; +use std::net::{IpAddr, Ipv4Addr}; use std::path::PathBuf; use crossbeam_channel::{unbounded, Sender}; @@ -198,7 +199,14 @@ impl WorkerState { return; } }; - let lan_ip = pick_lan_ip().unwrap_or_else(|| actual.ip().to_string()); + // One snapshot of the interfaces for the whole session: SSDP + // joins each of them, and the first one is the address shown in + // Settings. Each HTTP reply names the address its own request + // came in on instead (see `http::LocalAddr`). + let lan = lan_addrs(); + let lan_ip = lan + .first() + .map_or_else(|| actual.ip().to_string(), |a| a.ip.to_string()); let url = format!("http://{lan_ip}:{}", actual.port()); let (shutdown_tx, shutdown_rx) = oneshot::channel(); @@ -213,7 +221,10 @@ impl WorkerState { let app = http::router(ctx); tokio::spawn(async move { - let serve = axum::serve(listener, app); + let serve = axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ); tokio::select! { res = serve => { if let Err(err) = res { @@ -231,7 +242,7 @@ impl WorkerState { // our LOCATION can keep talking to us; only auto-discovery // breaks. Surface the error in `last_error` so the Settings // UI can warn the user. - let ssdp_err = match ssdp::spawn(cfg.server_name.clone(), lan_ip.clone(), actual.port()) { + let ssdp_err = match ssdp::spawn(cfg.server_name.clone(), lan, actual.port()) { Ok(handle) => { self.ssdp = Some(handle); None @@ -267,19 +278,179 @@ impl WorkerState { } } -/// Pick the first non-loopback IPv4 interface. Returned as a string -/// because every consumer (description.xml, status URL, SSDP LOCATION, -/// and the MPD server's advertised address) formats it back into URLs -/// anyway — keeping it as `String` avoids re-running the lookup at each -/// touch point. -pub(crate) fn pick_lan_ip() -> Option { - let addrs = if_addrs::get_if_addrs().ok()?; - addrs +/// One IPv4 address of this host, with the netmask that tells which +/// peers sit on the same link. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct LanAddr { + pub ip: Ipv4Addr, + pub netmask: Ipv4Addr, +} + +impl LanAddr { + /// Whether `peer` is on this address's subnet. A `/0` mask would + /// claim every peer, so it claims none. + pub fn contains(&self, peer: Ipv4Addr) -> bool { + let mask = u32::from(self.netmask); + mask != 0 && u32::from(self.ip) & mask == u32::from(peer) & mask + } +} + +/// The address to hand a peer: the one on its subnet, else the +/// preferred one. An IPv4-mapped IPv6 peer counts as IPv4. +pub(crate) fn local_ip_for(addrs: &[LanAddr], peer: IpAddr) -> Option { + let on_link = match peer.to_canonical() { + IpAddr::V4(v4) => addrs.iter().find(|a| a.contains(v4)), + IpAddr::V6(_) => None, + }; + on_link.or_else(|| addrs.first()).map(|a| a.ip) +} + +/// Every non-loopback IPv4 address of this host, best first (see +/// [`interface_rank`]). Empty when enumeration fails. +pub(crate) fn lan_addrs() -> Vec { + let interfaces = match if_addrs::get_if_addrs() { + Ok(interfaces) => interfaces, + Err(err) => { + tracing::warn!(?err, "listing network interfaces failed"); + return Vec::new(); + } + }; + let mut ranked: Vec<(u8, LanAddr)> = interfaces .into_iter() - .filter(|a| !a.is_loopback()) - .filter_map(|a| match a.ip() { - std::net::IpAddr::V4(v4) => Some(v4.to_string()), - std::net::IpAddr::V6(_) => None, + .filter(|i| !i.is_loopback()) + .filter_map(|i| match &i.addr { + if_addrs::IfAddr::V4(v4) => Some(( + interface_rank(&i.name, v4.ip, i.is_oper_up(), i.is_p2p()), + LanAddr { + ip: v4.ip, + netmask: v4.netmask, + }, + )), + if_addrs::IfAddr::V6(_) => None, }) - .next() + .collect(); + // Stable: within a rank, the OS order still decides. + ranked.sort_by_key(|(rank, _)| *rank); + ranked.into_iter().map(|(_, addr)| addr).collect() +} + +/// Lower is better. A receiver can only reach an address on its own +/// LAN, so a private address on a physical, running interface comes +/// first; a VPN tunnel or a VM / container bridge (Docker, WSL and +/// Hyper-V's default switch hand out private 172.x addresses too) is +/// demoted below any of those, and link-local comes last. Nothing is +/// dropped: a host whose only address looks virtual still gets served +/// on it. +fn interface_rank(name: &str, ip: Ipv4Addr, up: bool, p2p: bool) -> u8 { + if ip.is_link_local() { + return 4; + } + let demoted = !up || p2p || looks_virtual(name); + match (demoted, ip.is_private()) { + (false, true) => 0, + (false, false) => 1, + (true, true) => 2, + (true, false) => 3, + } +} + +/// Interface names that virtualisation and VPN software give the +/// adapters it creates: the adapter's friendly name on Windows, the +/// kernel name on unix. A heuristic, which is why it only ever demotes +/// (see [`interface_rank`]). +fn looks_virtual(name: &str) -> bool { + const PREFIXES: &[&str] = &[ + "docker", "br-", "veth", "virbr", "vboxnet", "vmnet", "tun", "tap", "wg", "zt", "utun", + "lxcbr", "lxdbr", "podman", "cni", "flannel", + ]; + const WORDS: &[&str] = &[ + "vethernet", + "virtualbox", + "vmware", + "hyper-v", + "wsl", + "docker", + "tailscale", + "zerotier", + "wireguard", + "openvpn", + "nordlynx", + "vpn", + ]; + let name = name.to_ascii_lowercase(); + PREFIXES.iter().any(|p| name.starts_with(p)) || WORDS.iter().any(|w| name.contains(w)) +} + +/// The preferred LAN address, for a display that can show only one: +/// the DLNA status URL, and the address the MPD server shows in +/// Settings. +pub(crate) fn pick_lan_ip() -> Option { + lan_addrs().first().map(|a| a.ip.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn addr(ip: [u8; 4], prefix: u32) -> LanAddr { + LanAddr { + ip: Ipv4Addr::from(ip), + netmask: Ipv4Addr::from(u32::MAX.checked_shl(32 - prefix).unwrap_or(0)), + } + } + + #[test] + fn a_physical_lan_beats_a_virtual_bridge_listed_first() { + let wsl = interface_rank("vEthernet (WSL)", Ipv4Addr::new(172, 20, 0, 1), true, false); + let docker = interface_rank("docker0", Ipv4Addr::new(172, 17, 0, 1), true, false); + let wifi = interface_rank("Wi-Fi", Ipv4Addr::new(192, 168, 1, 20), true, false); + assert!(wifi < wsl && wifi < docker); + } + + #[test] + fn tunnels_down_links_and_link_local_are_demoted() { + let lan = interface_rank("eth0", Ipv4Addr::new(10, 0, 0, 2), true, false); + let tunnel = interface_rank("ppp0", Ipv4Addr::new(10, 8, 0, 2), true, true); + let down = interface_rank("eth1", Ipv4Addr::new(192, 168, 2, 2), false, false); + let apipa = interface_rank("Ethernet 2", Ipv4Addr::new(169, 254, 3, 4), true, false); + let vpn = interface_rank("tailscale0", Ipv4Addr::new(100, 64, 0, 7), true, false); + assert!(lan < tunnel && lan < down && lan < vpn); + assert!(vpn < apipa); + } + + #[test] + fn a_physical_public_address_beats_a_virtual_private_one() { + let public = interface_rank("eno1", Ipv4Addr::new(130, 79, 1, 2), true, false); + let bridge = interface_rank("virbr0", Ipv4Addr::new(192, 168, 122, 1), true, false); + assert!(public < bridge); + } + + #[test] + fn a_plain_lan_name_is_not_virtual() { + for name in ["eth0", "enp3s0", "wlan0", "en0", "Ethernet", "Wi-Fi", "br0"] { + assert!(!looks_virtual(name), "{name}"); + } + } + + #[test] + fn the_peer_gets_the_address_on_its_subnet() { + let addrs = [addr([192, 168, 1, 20], 24), addr([172, 20, 0, 1], 20)]; + let peer = |ip: [u8; 4]| IpAddr::V4(Ipv4Addr::from(ip)); + let wsl = Some(Ipv4Addr::new(172, 20, 0, 1)); + let lan = Some(Ipv4Addr::new(192, 168, 1, 20)); + assert_eq!(local_ip_for(&addrs, peer([172, 20, 3, 9])), wsl); + assert_eq!(local_ip_for(&addrs, peer([192, 168, 1, 77])), lan); + // Off every subnet (a routed peer): the preferred address. + assert_eq!(local_ip_for(&addrs, peer([10, 1, 1, 1])), lan); + let mapped = IpAddr::V6(Ipv4Addr::new(172, 20, 3, 9).to_ipv6_mapped()); + assert_eq!(local_ip_for(&addrs, mapped), wsl); + assert_eq!(local_ip_for(&[], peer([10, 1, 1, 1])), None); + } + + #[test] + fn a_zero_mask_claims_no_peer() { + assert!(!addr([10, 0, 0, 1], 0).contains(Ipv4Addr::new(192, 168, 1, 1))); + assert!(addr([10, 0, 0, 1], 32).contains(Ipv4Addr::new(10, 0, 0, 1))); + assert!(!addr([10, 0, 0, 1], 32).contains(Ipv4Addr::new(10, 0, 0, 2))); + } } diff --git a/src-tauri/crates/app/src/dlna/ssdp.rs b/src-tauri/crates/app/src/dlna/ssdp.rs index 8bf715c0f..d33c60feb 100644 --- a/src-tauri/crates/app/src/dlna/ssdp.rs +++ b/src-tauri/crates/app/src/dlna/ssdp.rs @@ -16,6 +16,16 @@ //! and reply with a unicast HTTP/1.1 200 OK pointing at our //! `/description.xml`. //! +//! ## One host, several interfaces +//! +//! A desktop routinely carries a VPN, WSL, Hyper-V or Docker adapter +//! next to its real LAN, often listed first. The group is joined on +//! every IPv4 interface, each NOTIFY batch goes out of each interface +//! with that interface's own address in `LOCATION`, and an M-SEARCH +//! reply names the address on the requester's subnet (the preferred +//! one when none matches). An address the receiver cannot reach is +//! never the only one it hears about. +//! //! ## Search targets answered //! //! - `ssdp:all` — everything @@ -32,7 +42,7 @@ //! [`WorkerState`](super::WorkerState); a Stop or Reconfigure replaces //! the handle, terminating the previous announcer cleanly. -use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV4}; +use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4}; use std::sync::Arc; use std::time::Duration; @@ -40,7 +50,7 @@ use socket2::{Domain, Protocol, Socket, Type}; use tokio::net::UdpSocket; use tokio::task::JoinHandle; -use crate::dlna::description::device_uuid; +use crate::dlna::{description::device_uuid, local_ip_for, LanAddr}; /// Standard SSDP multicast endpoint. Same on every UPnP network. const SSDP_ADDR: SocketAddrV4 = SocketAddrV4::new(Ipv4Addr::new(239, 255, 255, 250), 1900); @@ -64,26 +74,32 @@ impl Drop for SsdpHandle { /// Spawn the announcer + M-SEARCH responder tasks. /// -/// `lan_ip` is the address advertised in `LOCATION:` headers — the -/// caller has already resolved the host's primary LAN interface so -/// controllers can reach `/description.xml`. +/// `addrs` are the host's IPv4 addresses, best first (see +/// [`lan_addrs`](super::lan_addrs)); each one is joined to the group +/// and advertised on its own link. /// /// `port` is the HTTP port the description lives on (already bound /// by the [`http`](super::http) layer). -pub fn spawn(server_name: String, lan_ip: String, port: u16) -> std::io::Result { +pub fn spawn(server_name: String, addrs: Vec, port: u16) -> std::io::Result { + if addrs.is_empty() { + // Nothing to advertise that a receiver could reach: say so + // rather than announce `0.0.0.0`. + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "no IPv4 network interface", + )); + } let uuid = device_uuid(&server_name).to_string(); - let location = format!("http://{lan_ip}:{port}/description.xml"); let server_header = "WaveFlow/0.1 UPnP/1.0 WaveFlowMediaServer/1.0".to_string(); + let mcast_socket = build_multicast_socket(&addrs)?; let shared = Arc::new(SsdpShared { uuid, - location, + addrs, + port, server: server_header, }); - let bind_ip: Ipv4Addr = lan_ip.parse().unwrap_or(Ipv4Addr::UNSPECIFIED); - let mcast_socket = build_multicast_socket(bind_ip)?; - let announcer = tokio::spawn(announce_loop(shared.clone())); let responder = tokio::spawn(respond_loop(shared, mcast_socket)); @@ -96,16 +112,31 @@ pub fn spawn(server_name: String, lan_ip: String, port: u16) -> std::io::Result< #[derive(Debug)] struct SsdpShared { uuid: String, - location: String, + addrs: Vec, + port: u16, server: String, } -/// Build a UDP socket joined to the SSDP multicast group. We use -/// socket2 because tokio's `UdpSocket::join_multicast_v4` works but -/// doesn't expose the `SO_REUSEADDR` / `SO_REUSEPORT` flags we need -/// for the responder to coexist with other UPnP services running on -/// the same machine (e.g. Windows Media Player). -fn build_multicast_socket(interface: Ipv4Addr) -> std::io::Result { +impl SsdpShared { + fn location(&self, ip: Ipv4Addr) -> String { + location_url(ip, self.port) + } +} + +fn location_url(ip: Ipv4Addr, port: u16) -> String { + format!("http://{ip}:{port}/description.xml") +} + +/// Build a UDP socket joined to the SSDP multicast group on every +/// address in `addrs`. We use socket2 because tokio's +/// `UdpSocket::join_multicast_v4` works but doesn't expose the +/// `SO_REUSEADDR` / `SO_REUSEPORT` flags we need for the responder to +/// coexist with other UPnP services running on the same machine (e.g. +/// Windows Media Player). +/// +/// An interface that refuses the membership (a link that is down, a +/// tunnel without multicast) is skipped; only refusing them all fails. +fn build_multicast_socket(addrs: &[LanAddr]) -> std::io::Result { let sock = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?; // SO_REUSEADDR is enough for our use case: it lets the SSDP // socket coexist with other UPnP services already bound to @@ -117,7 +148,20 @@ fn build_multicast_socket(interface: Ipv4Addr) -> std::io::Result { sock.set_nonblocking(true)?; let bind: SocketAddr = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 1900).into(); sock.bind(&bind.into())?; - sock.join_multicast_v4(SSDP_ADDR.ip(), &interface)?; + let mut joined = 0; + let mut last_err = None; + for addr in addrs { + match sock.join_multicast_v4(SSDP_ADDR.ip(), &addr.ip) { + Ok(()) => joined += 1, + Err(err) => { + tracing::debug!(ip = %addr.ip, ?err, "SSDP join failed on interface"); + last_err = Some(err); + } + } + } + if joined == 0 { + return Err(last_err.unwrap_or_else(|| std::io::ErrorKind::NotFound.into())); + } sock.set_multicast_loop_v4(false)?; sock.set_multicast_ttl_v4(4)?; @@ -133,16 +177,35 @@ async fn announce_loop(shared: Arc) { // First batch goes out immediately so controllers learn about us // without waiting the full interval. loop { - if let Err(err) = send_alive_batch(&shared).await { - tracing::warn!(?err, "SSDP NOTIFY alive failed"); + // One batch per interface: a multicast frame leaves through a + // single interface, so one batch would reach one link only. + let mut sent = false; + for addr in &shared.addrs { + match send_alive_batch(&shared, addr.ip).await { + Ok(()) => sent = true, + Err(err) => tracing::debug!(ip = %addr.ip, ?err, "SSDP NOTIFY failed on interface"), + } + } + if !sent { + tracing::warn!("SSDP NOTIFY alive failed on every interface"); } tokio::time::sleep(interval).await; } } -async fn send_alive_batch(shared: &SsdpShared) -> std::io::Result<()> { - let socket = UdpSocket::bind(SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0)).await?; - socket.set_multicast_ttl_v4(4)?; +/// Send one NOTIFY batch out of the interface that owns `ip`, +/// advertising `ip` itself. +async fn send_alive_batch(shared: &SsdpShared, ip: Ipv4Addr) -> std::io::Result<()> { + let sock = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?; + sock.set_nonblocking(true)?; + // Name the egress interface and bind its address too, so the frame + // leaves through that link and carries a source address on it. + let bind: SocketAddr = SocketAddrV4::new(ip, 0).into(); + sock.bind(&bind.into())?; + sock.set_multicast_if_v4(&ip)?; + sock.set_multicast_ttl_v4(4)?; + let socket = UdpSocket::from_std(sock.into())?; + let location = shared.location(ip); for nt in NOTIFICATION_TARGETS { let nt_str = expand_target(nt, &shared.uuid); let usn = build_usn(&nt_str, &shared.uuid); @@ -155,7 +218,6 @@ async fn send_alive_batch(shared: &SsdpShared) -> std::io::Result<()> { NTS: ssdp:alive\r\n\ SERVER: {server}\r\n\ USN: {usn}\r\n\r\n", - location = shared.location, server = shared.server, ); socket.send_to(frame.as_bytes(), SSDP_ADDR).await?; @@ -187,6 +249,12 @@ async fn respond_loop(shared: Arc, socket: UdpSocket) { Some(s) => s, None => continue, }; + // The address on the requester's subnet is one it can reach; + // `spawn` refuses an empty list, so there is always a fallback. + let Some(ip) = local_ip_for(&shared.addrs, peer.ip()) else { + continue; + }; + let location = shared.location(ip); let targets = matching_targets(&st, &shared.uuid); for target in targets { let usn = build_usn(&target, &shared.uuid); @@ -198,7 +266,6 @@ async fn respond_loop(shared: Arc, socket: UdpSocket) { SERVER: {server}\r\n\ ST: {target}\r\n\ USN: {usn}\r\n\r\n", - location = shared.location, server = shared.server, ); if let Err(err) = socket.send_to(reply.as_bytes(), peer).await { @@ -308,6 +375,20 @@ mod tests { assert!(matching_targets("urn:foo:bar", "abc").is_empty()); } + #[test] + fn location_names_the_interface_address() { + assert_eq!( + location_url(Ipv4Addr::new(192, 168, 1, 20), 8200), + "http://192.168.1.20:8200/description.xml" + ); + } + + #[test] + fn spawn_refuses_an_empty_interface_list() { + let err = spawn("WaveFlow".into(), Vec::new(), 8200).err(); + assert_eq!(err.map(|e| e.kind()), Some(std::io::ErrorKind::NotFound)); + } + #[test] fn build_usn_handles_uuid_only_target() { assert_eq!(build_usn("uuid:abc", "abc"), "uuid:abc");