breadcast/breadcast-core/src/dlna/discovery.rs
Breadway 8c745d18e0
Some checks failed
dev release / build (push) Failing after 12s
Implement Cast Streaming mirroring, DLNA casting, daemon+GUI, and breadd integration
Builds out the full v1 scope: a vendored+patched openscreen subset for
low-latency Cast Streaming (Mirroring receiver 0F5096E8) alongside the
existing Cast V2/HLS and new DLNA/AVTransport casting paths, breadcastd's
Idle/Casting state machine with a private IPC socket, the breadcast GTK4
popup as a thin IPC client, and bread.cast.*/bread.command.cast.* breadd
integration (device discovery, start/stop, mirroring lifecycle events).
Also adds bakery/systemd/Forgejo CI packaging.

Validated end-to-end against a real Chromecast/Google TV: negotiated
Cast Streaming session, live pipeline playback, and daemon+GUI click-to-cast/
stop through the actual popup.
2026-08-03 09:07:21 +08:00

131 lines
5.5 KiB
Rust

use std::collections::{HashMap, HashSet};
use std::time::Duration;
use rupnp::ssdp::SearchTarget;
use tokio::sync::mpsc;
use tracing::{debug, warn};
use super::device::DlnaDevice;
use super::AV_TRANSPORT;
/// How often to re-issue an SSDP search burst. Unlike mDNS (continuous
/// multicast browsing via `mdns-sd` in [`crate::discovery`]), SSDP has no
/// "subscribe and get pushed updates" primitive exposed by `rupnp` —
/// discovery here is "ask, wait [`SEARCH_TIMEOUT`], collect whoever
/// answered," repeated on this interval, rather than event-driven.
const POLL_INTERVAL: Duration = Duration::from_secs(30);
/// How long to wait for M-SEARCH responses on each poll. SSDP devices are
/// expected to jitter their response within a device-chosen window, so this
/// needs to be more than an instant, but this is still per-poll latency
/// before newly-found devices are reported.
const SEARCH_TIMEOUT: Duration = Duration::from_secs(3);
/// A device not reconfirmed within this many consecutive polls is reported
/// lost. More than one (rather than declaring it gone after a single missed
/// poll) tolerates an occasional dropped UDP response — SSDP runs over
/// unreliable multicast, so one missed reply is routine, not a signal the
/// device actually left the network.
const MISSED_POLLS_BEFORE_LOST: u32 = 2;
/// A change in the set of DLNA renderers visible on the LAN. Mirrors
/// [`crate::discovery::DiscoveryEvent`]'s shape for consistency between the
/// two casting protocols, though the underlying mechanism differs (see
/// [`DlnaDiscovery`]'s docs).
#[derive(Debug, Clone)]
pub enum DlnaDiscoveryEvent {
Found(DlnaDevice),
Lost { url: String },
}
/// Periodically searches for UPnP `AVTransport` services (i.e. media
/// renderers — smart TVs, DLNA-capable receivers, and what Windows' own
/// "Cast to Device" targets) on the LAN via SSDP, and forwards found/lost
/// devices over a channel.
///
/// Runs as a background `tokio` task rather than the dedicated OS thread
/// [`crate::discovery::Discovery`] needs — `mdns-sd`'s browse channel is a
/// blocking `flume` receiver with no async-friendly interface, but `rupnp`
/// and `ssdp-client` are natively `tokio`-async, so a plain spawned task is
/// both sufficient and more idiomatic here. Dropping the returned
/// `DlnaDiscovery` aborts that task.
pub struct DlnaDiscovery {
task: tokio::task::JoinHandle<()>,
}
impl Drop for DlnaDiscovery {
fn drop(&mut self) {
self.task.abort();
}
}
impl DlnaDiscovery {
/// Starts polling and returns the handle plus a receiver of events.
pub fn start() -> (Self, mpsc::UnboundedReceiver<DlnaDiscoveryEvent>) {
let (tx, rx) = mpsc::unbounded_channel();
let task = tokio::spawn(async move {
// description URL -> (device, consecutive polls since last confirmed)
let mut known: HashMap<String, (DlnaDevice, u32)> = HashMap::new();
loop {
let search_target = SearchTarget::URN(AV_TRANSPORT);
match rupnp::discover(&search_target, SEARCH_TIMEOUT, None).await {
Ok(stream) => {
use futures_util::StreamExt;
let mut stream = std::pin::pin!(stream);
let mut confirmed: HashSet<String> = HashSet::new();
while let Some(result) = stream.next().await {
let device = match result {
Ok(device) => device,
Err(e) => {
warn!(error = %e, "failed to resolve a discovered UPnP device, skipping");
continue;
}
};
let url = device.url().to_string();
confirmed.insert(url.clone());
if let Some(entry) = known.get_mut(&url) {
entry.1 = 0;
continue;
}
let dlna_device = DlnaDevice {
friendly_name: device.friendly_name().to_string(),
url: url.clone(),
};
known.insert(url, (dlna_device.clone(), 0));
debug!(?dlna_device, "DLNA renderer found");
if tx.send(DlnaDiscoveryEvent::Found(dlna_device)).is_err() {
return; // receiver dropped, stop polling
}
}
known.retain(|url, (_, missed)| {
if confirmed.contains(url) {
true
} else {
*missed += 1;
if *missed >= MISSED_POLLS_BEFORE_LOST {
debug!(%url, "DLNA renderer lost");
let _ = tx.send(DlnaDiscoveryEvent::Lost { url: url.clone() });
false
} else {
true
}
}
});
}
Err(e) => warn!(error = %e, "SSDP search for AVTransport devices failed"),
}
tokio::time::sleep(POLL_INTERVAL).await;
}
});
(Self { task }, rx)
}
}