UniLinux #1
@@ -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
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
@@ -8,8 +9,9 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<link rel="stylesheet" href="/css/style.css?v=2">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="background-effects">
|
||||
<div class="glow-orb orb-1"></div>
|
||||
@@ -34,7 +36,21 @@
|
||||
</div>
|
||||
|
||||
<div class="container hidden" id="app-container">
|
||||
<div id="top-banner" class="top-banner hidden"></div>
|
||||
<div id="top-banner" class="top-banner hidden">
|
||||
<div id="top-banner-title" class="banner-title"></div>
|
||||
<div class="user-controls">
|
||||
<label>页面主题:
|
||||
<select id="theme-selector">
|
||||
<option value="light">白天模式</option>
|
||||
<option value="dark">深夜模式</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="user-info">
|
||||
<span id="current-username"></span>
|
||||
<button id="logout-btn" class="action-btn">退出登录</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="dashboard">
|
||||
<section class="video-list-section glass-panel">
|
||||
@@ -54,21 +70,8 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="codec-panel">
|
||||
<label for="codec-select">编码方式:</label>
|
||||
<select id="codec-select">
|
||||
<option value="h264">H264</option>
|
||||
<option value="h265">H265</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="codec-panel">
|
||||
<label for="encoder-select">硬件编码器:</label>
|
||||
<select id="encoder-select">
|
||||
<option value="software">Software</option>
|
||||
<option value="neon">NEON</option>
|
||||
<option value="nvidia">NVIDIA</option>
|
||||
<option value="intel">Intel</option>
|
||||
<option value="vaapi">VAAPI</option>
|
||||
</select>
|
||||
<label for="encoder-select">编码加速:</label>
|
||||
<select id="encoder-select"></select>
|
||||
</div>
|
||||
<div id="loading-spinner" class="spinner-container">
|
||||
<div class="spinner"></div>
|
||||
@@ -88,7 +91,13 @@
|
||||
<!-- Download Phase -->
|
||||
<div id="download-phase" class="phase-container">
|
||||
<div class="phase-icon download-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>正在从 S3 下载源文件...</h3>
|
||||
<p id="download-size-text" class="phase-detail">准备下载...</p>
|
||||
@@ -102,7 +111,12 @@
|
||||
<!-- Transcode Phase -->
|
||||
<div id="transcode-phase" class="phase-container hidden">
|
||||
<div class="phase-icon transcode-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<polygon points="23 7 16 12 23 17 23 7" />
|
||||
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>正在转码并流式传输...</h3>
|
||||
<p id="transcode-detail-text" class="phase-detail">FFmpeg 转码中...</p>
|
||||
@@ -119,58 +133,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<video id="video-player" preload="auto" class="hidden"></video>
|
||||
<video id="video-player" preload="auto" controls playsinline webkit-playsinline
|
||||
class="hidden"></video>
|
||||
<div id="seeking-overlay" class="seeking-overlay hidden">
|
||||
<div class="spinner"></div>
|
||||
<span>跳转中...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="custom-controls" class="custom-controls hidden">
|
||||
<div class="controls-left">
|
||||
<button id="control-play-toggle" class="control-btn icon-btn" type="button" aria-label="Play">></button>
|
||||
<div id="playback-status" class="playback-status paused">
|
||||
<span class="status-dot"></span>
|
||||
<span id="playback-status-text">Paused</span>
|
||||
</div>
|
||||
<div class="control-popover volume-popover">
|
||||
<button id="control-mute-toggle" class="control-btn icon-btn" type="button" aria-label="Mute">o</button>
|
||||
<div class="popover-panel">
|
||||
<div class="volume-control">
|
||||
<input id="volume-slider" class="volume-slider" type="range" min="0" max="1" step="0.05" value="1" />
|
||||
<span id="volume-value" class="volume-value">100%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="custom-seek-container" class="control-seek hidden">
|
||||
<span id="seek-current-time" class="control-time">00:00:00</span>
|
||||
<div id="seek-bar" class="seek-bar">
|
||||
<div id="seek-bar-transcode" class="seek-bar-transcode"></div>
|
||||
<div id="seek-bar-progress" class="seek-bar-progress"></div>
|
||||
<div id="seek-bar-handle" class="seek-bar-handle"></div>
|
||||
</div>
|
||||
<span id="seek-total-time" class="control-time">00:00:00</span>
|
||||
</div>
|
||||
<div class="controls-right">
|
||||
<div class="control-popover speed-popover">
|
||||
<button id="control-speed-toggle" class="control-btn" type="button" aria-label="Playback Speed">1x</button>
|
||||
<div class="popover-panel">
|
||||
<label class="speed-control" for="playback-speed">
|
||||
<span>Speed</span>
|
||||
<select id="playback-speed" class="speed-select">
|
||||
<option value="0.5">0.5x</option>
|
||||
<option value="0.75">0.75x</option>
|
||||
<option value="1" selected>1x</option>
|
||||
<option value="1.25">1.25x</option>
|
||||
<option value="1.5">1.5x</option>
|
||||
<option value="2">2x</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button id="control-fullscreen-toggle" class="control-btn icon-btn square-btn" type="button" aria-label="Fullscreen">[]</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="now-playing" class="now-playing hidden">
|
||||
<h3>Now Playing</h3>
|
||||
<p id="current-video-title">video.mp4</p>
|
||||
@@ -181,6 +150,8 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/js/main.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
|
||||
<script src="/js/main.js?v=2"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
@@ -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 = `<p style="color: #ef4444;">Transcode Failed: ${err.message}</p>`;
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
438
server.js
438
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) {
|
||||
|
||||
Reference in New Issue
Block a user