diff --git a/public/css/style.css b/public/css/style.css index 28de710..56e144a 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -653,6 +653,14 @@ header p { transform: translateY(-1px); } +.stop-btn { + background: #dc2626; +} + +.stop-btn:hover { + background: #b91c1c; +} + .play-btn:disabled { opacity: 0.65; background: rgba(37, 99, 235, 0.5); diff --git a/public/index.html b/public/index.html index 75af97f..f21e7f7 100644 --- a/public/index.html +++ b/public/index.html @@ -102,6 +102,7 @@
video.mp4
+ diff --git a/public/js/main.js b/public/js/main.js index 549f997..1ac690f 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -19,7 +19,7 @@ document.addEventListener('DOMContentLoaded', () => { const nowPlaying = document.getElementById('now-playing'); const currentVideoTitle = document.getElementById('current-video-title'); const transcodeBtn = document.getElementById('transcode-btn'); - const playBtn = document.getElementById('play-btn'); + const stopTranscodeBtn = document.getElementById('stop-transcode-btn'); const stopTranscodeBtn = document.getElementById('stop-transcode-btn'); const playBtn = document.getElementById('play-btn'); const progressInfo = document.getElementById('progress-info'); const progressText = document.getElementById('progress-text'); const progressFill = document.getElementById('progress-fill'); @@ -114,6 +114,11 @@ document.addEventListener('DOMContentLoaded', () => { progressInfo.classList.add('hidden'); progressText.textContent = ''; progressFill.style.width = '0%'; + if (stopTranscodeBtn) { + stopTranscodeBtn.classList.add('hidden'); + stopTranscodeBtn.disabled = false; + stopTranscodeBtn.textContent = 'Stop Transcode'; + } }; const setAuthHeaders = (username, password) => { @@ -403,6 +408,11 @@ document.addEventListener('DOMContentLoaded', () => { transcodeBtn.textContent = 'Start Transcode'; transcodeBtn.classList.remove('hidden'); } + if (stopTranscodeBtn) { + stopTranscodeBtn.classList.add('hidden'); + stopTranscodeBtn.disabled = false; + stopTranscodeBtn.textContent = 'Stop Transcode'; + } if (playBtn) { playBtn.classList.add('hidden'); } @@ -422,6 +432,11 @@ document.addEventListener('DOMContentLoaded', () => { transcodeBtn.disabled = true; transcodeBtn.textContent = 'Starting...'; } + if (stopTranscodeBtn) { + stopTranscodeBtn.classList.remove('hidden'); + stopTranscodeBtn.disabled = false; + stopTranscodeBtn.textContent = 'Stop Transcode'; + } stopPolling(); transcodingOverlay.classList.remove('hidden'); setProgress({ status: 'Starting download...', percent: 0, details: 'Starting download...' }); @@ -448,6 +463,38 @@ document.addEventListener('DOMContentLoaded', () => { } }; + const stopTranscode = async () => { + if (!currentVideoKey || !stopTranscodeBtn) return; + stopTranscodeBtn.disabled = true; + stopTranscodeBtn.textContent = 'Stopping...'; + + try { + const res = await fetch('/api/stop-transcode', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key: currentVideoKey }) + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Failed to stop transcode'); + + setProgress({ status: 'cancelled', percent: 0, details: 'Transcode stopped' }); + if (transcodeBtn) { + transcodeBtn.disabled = false; + transcodeBtn.textContent = 'Start Transcode'; + transcodeBtn.classList.remove('hidden'); + } + stopTranscodeBtn.classList.add('hidden'); + } catch (err) { + console.error('Stop transcode failed:', err); + alert(`Stop transcode failed: ${err.message}`); + } finally { + if (stopTranscodeBtn) { + stopTranscodeBtn.disabled = false; + stopTranscodeBtn.textContent = 'Stop Transcode'; + } + } + }; + // Poll the backend to check if the generated MP4 file is accessible const pollForMp4Ready = (key, mp4Url) => { let attempts = 0; @@ -490,6 +537,9 @@ document.addEventListener('DOMContentLoaded', () => { transcodingOverlay.classList.add('hidden'); videoPlayer.classList.remove('hidden'); playBtn.classList.add('hidden'); + if (stopTranscodeBtn) { + stopTranscodeBtn.classList.add('hidden'); + } resetProgress(); videoPlayer.src = url; @@ -511,6 +561,9 @@ document.addEventListener('DOMContentLoaded', () => { if (clearTranscodeCacheBtn) { clearTranscodeCacheBtn.addEventListener('click', clearTranscodeCache); } + if (stopTranscodeBtn) { + stopTranscodeBtn.addEventListener('click', stopTranscode); + } if (loginBtn) { loginBtn.addEventListener('click', login); } diff --git a/server.js b/server.js index 670813d..5f3a3f4 100644 --- a/server.js +++ b/server.js @@ -59,6 +59,7 @@ const createS3Client = (credentials) => { }; const progressMap = {}; +const transcodeProcesses = new Map(); const wsSubscriptions = new Map(); const getProgressKey = (key) => key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_')).join('/'); @@ -187,6 +188,14 @@ const clearMp4Cache = () => { }; const clearTranscodeCache = () => { + for (const command of transcodeProcesses.values()) { + try { + if (typeof command.kill === 'function') { + command.kill('SIGKILL'); + } + } catch (_) {} + } + transcodeProcesses.clear(); clearMp4Cache(); Object.keys(progressMap).forEach((key) => delete progressMap[key]); }; @@ -287,6 +296,42 @@ app.post('/api/clear-transcode-cache', (req, res) => { } }); +app.post('/api/stop-transcode', (req, res) => { + try { + const { key } = req.body; + if (!key) { + return res.status(400).json({ error: 'Video key is required' }); + } + + const progressKey = getProgressKey(key); + const command = transcodeProcesses.get(progressKey); + if (!command) { + return res.status(404).json({ error: 'No active transcode found for this key' }); + } + + try { + if (typeof command.kill === 'function') { + command.kill('SIGKILL'); + } + } catch (killError) { + console.warn('Failed to kill transcode process:', killError); + } + + transcodeProcesses.delete(progressKey); + progressMap[progressKey] = { + status: 'cancelled', + percent: 0, + details: 'Transcode stopped by user', + mp4Url: `/mp4/${progressKey}/video.mp4` + }; + broadcastWs(progressKey, { type: 'progress', key, progress: progressMap[progressKey] }); + res.json({ message: 'Transcode stopped' }); + } catch (error) { + console.error('Error stopping transcode:', error); + res.status(500).json({ error: 'Failed to stop transcode', detail: error.message }); + } +}); + // Endpoint to transcode S3 video streaming to MP4 app.post('/api/transcode', async (req, res) => { const { bucket, key, codec, encoder } = req.body; @@ -320,6 +365,7 @@ app.post('/api/transcode', async (req, res) => { const mp4Url = `/mp4/${progressKey}/video.mp4`; progressMap[progressKey] = { status: 'pending', percent: 0, details: 'Waiting for ffmpeg to start', mp4Url }; + let currentFfmpegCommand = null; // If it already exists, just return the URL if (fs.existsSync(mp4Path)) { @@ -396,6 +442,8 @@ app.post('/api/transcode', async (req, res) => { .videoCodec(encoderName) .audioCodec('aac') .outputOptions(createFfmpegOptions(encoderName)); + transcodeProcesses.set(progressKey, command); + currentFfmpegCommand = command; if (/_vaapi$/.test(encoderName)) { command .inputOptions(['-vaapi_device', '/dev/dri/renderD128']) @@ -435,6 +483,7 @@ app.post('/api/transcode', async (req, res) => { console.error(`Output verification failed for ${mp4Path}:`, verifyError); progressState = { status: 'failed', percent: progressMap[progressKey]?.percent || 0, details: `Output verification failed: ${verifyError.message}`, mp4Url }; } + transcodeProcesses.delete(progressKey); progressMap[progressKey] = progressState; broadcastWs(progressKey, { type: 'progress', key, progress: progressState }); if (progressState.status === 'finished') { @@ -445,6 +494,7 @@ app.post('/api/transcode', async (req, res) => { const errMessage = err?.message || ''; const isHardwareFailure = !attemptedSoftwareFallback && encoderName !== codecMap.software[safeCodec] && shouldRetryWithSoftware(errMessage); if (isHardwareFailure) { + transcodeProcesses.delete(progressKey); attemptedSoftwareFallback = true; console.warn(`Hardware encoder failed for ${key}; retrying with software encoder`, errMessage); try { @@ -465,6 +515,7 @@ app.post('/api/transcode', async (req, res) => { } cleanupTmpInput(); + transcodeProcesses.delete(progressKey); console.error(`Error transcoding ${key}:`, err); const failedState = { status: 'failed', percent: progressMap[progressKey]?.percent || 0, details: err.message || 'Transcoding failed', mp4Url }; progressMap[progressKey] = failedState;