feat: Automatically recover from frozen live streams (#2569)

- Closes: #2099
This commit is contained in:
Dermot Duffy
2026-07-07 21:37:24 -07:00
committed by GitHub
parent 9f956fe89f
commit fb1bbc739e
73 changed files with 3321 additions and 364 deletions
+2 -2
View File
@@ -8,8 +8,8 @@ import { ConnectionIssue } from './issues/connection';
import { EventSubscriptionIssue } from './issues/event-subscription';
import { InitializationIssue } from './issues/initialization';
import { LegacyResourceIssue } from './issues/legacy-resource';
import { MediaLoadIssue } from './issues/media-load';
import { MediaQueryIssue } from './issues/media-query';
import { MediaUnavailableIssue } from './issues/media-unavailable';
import { ViewIncompatibleIssue } from './issues/view-incompatible';
export const createIssueManager = (
@@ -32,7 +32,7 @@ export const createIssueManager = (
manager.addIssue(new InitializationIssue(api));
manager.addIssue(new LegacyResourceIssue(changeCallback));
manager.addIssue(new MediaQueryIssue(api));
manager.addIssue(new MediaLoadIssue(api, changeCallback));
manager.addIssue(new MediaUnavailableIssue(api, changeCallback));
return manager;
};
+4 -4
View File
@@ -14,10 +14,10 @@ import type {
IssueTriggerContextKey,
} from './types';
// Exponential backoff schedule for 'auto' retry. The base is set above the
// per-media retry threshold (~10s) so the issue-level backoff kicks in *after*
// lower-level recovery has had a chance to work, not in parallel with it.
export const RETRY_EXPONENTIAL_BASE_SECONDS = 30;
// Exponential backoff schedule for 'auto' retry. The first attempt fires
// quickly so recovery is prompt, then the delay doubles (up to the max) so a
// persistently-failing issue is not retried indefinitely at a tight interval.
export const RETRY_EXPONENTIAL_BASE_SECONDS = 5;
export const RETRY_EXPONENTIAL_MAX_SECONDS = 600;
// Wraps the passive IssueStateManager with reaction logic. A single
@@ -1,7 +1,7 @@
import type { Notification } from '../../../config/schema/actions/types';
import type { SubscriptionHealthInterface } from '../../../ha/connection/subscription-health-monitor';
import type { UnlistenCallback } from '../../../health';
import { localize } from '../../../localize/localize';
import type { UnsubscribeCallback } from '../../../types';
import { createRetryControl } from '../retry-control';
import type { Issue, IssueDescription } from '../types';
@@ -27,7 +27,7 @@ export class EventSubscriptionIssue implements Issue {
public readonly key = 'event_subscription' as const;
private _health: SubscriptionHealthInterface<string>;
private _unsubscribe: UnlistenCallback;
private _unsubscribe: UnsubscribeCallback;
constructor(health: SubscriptionHealthInterface<string>, changeCallback: () => void) {
this._health = health;
@@ -1,9 +1,13 @@
import type { IssueTriggerContext } from 'issue';
import type { ConditionState } from '../../../condition-trigger/conditions/types.js';
import type { Notification } from '../../../config/schema/actions/types.js';
import type {
Notification,
NotificationDetail,
} from '../../../config/schema/actions/types.js';
import { TROUBLESHOOTING_MEDIA_URL } from '../../../const.js';
import { localize } from '../../../localize/localize.js';
import type { UnsubscribeCallback } from '../../../types.js';
import { Timer } from '../../../utils/timer.js';
import { IMAGE_VIEW_TARGET_ID_SENTINEL } from '../../../view/target-id.js';
import { isAnyMediaViewName } from '../../../view/view.js';
@@ -11,19 +15,52 @@ import type { CardIssueManagerAPI } from '../../types.js';
import { createRetryControl } from '../retry-control.js';
import type { Issue, IssueDescription } from '../types.js';
// Why the media_unavailable issue fired, so the notification and the reconnecting
// placeholder can explain the specific cause rather than a generic message.
export type MediaUnavailableIssueReason =
| 'entity_unavailable'
| 'not_loading'
| 'playback_error'
| 'stalled';
declare module 'issue' {
interface IssueTriggerContext {
media_load: { targetID: string };
media_unavailable: { targetID: string; reason: MediaUnavailableIssueReason };
}
}
const MEDIA_LOADING_TIMEOUT_SECONDS = 10;
export class MediaLoadIssue implements Issue {
public readonly key = 'media_load' as const;
// The per-cause presentation (localization key + icon), shared by the
// notification metadata and the reconnecting placeholder so each cause is
// described in exactly one place.
export const MEDIA_UNAVAILABLE_REASONS: Record<
MediaUnavailableIssueReason,
{ localizationKey: string; icon: string }
> = {
entity_unavailable: {
localizationKey: 'issues.media_unavailable.reasons.entity_unavailable',
icon: 'mdi:cctv-off',
},
not_loading: {
localizationKey: 'issues.media_unavailable.reasons.not_loading',
icon: 'mdi:progress-helper',
},
playback_error: {
localizationKey: 'issues.media_unavailable.reasons.playback_error',
icon: 'mdi:alert-circle',
},
stalled: {
localizationKey: 'issues.media_unavailable.reasons.stalled',
icon: 'mdi:motion-pause',
},
};
export class MediaUnavailableIssue implements Issue {
public readonly key = 'media_unavailable' as const;
private _issueActive = false;
private _erroredTargetIDs = new Set<string>();
private _erroredTargets = new Map<string, MediaUnavailableIssueReason>();
// Timer fires when a target has been loading too long without success.
private _timer = new Timer();
@@ -31,18 +68,31 @@ export class MediaLoadIssue implements Issue {
private _api: CardIssueManagerAPI;
private _onChange: (() => void) | null;
private _unsubscribeCallback: UnsubscribeCallback;
constructor(api: CardIssueManagerAPI, onChange?: () => void) {
this._api = api;
this._onChange = onChange ?? null;
// Clear a target's error on a genuine media (re)load.
this._unsubscribeCallback = this._api
.getMediaLoadedInfoManager()
.subscribe((change) => {
// A reconnect replay (`cached`) did not actually reload the media, and
// unload / select changes are irrelevant here; only a genuine load
// clears the error.
if (change.type === 'load' && !change.cached) {
this._onMediaLoad(change.targetID);
}
});
}
// =========================================================================
// Explicit trigger -- called when a component fires an issue:trigger event.
// =========================================================================
public trigger(context: IssueTriggerContext['media_load']): void {
this._erroredTargetIDs.add(context.targetID);
public trigger(context: IssueTriggerContext['media_unavailable']): void {
this._erroredTargets.set(context.targetID, context.reason);
}
// =========================================================================
@@ -55,10 +105,29 @@ export class MediaLoadIssue implements Issue {
return;
}
// A known error for the current target activates immediately, even if its
// (frozen) media still reads as loaded (it might be loaded but then
// reported a playback error that stops playback but leaves the player
// attached). Errors are cleared out-of-band by `_onMediaLoad` on a genuine
// reload.
if (this._hasError(state)) {
this._activate();
return;
}
if (state.mediaLoadedInfo) {
this._handleMediaLoaded(state);
} else {
this._handleMediaNotLoaded(state);
// Loaded with no known error: healthy.
this._deactivate();
return;
}
this._handlePendingLoad(state);
}
// A genuine media (re)load for a target clears its error.
private _onMediaLoad(targetID: string): void {
if (this._erroredTargets.delete(targetID)) {
this._onChange?.();
}
}
@@ -82,28 +151,26 @@ export class MediaLoadIssue implements Issue {
}
public getNotification(): Notification {
const targets = new Set(this._erroredTargetIDs);
if (this._timerTargetID) {
targets.add(this._timerTargetID);
const targets = new Map(this._erroredTargets);
// The pending-load timer's target is a slow initial load that has not yet
// errored.
if (this._timerTargetID && !targets.has(this._timerTargetID)) {
targets.set(this._timerTargetID, 'not_loading');
}
return {
heading: {
text: localize('issues.media_load.heading'),
text: localize('issues.media_unavailable.heading'),
icon: 'mdi:cctv-off',
severity: 'high' as const,
},
body: {
text: localize('issues.media_load.text'),
text: localize('issues.media_unavailable.text'),
},
...(targets.size && {
metadata: Array.from(targets).map((id) => ({
text:
id === IMAGE_VIEW_TARGET_ID_SENTINEL
? localize('editor.image')
: this._api.getCameraManager().getCameraMetadata(id)?.title ?? id,
icon: id === IMAGE_VIEW_TARGET_ID_SENTINEL ? 'mdi:image' : 'mdi:cctv',
})),
metadata: Array.from(targets).map(([id, reason]) =>
this._getTargetDetail(id, reason),
),
}),
link: {
url: TROUBLESHOOTING_MEDIA_URL,
@@ -113,6 +180,22 @@ export class MediaLoadIssue implements Issue {
};
}
// A per-camera notification detail: the target's name and its specific cause
// (e.g. "Office: Stream stalled"), with that cause's icon.
private _getTargetDetail(
id: string,
reason: MediaUnavailableIssueReason,
): NotificationDetail {
const isImage = id === IMAGE_VIEW_TARGET_ID_SENTINEL;
const name = isImage
? localize('editor.image')
: this._api.getCameraManager().getCameraMetadata(id)?.title ?? id;
return {
text: `${name}: ${localize(MEDIA_UNAVAILABLE_REASONS[reason].localizationKey)}`,
icon: isImage ? 'mdi:image' : MEDIA_UNAVAILABLE_REASONS[reason].icon,
};
}
// =========================================================================
// Retry -- called by the manager to schedule a media reload.
// =========================================================================
@@ -125,7 +208,7 @@ export class MediaLoadIssue implements Issue {
// Build the set of targets to retry: all errored targets plus the
// target the pending timer was tracking (so a user-initiated retry
// works even before the timeout fires).
const retryTargets = new Set(this._erroredTargetIDs);
const retryTargets = new Set(this._erroredTargets.keys());
if (this._timerTargetID) {
retryTargets.add(this._timerTargetID);
}
@@ -140,11 +223,11 @@ export class MediaLoadIssue implements Issue {
mediaEpoch[id] = (mediaEpoch[id] ?? 0) + 1;
}
// Intentionally keep _issueActive, _erroredTargetIDs, and the pending
// Intentionally keep _issueActive, _erroredTargets, and the pending
// timer in place. The issue stays visible while the provider
// re-attempts loading underneath. If the retry succeeds,
// _handleMediaLoaded will clear everything when media:loaded fires. If
// it fails silently (e.g. bogus stream name), the error stays visible
// re-attempts loading underneath. If the retry succeeds, the fresh media
// load clears everything (_onMediaLoad drops the errored target). If it
// fails silently (e.g. bogus stream name), the error stays visible
// immediately -- no new 10s grace period.
this._api.getViewManager().setViewWithMergedContext({ mediaEpoch });
return false;
@@ -156,14 +239,19 @@ export class MediaLoadIssue implements Issue {
public reset(): void {
this._deactivate();
this._erroredTargetIDs.clear();
this._erroredTargets.clear();
}
// Stop reacting to media loads at end of life.
public destroy(): void {
this._unsubscribeCallback();
}
// Stop the pending-load timer so offscreen time doesn't count toward the
// 10s threshold. Preserve _issueActive, _erroredTargetIDs, and
// 10s threshold. Preserve _issueActive, _erroredTargets, and
// _timerTargetID: already-visible errors remain visible on reattach, and
// retaining _timerTargetID lets the existing active/target-mismatch guard
// in _handleMediaNotLoaded avoid spuriously deactivating the preserved
// in _handlePendingLoad avoid spuriously deactivating the preserved
// issue when the same target is still loading on resume. The timer is
// re-armed with a fresh window by the next detectDynamic pass.
public suspend(): void {
@@ -174,36 +262,20 @@ export class MediaLoadIssue implements Issue {
// Private helpers.
// =========================================================================
// Media loaded successfully: deactivate and clear the error for this target
// so it won't immediately re-trigger on the next evaluation.
private _handleMediaLoaded(state: ConditionState): void {
this._deactivate();
if (state.targetID) {
this._erroredTargetIDs.delete(state.targetID);
}
}
// Media not yet loaded: activate immediately if there is a known provider
// error for this target, otherwise start a timeout to detect slow loads.
private _handleMediaNotLoaded(state: ConditionState): void {
// No targetID means no provider is actively rendering media (e.g. the
// viewer shows "No media to display" instead of showing a player). Don't
// start the timeout as there's nothing to wait for.
// Media not yet loaded and no known error: start (or keep) a timeout to catch
// a slow or failed initial load. No targetID means no provider is rendering
// media (e.g. the viewer shows "No media to display"), so there's nothing to
// wait for.
private _handlePendingLoad(state: ConditionState): void {
if (!state.targetID) {
this._deactivate();
return;
}
if (this._hasError(state)) {
this._activate();
return;
}
const targetID = state.targetID;
// When the target changes to one without a known error, clear the active
// state so the new target gets its own timeout window instead of
// inheriting the previous target's error.
// When the target changes, clear the active state so the new target gets
// its own timeout window instead of inheriting the previous target's.
if (this._issueActive && this._timerTargetID !== targetID) {
this._deactivate();
}
@@ -213,9 +285,7 @@ export class MediaLoadIssue implements Issue {
this._timerTargetID = targetID;
this._timer.start(MEDIA_LOADING_TIMEOUT_SECONDS, () => {
// Record the error on timeout so retry() knows which epoch to bump.
// targetID is guaranteed non-null here -- the null case bails at the
// top of _handleMediaNotLoaded.
this._erroredTargetIDs.add(targetID);
this._erroredTargets.set(targetID, 'not_loading');
this._activate();
this._onChange?.();
});
@@ -223,7 +293,7 @@ export class MediaLoadIssue implements Issue {
}
private _hasError(state: ConditionState): boolean {
return !!state.targetID && this._erroredTargetIDs.has(state.targetID);
return !!state.targetID && this._erroredTargets.has(state.targetID);
}
private _activate(): void {
+1 -1
View File
@@ -13,8 +13,8 @@ export type IssueKey =
| 'event_subscription'
| 'initialization'
| 'legacy_resource'
| 'media_load'
| 'media_query'
| 'media_unavailable'
| 'view_incompatible';
export interface IssueDescription {