feat: Surface the provider's error cause in the media_unavailable notification (#2599)

- Closes: #2592
This commit is contained in:
Dermot Duffy
2026-07-22 20:45:49 -07:00
committed by GitHub
parent 5a549c0326
commit 448fa2d9f1
18 changed files with 255 additions and 90 deletions
@@ -28,10 +28,23 @@ export type MediaUnavailableIssueReason =
declare module 'issue' {
interface IssueTriggerContext {
media_unavailable: { targetID: string; reason: MediaUnavailableIssueReason };
media_unavailable: {
targetID: string;
reason: MediaUnavailableIssueReason;
// Free text naming the specific failure (e.g. the message a player
// reported), when the trigger source knew it.
description?: string;
};
}
}
// What is known about one target's failure.
interface TargetError {
reason: MediaUnavailableIssueReason;
description?: string;
}
const MEDIA_LOADING_TIMEOUT_SECONDS = 10;
// The per-cause presentation (localization key + icon), shared by the
@@ -75,7 +88,7 @@ export class MediaUnavailableIssue implements Issue {
public readonly key = 'media_unavailable' as const;
private _issueActive = false;
private _erroredTargets = new Map<string, MediaUnavailableIssueReason>();
private _erroredTargets = new Map<string, TargetError>();
// Timer fires when a target has been loading too long without success.
private _timer = new Timer();
@@ -107,7 +120,10 @@ export class MediaUnavailableIssue implements Issue {
// =========================================================================
public trigger(context: IssueTriggerContext['media_unavailable']): void {
this._erroredTargets.set(context.targetID, context.reason);
this._erroredTargets.set(context.targetID, {
reason: context.reason,
description: context.description,
});
}
// =========================================================================
@@ -168,11 +184,24 @@ export class MediaUnavailableIssue implements Issue {
public getNotification(): Notification {
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');
// errored. Gate on the timer still running: once it is stopped (a hard error
// on another target took over, or the view moved on), _timerTargetID lingers
// and would otherwise paint a stale "not loading" line for a target that has
// since loaded.
if (
this._timerTargetID &&
this._timer.isRunning() &&
!targets.has(this._timerTargetID)
) {
targets.set(this._timerTargetID, { reason: 'not_loading' });
}
// The free-text causes go in the context block rather than on the metadata
// lines, which stay short enough to scan when several cameras fail at once.
const context = Array.from(targets)
.filter(([, error]) => error.description)
.map(([id, error]) => `${this._getTargetName(id)}: ${error.description}`);
return {
heading: {
text: localize('issues.media_unavailable.heading'),
@@ -183,10 +212,11 @@ export class MediaUnavailableIssue implements Issue {
text: localize('issues.media_unavailable.text'),
},
...(targets.size && {
metadata: Array.from(targets).map(([id, reason]) =>
this._getTargetDetail(id, reason),
metadata: Array.from(targets).map(([id, error]) =>
this._getTargetDetail(id, error.reason),
),
}),
...(context.length && { context }),
link: {
url: TROUBLESHOOTING_MEDIA_URL,
title: localize('issues.troubleshooting_guide'),
@@ -202,15 +232,22 @@ export class MediaUnavailableIssue implements Issue {
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)}`,
text: `${this._getTargetName(id)}: ${localize(
MEDIA_UNAVAILABLE_REASONS[reason].localizationKey,
)}`,
icon: isImage ? 'mdi:image' : MEDIA_UNAVAILABLE_REASONS[reason].icon,
};
}
// The user-facing name of a target: a camera's title, or the label for the
// image view (which may have no camera behind it).
private _getTargetName(id: string): string {
return id === IMAGE_VIEW_TARGET_ID_SENTINEL
? localize('editor.image')
: this._api.getCameraManager().getCameraMetadata(id)?.title ?? id;
}
// =========================================================================
// Retry -- called by the manager to schedule a media reload.
// =========================================================================
@@ -300,7 +337,7 @@ export class MediaUnavailableIssue implements Issue {
this._timerTargetID = targetID;
this._timer.start(MEDIA_LOADING_TIMEOUT_SECONDS, () => {
// Record the error on timeout so retry() knows which epoch to bump.
this._erroredTargets.set(targetID, 'not_loading');
this._erroredTargets.set(targetID, { reason: 'not_loading' });
this._activate();
this._onChange?.();
});