fix(cast): survive a DLNA renderer that never reports STOPPED

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
This commit is contained in:
vicrodh committed 2026-09-02 20:34:58 -06:00
1 parent 7a311e504f
commit ca0aa0e6bf
2 files changed
+150 -12

No files matched your search

+49 -9
View File
@@ -58,6 +58,9 @@ pub struct DlnaConnection {
// it false so the pre-check runs.
uri_freshly_set: bool,
is_playing: bool,
// Consecutive GetTransportInfo failures while GetPositionInfo still
// answers; only the first and every 60th are logged (the poll is 1 Hz).
transport_query_failures: std::sync::atomic::AtomicU32,
}
impl DlnaConnection {
@@ -112,6 +115,7 @@ impl DlnaConnection {
last_volume_sent: None,
uri_freshly_set: false,
is_playing: false,
transport_query_failures: std::sync::atomic::AtomicU32::new(0),
})
}
@@ -548,8 +552,11 @@ impl DlnaConnection {
.map_err(|_| DlnaError::Playback("GetPositionInfo timed out".to_string()))?
.map_err(|e| DlnaError::Playback(e.to_string()))?;
// Get transport state
let transport_response = tokio::time::timeout(
// Get transport state. A renderer that answers GetPositionInfo but
// faults on GetTransportInfo (#733: a KEF LSX that also faults on Stop
// and Seek) is alive, so this degrades to `UNKNOWN` instead of failing
// the whole read — the caller keeps the position and decides on it.
let transport_response = match tokio::time::timeout(
std::time::Duration::from_secs(5),
av_service.action(
&self.device_url,
@@ -558,8 +565,21 @@ impl DlnaConnection {
),
)
.await
.map_err(|_| DlnaError::Playback("GetTransportInfo timed out".to_string()))?
.map_err(|e| DlnaError::Playback(e.to_string()))?;
{
Ok(Ok(response)) => {
self.transport_query_failures
.store(0, std::sync::atomic::Ordering::Relaxed);
Some(response)
}
Ok(Err(e)) => {
self.note_transport_query_failure(&e.to_string());
None
}
Err(_) => {
self.note_transport_query_failure("timed out");
None
}
};
// Parse RelTime (position) - format: "HH:MM:SS" or "H:MM:SS"
let rel_time = position_response
@@ -575,11 +595,19 @@ impl DlnaConnection {
.unwrap_or("0:00:00");
let duration_secs = parse_time_string(track_duration);
// Get transport state (PLAYING, PAUSED_PLAYBACK, STOPPED, etc.)
let transport_state = transport_response
.get("CurrentTransportState")
.map(|s| s.to_string())
.unwrap_or_else(|| "UNKNOWN".to_string());
// Get transport state (PLAYING, PAUSED_PLAYBACK, STOPPED, etc.). When
// the query itself faulted, fall back to the last transport command
// QBZ sent: the position keeps flowing and the end-stall rule in the
// poll takes over if it freezes at the end.
let transport_state = match transport_response
.as_ref()
.and_then(|r| r.get("CurrentTransportState"))
{
Some(state) => state.to_string(),
None if transport_response.is_some() => "UNKNOWN".to_string(),
None if self.is_playing => "PLAYING".to_string(),
None => "PAUSED_PLAYBACK".to_string(),
};
Ok(DlnaPositionInfo {
position_secs,
@@ -587,6 +615,18 @@ impl DlnaConnection {
transport_state,
})
}
fn note_transport_query_failure(&self, reason: &str) {
let n = self
.transport_query_failures
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
+ 1;
if n == 1 || n % 60 == 0 {
log::warn!(
"DLNA: GetTransportInfo failed ({n} in a row, position still answers): {reason}"
);
}
}
}
/// Parse time string "HH:MM:SS" or "H:MM:SS" to seconds