diff --git a/.env.example b/.env.example index ace6996..d33d5d8 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,7 @@ S3_FORCE_PATH_STYLE=true # Application Title APP_TITLE=S3 Media Transcoder + +# Session Cache (Valkey / Redis) +VALKEY_URL=redis://localhost:6379 +VALKEY_DB=0 diff --git a/README.md b/README.md index 6eb973c..0f13302 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ To properly test and run this project, you will need to prepare your environment 1. **Install Node.js & FFmpeg**: - Ensure Node.js (v18+) is installed. - Install **FFmpeg** on your system and make sure it is available in your PATH environment variable. The Node.js library `fluent-ffmpeg` requires it. + - If you plan to use Rockchip hardware encoding, make sure your FFmpeg build includes `h264_rkmpp` / `hevc_rkmpp` support and the device has the Rockchip MPP runtime available. + - The service always uses Jellyfin FFmpeg by default: `/usr/lib/jellyfin-ffmpeg/ffmpeg` and `/usr/lib/jellyfin-ffmpeg/ffprobe`. You can override them with `JELLYFIN_FFMPEG_PATH` and `JELLYFIN_FFPROBE_PATH`. 2. **AWS S3 / MinIO Configuration**: - Modify the `.env` file (copy from `.env.example`). diff --git a/ecosystem.config.js b/ecosystem.config.js index 834cb8d..80a679e 100644 --- a/ecosystem.config.js +++ b/ecosystem.config.js @@ -17,7 +17,9 @@ module.exports = { S3_BUCKET_ADDRESS: 'https://s3.example.com/your_bucket_name', S3_ENDPOINT: 'http://127.0.0.1:9000', S3_FORCE_PATH_STYLE: 'true', - APP_TITLE: 'S3 Media Transcoder' + APP_TITLE: 'S3 Media Transcoder', + VALKEY_URL: 'redis://localhost:6379', + VALKEY_DB: 0 } } ] diff --git a/package.json b/package.json index 3ffb383..b16f8bf 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "dotenv": "^16.4.5", "express": "^4.19.2", "fluent-ffmpeg": "^2.1.3", + "ioredis": "^5.3.0", "ws": "^8.13.0" } -} +} \ No newline at end of file diff --git a/public/css/style.css b/public/css/style.css index a6e8646..b52f419 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -10,6 +10,17 @@ --font-main: 'Inter', sans-serif; } +:root[data-theme="dark"] { + --bg-dark: #0f172a; + --panel-bg: rgba(30, 41, 59, 0.85); + --panel-border: rgba(148, 163, 184, 0.15); + --text-primary: #f8fafc; + --text-secondary: #94a3b8; + --accent: #3b82f6; + --accent-hover: #60a5fa; + --accent-glow: rgba(59, 130, 246, 0.35); +} + * { margin: 0; padding: 0; @@ -109,7 +120,7 @@ header p { align-items: flex-start; } -.dashboard > * { +.dashboard>* { min-width: 0; } @@ -247,10 +258,37 @@ header p { margin-bottom: 1.5rem; color: var(--text-primary); font-size: 1.25rem; - font-weight: 700; display: flex; align-items: center; - justify-content: flex-start; + justify-content: space-between; +} + +.banner-title { + font-weight: 700; +} + +.user-controls { + display: flex; + align-items: center; + gap: 1.5rem; + font-size: 0.95rem; + font-weight: 500; +} + +.user-info { + display: flex; + align-items: center; + gap: 0.75rem; +} + +#theme-selector { + background: var(--panel-bg); + color: var(--text-primary); + border: 1px solid var(--panel-border); + padding: 0.35rem 0.5rem; + border-radius: 6px; + font-size: 0.9rem; + outline: none; } .top-banner.hidden { @@ -699,8 +737,15 @@ header p { } @keyframes statusPulse { - 0%, 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.35); } - 50% { box-shadow: 0 0 0 10px rgba(34, 197, 94, 0); } + + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.35); + } + + 50% { + box-shadow: 0 0 0 10px rgba(34, 197, 94, 0); + } } .control-btn { @@ -907,8 +952,15 @@ header p { } @keyframes phaseIn { - from { opacity: 0; transform: translateY(12px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(12px); + } + + to { + opacity: 1; + transform: translateY(0); + } } .phase-container h3 { @@ -940,8 +992,15 @@ header p { } @keyframes pulse-download { - 0%, 100% { box-shadow: 0 0 0 0 rgba(6, 182, 212, 0.3); } - 50% { box-shadow: 0 0 0 12px rgba(6, 182, 212, 0); } + + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(6, 182, 212, 0.3); + } + + 50% { + box-shadow: 0 0 0 12px rgba(6, 182, 212, 0); + } } .phase-icon.transcode-icon { @@ -951,8 +1010,15 @@ header p { } @keyframes pulse-transcode { - 0%, 100% { box-shadow: 0 0 0 0 rgba(139, 92, 246, 0.3); } - 50% { box-shadow: 0 0 0 12px rgba(139, 92, 246, 0); } + + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(139, 92, 246, 0.3); + } + + 50% { + box-shadow: 0 0 0 12px rgba(139, 92, 246, 0); + } } /* Transcode stats */ @@ -1089,4 +1155,4 @@ header p { .control-seek { min-width: 220px; } -} +} \ No newline at end of file diff --git a/public/index.html b/public/index.html index 2809741..82de861 100644 --- a/public/index.html +++ b/public/index.html @@ -1,5 +1,6 @@ + @@ -8,8 +9,9 @@ - + +
@@ -34,7 +36,21 @@
- - -
-
- - + +
@@ -88,7 +91,13 @@
- + + + + +

正在从 S3 下载源文件...

准备下载...

