Implement Cast Streaming mirroring, DLNA casting, daemon+GUI, and breadd integration
Some checks failed
dev release / build (push) Failing after 12s
Some checks failed
dev release / build (push) Failing after 12s
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.
This commit is contained in:
parent
887c29002f
commit
8c745d18e0
283 changed files with 36788 additions and 0 deletions
16
breadcast-core/src/dlna/device.rs
Normal file
16
breadcast-core/src/dlna/device.rs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/// A DLNA/UPnP media renderer discovered on the LAN via SSDP — the class of
|
||||
/// device Windows' own "Cast to Device" (Win+K) targets, and what most
|
||||
/// non-Chromecast smart TVs (Samsung, LG/Tizen, Sony) expose alongside or
|
||||
/// instead of Google Cast.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DlnaDevice {
|
||||
/// Human-readable name from the device's `friendlyName` element.
|
||||
pub friendly_name: String,
|
||||
/// The device description XML URL (e.g. `http://192.168.1.50:1400/desc.xml`).
|
||||
/// This, not a separately-tracked id, is what uniquely identifies a UPnP
|
||||
/// device here — UPnP itself has a UDN concept, but reading it requires
|
||||
/// enabling `rupnp`'s `full_device_spec` feature for that one field,
|
||||
/// while the description URL is already available for free and is
|
||||
/// exactly what `rupnp::Device` itself keys its own identity on.
|
||||
pub url: String,
|
||||
}
|
||||
131
breadcast-core/src/dlna/discovery.rs
Normal file
131
breadcast-core/src/dlna/discovery.rs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
23
breadcast-core/src/dlna/mod.rs
Normal file
23
breadcast-core/src/dlna/mod.rs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
//! DLNA/UPnP media-renderer casting: discovery via SSDP, control via the
|
||||
//! `AVTransport` service's SOAP actions. This is the protocol behind most
|
||||
//! non-Chromecast smart TVs (Samsung, LG/Tizen, Sony) and behind Windows'
|
||||
//! own "Cast to Device" (Win+K) flyout — a different device population
|
||||
//! than [`crate::cast_sender`]'s Cast V2/CASTV2, not an alternate path to
|
||||
//! the same devices.
|
||||
//!
|
||||
//! Reuses the rest of breadcast-core as-is: the same [`crate::capture`],
|
||||
//! [`crate::pipeline`], and [`crate::http_server`] produce the HLS stream a
|
||||
//! [`DlnaSession`] is handed a URL to — only discovery and the
|
||||
//! device-control protocol differ from the Cast path.
|
||||
|
||||
mod device;
|
||||
mod discovery;
|
||||
mod session;
|
||||
|
||||
use rupnp::ssdp::URN;
|
||||
|
||||
pub use device::DlnaDevice;
|
||||
pub use discovery::{DlnaDiscovery, DlnaDiscoveryEvent};
|
||||
pub use session::DlnaSession;
|
||||
|
||||
const AV_TRANSPORT: URN = URN::service("schemas-upnp-org", "AVTransport", 1);
|
||||
122
breadcast-core/src/dlna/session.rs
Normal file
122
breadcast-core/src/dlna/session.rs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
use anyhow::{Context, Result};
|
||||
use rupnp::Service;
|
||||
|
||||
use super::device::DlnaDevice;
|
||||
use super::AV_TRANSPORT;
|
||||
|
||||
/// A connected UPnP `AVTransport` control point for a single DLNA media
|
||||
/// renderer.
|
||||
///
|
||||
/// Unlike [`CastSession`](crate::CastSession), there is no persistent
|
||||
/// connection or background thread to manage here: every UPnP action is an
|
||||
/// independent SOAP-over-HTTP request, so `DlnaSession` is just a cheap,
|
||||
/// `Clone`-able handle to the renderer's resolved control endpoint.
|
||||
/// Concurrent `load()`/`stop()` calls are naturally safe — each is its own
|
||||
/// HTTP request — with none of the single-socket message-demultiplexing
|
||||
/// hazard `CastSession` has to run an actor thread to avoid for CASTV2.
|
||||
#[derive(Clone)]
|
||||
pub struct DlnaSession {
|
||||
device_url: rupnp::http::Uri,
|
||||
service: Service,
|
||||
}
|
||||
|
||||
impl DlnaSession {
|
||||
/// Fetches `device`'s full description and resolves its `AVTransport`
|
||||
/// service. Fails if the device no longer answers, or turns out not to
|
||||
/// expose `AVTransport` after all — shouldn't happen given discovery
|
||||
/// already searched for exactly that service, but a device's
|
||||
/// description can in principle change between being found and being
|
||||
/// connected to.
|
||||
pub async fn connect(device: &DlnaDevice) -> Result<Self> {
|
||||
let device_url: rupnp::http::Uri = device
|
||||
.url
|
||||
.parse()
|
||||
.with_context(|| format!("invalid device description URL: {}", device.url))?;
|
||||
|
||||
let full_device = rupnp::Device::from_url(device_url.clone())
|
||||
.await
|
||||
.with_context(|| format!("failed to fetch device description from {}", device.url))?;
|
||||
|
||||
let service = full_device
|
||||
.find_service(&AV_TRANSPORT)
|
||||
.with_context(|| format!("{} has no AVTransport service", device.friendly_name))?
|
||||
.clone();
|
||||
|
||||
Ok(Self { device_url, service })
|
||||
}
|
||||
|
||||
/// Sets `content_url` as the renderer's current transport URI and
|
||||
/// starts playback.
|
||||
///
|
||||
/// `content_url` is XML-escaped before being embedded in the SOAP
|
||||
/// request body — in breadcast's own usage it's always a URL this
|
||||
/// process generated itself (safe by construction), but nothing about
|
||||
/// this function's signature guarantees that stays true for every
|
||||
/// caller, and an unescaped `&` alone would produce malformed XML the
|
||||
/// renderer would reject with no useful diagnostic.
|
||||
pub async fn load(&self, content_url: &str) -> Result<()> {
|
||||
let escaped = xml_escape(content_url);
|
||||
let set_uri_payload = format!(
|
||||
"<InstanceID>0</InstanceID><CurrentURI>{escaped}</CurrentURI><CurrentURIMetaData></CurrentURIMetaData>"
|
||||
);
|
||||
self.service
|
||||
.action(&self.device_url, "SetAVTransportURI", &set_uri_payload)
|
||||
.await
|
||||
.context("SetAVTransportURI failed")?;
|
||||
|
||||
self.service
|
||||
.action(&self.device_url, "Play", "<InstanceID>0</InstanceID><Speed>1</Speed>")
|
||||
.await
|
||||
.context("Play failed")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stops playback. Unlike `CastSession::stop`, there's no persistent
|
||||
/// session or launched app to tear down — this is just the `Stop`
|
||||
/// action.
|
||||
pub async fn stop(&self) -> Result<()> {
|
||||
self.service
|
||||
.action(&self.device_url, "Stop", "<InstanceID>0</InstanceID>")
|
||||
.await
|
||||
.context("Stop failed")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Polls `GetTransportInfo` and returns the renderer's own reported
|
||||
/// `CurrentTransportState` (e.g. `"PLAYING"`, `"TRANSITIONING"`,
|
||||
/// `"STOPPED"`, `"NO_MEDIA_PRESENT"`).
|
||||
///
|
||||
/// This is a request/response poll, not a push subscription —
|
||||
/// `AVTransport` does support UPnP eventing (`Service::subscribe` in
|
||||
/// `rupnp`) for state pushed as it changes, but that needs a locally
|
||||
/// bound HTTP callback listener, which is more machinery than a status
|
||||
/// check is worth for now. A caller that wants live updates polls this
|
||||
/// on an interval instead.
|
||||
pub async fn transport_state(&self) -> Result<String> {
|
||||
let response = self
|
||||
.service
|
||||
.action(&self.device_url, "GetTransportInfo", "<InstanceID>0</InstanceID>")
|
||||
.await
|
||||
.context("GetTransportInfo failed")?;
|
||||
response
|
||||
.get("CurrentTransportState")
|
||||
.cloned()
|
||||
.context("GetTransportInfo response had no CurrentTransportState")
|
||||
}
|
||||
}
|
||||
|
||||
fn xml_escape(input: &str) -> String {
|
||||
let mut escaped = String::with_capacity(input.len());
|
||||
for c in input.chars() {
|
||||
match c {
|
||||
'&' => escaped.push_str("&"),
|
||||
'<' => escaped.push_str("<"),
|
||||
'>' => escaped.push_str(">"),
|
||||
'"' => escaped.push_str("""),
|
||||
'\'' => escaped.push_str("'"),
|
||||
other => escaped.push(other),
|
||||
}
|
||||
}
|
||||
escaped
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue