Retire cancelled playback sessions and preserve VOD content time

Adapted from MikaelKW/nodecast-tv-plus playback lease work beginning at 43a58c7685f541498663b827c4b9d6b3753e7202. Reject superseded requests, release on cancellation, bound live restart attempts and await FFmpeg close before deleting cache.

Co-authored-by: Mikael <mikaelkwessel@gmail.com>
This commit is contained in:
Technomancer702
2026-10-12 17:11:13 -07:00
parent 1f665dcb11
commit e30978d1e2
12 changed files with 728 additions and 220 deletions

View File

@ -0,0 +1,52 @@
(function (root) {
const cancelled = () => Object.assign(new Error('Playback was superseded'), { name: 'AbortError' });
class PlaybackSessions {
constructor(isLive) {
this.isLive = isLive;
this.leaseId = null;
this.sequence = 0;
this.controller = null;
this.timer = null;
root.addEventListener?.('pagehide', () => this.stop());
}
async create(url, options) {
this.leaseId ||= root.crypto.randomUUID?.() || Array.from(root.crypto.getRandomValues(new Uint8Array(16)),
byte => byte.toString(16).padStart(2, '0')).join('');
const leaseId = this.leaseId, sequence = ++this.sequence;
this.controller?.abort();
const controller = this.controller = new AbortController();
const response = await fetch('/api/transcode/session', {
method: 'POST', credentials: 'same-origin', signal: controller.signal,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...options, url, isLive: this.isLive, playbackLeaseId: leaseId, requestSequence: sequence })
});
const session = await response.json();
if (controller.signal.aborted || leaseId !== this.leaseId || sequence !== this.sequence) {
if (session.sessionId) fetch(`/api/transcode/${session.sessionId}`, { method: 'DELETE', keepalive: true }).catch(() => {});
throw cancelled();
}
if (!response.ok) throw new Error(session.error || 'Unable to start playback');
this.timer ||= setInterval(() => {
if (this.leaseId) fetch('/api/transcode/lease/heartbeat', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ playbackLeaseId: this.leaseId })
}).catch(() => {});
}, 30000);
return session;
}
stop() {
const leaseId = this.leaseId;
this.leaseId = null;
this.sequence++;
this.controller?.abort();
this.controller = null;
clearInterval(this.timer);
this.timer = null;
if (leaseId) fetch('/api/transcode/lease', {
method: 'DELETE', keepalive: true, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ playbackLeaseId: leaseId })
}).catch(() => {});
}
}
root.PlaybackSessions = PlaybackSessions;
}(typeof window === 'object' ? window : globalThis));

View File

