Keep http 1.5 and tolerate empty or relative service URLs in a local rupnp patch. Cover standard DLNA SOAP requests and malformed descriptions.
Avoid logger locks in fatal handlers, flush emitted log batches, and preserve CI symbols after smoke failures.
cx
rupnp parses a device description's SCPDURL / controlURL / eventSubURL as
http::uri::PathAndQuery. http 1.5.0 rewrote scan_path_and_query and now
rejects two shapes 1.4.0 accepted: an empty element (ErrorKind::Empty,
printed as "empty string") and any relative URL without a leading slash
(PathDoesNotStartWithSlash). Both are common in old UPnP firmware, so the
transitive bump made rupnp drop those renderers during discovery.
Measured in the lockfiles: v2.0.2 resolved http 1.4.0, v2.1.0 resolved
1.5.0, and nothing in qbz-cast changed between them. A KEF LSX gen 1 that
worked in 2.0.2 reported "Visible Renderers: 0" in 2.1.0 with
"Invalid response: empty string", which is exactly
InvalidResponse(InvalidUri(Empty)) reproduced by the new test.
qbz-cast takes http as a direct dependency at "=1.4.0" so cargo unifies
the workspace on it, and device.rs now uses that crate instead of the
rupnp re-export. The new integration test serves two renderer
descriptions over loopback, one with empty and one with relative service
sub-URLs, and requires both to parse: it is red on 1.5.0 and green on
1.4.0, so it fails loudly if the pin is ever lifted. test-crates and its
local mirror gain the named gate.
Closes-when-confirmed: #745
cc
A KEF LSX reached the end of every track and never advanced (#733); its
poll answers were swallowed, so an INFO log could not tell a mute renderer
from one that never reports STOPPED. The DLNA poll now logs a failed
position read, GetTransportInfo faults degrade to the last transport
command instead of failing the whole read, and a RelTime frozen inside the
end guard for five polls without STOPPED counts as end-of-track. A user
pause never counts. Pure helper with unit tests.
cc
InMemorySource::from_shared decodes an Arc<Vec<u8>> without copying and
MediaServer::register_audio_shared serves the same Arc, so the cast media
server and the cast visualizer's shadow decoder can sit on one copy of a
100 MB track.
cc
MediaServer gains a RangeSource entry whose bytes may still be arriving
(known final size, blocking reads) and serves every request on its own
thread so a slow media body no longer stalls the HEAD probe or a seek's
second Range request. Chromecast: play/pause/stop never launch the
Default Media Receiver (an app launched with nothing loaded sat IDLE and
read as "track ended"); disconnect stops the receiver app instead of
leaving it on screen with the last item.
cc
`get_media_position` returned `NotConnected` whenever no media app had
been launched yet, and the 1 Hz poll folded that into "no answer", so a
connect made while paused hit LOST_POLL_MAX and disconnected after
exactly 5 s (reproduced three times on the 2026-08-29 smoke). Add a
typed `CastError::NoMediaSession`, and while idle prove liveness with
the receiver-level `get_status` request instead of the media session.
Also coalesce cast volume: a slider drag now hands the newest level to
one sender task every 120 ms instead of one Cast/SOAP round trip per
pixel.
cc
The window loop broke on the FIRST device whose description fetch/parse
failed, silently dropping every other responder in that window — and
every subsequent window, forever, with zero log lines. One malformed
UPnP device on the LAN (router, TV, printer) could starve the whole
DLNA list. Now each error is logged and skipped, and every discovered
renderer is logged at info level (name / ip / capability flags), so the
next field report is self-diagnosing.
km
The rustls crypto provider was not installed for `qbzd`, which caused the
process to panic:
> thread 'tokio-rt-worker' (15737) panicked at [...]/rustls-0.23.37/src/crypto/mod.rs:249:14:
>
> Could not automatically determine the process-level CryptoProvider from
> Rustls crate features.
> Call CryptoProvider::install_default() before this point to select a
> provider manually, or make sure exactly one of the 'aws-lc-rs' and 'ring'
> features is enabled.
> See the documentation of the CryptoProvider type for more information.
The function `ensure_crypto_provider()` was defined in the `qbz-cast` crate
which is obviously not a dependency of `qbzd`.
This commit moves `ensure_crypto_provider()` to `qbz-app`, calls it from `qbzd`
and updates `qbz` accordingly.
Session tokens in /audio/<token>/<id> must not appear in log bundles. Port
of the remaining delta of 9aab6233 (pr/605) onto current pre-release: add
redact_media_uri() to media_server.rs and redact the URL in the three
handle_request logs (request, 404 parse, 403 token mismatch). The device.rs
side and the CSPRNG tokens already landed via PR #586.
km
DSD containers (.dsf/.dff) fell through to application/octet-stream in the
media-server MIME map, which strict renderers reject (AVTransport 714), and
the serve path read the WHOLE requested range into RAM before emitting a
single header byte — a 32/384 PCM or DSD track (0.7-1.4 GB) stalled the
response long enough for renderer fetch timeouts and blocked the
single-threaded server for every other request (the reported 'had to
restart QBZ' wedge).
- content_type_for_path + the cast_service UI mirror gain audio/x-dsf /
audio/x-dff (de-facto DLNA MIMEs; HQPlayer plays native DSF/DFF).
- open_range streams file entries straight from disk (seek + bounded Take);
headers go out immediately and RAM stays bounded. In-memory (Qobuz)
entries keep the bounded slice copy. 0-byte entries keep the old 500.
- DSD-aware cast quality label (was the nonsense '1-bit / 2822.4 kHz').
- Best-effort renderer stop() when DLNA load/play fails so it doesn't sit
half-loaded on a faulted URI.
cargo check + 7/7 qbz-cast tests (new streaming + MIME coverage).
km
The mDNS TXT id record is the Cast device UUID - stable across restarts
and renames, directly comparable to a DLNA UDN. When a device omits it,
discovery falls back to the mDNS fullname, which is derived from the
friendly name and silently detaches the moment the user renames the
speaker. The per-renderer quality cap (fix 4) must not be keyed on such
an id - a cap that silently stops applying is the failure mode #638
exists to eliminate - so DiscoveredDevice now says which case it is.
Additive field on the discovery DTO only; no behavior change for
existing consumers. Verified: the struct has a single construction site.
cc
A renderer left mid-playback by a previous session re-requests its
orphaned CurrentURI against the fresh media server (new path token ->
403 loop) and reports a stale PLAYING position. That poisons
cast_max_position and trips the premature-STOPPED end-detection guard
into auto-advancing the first cast track. Issue a best-effort AVTransport
Stop right after connect so the transport starts clean.
cc
getrandom::getrandom() was renamed to getrandom::fill() in 0.3; adopt fill() at the cast token call site. Consolidates onto the getrandom 0.4.2 that tempfile and rand already pull in.
cc
register_audio inserted every cast track's complete bytes into the
entries map and NOTHING ever removed them — not stop, not disconnect;
only app shutdown dropped the server. A DLNA session therefore grew by
one full track per auto-advance (issue #550: 50 MB at launch to
4.5 GB, never released). Three changes:
- register_audio/register_file evict every other entry — only the
track being cast is servable anyway.
- stop_if_cast and disconnect clear the entries outright, so stopping
actually releases the buffers.
- MediaSource::Data is Arc-backed: the per-request entry clone no
longer copies the whole track (it did, on every GET/HEAD/range
request), and an in-flight response keeps its own Arc so eviction
can never truncate an ongoing range request.
Fixes#550
cc
Three device-side gaps that kept strict renderers (Signalyst HQPlayer 6)
from working:
- Services were matched by the hard-coded ":1" URN, missing renderers
that only announce AVTransport/RenderingControl :2/:3. Match the
service name at any UPnP version (unit-tested; recursion over
embedded devices preserved via services_iter).
- DIDL protocolInfo advertised "*" as its 4th field; strict renderers
cross-check it against the response headers. Advertise the same
DLNA.ORG_OP/FLAGS the media server sends.
- HQPlayer clears its transport at natural end-of-track, so a bare Play
(manual button or late auto-advance) faulted 702 "No contents" or
no-op'd. play() now pre-checks an idle transport (STOPPED/
NO_MEDIA_PRESENT — PAUSED untouched so resume keeps position) and
re-asserts the last SetAVTransportURI first, plus a bounded retry net
(6 attempts, linear backoff) for raw-status 701/702 settle races.
Based on the DLNA hardening contributed in PR #547; retry-net comment
corrected against rupnp 3.0's actual error mapping (non-200 becomes
HttpErrorCode before any SOAP parse — the UPnPError arm only covers
fault-inside-200).
cc
Strict DLNA renderers (e.g. Signalyst HQPlayer 6) HEAD-probe the media
URL before playing and reject chunked bodies: tiny_http auto-selected
Transfer-Encoding: chunked (dropping Content-Length) past its 32 KiB
threshold, so those renderers played the buffered seconds and went
STOPPED. Answer HEAD with 200 + Content-Type/Accept-Ranges/DLNA
headers, pin Identity encoding with an explicit Content-Length on all
media responses (200/206/HEAD), and advertise transferMode/
contentFeatures on GET too. Range handling is unchanged. Neutral for
renderers that never HEAD-probe and ignore the extra headers.
Based on the DLNA hardening contributed in PR #547.
cc
rust_cast opens a rustls TLS channel to the Chromecast. In the full binary both
the aws-lc-rs and ring providers are enabled (reqwest pulls ring, rust_cast pulls
aws-lc-rs), so rustls can't auto-select one and the worker thread panicked with
"Could not automatically determine the process-level CryptoProvider" — surfacing
as "Connection error: Thread response error".
Add qbz_cast::ensure_crypto_provider() (installs aws-lc-rs once, idempotent) and
call it at the top of ChromecastHandle::connect and DlnaConnection::connect.
cc
Additive, backward-compatible enhancements to the shared cast crate (the
Tauri shims are pure re-exports, so existing callers are unaffected).
- DLNA timeouts: pause/stop/seek/set_volume now run through a shared
run_action() with a 10s timeout (was none) so a hung renderer can't block
the caller forever. New DlnaError::Timeout / CastError::Timeout variants.
- DLNA mute: add set_mute / get_mute (RenderingControl SetMute/GetMute,
Master channel) — was missing alongside set_volume.
- DLNA capability detection is now version-agnostic (matches AVTransport:2/:3,
not only :1) so newer renderers report has_av_transport correctly.
- Media server endpoint auth: served path is now /audio/<token>/<id> with a
128-bit per-session token (RandomState-seeded, no new dep). Token lives in
the PATH, not a query string, so DLNA renderers that strip queries still
authenticate. Mismatch -> 403.
- register_file content-type parity: content_type_for_path now mirrors the
rich local-cast MIME map (audio/x-ape, mp3/ogg/opus/aac, m4a/alac->audio/mp4)
instead of the poorer table; add register_file_with_content_type so the
caller can supply an exact MIME. Local files stream from disk (no full-RAM
read) per decision D4.
Unit-tested: tokened-path parsing, rich MIME map, token randomness.
cc
Google Cast closes a control connection that goes roughly 10 seconds without a heartbeat PING. QBZ pinged every 25 seconds, so any cast connection left idle between connect and load — for example while the track downloads, or across a delayed second play attempt — was dropped by the receiver. The next LOAD then failed with EPIPE (broken pipe): the device chimed on connect but never played audio, and the heartbeat thread logged repeating broken-pipe warnings.
Pinging every 5s is the cadence the Cast protocol expects and keeps the connection alive through those idle gaps.
Fixes#439.
cc
AirPlay was scaffolded in both crates/qbz-cast/src/airplay and
src-tauri/src/cast/airplay but never worked at runtime: the RAOP
sender required an Apple MFi-licensed stack with no free/open
alternative, and the backend state was already disabled via the
commented `.manage(airplay_state)` line in src-tauri/src/lib.rs.
The CastPicker UI already hid the AirPlay tab pending streaming
support that was never going to ship.
Delete:
- crates/qbz-cast/src/airplay/ (entire directory)
- src-tauri/src/cast/airplay/ (entire directory)
- 10x v2_airplay_* commands from commands_v2/library.rs + their
tauri::generate_handler entries
- AirPlayError from qbz-cast errors
- 'airplay' from CastProtocol + every case arm in castStore.ts,
CastPicker.svelte, AudioOutputBadges.svelte
- Corresponding i18n keys if present
Zero behavior change for Chromecast / DLNA / QConnect / MPRIS. If
AirPlay ever becomes feasible we'll revisit from scratch with a
real streaming backend.
cc
- Upgrade rust_cast 0.18.1 → 0.21 (switches from openssl to rustls,
removing the openssl-sys dependency that blocked x86_64 cross-compile)
- Split mupdf sys-lib features to Linux-only (on macOS there are no GTK
symbol conflicts, and system libs don't support x86_64 cross-compile
from Apple Silicon)
- Update release-macos workflow to build both aarch64 and x86_64 DMGs
using a matrix strategy
Extract casting functionality (Chromecast, AirPlay, DLNA) to standalone
qbz-cast crate. This crate is Tauri-agnostic and can be used by TUI/CLI
clients.
- Move device connection, discovery, threading, and media server to crate
- Keep only Tauri commands in src-tauri/src/cast/
- Thin re-exports in src-tauri for backwards compatibility
- Remove duplicate dependencies from src-tauri Cargo.toml