@@ -102,7 +111,12 @@
- + - - + + - + + \ No newline at end of file diff --git a/public/js/main.js b/public/js/main.js index 8edaaa2..c042307 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -10,7 +10,6 @@ document.addEventListener('DOMContentLoaded', () => { const loginPasswordInput = document.getElementById('login-password'); const loginBtn = document.getElementById('login-btn'); const loginError = document.getElementById('login-error'); - const codecSelect = document.getElementById('codec-select'); const encoderSelect = document.getElementById('encoder-select'); const playerOverlay = document.getElementById('player-overlay'); const transcodingOverlay = document.getElementById('transcoding-overlay'); @@ -20,6 +19,10 @@ document.addEventListener('DOMContentLoaded', () => { const currentVideoTitle = document.getElementById('current-video-title'); const transcodeBtn = document.getElementById('transcode-btn'); const stopTranscodeBtn = document.getElementById('stop-transcode-btn'); + const themeSelector = document.getElementById('theme-selector'); + const currentUsername = document.getElementById('current-username'); + const logoutBtn = document.getElementById('logout-btn'); + const topBannerTitle = document.getElementById('top-banner-title'); const playBtn = document.getElementById('play-btn'); const topBanner = document.getElementById('top-banner'); const customControls = document.getElementById('custom-controls'); @@ -59,6 +62,7 @@ document.addEventListener('DOMContentLoaded', () => { const seekTotalTime = document.getElementById('seek-total-time'); const seekingOverlay = document.getElementById('seeking-overlay'); + let hlsInstance = null; let currentPollInterval = null; let selectedBucket = null; let selectedKey = null; @@ -84,12 +88,13 @@ document.addEventListener('DOMContentLoaded', () => { let controlsPointerReleaseTimeout = null; if (videoPlayer) { - videoPlayer.controls = false; + videoPlayer.controls = true; } if (playbackSpeed) { videoPlayer.playbackRate = parseFloat(playbackSpeed.value) || 1; } + const formatBytes = (bytes) => { if (!bytes || bytes === 0) return '0 B'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; @@ -114,16 +119,36 @@ document.addEventListener('DOMContentLoaded', () => { const createStreamSessionId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; - const buildStreamUrl = (targetSeconds = null) => { - const codec = codecSelect?.value || 'h264'; - const encoder = encoderSelect?.value || 'software'; - const streamSessionId = createStreamSessionId(); - let streamUrl = `/api/stream?bucket=${encodeURIComponent(selectedBucket)}&key=${encodeURIComponent(selectedKey)}&codec=${encodeURIComponent(codec)}&encoder=${encodeURIComponent(encoder)}&streamSessionId=${encodeURIComponent(streamSessionId)}`; - if (typeof targetSeconds === 'number' && Number.isFinite(targetSeconds) && targetSeconds > 0) { - streamUrl += `&ss=${targetSeconds}`; + const populateSelect = (selectEl, options, fallbackValue) => { + if (!selectEl) return; + selectEl.innerHTML = ''; + (options || []).forEach((item) => { + const option = document.createElement('option'); + option.value = item.value; + option.textContent = item.label || item.value; + selectEl.appendChild(option); + }); + + const preferredValue = options?.some((item) => item.value === fallbackValue) + ? fallbackValue + : options?.[0]?.value; + + if (preferredValue) { + selectEl.value = preferredValue; + } + }; + + const buildHlsPlaylistUrl = () => { + const decoder = 'auto'; + const encoder = encoderSelect?.value || 'h264_rkmpp'; + let streamUrl = `/api/hls/playlist.m3u8?bucket=${encodeURIComponent(selectedBucket)}&key=${encodeURIComponent(selectedKey)}&decoder=${encodeURIComponent(decoder)}&encoder=${encodeURIComponent(encoder)}`; + const sessionId = localStorage.getItem('sessionId'); + if (sessionId) { + streamUrl += `&sessionId=${encodeURIComponent(sessionId)}`; + } else { + if (s3Username) streamUrl += `&username=${encodeURIComponent(s3Username)}`; + if (s3Password) streamUrl += `&password=${encodeURIComponent(s3Password)}`; } - if (s3Username) streamUrl += `&username=${encodeURIComponent(s3Username)}`; - if (s3Password) streamUrl += `&password=${encodeURIComponent(s3Password)}`; return streamUrl; }; @@ -400,36 +425,9 @@ document.addEventListener('DOMContentLoaded', () => { }); videoPlayer.addEventListener('volumechange', updateVolumeControls); - videoPlayer.addEventListener('click', () => { - if (!isStreamActive) return; - if (videoPlayer.paused) { - videoPlayer.play().catch(() => {}); - return; - } - videoPlayer.pause(); - }); - // Browsers usually cannot seek inside a live fragmented MP4 stream. - // When the user drags the native video controls, remap that action to our server-side seek flow. - videoPlayer.addEventListener('seeking', () => { - if (internalSeeking || !isStreamActive || videoDuration <= 0) return; - - const requestedAbsoluteTime = Math.max(0, Math.min(seekOffset + (videoPlayer.currentTime || 0), videoDuration - 0.5)); - const drift = Math.abs(requestedAbsoluteTime - lastAbsolutePlaybackTime); - - if (drift < 1) { - return; - } - - internalSeeking = true; - setPlaybackStatus('Seeking', 'seeking'); - seekToTime(requestedAbsoluteTime); - setTimeout(() => { - internalSeeking = false; - }, 500); - }); // Seek bar interaction: click or drag const getSeekRatio = (e) => { @@ -531,46 +529,7 @@ document.addEventListener('DOMContentLoaded', () => { }); } - const seekToTime = (targetSeconds) => { - if (!selectedKey || !selectedBucket || videoDuration <= 0) return; - targetSeconds = Math.max(0, Math.min(targetSeconds, videoDuration - 0.5)); - seekOffset = targetSeconds; - isStreamActive = false; - lastAbsolutePlaybackTime = targetSeconds; - updateSeekBarPosition(targetSeconds); - // Show seeking indicator - if (seekingOverlay) seekingOverlay.classList.remove('hidden'); - setPlaybackStatus('Seeking', 'seeking'); - revealPlaybackChrome(); - if (pendingSeekTimeout) { - clearTimeout(pendingSeekTimeout); - pendingSeekTimeout = null; - } - - // Build new stream URL with ss parameter - const streamUrl = buildStreamUrl(targetSeconds); - - // Changing src automatically aborts the previous HTTP request, - // which triggers res.on('close') on the server, killing the old ffmpeg process - videoPlayer.src = streamUrl; - videoPlayer.load(); - - const onCanPlay = () => { - if (seekingOverlay) seekingOverlay.classList.add('hidden'); - lastAbsolutePlaybackTime = targetSeconds; - videoPlayer.play().catch(() => {}); - updatePlayControls(); - videoPlayer.removeEventListener('canplay', onCanPlay); - }; - videoPlayer.addEventListener('canplay', onCanPlay, { once: true }); - - // Timeout fallback: hide seeking overlay after 8s even if canplay doesn't fire - pendingSeekTimeout = setTimeout(() => { - if (seekingOverlay) seekingOverlay.classList.add('hidden'); - pendingSeekTimeout = null; - }, 8000); - }; // --- End custom seek bar --- @@ -581,9 +540,10 @@ document.addEventListener('DOMContentLoaded', () => { if (!res.ok) throw new Error('Failed to load config'); const data = await res.json(); const title = data.title || 'S3 Media Transcoder'; - topBanner.textContent = title; + if (topBannerTitle) topBannerTitle.textContent = title; topBanner.classList.remove('hidden'); document.title = title; + populateSelect(encoderSelect, data.videoEncoders || [], data.defaultVideoEncoder || 'h264_rkmpp'); } catch (err) { console.error('Config load failed:', err); } @@ -621,6 +581,21 @@ document.addEventListener('DOMContentLoaded', () => { if (s3Password) s3AuthHeaders['X-S3-Password'] = s3Password; }; + const setSessionAuth = (sessionId, username) => { + s3AuthHeaders = { 'X-Session-ID': sessionId }; + localStorage.setItem('sessionId', sessionId); + if (username) { + localStorage.setItem('username', username); + if (currentUsername) currentUsername.textContent = username; + } + }; + + const clearSessionAuth = () => { + s3AuthHeaders = {}; + localStorage.removeItem('sessionId'); + localStorage.removeItem('username'); + }; + const showLogin = () => { if (loginScreen) loginScreen.classList.remove('hidden'); if (appContainer) appContainer.classList.add('hidden'); @@ -667,16 +642,19 @@ document.addEventListener('DOMContentLoaded', () => { clearLoginError(); try { - setAuthHeaders(username, password); - const res = await fetch('/api/buckets', { headers: s3AuthHeaders }); + const res = await fetch('/api/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }) + }); + const data = await res.json(); if (!res.ok) { - const data = await res.json().catch(() => ({})); throw new Error(data.error || 'Login failed'); } - const data = await res.json(); if (!Array.isArray(data.buckets) || data.buckets.length === 0) { throw new Error('No buckets available for this account'); } + setSessionAuth(data.sessionId, data.username); renderBuckets(data.buckets); showApp(); selectedBucket = data.buckets[0].Name; @@ -687,7 +665,7 @@ document.addEventListener('DOMContentLoaded', () => { } catch (err) { console.error('Login error:', err); showLoginError(err.message); - setAuthHeaders('', ''); + clearSessionAuth(); } finally { if (loginBtn) { loginBtn.disabled = false; @@ -852,6 +830,10 @@ document.addEventListener('DOMContentLoaded', () => { videoPlayer.classList.add('hidden'); videoPlayer.pause(); videoPlayer.removeAttribute('src'); + if (hlsInstance) { + hlsInstance.destroy(); + hlsInstance = null; + } videoPlayer.load(); isStreamActive = false; videoDuration = 0; @@ -908,25 +890,58 @@ document.addEventListener('DOMContentLoaded', () => { transcodingOverlay.classList.remove('hidden'); try { - const codec = codecSelect?.value || 'h264'; - const encoder = encoderSelect?.value || 'software'; if (!selectedBucket) throw new Error('No bucket selected'); - const streamUrl = buildStreamUrl(); - videoPlayer.src = streamUrl; - videoPlayer.load(); - videoPlayer.addEventListener('loadedmetadata', () => { - transcodingOverlay.classList.add('hidden'); - videoPlayer.classList.remove('hidden'); - isStreamActive = true; - lastAbsolutePlaybackTime = seekOffset; - showSeekBar(); - showCustomControls(); - updateSeekBarPosition(seekOffset); - updatePlayControls(); - updateVolumeControls(); - updateFullscreenControls(); - schedulePlaybackChromeHide(); - }, { once: true }); + const streamUrl = buildHlsPlaylistUrl(); + if (hlsInstance) { + hlsInstance.destroy(); + hlsInstance = null; + } + if (window.Hls && Hls.isSupported()) { + hlsInstance = new Hls({ maxBufferLength: 30, maxMaxBufferLength: 60 }); + hlsInstance.loadSource(streamUrl); + hlsInstance.attachMedia(videoPlayer); + hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => { + transcodingOverlay.classList.add('hidden'); + videoPlayer.classList.remove('hidden'); + isStreamActive = true; + videoPlayer.play().catch(() => { }); + showSeekBar(); + showCustomControls(); + updatePlayControls(); + updateVolumeControls(); + updateFullscreenControls(); + schedulePlaybackChromeHide(); + }); + hlsInstance.on(Hls.Events.ERROR, function (event, data) { + if (data.fatal) { + switch (data.type) { + case Hls.ErrorTypes.NETWORK_ERROR: + hlsInstance.startLoad(); + break; + case Hls.ErrorTypes.MEDIA_ERROR: + hlsInstance.recoverMediaError(); + break; + default: + hlsInstance.destroy(); + break; + } + } + }); + } else if (videoPlayer.canPlayType('application/vnd.apple.mpegurl')) { + videoPlayer.src = streamUrl; + videoPlayer.addEventListener('loadedmetadata', () => { + transcodingOverlay.classList.add('hidden'); + videoPlayer.classList.remove('hidden'); + isStreamActive = true; + videoPlayer.play().catch(() => { }); + showSeekBar(); + showCustomControls(); + updatePlayControls(); + updateVolumeControls(); + updateFullscreenControls(); + schedulePlaybackChromeHide(); + }, { once: true }); + } } catch (err) { console.error(err); transcodingOverlay.innerHTML = `

Transcode Failed: ${err.message}

`; @@ -948,6 +963,10 @@ document.addEventListener('DOMContentLoaded', () => { if (!res.ok) throw new Error(data.error || 'Failed to stop transcode'); handleProgress({ status: 'cancelled', percent: 0, details: 'Transcode stopped' }); + if (hlsInstance) { + hlsInstance.destroy(); + hlsInstance = null; + } isStreamActive = false; videoPlayer.pause(); videoPlayer.removeAttribute('src'); @@ -1009,7 +1028,7 @@ document.addEventListener('DOMContentLoaded', () => { controlPlayToggle.addEventListener('click', () => { if (!isStreamActive) return; if (videoPlayer.paused) { - videoPlayer.play().catch(() => {}); + videoPlayer.play().catch(() => { }); } else { videoPlayer.pause(); } @@ -1070,34 +1089,7 @@ document.addEventListener('DOMContentLoaded', () => { document.addEventListener('fullscreenchange', updateFullscreenControls); - document.addEventListener('keydown', (event) => { - if (!isStreamActive) return; - const tagName = event.target?.tagName; - if (tagName === 'INPUT' || tagName === 'SELECT' || tagName === 'TEXTAREA' || tagName === 'BUTTON') return; - if (event.code === 'Space') { - event.preventDefault(); - if (videoPlayer.paused) { - videoPlayer.play().catch(() => {}); - } else { - videoPlayer.pause(); - } - revealPlaybackChrome(); - schedulePlaybackChromeHide(); - return; - } - - if (event.code === 'ArrowRight') { - event.preventDefault(); - seekToTime((seekOffset + (videoPlayer.currentTime || 0)) + 5); - return; - } - - if (event.code === 'ArrowLeft') { - event.preventDefault(); - seekToTime((seekOffset + (videoPlayer.currentTime || 0)) - 5); - } - }); updatePlayControls(); updateVolumeControls(); @@ -1123,6 +1115,61 @@ document.addEventListener('DOMContentLoaded', () => { }); } - // Initial state: require login before loading data - showLogin(); + if (logoutBtn) { + logoutBtn.addEventListener('click', async () => { + const sessionId = localStorage.getItem('sessionId'); + if (sessionId) { + try { + await fetch('/api/logout', { + method: 'POST', + headers: { 'X-Session-ID': sessionId } + }); + } catch(e){} + } + clearSessionAuth(); + location.reload(); + }); + } + + if (themeSelector) { + themeSelector.addEventListener('change', (e) => { + const theme = e.target.value; + document.documentElement.setAttribute('data-theme', theme); + localStorage.setItem('theme', theme); + }); + } + + const initApp = async () => { + const savedSession = localStorage.getItem('sessionId'); + const savedUsername = localStorage.getItem('username'); + const savedTheme = localStorage.getItem('theme') || 'light'; + + document.documentElement.setAttribute('data-theme', savedTheme); + if (themeSelector) themeSelector.value = savedTheme; + + if (savedSession) { + setSessionAuth(savedSession, savedUsername); + try { + const res = await fetch('/api/buckets', { headers: s3AuthHeaders }); + if (!res.ok) throw new Error('Session invalid'); + const data = await res.json(); + renderBuckets(data.buckets); + showApp(); + selectedBucket = data.buckets[0].Name; + if (bucketSelect) bucketSelect.value = selectedBucket; + loadConfig(); + connectWebSocket(); + await fetchVideos(selectedBucket); + } catch (err) { + console.error('Auto login failed:', err); + clearSessionAuth(); + showLogin(); + } + } else { + showLogin(); + } + }; + + // Initial state: attempt auto login + initApp(); }); diff --git a/server.js b/server.js index f5a5417..7f884ff 100644 --- a/server.js +++ b/server.js @@ -8,13 +8,34 @@ const http = require('http'); const WebSocket = require('ws'); const ffmpeg = require('fluent-ffmpeg'); const { S3Client, ListBucketsCommand, ListObjectsV2Command, GetObjectCommand } = require('@aws-sdk/client-s3'); +const crypto = require('crypto'); +const Redis = require('ioredis'); dotenv.config(); +const CACHE_DIR = path.join(__dirname, 'cache'); +if (!fs.existsSync(CACHE_DIR)) { + fs.mkdirSync(CACHE_DIR, { recursive: true }); +} const app = express(); const PORT = process.env.PORT || 3000; const HOST = process.env.HOST || process.env.LISTEN_ADDRESS || '0.0.0.0'; const server = http.createServer(app); +const JELLYFIN_FFMPEG_PATH = process.env.JELLYFIN_FFMPEG_PATH || '/usr/lib/jellyfin-ffmpeg/ffmpeg'; +const JELLYFIN_FFPROBE_PATH = process.env.JELLYFIN_FFPROBE_PATH || '/usr/lib/jellyfin-ffmpeg/ffprobe'; + +if (typeof ffmpeg.setFfmpegPath === 'function') { + ffmpeg.setFfmpegPath(JELLYFIN_FFMPEG_PATH); +} +if (typeof ffmpeg.setFfprobePath === 'function') { + ffmpeg.setFfprobePath(JELLYFIN_FFPROBE_PATH); +} + +const redisUrl = process.env.VALKEY_URL || process.env.REDIS_URL || 'redis://localhost:6379'; +const redisDb = parseInt(process.env.VALKEY_DB || process.env.REDIS_DB || '0', 10); +const redisClient = new Redis(redisUrl, { + db: isNaN(redisDb) ? 0 : redisDb +}); app.use(cors()); app.use(express.json()); @@ -62,6 +83,17 @@ const progressMap = {}; const transcodeProcesses = new Map(); const wsSubscriptions = new Map(); +const AVAILABLE_VIDEO_ENCODERS = [ + { value: 'h264_rkmpp', label: 'H.264(RKMPP HighSpeed)' }, + { value: 'hevc_rkmpp', label: 'H.265(RKMPP HighSpeed)' }, + { value: 'libx264', label: 'H.264(Software Slow)' }, + { value: 'libx265', label: 'H.265(Software Slow)' } +]; + +const AVAILABLE_VIDEO_DECODERS = [ + { value: 'auto', label: 'Auto Select Decoder' } +]; + const getProgressKey = (key) => key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_')).join('/'); const createStreamSessionId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; @@ -95,19 +127,44 @@ const broadcastWs = (key, payload) => { }; const createFfmpegOptions = (encoderName) => { - const options = ['-preset fast']; + const options = []; if (encoderName === 'libx264' || encoderName === 'libx265') { - options.push('-crf', '23', '-threads', '0'); + options.push('-preset', 'fast', '-crf', '23', '-threads', '0'); } else if (/_nvenc$/.test(encoderName)) { - options.push('-rc:v', 'vbr_hq', '-cq', '19'); + options.push('-preset', 'fast', '-rc:v', 'vbr_hq', '-cq', '19'); } else if (/_qsv$/.test(encoderName)) { - options.push('-global_quality', '23'); + options.push('-preset', 'fast', '-global_quality', '23'); } else if (/_vaapi$/.test(encoderName)) { options.push('-qp', '23'); + } else if (/_rkmpp$/.test(encoderName)) { + options.push('-qp_init', '23', '-pix_fmt', 'nv12'); } return options; }; +const isRkmppCodec = (codecName) => /_rkmpp$/.test(codecName); +const isVaapiCodec = (codecName) => /_vaapi$/.test(codecName); +const availableEncoderValues = new Set(AVAILABLE_VIDEO_ENCODERS.map((item) => item.value)); +const availableDecoderValues = new Set(AVAILABLE_VIDEO_DECODERS.map((item) => item.value)); + +const getRkmppDecoderName = (metadata) => { + const videoStream = (metadata?.streams || []).find((stream) => stream.codec_type === 'video'); + const codecName = (videoStream?.codec_name || '').toLowerCase(); + const decoderMap = { + av1: 'av1_rkmpp', + h263: 'h263_rkmpp', + h264: 'h264_rkmpp', + hevc: 'hevc_rkmpp', + mjpeg: 'mjpeg_rkmpp', + mpeg1video: 'mpeg1_rkmpp', + mpeg2video: 'mpeg2_rkmpp', + mpeg4: 'mpeg4_rkmpp', + vp8: 'vp8_rkmpp', + vp9: 'vp9_rkmpp' + }; + return decoderMap[codecName] || null; +}; + const parseFpsValue = (fpsText) => { if (typeof fpsText !== 'string' || !fpsText.trim()) { return 0; @@ -146,6 +203,8 @@ const getSeekFriendlyOutputOptions = (encoderName, metadata) => { options.push('-forced-idr', '1', '-force_key_frames', 'expr:gte(t,n_forced*2)'); } else if (/_qsv$/.test(encoderName)) { options.push('-idr_interval', '1'); + } else if (/_rkmpp$/.test(encoderName)) { + options.push('-force_key_frames', 'expr:gte(t,n_forced*2)'); } return options; @@ -153,7 +212,7 @@ const getSeekFriendlyOutputOptions = (encoderName, metadata) => { const shouldRetryWithSoftware = (message) => { if (!message) return false; - return /Cannot load libcuda\.so\.1|Could not open encoder before EOF|Error while opening encoder|Operation not permitted|Invalid argument/i.test(message); + return /Cannot load libcuda\.so\.1|Could not open encoder before EOF|Error while opening encoder|Operation not permitted|Invalid argument|mpp_create|rkmpp/i.test(message); }; const probeFile = (filePath) => { @@ -213,8 +272,20 @@ const stopActiveTranscode = (progressKey) => { return true; }; -const extractS3Credentials = (req) => { +const extractS3Credentials = async (req) => { const query = req.query || {}; + const sessionId = req.headers['x-session-id'] || req.body?.sessionId || query.sessionId || ''; + if (sessionId) { + try { + const cachedCreds = await redisClient.get(`session:${sessionId}`); + if (cachedCreds) { + const creds = JSON.parse(cachedCreds); + return { username: creds.username, password: creds.password }; + } + } catch (e) { + console.error('Session retrieval error:', e); + } + } const username = req.headers['x-s3-username'] || req.body?.username || query.username || query.accessKeyId || ''; const password = req.headers['x-s3-password'] || req.body?.password || query.password || query.secretAccessKey || ''; return { @@ -257,7 +328,7 @@ wss.on('connection', (ws) => { const clearDownloadCache = () => { - const tmpDir = os.tmpdir(); + const tmpDir = CACHE_DIR; try { if (!fs.existsSync(tmpDir)) return; const files = fs.readdirSync(tmpDir); @@ -265,6 +336,9 @@ const clearDownloadCache = () => { if (file.startsWith('s3-input-') && file.endsWith('.tmp')) { const filePath = path.join(tmpDir, file); fs.rmSync(filePath, { force: true }); + } else if (file.startsWith('hls-')) { + const filePath = path.join(tmpDir, file); + fs.rmSync(filePath, { recursive: true, force: true }); } } } catch (err) { @@ -274,10 +348,9 @@ const clearDownloadCache = () => { }; -// Endpoint to list available buckets app.get('/api/buckets', async (req, res) => { try { - const auth = extractS3Credentials(req); + const auth = await extractS3Credentials(req); const s3Client = createS3Client(auth); const command = new ListBucketsCommand({}); const response = await s3Client.send(command); @@ -297,7 +370,7 @@ app.get('/api/videos', async (req, res) => { return res.status(400).json({ error: 'Bucket name is required' }); } const allObjects = []; - const auth = extractS3Credentials(req); + const auth = await extractS3Credentials(req); const s3Client = createS3Client(auth); let continuationToken; @@ -333,9 +406,44 @@ app.get('/api/videos', async (req, res) => { } }); +app.post('/api/login', async (req, res) => { + const { username, password } = req.body; + if (!username || !password) return res.status(400).json({ error: 'Missing credentials' }); + try { + const s3Client = createS3Client({ username, password }); + const command = new ListBucketsCommand({}); + const response = await s3Client.send(command); + const buckets = response.Buckets || []; + + const sessionId = crypto.randomBytes(32).toString('hex'); + await redisClient.set(`session:${sessionId}`, JSON.stringify({ username, password }), 'EX', 7 * 24 * 3600); + + res.json({ success: true, sessionId, username, buckets }); + } catch (error) { + console.error('Login error:', error); + res.status(401).json({ error: 'Login failed', detail: error.message }); + } +}); + +app.post('/api/logout', async (req, res) => { + const sessionId = req.headers['x-session-id'] || req.body?.sessionId; + if (sessionId) { + try { await redisClient.del(`session:${sessionId}`); } catch (e) { } + } + res.json({ success: true }); +}); + app.get('/api/config', (req, res) => { const title = process.env.APP_TITLE || 'S3 Media Transcoder'; - res.json({ title }); + res.json({ + title, + ffmpegPath: JELLYFIN_FFMPEG_PATH, + ffprobePath: JELLYFIN_FFPROBE_PATH, + defaultVideoEncoder: 'h264_rkmpp', + defaultVideoDecoder: 'auto', + videoEncoders: AVAILABLE_VIDEO_ENCODERS, + videoDecoders: AVAILABLE_VIDEO_DECODERS + }); }); app.post('/api/clear-download-cache', (req, res) => { @@ -374,11 +482,283 @@ app.post('/api/stop-transcode', (req, res) => { } }); +const HLS_SEGMENT_TIME = 6; +const waitForSegment = async (hlsDir, segIndex, timeoutMs = 45000) => { + const start = Date.now(); + const segPath = path.join(hlsDir, `segment_${segIndex}.ts`); + const m3u8Path = path.join(hlsDir, `temp.m3u8`); + + while (Date.now() - start < timeoutMs) { + if (fs.existsSync(m3u8Path)) { + const m3u8Content = fs.readFileSync(m3u8Path, 'utf8'); + if (m3u8Content.includes(`segment_${segIndex}.ts`)) { + return true; + } + if (m3u8Content.includes(`#EXT-X-ENDLIST`)) { + if (fs.existsSync(segPath)) return true; + return false; + } + } + await new Promise(r => setTimeout(r, 200)); + } + return false; +}; + +app.get('/api/hls/playlist.m3u8', async (req, res) => { + const bucket = req.query.bucket; + const key = req.query.key; + if (!bucket || !key) return res.status(400).send('Bad Request'); + + const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_')); + const safeBucket = bucket.replace(/[^a-zA-Z0-9_\-]/g, '_'); + const tmpInputPath = path.join(CACHE_DIR, `s3-input-${safeBucket}-${safeKeySegments.join('-')}.tmp`); + + const auth = await extractS3Credentials(req); + const s3Client = createS3Client(auth); + let totalBytes = 0; + const progressKey = getProgressKey(key); + const streamSessionId = createStreamSessionId(); + let downloadedBytes = 0; + + if (!fs.existsSync(tmpInputPath)) { + try { + const command = new GetObjectCommand({ Bucket: bucket, Key: key }); + const response = await s3Client.send(command); + totalBytes = response.ContentLength; + const s3Stream = response.Body; + + progressMap[progressKey] = { + status: 'downloading', + percent: 0, + downloadedBytes: 0, + totalBytes, + streamSessionId, + details: 'Downloading full source before streaming...', + mp4Url: null + }; + broadcastWs(progressKey, { type: 'progress', key, progress: progressMap[progressKey] }); + + await new Promise((resolve, reject) => { + const writeStream = fs.createWriteStream(tmpInputPath); + + s3Stream.on('data', (chunk) => { + downloadedBytes += chunk.length; + const percent = totalBytes ? Math.min(100, Math.round((downloadedBytes / totalBytes) * 100)) : 0; + + const downloadState = { + status: 'downloading', + percent, + downloadedBytes, + totalBytes, + streamSessionId, + details: totalBytes ? `Downloading source ${percent}%` : 'Downloading source...', + mp4Url: null + }; + progressMap[progressKey] = downloadState; + broadcastWs(progressKey, { type: 'progress', key, progress: downloadState }); + }); + + s3Stream.on('error', reject); + writeStream.on('error', reject); + writeStream.on('finish', resolve); + s3Stream.pipe(writeStream); + }); + + progressMap[progressKey] = { + status: 'downloaded', + percent: 100, + downloadedBytes, + totalBytes, + streamSessionId, + details: 'Source download complete, parsing for HLS...', + mp4Url: null + }; + broadcastWs(progressKey, { type: 'progress', key, progress: progressMap[progressKey] }); + + } catch (err) { + console.error('S3 Download Failed:', err); + return res.status(500).send('S3 Download Failed'); + } + } else { + const stats = fs.statSync(tmpInputPath); + totalBytes = stats.size; + downloadedBytes = totalBytes; + progressMap[progressKey] = { + status: 'downloaded', + percent: 100, + downloadedBytes, + totalBytes, + streamSessionId, + details: 'Source already downloaded locally, parsing for HLS...', + mp4Url: null + }; + broadcastWs(progressKey, { type: 'progress', key, progress: progressMap[progressKey] }); + } + + let duration = 0; + try { + const metadata = await probeFile(tmpInputPath); + duration = parseFloat(metadata.format?.duration || 0); + } catch (err) { } + + if (duration <= 0) duration = 3600; + + const totalSegments = Math.ceil(duration / HLS_SEGMENT_TIME); + + let m3u8 = `#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:${HLS_SEGMENT_TIME}\n#EXT-X-MEDIA-SEQUENCE:0\n#EXT-X-PLAYLIST-TYPE:VOD\n`; + for (let i = 0; i < totalSegments; i++) { + let segDur = HLS_SEGMENT_TIME; + if (i === totalSegments - 1 && duration % HLS_SEGMENT_TIME !== 0) { + segDur = (duration % HLS_SEGMENT_TIME) || HLS_SEGMENT_TIME; + } + m3u8 += `#EXTINF:${segDur.toFixed(6)},\nsegment.ts?bucket=${encodeURIComponent(bucket)}&key=${encodeURIComponent(key)}&seg=${i}&encoder=${req.query.encoder || 'h264_rkmpp'}&decoder=${req.query.decoder || 'auto'}\n`; + } + m3u8 += `#EXT-X-ENDLIST\n`; + + res.setHeader('Content-Type', 'application/vnd.apple.mpegurl'); + res.setHeader('Cache-Control', 'no-cache'); + res.send(m3u8); +}); + +const hlsProcesses = new Map(); + +app.get('/api/hls/segment.ts', async (req, res) => { + const bucket = req.query.bucket; + const key = req.query.key; + const seg = parseInt(req.query.seg || '0'); + const requestedEncoder = req.query.encoder || 'h264_rkmpp'; + const requestedDecoder = req.query.decoder || 'auto'; + + if (!bucket || !key || isNaN(seg)) return res.status(400).send('Bad Request'); + + const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_')); + const safeBucket = bucket.replace(/[^a-zA-Z0-9_\-]/g, '_'); + const tmpInputPath = path.join(CACHE_DIR, `s3-input-${safeBucket}-${safeKeySegments.join('-')}.tmp`); + + const progressKey = safeKeySegments.join('/'); + const hlsDir = path.join(CACHE_DIR, `hls-${safeBucket}-${progressKey}`); + if (!fs.existsSync(hlsDir)) fs.mkdirSync(hlsDir, { recursive: true }); + + const targetSegPath = path.join(hlsDir, `segment_${seg}.ts`); + let currentProcess = hlsProcesses.get(progressKey); + + const checkIsCachedAndCompleted = () => { + if (!fs.existsSync(targetSegPath)) return false; + const m3u8Path = path.join(hlsDir, `temp.m3u8`); + if (fs.existsSync(m3u8Path) && fs.readFileSync(m3u8Path, 'utf8').includes(`segment_${seg}.ts`)) return true; + if (currentProcess && Math.abs((currentProcess.currentSeg || 0) - seg) > 3) return true; + // If there's no active process, any existing file is from a past complete run + if (!currentProcess) return true; + return false; + }; + + if (checkIsCachedAndCompleted()) { + if (currentProcess) currentProcess.currentSeg = Math.max(currentProcess.currentSeg, seg); + res.setHeader('Content-Type', 'video/MP2T'); + return res.sendFile(targetSegPath); + } + + const needsNewProcess = !currentProcess || (!fs.existsSync(targetSegPath) && (seg < (currentProcess.currentSeg || 0) || seg > (currentProcess.currentSeg || 0) + 4)); + + if (needsNewProcess) { + if (currentProcess && currentProcess.command) { + try { currentProcess.command.kill('SIGKILL'); } catch (e) { } + } + + const startTime = Math.max(0, seg * HLS_SEGMENT_TIME); + + let sourceMetadata = null; + try { sourceMetadata = await probeFile(tmpInputPath); } catch (e) { } + + const encoderName = availableEncoderValues.has(requestedEncoder) ? requestedEncoder : 'h264_rkmpp'; + const decoderName = availableDecoderValues.has(requestedDecoder) ? requestedDecoder : 'auto'; + + const m3u8Path = path.join(hlsDir, `temp.m3u8`); + if (fs.existsSync(m3u8Path)) fs.unlinkSync(m3u8Path); + + const ffmpegCommand = ffmpeg().input(tmpInputPath); + if (startTime > 0) ffmpegCommand.seekInput(startTime); + + ffmpegCommand.videoCodec(encoderName).audioCodec('aac'); + + if (isVaapiCodec(encoderName)) { + ffmpegCommand.inputOptions(['-vaapi_device', '/dev/dri/renderD128']).videoFilters('format=nv12,hwupload'); + } + const resolvedDecoderName = decoderName === 'auto' && isRkmppCodec(encoderName) ? getRkmppDecoderName(sourceMetadata) : decoderName; + if (resolvedDecoderName && resolvedDecoderName !== 'auto') ffmpegCommand.inputOptions(['-c:v', resolvedDecoderName]); + + const segmentFilename = path.join(hlsDir, `segment_%d.ts`); + const hlsOptions = createFfmpegOptions(encoderName).concat([ + '-f', 'hls', + '-hls_time', HLS_SEGMENT_TIME.toString(), + '-hls_list_size', '0', + '-hls_segment_filename', segmentFilename, + '-start_number', seg.toString(), + '-copyts', + '-avoid_negative_ts', 'disabled', + '-muxdelay', '0', + '-muxpreload', '0' + ]); + + ffmpegCommand.outputOptions(hlsOptions).output(m3u8Path); + ffmpegCommand.on('error', (err) => { + console.error('HLS FFmpeg Error:', err.message); + }); + + ffmpegCommand.on('progress', (progress) => { + const timemarkSeconds = parseTimemarkToSeconds(progress.timemark || '0'); + const absoluteSeconds = startTime + (isFinite(timemarkSeconds) ? timemarkSeconds : 0); + const totalDuration = parseFloat(sourceMetadata?.format?.duration || 0); + + let percent = 0; + if (totalDuration > 0) { + percent = Math.min(Math.max(Math.round((absoluteSeconds / totalDuration) * 100), 0), 100); + } + + const progressState = { + status: 'transcoding', + percent, + frame: progress.frames || null, + fps: progress.currentFps || null, + bitrate: progress.currentKbps || null, + timemark: progress.timemark || null, + absoluteSeconds, + duration: totalDuration || null, + startSeconds: startTime, + details: `处理进度 ${percent}%`, + mp4Url: null + }; + progressMap[progressKey] = progressState; + broadcastWs(progressKey, { type: 'progress', key, progress: progressState }); + + console.log(`[FFmpeg] ${progressKey} | ${progress.timemark} | ${progress.currentFps}fps | ${progress.currentKbps}kbps | ${percent}%`); + }); + + ffmpegCommand.on('end', () => { + console.log(`[FFmpeg] ${progressKey} HLS transcode completed.`); + }); + + ffmpegCommand.run(); + currentProcess = { command: ffmpegCommand, currentSeg: seg }; + hlsProcesses.set(progressKey, currentProcess); + } + + const ready = await waitForSegment(hlsDir, seg); + if (!ready) { + return res.status(500).send('Segment generation timeout'); + } + + if (currentProcess) currentProcess.currentSeg = Math.max(currentProcess.currentSeg, seg); + + res.setHeader('Content-Type', 'video/MP2T'); + res.sendFile(targetSegPath); +}); + app.get('/api/stream', async (req, res) => { const bucket = req.query.bucket; const key = req.query.key; - const codec = req.query.codec; - const encoder = req.query.encoder; + const requestedDecoder = typeof req.query.decoder === 'string' ? req.query.decoder.trim() : 'auto'; + const requestedEncoder = typeof req.query.encoder === 'string' ? req.query.encoder.trim() : 'h264_rkmpp'; const startSeconds = parseFloat(req.query.ss) || 0; const streamSessionId = typeof req.query.streamSessionId === 'string' && req.query.streamSessionId.trim() ? req.query.streamSessionId.trim() @@ -391,24 +771,16 @@ app.get('/api/stream', async (req, res) => { return res.status(400).json({ error: 'Video key is required' }); } - const safeCodec = codec === 'h265' ? 'h265' : 'h264'; - const safeEncoder = ['nvidia', 'intel', 'vaapi', 'neon'].includes(encoder) ? encoder : 'software'; - const codecMap = { - software: { h264: 'libx264', h265: 'libx265' }, - neon: { h264: 'libx264', h265: 'libx265' }, - nvidia: { h264: 'h264_nvenc', h265: 'hevc_nvenc' }, - intel: { h264: 'h264_qsv', h265: 'hevc_qsv' }, - vaapi: { h264: 'h264_vaapi', h265: 'hevc_vaapi' } - }; - const videoCodec = codecMap[safeEncoder][safeCodec]; + const videoEncoder = availableEncoderValues.has(requestedEncoder) ? requestedEncoder : 'h264_rkmpp'; + const requestedVideoDecoder = availableDecoderValues.has(requestedDecoder) ? requestedDecoder : 'auto'; const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_')); const progressKey = safeKeySegments.join('/'); const safeBucket = bucket.replace(/[^a-zA-Z0-9_\-]/g, '_'); - const tmpInputPath = path.join(os.tmpdir(), `s3-input-${safeBucket}-${safeKeySegments.join('-')}.tmp`); + const tmpInputPath = path.join(CACHE_DIR, `s3-input-${safeBucket}-${safeKeySegments.join('-')}.tmp`); const cacheExists = fs.existsSync(tmpInputPath); - const auth = extractS3Credentials(req); + const auth = await extractS3Credentials(req); const s3Client = createS3Client(auth); try { @@ -517,7 +889,7 @@ app.get('/api/stream', async (req, res) => { let ffmpegCommand = null; - const startStream = (encoderName) => { + const startStream = (encoderName, decoderName) => { const streamingOptions = createFfmpegOptions(encoderName).concat(getSeekFriendlyOutputOptions(encoderName, sourceMetadata)); ffmpegCommand = ffmpeg() .input(tmpInputPath) @@ -534,12 +906,20 @@ app.get('/api/stream', async (req, res) => { } } - if (/_vaapi$/.test(encoderName)) { + if (isVaapiCodec(encoderName)) { ffmpegCommand .inputOptions(['-vaapi_device', '/dev/dri/renderD128']) .videoFilters('format=nv12,hwupload'); } + const resolvedDecoderName = decoderName === 'auto' && isRkmppCodec(encoderName) + ? getRkmppDecoderName(sourceMetadata) + : decoderName; + + if (resolvedDecoderName && resolvedDecoderName !== 'auto') { + ffmpegCommand.inputOptions(['-c:v', resolvedDecoderName]); + } + transcodeProcesses.set(progressKey, { command: ffmpegCommand, streamSessionId }); ffmpegCommand @@ -622,14 +1002,14 @@ app.get('/api/stream', async (req, res) => { if (ffmpegCommand && typeof ffmpegCommand.kill === 'function') { try { ffmpegCommand.kill('SIGKILL'); - } catch (_) {} + } catch (_) { } } if (transcodeProcesses.get(progressKey)?.streamSessionId === streamSessionId) { transcodeProcesses.delete(progressKey); } }); - startStream(videoCodec); + startStream(videoEncoder, requestedVideoDecoder); } catch (error) { console.error('Error in stream:', error); if (!res.headersSent) {