@ -813,38 +813,29 @@ class VideoPlayer {
* Start a HLS transcode session
*/
async startTranscodeSession(url, options = {}) {
const generation = this.playbackGeneration;
this.playbackSessions ||= new PlaybackSessions(true);
const sessionOptions = options;
try {
console.log('[Player] Starting HLS transcode session...', options);
const res = await fetch('/api/transcode/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, ...options })
});
if (!res.ok) throw new Error('Failed to start session');
const session = await res.json();
const session = await this.playbackSessions.create(url, sessionOptions);
if (generation !== this.playbackGeneration) return null;
this.currentSessionId = session.sessionId;
return session.playlistUrl;
} catch (err) {
console.error('[Player] Session start failed:', err);
// Fallback to direct transcode if session fails
return `/api/transcode?url=${encodeURIComponent(url)}`;
} catch (error) {
if (error.name !== 'AbortError' && generation === this.playbackGeneration) {
this.stopTranscodeSession();
console.error('Unable to start playback:', error.message);
this.showError('Unable to start transcoding. Please try again.');
}
return null;
}
}
/**
* Stop and cleanup current transcode session
*/
async stopTranscodeSession() {
if (this.currentSessionId) {
console.log('[Player] Stopping transcode session:', this.currentSessionId);
try {
// Fire and forget cleanup
fetch(`/api/transcode/${this.currentSessionId}`, { method: 'DELETE' });
} catch (err) {
console.error('Failed to stop session:', err);
}
this.currentSessionId = null;
}
stopTranscodeSession() {
this.playbackSessions?.stop();
this.currentSessionId = null;
}
/**
@ -920,6 +911,7 @@ class VideoPlayer {
const statusMode = this.settings.upscaleEnabled ? 'upscaling' : 'transcoding';
this.updateTranscodeStatus(statusMode, statusText);
if (this.playbackGeneration !== playbackGeneration) return;
const playlistUrl = await this.startTranscodeSession(streamUrl, {
videoMode,
videoCodec: info.video,
@ -927,6 +919,7 @@ class VideoPlayer {
audioChannels: info.audioChannels,
videoHeight: info.height
});
if (!playlistUrl || this.playbackGeneration !== playbackGeneration) return;
this.currentUrl = playlistUrl; // Update currentUrl for HLS reload
this.playHls(playlistUrl);
@ -960,48 +953,20 @@ class VideoPlayer {
}
}
if (this.playbackGeneration !== playbackGeneration) return;
// CHECK: Force Video Transcode (Full) or Upscaling
if (this.settings.forceVideoTranscode || this.settings.upscaleEnabled) {
const statusText = this.settings.upscaleEnabled ? 'Upscaling' : 'Transcoding (Video)';
const statusMode = this.settings.upscaleEnabled ? 'upscaling' : 'transcoding';
console.log(`[Player] ${statusText} enabled. Starting session (encode)...`);
this.updateTranscodeStatus(statusMode, statusText);
if (this.playbackGeneration !== playbackGeneration) return;
const playlistUrl = await this.startTranscodeSession(streamUrl, { videoMode: 'encode' });
if (!playlistUrl || this.playbackGeneration !== playbackGeneration) return;
this.currentUrl = playlistUrl;
// Load HLS
this.updateNowPlaying(channel, 'Transcoding (Video)');
// ... (rest is same logic flow, simplified by just falling through to playHls call if I refactored)
// But for minimize drift, I'll copy the block logic for HLS playback init
// Actually, I can just fall through if I set looksLikeHls = true?
// No, play logic is sequential.
if (Hls.isSupported()) {
// Start HLS
// ... this repeats code. I should probably just set currentUrl and let HLS block handle?
// But HLS block is lower down.
// I will just execute the HLS init here as before.
// Actually, easiest way is to re-assign streamUrl and goto start? No.
// Copy existing forceTranscode block logic
if (this.hls) {
this.hls.destroy();
}
this.hls = new Hls();
this.hls.loadSource(playlistUrl);
this.hls.attachMedia(this.video);
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
this.video.play().catch(console.error);
});
// Handle errors
this.hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) {
console.log('[Player] HLS fatal error');
this.hls.destroy();
}
});
return; // Exit
}
this.playHls(playlistUrl);
return;
}
// CHECK: Force Audio Transcode (Copy Video) - legacy forceTranscode setting
@ -1014,10 +979,13 @@ class VideoPlayer {
try {
const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}`);
const info = await probeRes.json();
videoCodec = info.video;
if (this.playbackGeneration !== playbackGeneration) return;
videoCodec = info.video;
} catch (e) { console.warn('Probe failed for force audio, assuming h264'); }
if (this.playbackGeneration !== playbackGeneration) return;
const playlistUrl = await this.startTranscodeSession(streamUrl, { videoMode: 'copy', videoCodec });
if (!playlistUrl || this.playbackGeneration !== playbackGeneration) return;
this.currentUrl = playlistUrl;
console.log('[Player] Playing transcoded HLS stream:', playlistUrl);
@ -1210,20 +1178,24 @@ class VideoPlayer {
}
this.hls = new Hls(this.getHlsConfig());
const hls = this.hls, generation = this.playbackGeneration;
this.hls.loadSource(url);
this.hls.attachMedia(this.video);
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
if (this.hls !== hls || this.playbackGeneration !== generation) return;
this.video.play().catch(e => {
if (e.name !== 'AbortError') console.log('Autoplay prevented:', e);
});
});
this.hls.on(Hls.Events.ERROR, (event, data) => {
if (this.hls !== hls || this.playbackGeneration !== generation) return;
if (data.fatal) {
// Simple error handling for forced HLS/transcode modes
console.error('Fatal HLS error in transcode mode:', data);
this.hls.destroy();
this.stopTranscodeSession();
}
});
}