M9 diagnostics follow-ups: playback relabel, sort, connected fix, per-skip + track-identity #105

Merged
bvandeusen merged 4 commits from dev into main 2026-06-30 19:19:15 -04:00
7 changed files with 233 additions and 97 deletions
@@ -102,6 +102,7 @@ class DiagnosticsReporter @Inject constructor(
launch { collectServerHealth() } launch { collectServerHealth() }
launch { collectUpnpDrops() } launch { collectUpnpDrops() }
launch { collectPlayerState() } launch { collectPlayerState() }
launch { collectTrackChanges() }
launch { collectRoutes() } launch { collectRoutes() }
launch { heartbeatLoop() } launch { heartbeatLoop() }
} }
@@ -147,11 +148,13 @@ class DiagnosticsReporter @Inject constructor(
} }
private suspend fun collectPlayerState() { private suspend fun collectPlayerState() {
// 'playback', not 'upnp_sync' — player state applies to every output
// route (phone speaker, Bluetooth, UPnP), not just casting.
playerController.uiState playerController.uiState
.map { Triple(it.currentSource, it.isUpnpLoading, it.playbackError) } .map { Triple(it.currentSource, it.isUpnpLoading, it.playbackError) }
.distinctUntilChanged() .distinctUntilChanged()
.collect { (source, upnpLoading, err) -> .collect { (source, upnpLoading, err) ->
record("upnp_sync", buildJsonObject { record("playback", buildJsonObject {
put("event", "player_state") put("event", "player_state")
put("source", source ?: "") put("source", source ?: "")
put("upnp_loading", upnpLoading) put("upnp_loading", upnpLoading)
@@ -160,15 +163,44 @@ class DiagnosticsReporter @Inject constructor(
} }
} }
// Emit a snapshot at each track/queue-index change — captures a
// skip-induced local↔Sonos desync at the INSTANT it happens, which the
// 45s heartbeat misses. Note: uiState is a conflated StateFlow, so a
// very rapid skip burst may coalesce intermediate indices (we still get
// the boundaries + the local-vs-Sonos snapshot).
private suspend fun collectTrackChanges() {
playerController.uiState
.map { it.queueIndex to it.currentTrack?.id }
.distinctUntilChanged()
.collect { (index, _) ->
val ui = playerController.uiState.value
val casting = outputPicker.routesState.value.current.protocol !=
OutputRoute.Protocol.SYSTEM
record("playback", buildJsonObject {
put("event", "track_change")
put("local_index", index)
// Track IDENTITY, not just index — indices wobble across
// re-casts, so the id + Sonos URI make a desync unambiguous.
put("local_track_id", ui.currentTrack?.id ?: "")
put("local_pos_ms", ui.positionMs)
putSonos(this, casting)
put("upnp_loading", ui.isUpnpLoading)
put("server_health", networkStatus.state.value.name)
put("route", outputPicker.routesState.value.current.name)
})
}
}
private suspend fun collectRoutes() { private suspend fun collectRoutes() {
// 'playback' — route changes happen for all outputs. This only ever
// logs the ACTIVE route (routesState.current), so no "connected" flag.
outputPicker.routesState.map { it.current }.distinctUntilChanged().collect { r -> outputPicker.routesState.map { it.current }.distinctUntilChanged().collect { r ->
record("upnp_sync", buildJsonObject { record("playback", buildJsonObject {
put("event", "route") put("event", "route")
put("id", r.id) put("id", r.id)
put("name", r.name) put("name", r.name)
put("kind", r.kind.name) put("kind", r.kind.name)
put("protocol", r.protocol.name) put("protocol", r.protocol.name)
put("connected", r.isConnected)
}) })
} }
} }
@@ -186,13 +218,11 @@ class DiagnosticsReporter @Inject constructor(
put("is_playing", ui.isPlaying) put("is_playing", ui.isPlaying)
put("source", ui.currentSource ?: "") put("source", ui.currentSource ?: "")
put("local_index", ui.queueIndex) put("local_index", ui.queueIndex)
put("local_track_id", ui.currentTrack?.id ?: "")
put("local_pos_ms", ui.positionMs) put("local_pos_ms", ui.positionMs)
put("route", route.name) put("route", route.name)
put("route_protocol", route.protocol.name) put("route_protocol", route.protocol.name)
// UPnP/Sonos remote-vs-local — the desync signal. putSonos(this, routeActive)
put("sonos_track", remoteState.trackNumber)
put("sonos_pos_ms", remoteState.positionMs)
put("sonos_playing", remoteState.isPlaying)
addPowerFields(this) addPowerFields(this)
}) })
} }
@@ -257,6 +287,18 @@ class DiagnosticsReporter @Inject constructor(
addPowerFields(this) addPowerFields(this)
} }
// Sonos/UPnP remote-vs-local desync fields. Only meaningful while a
// remote route is active; zeroed otherwise so a stale RemotePlayerState
// from a just-ended cast can't masquerade as live Sonos data (the
// cast→phone handoff artifact). currentTrackUri is the desync ground
// truth — it carries the track the speaker is actually streaming.
private fun putSonos(builder: kotlinx.serialization.json.JsonObjectBuilder, casting: Boolean) {
builder.put("sonos_track", if (casting) remoteState.trackNumber else 0)
builder.put("sonos_pos_ms", if (casting) remoteState.positionMs else 0)
builder.put("sonos_playing", casting && remoteState.isPlaying)
if (casting) builder.put("sonos_uri", remoteState.currentTrackUri)
}
private fun addPowerFields(builder: kotlinx.serialization.json.JsonObjectBuilder) { private fun addPowerFields(builder: kotlinx.serialization.json.JsonObjectBuilder) {
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
builder.put("doze", pm.isDeviceIdleMode) builder.put("doze", pm.isDeviceIdleMode)
@@ -133,7 +133,7 @@ private fun RouteRow(
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
val subtitle = route.description ?: defaultSubtitle(route) val subtitle = route.description ?: defaultSubtitle(route, isSelected)
Text( Text(
text = subtitle, text = subtitle,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -198,10 +198,13 @@ private fun MulticastHintRow() {
} }
} }
private fun defaultSubtitle(route: OutputRoute): String = when (route.kind) { // isSelected (route == the active/selected route) drives the Bluetooth
// "Connected" subtitle. RouteInfo.connectionState can't — it stays
// DISCONNECTED for local SYSTEM routes even when they're in use.
private fun defaultSubtitle(route: OutputRoute, isSelected: Boolean): String = when (route.kind) {
OutputRoute.Kind.BuiltIn -> "Phone speaker" OutputRoute.Kind.BuiltIn -> "Phone speaker"
OutputRoute.Kind.Wired -> "Wired" OutputRoute.Kind.Wired -> "Wired"
OutputRoute.Kind.Bluetooth -> if (route.isConnected) "Connected" else "Available" OutputRoute.Kind.Bluetooth -> if (isSelected) "Connected" else "Available"
OutputRoute.Kind.Cast -> "Cast" OutputRoute.Kind.Cast -> "Cast"
OutputRoute.Kind.Other -> "Available" OutputRoute.Kind.Other -> "Available"
} }
@@ -21,7 +21,6 @@ data class OutputRoute(
val description: String?, val description: String?,
val kind: Kind, val kind: Kind,
val protocol: Protocol, val protocol: Protocol,
val isConnected: Boolean,
) { ) {
enum class Kind { BuiltIn, Wired, Bluetooth, Cast, Other } enum class Kind { BuiltIn, Wired, Bluetooth, Cast, Other }
@@ -43,9 +42,11 @@ data class OutputRoute(
/** /**
* Lift a MediaRouter [route] into the domain model. Kind is * Lift a MediaRouter [route] into the domain model. Kind is
* inferred from [MediaRouter.RouteInfo.getDeviceType]; unknown * inferred from [MediaRouter.RouteInfo.getDeviceType]; unknown
* device types fall through to [Kind.Other]. The * device types fall through to [Kind.Other]. "Active" is NOT an
* `connectionState` proxy is good enough for the chip's * intrinsic of the route — the caller derives it by comparing to
* "Connected"/"Available" subtitle. * the selected route (RouteSnapshot.current), because
* RouteInfo.connectionState only reflects remote-route handshakes
* and stays DISCONNECTED for local SYSTEM routes even when in use.
*/ */
fun fromRouteInfo(route: MediaRouter.RouteInfo): OutputRoute { fun fromRouteInfo(route: MediaRouter.RouteInfo): OutputRoute {
val kind = when (route.deviceType) { val kind = when (route.deviceType) {
@@ -58,15 +59,12 @@ data class OutputRoute(
MediaRouter.RouteInfo.DEVICE_TYPE_SPEAKER -> Kind.Other MediaRouter.RouteInfo.DEVICE_TYPE_SPEAKER -> Kind.Other
else -> Kind.Other else -> Kind.Other
} }
val connected =
route.connectionState == MediaRouter.RouteInfo.CONNECTION_STATE_CONNECTED
return OutputRoute( return OutputRoute(
id = route.id, id = route.id,
name = route.name, name = route.name,
description = route.description, description = route.description,
kind = kind, kind = kind,
protocol = Protocol.SYSTEM, protocol = Protocol.SYSTEM,
isConnected = connected,
) )
} }
@@ -76,10 +74,10 @@ data class OutputRoute(
* network speakers into the same `OutputPickerController` * network speakers into the same `OutputPickerController`
* routes stream the system routes come through. * routes stream the system routes come through.
* *
* `isConnected = false` because UPnP devices have no * UPnP devices have no MediaRouter connection-state concept —
* MediaRouter connection-state concept — they're always * they're always "available" on the LAN, and the picker's
* "available" on the LAN, and the picker's selected-route * selected-route rendering handles the "currently playing"
* rendering handles the "currently playing" indicator. * indicator.
* *
* Subtitle is `manufacturer modelName` joined by a single * Subtitle is `manufacturer modelName` joined by a single
* space, falling back to "Network speaker" when both fields * space, falling back to "Network speaker" when both fields
@@ -96,7 +94,6 @@ data class OutputRoute(
description = description, description = description,
kind = Kind.Other, kind = Kind.Other,
protocol = Protocol.UPNP, protocol = Protocol.UPNP,
isConnected = false,
) )
} }
} }
+2 -1
View File
@@ -21,7 +21,8 @@ import (
// variants do NOT require a new category here. // variants do NOT require a new category here.
var validDiagnosticKinds = map[string]struct{}{ var validDiagnosticKinds = map[string]struct{}{
"connectivity": {}, "connectivity": {},
"upnp_sync": {}, "upnp_sync": {}, // genuinely UPnP/Sonos-specific (drops, resync)
"playback": {}, // route + player-state, any output (phone/BT/UPnP)
"power": {}, "power": {},
"lifecycle": {}, "lifecycle": {},
"heartbeat": {}, "heartbeat": {},
@@ -0,0 +1,9 @@
-- Drop rows using the new value first so they don't violate the restored
-- (narrower) constraint, then revert the whitelist.
DELETE FROM diagnostic_events WHERE kind = 'playback';
ALTER TABLE diagnostic_events DROP CONSTRAINT diagnostic_events_kind_check;
ALTER TABLE diagnostic_events ADD CONSTRAINT diagnostic_events_kind_check
CHECK (kind IN (
'connectivity', 'upnp_sync', 'power', 'lifecycle',
'heartbeat', 'http'
));
@@ -0,0 +1,19 @@
-- Add 'playback' to the diagnostic_events kind whitelist (M9 follow-up).
--
-- `route` and `player_state` events fire for EVERY output route (phone
-- speaker, Bluetooth, UPnP/Sonos), so bucketing them under 'upnp_sync'
-- was misleading — an operator on Bluetooth earbuds saw "upnp_sync"
-- rows. 'upnp_sync' now means genuinely UPnP/Sonos-specific signal
-- (drops, and future resync/desync); general playback + route telemetry
-- moves to the new 'playback' kind.
--
-- Per the enum-CHECK-whitelist rule, the new value lands by dropping and
-- re-adding the constraint in the same change. Existing rows tagged
-- 'upnp_sync' stay as-is (debug telemetry on a 30-day retention).
ALTER TABLE diagnostic_events DROP CONSTRAINT diagnostic_events_kind_check;
ALTER TABLE diagnostic_events ADD CONSTRAINT diagnostic_events_kind_check
CHECK (kind IN (
'connectivity', 'upnp_sync', 'power', 'lifecycle',
'heartbeat', 'http', 'playback'
));
+139 -74
View File
@@ -22,11 +22,20 @@
const client = useQueryClient(); const client = useQueryClient();
const KINDS = ['connectivity', 'upnp_sync', 'power', 'lifecycle', 'heartbeat', 'http'] as const; const KINDS = [
'connectivity',
'playback',
'upnp_sync',
'power',
'lifecycle',
'heartbeat',
'http'
] as const;
function kindLabel(k: string): string { function kindLabel(k: string): string {
switch (k) { switch (k) {
case 'connectivity': return 'Connectivity'; case 'connectivity': return 'Connectivity';
case 'playback': return 'Playback';
case 'upnp_sync': return 'UPnP sync'; case 'upnp_sync': return 'UPnP sync';
case 'power': return 'Power'; case 'power': return 'Power';
case 'lifecycle': return 'Lifecycle'; case 'lifecycle': return 'Lifecycle';
@@ -36,13 +45,26 @@
} }
} }
// Default view = the most recent N events, no time window. The
// start/end window is an Advanced affordance.
const DEFAULT_LIMIT = 500;
// Filter state. // Filter state.
let accountId = $state(''); let accountId = $state('');
let clientId = $state(''); let clientId = $state('');
let kind = $state(''); let kind = $state('');
let fromLocal = $state(''); let fromLocal = $state('');
let toLocal = $state(''); let toLocal = $state('');
let limit = $state(500); let limit = $state(DEFAULT_LIMIT);
// True when an explicit time window is set (drives the caption wording).
const hasWindow = $derived(Boolean(fromLocal || toLocal));
function resetWindow() {
fromLocal = '';
toLocal = '';
limit = DEFAULT_LIMIT;
}
// datetime-local (browser-local, no tz) → RFC3339 UTC the API accepts. // datetime-local (browser-local, no tz) → RFC3339 UTC the API accepts.
function toRfc(v: string): string | undefined { function toRfc(v: string): string | undefined {
@@ -69,10 +91,17 @@
const devicesQuery = $derived($devicesStore); const devicesQuery = $derived($devicesStore);
const devices = $derived(devicesQuery.data ?? []); const devices = $derived(devicesQuery.data ?? []);
// Display sort. The API returns newest-first; default the view to that
// (most recent at the top), with an Oldest-first option for reading a
// timeline top-to-bottom. Export follows whatever's displayed.
let sortOrder = $state<'newest' | 'oldest'>('newest');
const diagStore = $derived(createAdminDiagnosticsQuery(filter)); const diagStore = $derived(createAdminDiagnosticsQuery(filter));
const diagQuery = $derived($diagStore); const diagQuery = $derived($diagStore);
// API returns newest-first; reverse to chronological for a readable timeline. const rows = $derived.by(() => {
const rows = $derived([...((diagQuery.data ?? []) as AdminDiagnostic[])].reverse()); const data = [...((diagQuery.data ?? []) as AdminDiagnostic[])]; // newest-first
return sortOrder === 'oldest' ? data.reverse() : data;
});
function fmtTime(iso: string): string { function fmtTime(iso: string): string {
const d = new Date(iso); const d = new Date(iso);
@@ -191,77 +220,107 @@
</div> </div>
</section> </section>
<!-- Filters --> <!-- Filters. Default view = most recent {DEFAULT_LIMIT} events, no time
<section class="flex flex-wrap items-end gap-3"> window; the start/end window lives under Advanced. -->
<label class="block text-xs text-text-secondary"> <section class="space-y-3">
Device <div class="flex flex-wrap items-end gap-3">
<select <label class="block text-xs text-text-secondary">
bind:value={clientId} Device
class="mt-1 block w-48 rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary" <select
> bind:value={clientId}
<option value="">All devices</option> class="mt-1 block w-48 rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
{#each devices as d (d.client_id)} >
<option value={d.client_id}>{d.client_id.slice(0, 12)} · {d.event_count}</option> <option value="">All devices</option>
{/each} {#each devices as d (d.client_id)}
</select> <option value={d.client_id}>{d.client_id.slice(0, 12)} · {d.event_count}</option>
</label> {/each}
<label class="block text-xs text-text-secondary"> </select>
Kind </label>
<select <label class="block text-xs text-text-secondary">
bind:value={kind} Kind
class="mt-1 block w-40 rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary" <select
> bind:value={kind}
<option value="">All kinds</option> class="mt-1 block w-40 rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
{#each KINDS as k (k)} >
<option value={k}>{kindLabel(k)}</option> <option value="">All kinds</option>
{/each} {#each KINDS as k (k)}
</select> <option value={k}>{kindLabel(k)}</option>
</label> {/each}
<label class="block text-xs text-text-secondary"> </select>
From </label>
<input <label class="block text-xs text-text-secondary">
type="datetime-local" Sort
bind:value={fromLocal} <select
class="mt-1 block rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary" bind:value={sortOrder}
/> class="mt-1 block w-36 rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
</label> >
<label class="block text-xs text-text-secondary"> <option value="newest">Newest first</option>
To <option value="oldest">Oldest first</option>
<input </select>
type="datetime-local" </label>
bind:value={toLocal}
class="mt-1 block rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
/>
</label>
<label class="block text-xs text-text-secondary">
Limit
<input
type="number"
min="1"
max="5000"
bind:value={limit}
class="mt-1 block w-24 rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
/>
</label>
<div class="ml-auto flex gap-2"> <div class="ml-auto flex gap-2">
<button <button
type="button" type="button"
onclick={onCopyJson} onclick={onCopyJson}
disabled={rows.length === 0} disabled={rows.length === 0}
class="inline-flex items-center gap-1.5 rounded border border-border px-3 py-2 text-sm text-text-primary hover:bg-surface-hover disabled:opacity-50" class="inline-flex items-center gap-1.5 rounded border border-border px-3 py-2 text-sm text-text-primary hover:bg-surface-hover disabled:opacity-50"
> >
<Copy size={15} /> Copy JSON <Copy size={15} /> Copy JSON
</button> </button>
<button <button
type="button" type="button"
onclick={onDownloadNdjson} onclick={onDownloadNdjson}
disabled={rows.length === 0} disabled={rows.length === 0}
class="inline-flex items-center gap-1.5 rounded border border-border px-3 py-2 text-sm text-text-primary hover:bg-surface-hover disabled:opacity-50" class="inline-flex items-center gap-1.5 rounded border border-border px-3 py-2 text-sm text-text-primary hover:bg-surface-hover disabled:opacity-50"
> >
<Download size={15} /> Download <Download size={15} /> Download
</button> </button>
</div>
</div> </div>
<!-- Advanced: explicit start/end window + row cap. Collapsed by
default so the common case (recent N) needs no interaction. -->
<details class="rounded-md border border-border" open={hasWindow}>
<summary class="cursor-pointer px-3 py-2 text-xs text-text-secondary">
Advanced filters{hasWindow ? ' · window active' : ''}
</summary>
<div class="flex flex-wrap items-end gap-3 border-t border-border px-3 py-3">
<label class="block text-xs text-text-secondary">
From
<input
type="datetime-local"
bind:value={fromLocal}
class="mt-1 block rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
/>
</label>
<label class="block text-xs text-text-secondary">
To
<input
type="datetime-local"
bind:value={toLocal}
class="mt-1 block rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
/>
</label>
<label class="block text-xs text-text-secondary">
Limit
<input
type="number"
min="1"
max="5000"
bind:value={limit}
class="mt-1 block w-24 rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary"
/>
</label>
<button
type="button"
onclick={resetWindow}
class="rounded border border-border px-3 py-2 text-sm text-text-secondary hover:bg-surface-hover"
>
Reset to recent {DEFAULT_LIMIT}
</button>
</div>
</details>
</section> </section>
<!-- Timeline --> <!-- Timeline -->
@@ -276,7 +335,13 @@
</p> </p>
{:else} {:else}
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-xs text-text-muted">{rows.length} events (oldest first)</span> <span class="text-xs text-text-muted">
{#if hasWindow}
{rows.length} events in window ({sortOrder === 'newest' ? 'newest' : 'oldest'} first)
{:else}
Most recent {rows.length} events ({sortOrder === 'newest' ? 'newest' : 'oldest'} first) · set a time window under Advanced
{/if}
</span>
</div> </div>
<ul class="divide-y divide-border rounded-md border border-border font-mono text-xs"> <ul class="divide-y divide-border rounded-md border border-border font-mono text-xs">
{#each rows as r (r.id)} {#each rows as r (r.id)}