fix(asset): show 'Generate proxy' CTA when an asset has a hi-res

master but no browser-playable proxy

Previously the player's "Retry processing" button only appeared for
assets in status='error'. Old recorder captures (e.g. the archived
'sRT Test_…' clips from May) live as status='archived' or 'ready' with
original_s3_key set but proxy_s3_key null. The /stream endpoint
correctly returned {url: null, reason: 'no_proxy'} for those, but the
player just showed "Preview not yet available" with no path forward —
which reads as "ingest worked, won't play, no idea why."

Two changes:

1) Capture {reason, has_source} from /stream so the UI can tell why
   playback isn't available.

2) Render a "Generate proxy" button (using the existing
   POST /assets/:id/retry endpoint, which the backend now accepts for
   any asset with original_s3_key but no proxy_s3_key) whenever the
   stream lookup returned no_proxy and the source exists. Original
   error-status retry path is preserved.

Closes the visible half of #1 — the user can now self-recover proxy-
less clips from the library without DB surgery.
This commit is contained in:
Zac Gaetano 2026-05-23 10:30:42 -04:00
parent 1afb150237
commit 7a6296585c

View file

@ -27,6 +27,10 @@ function AssetDetail({ asset, onClose }) {
// Stream / video state // Stream / video state
const [streamUrl, setStreamUrl] = React.useState(null); const [streamUrl, setStreamUrl] = React.useState(null);
const [streamType, setStreamType] = React.useState(null); const [streamType, setStreamType] = React.useState(null);
// Why the stream is unavailable: 'no_proxy' (has hi-res source, browser
// can't play it directly) or null (still loading / live / playable).
const [streamReason, setStreamReason] = React.useState(null);
const [streamHasSource, setStreamHasSource] = React.useState(false);
const [streamLoading, setStreamLoading] = React.useState(false); const [streamLoading, setStreamLoading] = React.useState(false);
const [videoDuration, setVideoDuration] = React.useState(0); const [videoDuration, setVideoDuration] = React.useState(0);
const [retrying, setRetrying] = React.useState(false); const [retrying, setRetrying] = React.useState(false);
@ -40,6 +44,8 @@ function AssetDetail({ asset, onClose }) {
if (!assetId) return; if (!assetId) return;
setStreamUrl(null); setStreamUrl(null);
setStreamType(null); setStreamType(null);
setStreamReason(null);
setStreamHasSource(false);
setVideoDuration(0); setVideoDuration(0);
setCurrentMs(0); setCurrentMs(0);
setPlaying(false); setPlaying(false);
@ -49,6 +55,10 @@ function AssetDetail({ asset, onClose }) {
if (r && r.url) { if (r && r.url) {
setStreamUrl(r.url); setStreamUrl(r.url);
setStreamType(r.type || 'mp4'); setStreamType(r.type || 'mp4');
} else if (r) {
// {url: null, reason: 'no_proxy', has_source: true|false}
setStreamReason(r.reason || null);
setStreamHasSource(!!r.has_source);
} }
}) })
.catch(function() {}) .catch(function() {})
@ -141,7 +151,9 @@ function AssetDetail({ asset, onClose }) {
if (retrying) return; if (retrying) return;
setRetrying(true); setRetrying(true);
window.ZAMPP_API.fetch('/assets/' + assetId + '/retry', { method: 'POST' }) window.ZAMPP_API.fetch('/assets/' + assetId + '/retry', { method: 'POST' })
.then(function() { window.alert('Re-queued for processing. Refresh in a moment.'); }) .then(function() {
window.alert('Re-queued for processing. The proxy worker will pick it up shortly; refresh in a minute to see the player.');
})
.catch(function(e) { window.alert('Retry failed: ' + (e.message || 'unknown error')); }) .catch(function(e) { window.alert('Retry failed: ' + (e.message || 'unknown error')); })
.finally(function() { setRetrying(false); }); .finally(function() { setRetrying(false); });
}; };
@ -216,12 +228,23 @@ function AssetDetail({ asset, onClose }) {
const visibleComments = comments.filter(function(c) { return showResolved || !c.resolved; }); const visibleComments = comments.filter(function(c) { return showResolved || !c.resolved; });
// The player overlay text reflects three states: live processing,
// explicit error, "ingested but never proxied" (the SRT-archive case),
// or generic not-ready.
const isMissingProxy = !streamUrl && streamReason === 'no_proxy' && streamHasSource;
const statusMessage = const statusMessage =
asset.status === 'processing' ? 'Processing…' : asset.status === 'processing' ? 'Processing…' :
asset.status === 'live' ? 'Live recording in progress' : asset.status === 'live' ? 'Live recording in progress' :
asset.status === 'error' ? 'Processing failed' : asset.status === 'error' ? 'Processing failed' :
isMissingProxy ? 'This clip was ingested but never got a browser-playable proxy.' :
'Preview not yet available'; 'Preview not yet available';
// "Retry processing" (for status=error) and "Generate proxy" (for
// status=ready/archived but missing proxy) both call the same /retry
// endpoint. Label depends on what the user is staring at.
const canRetry = (asset.status === 'error') || isMissingProxy;
const retryLabel = isMissingProxy ? 'Generate proxy' : 'Retry processing';
return ( return (
<div className="asset-detail fade-in"> <div className="asset-detail fade-in">
<div className="asset-detail-header"> <div className="asset-detail-header">
@ -273,23 +296,28 @@ function AssetDetail({ asset, onClose }) {
<FauxFrame seed={asset.seed || 1} /> <FauxFrame seed={asset.seed || 1} />
<div className="scanlines" /> <div className="scanlines" />
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ textAlign: 'center', color: 'var(--text-3)' }}> <div style={{ textAlign: 'center', color: 'var(--text-3)', maxWidth: 360, padding: '0 16px' }}>
{streamLoading ? ( {streamLoading ? (
<div style={{ fontSize: 13 }}>Loading</div> <div style={{ fontSize: 13 }}>Loading</div>
) : ( ) : (
<React.Fragment> <React.Fragment>
<div style={{ fontSize: 13, marginBottom: 8 }}>{statusMessage}</div> <div style={{ fontSize: 13, marginBottom: 8 }}>{statusMessage}</div>
<StatusDot status={asset.status} /> <StatusDot status={asset.status} />
{asset.status === 'error' && ( {canRetry && (
<button <button
className="btn ghost sm" className="btn primary sm"
style={{ marginTop: 12, display: 'block', margin: '12px auto 0' }} style={{ marginTop: 14, display: 'inline-flex' }}
onClick={retryProcessing} onClick={retryProcessing}
disabled={retrying} disabled={retrying}
> >
{retrying ? 'Retrying…' : 'Retry processing'} {retrying ? 'Queueing…' : retryLabel}
</button> </button>
)} )}
{isMissingProxy && (
<div style={{ fontSize: 11, color: 'var(--text-4)', marginTop: 10 }}>
The hi-res master is in storage. We just need to transcode a browser-playable copy.
</div>
)}
</React.Fragment> </React.Fragment>
)} )}
</div> </div>