Preserve playback position on watchdog recovery
This commit is contained in:
105
server/index.js
105
server/index.js
@@ -46,6 +46,20 @@ const MAX_YT_DLP_OUTPUT_BYTES = 8 * 1024 * 1024;
|
||||
const RELAY_BRANCH_PAUSE_BYTES = Math.floor(MAX_RELAY_BRANCH_QUEUE_BYTES / 2);
|
||||
const JPEG_SOI = Buffer.from([0xff, 0xd8]);
|
||||
const JPEG_EOI = Buffer.from([0xff, 0xd9]);
|
||||
const BEST_EFFORT_RESUME_MAX_SECONDS = 30 * 24 * 60 * 60;
|
||||
const RECORDED_MEDIA_EXTENSIONS = new Set([
|
||||
'.avi',
|
||||
'.flv',
|
||||
'.m4v',
|
||||
'.mkv',
|
||||
'.mov',
|
||||
'.mp4',
|
||||
'.mpeg',
|
||||
'.mpg',
|
||||
'.ogv',
|
||||
'.webm',
|
||||
'.wmv',
|
||||
]);
|
||||
|
||||
const defaults = {
|
||||
fps: clampInteger(process.env.DEFAULT_FPS, 24, 1, 30),
|
||||
@@ -169,8 +183,10 @@ app.post('/api/session', async (request, response) => {
|
||||
options,
|
||||
duration: null,
|
||||
metadataStatus,
|
||||
metadataProbePromise: null,
|
||||
seekSeconds: 0,
|
||||
seekGeneration: 0,
|
||||
forceSplitPlayback: false,
|
||||
createdAt: Date.now(),
|
||||
lastUsedAt: Date.now(),
|
||||
});
|
||||
@@ -201,7 +217,7 @@ app.get('/api/session/:sessionId', (request, response) => {
|
||||
response.json(formatSessionPayload(session));
|
||||
});
|
||||
|
||||
app.post('/api/session/:sessionId/seek', (request, response) => {
|
||||
app.post('/api/session/:sessionId/seek', async (request, response) => {
|
||||
const session = getSession(request.params.sessionId);
|
||||
|
||||
if (!session) {
|
||||
@@ -209,11 +225,6 @@ app.post('/api/session/:sessionId/seek', (request, response) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isSessionSeekable(session)) {
|
||||
response.status(409).json({ error: 'This stream is not seekable.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedSeconds = Number(request.body?.time);
|
||||
|
||||
if (!Number.isFinite(requestedSeconds)) {
|
||||
@@ -221,21 +232,42 @@ app.post('/api/session/:sessionId/seek', (request, response) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const seekSeconds = clampNumber(requestedSeconds, 0, session.duration);
|
||||
const allowBestEffort = request.body?.allowBestEffort === true;
|
||||
|
||||
if (!isSessionSeekable(session) && allowBestEffort && session.metadataStatus !== 'unavailable') {
|
||||
await ensureSessionDuration(session);
|
||||
}
|
||||
|
||||
const canSeekExactly = isSessionSeekable(session);
|
||||
const canResumeBestEffort = allowBestEffort && canBestEffortResumeWithoutDuration(session);
|
||||
|
||||
if (!canSeekExactly && !canResumeBestEffort) {
|
||||
response.status(409).json({ error: 'This stream is not seekable.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const seekSeconds = canSeekExactly
|
||||
? clampNumber(requestedSeconds, 0, session.duration)
|
||||
: clampNumber(requestedSeconds, 0, BEST_EFFORT_RESUME_MAX_SECONDS);
|
||||
const playback = playbacks.get(session.id);
|
||||
const stopReason = allowBestEffort ? 'client_recovery_seek' : 'client_seek';
|
||||
|
||||
session.seekSeconds = seekSeconds;
|
||||
session.seekGeneration += 1;
|
||||
session.lastUsedAt = Date.now();
|
||||
|
||||
if (!canSeekExactly && canResumeBestEffort && PLAYBACK_CONNECTION_MODE === 'relay') {
|
||||
session.forceSplitPlayback = true;
|
||||
}
|
||||
|
||||
if (playback && !playback.closed) {
|
||||
playback.stop('client_seek');
|
||||
playback.stop(stopReason);
|
||||
if (playbacks.get(session.id) === playback) {
|
||||
playbacks.delete(session.id);
|
||||
}
|
||||
}
|
||||
|
||||
logInfo(`session seeked id=${shortId(session.id)} time=${seekSeconds.toFixed(3)} duration=${session.duration.toFixed(3)}`);
|
||||
logInfo(`session seeked id=${shortId(session.id)} reason=${stopReason} time=${seekSeconds.toFixed(3)} duration=${Number.isFinite(session.duration) ? session.duration.toFixed(3) : 'unknown'} exact=${canSeekExactly}`);
|
||||
response.json(formatSessionPayload(session));
|
||||
});
|
||||
|
||||
@@ -701,7 +733,26 @@ function hasRecordedDuration(session) {
|
||||
return Number.isFinite(session.duration) && session.duration > 0;
|
||||
}
|
||||
|
||||
function canBestEffortResumeWithoutDuration(session) {
|
||||
return session.sourceKind === 'youtube'
|
||||
|| isLikelyRecordedMediaUrl(session.originalUrl)
|
||||
|| isLikelyRecordedMediaUrl(session.url);
|
||||
}
|
||||
|
||||
function isLikelyRecordedMediaUrl(value) {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return RECORDED_MEDIA_EXTENSIONS.has(path.extname(parsed.pathname).toLowerCase());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionPlaybackConnectionMode(session) {
|
||||
if (session.forceSplitPlayback) {
|
||||
return 'split';
|
||||
}
|
||||
|
||||
if (PLAYBACK_CONNECTION_MODE === 'relay' && hasRecordedDuration(session)) {
|
||||
return 'split';
|
||||
}
|
||||
@@ -710,26 +761,48 @@ function getSessionPlaybackConnectionMode(session) {
|
||||
}
|
||||
|
||||
function startSessionMetadataProbe(session) {
|
||||
void probeSessionDuration(session).then((duration) => {
|
||||
void ensureSessionDuration(session);
|
||||
}
|
||||
|
||||
function ensureSessionDuration(session) {
|
||||
if (hasRecordedDuration(session)) {
|
||||
return Promise.resolve(session.duration);
|
||||
}
|
||||
|
||||
if (session.metadataProbePromise) {
|
||||
return session.metadataProbePromise;
|
||||
}
|
||||
|
||||
session.metadataStatus = 'pending';
|
||||
|
||||
const probePromise = probeSessionDuration(session).then((duration) => {
|
||||
const current = sessions.get(session.id);
|
||||
|
||||
if (current !== session) {
|
||||
return;
|
||||
return duration;
|
||||
}
|
||||
|
||||
session.duration = duration;
|
||||
session.metadataStatus = Number.isFinite(duration) ? 'ready' : 'unavailable';
|
||||
return duration;
|
||||
}).catch((error) => {
|
||||
const current = sessions.get(session.id);
|
||||
|
||||
if (current !== session) {
|
||||
return;
|
||||
if (current === session) {
|
||||
session.duration = null;
|
||||
session.metadataStatus = 'unavailable';
|
||||
logWarn(`duration probe failed id=${shortId(session.id)} error=${oneLine(error.message)}`);
|
||||
}
|
||||
|
||||
session.duration = null;
|
||||
session.metadataStatus = 'unavailable';
|
||||
logWarn(`duration probe failed id=${shortId(session.id)} error=${oneLine(error.message)}`);
|
||||
return null;
|
||||
}).finally(() => {
|
||||
if (session.metadataProbePromise === probePromise) {
|
||||
session.metadataProbePromise = null;
|
||||
}
|
||||
});
|
||||
|
||||
session.metadataProbePromise = probePromise;
|
||||
return probePromise;
|
||||
}
|
||||
|
||||
async function probeSessionDuration(session) {
|
||||
|
||||
Reference in New Issue
Block a user