Compare commits

..

10 Commits

Author SHA1 Message Date
CN-JS-HuiBai
df5999c338 优化布局Again 2026-04-04 14:03:17 +08:00
CN-JS-HuiBai
3b65c306de 优化布局 2026-04-04 14:02:49 +08:00
CN-JS-HuiBai
a6af3765a8 优化页面布局,修复故障视频拉动进度条的故障 2026-04-04 13:58:12 +08:00
CN-JS-HuiBai
b8bd8a8d76 优化用户名显示方式 2026-04-04 13:38:26 +08:00
CN-JS-HuiBai
624a327246 优化界面布局Again2.0 2026-04-04 13:34:44 +08:00
CN-JS-HuiBai
5812b23f1c 优化界面布局Again 2026-04-04 13:31:31 +08:00
CN-JS-HuiBai
b76044ef0d 优化界面布局 2026-04-04 13:30:00 +08:00
CN-JS-HuiBai
7eec653233 修复下载进度判断 2026-04-04 13:26:36 +08:00
CN-JS-HuiBai
9e4231a747 优化登陆页面 2026-04-04 13:17:30 +08:00
CN-JS-HuiBai
bae9f1dba5 优化README 2026-04-04 13:06:22 +08:00
5 changed files with 375 additions and 273 deletions

View File

@@ -1,27 +1,57 @@
# Media Coding Web - Configuration & Run Guide # Media Coding Web - Configuration & Run Guide
To properly test and run this project, you will need to prepare your environment: Media Coding Web is a high-performance video transcoding and streaming web application. It retrieves source media files from AWS S3 or MinIO, transcodes them on the fly utilizing hardware-accelerated video encoders, and streams them efficiently to the browser using HLS (m3u8).
1. **Install Node.js & FFmpeg**: ## Features
- 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.
- Note: The platform supports Intel QSV, Nvidia NVENC, and VAAPI hardware encoders. Ensure your FFmpeg build includes support for `h264_qsv` / `hevc_qsv` / `h264_nvenc` / `hevc_nvenc` / `h264_vaapi` / `hevc_vaapi` if you wish to use them.
- 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**: - **High-Performance HLS Streaming:** Streams video using HLS for fast startup, localized buffering, native browser video controls, and frame-accurate seeking.
- Modify the `.env` file (copy from `.env.example`). - **Hardware-Accelerated Encoding:** Detects and seamlessly supports Intel QSV, NVIDIA NVENC, and VAAPI hardware encoders natively via FFmpeg.
- Add your `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`, and `S3_BUCKET_NAME`. - **AWS S3 / MinIO Integration:** Securely downloads media from S3-compatible endpoints to a dedicated local cache directory before processing.
- If using **MinIO**, ensure you set the `S3_ENDPOINT` (e.g., `http://127.0.0.1:9000`) and set `S3_FORCE_PATH_STYLE=true`. - **Persistent Sessions:** Utilizes Valkey (Redis) for stateful session management, eliminating repetitive login actions.
- Ensure your bucket has `.mp4` video files. - **Real-Time Progress:** Displays live feedback for both background S3 downloading and FFmpeg transcoding, communicated to the web application via WebSockets.
- **Modern User Interface:** Features a sleek and highly responsive user interface complete with built-in light and dark mode switching.
- **Centralized Log Storage:** Persists application activity and debugging events safely within the project's `/logs/` directory utilizing Winston loggers.
- **Unified Configuration:** A single `.env` file powers the entire web application settings.
3. **Install Dependencies & Start**: ## Prerequisites
To properly run and deploy this project, your environment should contain:
1. **Node.js**: Version 18.x or newer is required to natively support all APIs.
2. **FFmpeg with Hardware Support**:
- Ensure you configure an FFmpeg instance on your system.
- For maximal performance, the application defaults to searching for `jellyfin-ffmpeg` located at `/usr/lib/jellyfin-ffmpeg/ffmpeg`. Feel free to point it at any other binary containing QSV or NVENC support by modifying the `JELLYFIN_FFMPEG_PATH` path inside `.env`.
3. **Valkey (or Redis)**: An active Redis-compatible cache server string is required for user sessions.
4. **AWS S3 / MinIO Account**: An object storage endpoint containing your encoded `.mp4` media catalog.
## Configuration & Installation
1. **Install Dependencies**:
Navigate into the project root directory and execute:
```bash ```bash
npm install npm install
npm start
``` ```
4. **Test Delivery**: 2. **Establish Environment Variables**:
- Open your browser to `http://localhost:3000`. Create a pristine copy of the `.env.example` structure:
- The application should display available `.mp4` items from your S3 bucket. ```bash
- Click one video, and the Node server will begin to read the S3 stream and pipe it to FFmpeg, transcoding it into HLS segments inside the `/public/hls/` directory. cp .env.example .env
- The frontend polls via `/api/status`, and once the index playlist is available, HLS playback starts! ```
Open `.env` using your preferred editor to tailor your environment:
- **Valkey/Redis Settings**: Configure `VALKEY_URL`.
- **S3 Connectivity**: Insert your `S3_BUCKET_ADDRESS` and `AWS_REGION`. For MinIO specifically, fill the `S3_ENDPOINT` key (e.g. `http://127.0.0.1:9000`) and verify `S3_FORCE_PATH_STYLE=true`.
## Running the Application
To launch the web interface and application server, simply run:
```bash
npm start
```
*(You may also run `npm run dev` to execute the local environment.)*
## Verifying Setup
1. Open a browser and load `http://localhost:3000` (or your defined `PORT`).
2. Log into the system using your relevant S3 authorization if using local user mode.
3. The server will communicate directly to your S3 bucket and load the application's root dashboard.
4. Upon clicking any available video media, the server will rapidly download the required chunks to the local cache, start compiling HLS indexing data using native hardware encoding, and pipe an advanced native video stream back into your browser.

View File

@@ -105,6 +105,7 @@ header h1 {
header h1 span { header h1 span {
background: linear-gradient(135deg, #3b82f6, #8b5cf6); background: linear-gradient(135deg, #3b82f6, #8b5cf6);
-webkit-background-clip: text; -webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
} }
@@ -136,35 +137,37 @@ header p {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: radial-gradient(circle at top left, rgba(249, 250, 252, 0.95), rgba(255, 255, 255, 0.98)); background: transparent;
z-index: 50; z-index: 50;
padding: 2rem; padding: 2rem;
} }
.login-card { .login-card {
width: min(480px, 100%); width: min(480px, 100%);
background: #ffffff; background: var(--panel-bg);
border: 1px solid rgba(148, 163, 184, 0.3); backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--panel-border);
border-radius: 24px; border-radius: 24px;
padding: 2rem; padding: 2rem;
box-shadow: 0 20px 60px rgba(15, 23, 42, 0.12); box-shadow: 0 20px 40px rgba(148, 163, 184, 0.15);
} }
.login-card h2 { .login-card h2 {
margin: 0 0 0.75rem; margin: 0 0 0.75rem;
font-size: 1.75rem; font-size: 1.75rem;
color: #111827; color: var(--text-primary);
} }
.login-card p { .login-card p {
color: #6b7280; color: var(--text-secondary);
margin: 0 0 1.5rem; margin: 0 0 1.5rem;
} }
.login-card label { .login-card label {
display: block; display: block;
margin-bottom: 1rem; margin-bottom: 1rem;
color: #4b5563; color: var(--text-secondary);
font-size: 0.95rem; font-size: 0.95rem;
} }
@@ -175,17 +178,17 @@ header p {
.login-card input { .login-card input {
width: 100%; width: 100%;
border: 1px solid rgba(148, 163, 184, 0.5); border: 1px solid var(--panel-border);
background: #f8fafc; background: rgba(255, 255, 255, 0.08);
color: #111827; color: var(--text-primary);
border-radius: 12px; border-radius: 12px;
padding: 0.85rem 1rem; padding: 0.85rem 1rem;
outline: none; outline: none;
} }
.login-card input:focus { .login-card input:focus {
border-color: #2563eb; border-color: var(--accent);
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.12); box-shadow: 0 0 0 4px var(--accent-glow);
} }
.login-card .primary-btn { .login-card .primary-btn {
@@ -300,13 +303,6 @@ header p {
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
.section-actions-row {
display: block;
margin-bottom: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--panel-border);
}
.section-actions { .section-actions {
display: flex; display: flex;
flex-wrap: nowrap; flex-wrap: nowrap;
@@ -315,6 +311,12 @@ header p {
justify-content: flex-start; justify-content: flex-start;
} }
.banner-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.action-btn { .action-btn {
border: none; border: none;
background: #2563eb; background: #2563eb;

View File

@@ -42,12 +42,14 @@
<label>页面主题: <label>页面主题:
<select id="theme-selector"> <select id="theme-selector">
<option value="light">白天模式</option> <option value="light">白天模式</option>
<option value="dark">夜模式</option> <option value="dark">夜模式</option>
</select> </select>
</label> </label>
<div class="banner-actions">
<button id="refresh-btn" class="action-btn" title="刷新列表">刷新列表</button>
</div>
<div class="user-info"> <div class="user-info">
<span id="current-username"></span> <button id="logout-btn" class="action-btn danger">退出登录</button>
<button id="logout-btn" class="action-btn">退出登录</button>
</div> </div>
</div> </div>
</div> </div>
@@ -55,13 +57,7 @@
<main class="dashboard"> <main class="dashboard">
<section class="video-list-section glass-panel"> <section class="video-list-section glass-panel">
<div class="section-header"> <div class="section-header">
<h2>Available Videos</h2> <h2 id="video-list-header">Available Videos</h2>
</div>
<div class="section-actions-row">
<div class="section-actions">
<button id="refresh-btn" class="action-btn" title="刷新列表">刷新列表</button>
<button id="clear-download-cache-btn" class="action-btn" title="清空下载缓存">清空下载缓存</button>
</div>
</div> </div>
<div class="bucket-panel"> <div class="bucket-panel">
<label for="bucket-select">Select Bucket</label> <label for="bucket-select">Select Bucket</label>
@@ -143,8 +139,12 @@
<div id="now-playing" class="now-playing hidden"> <div id="now-playing" class="now-playing hidden">
<h3>Now Playing</h3> <h3>Now Playing</h3>
<p id="current-video-title">video.mp4</p> <p id="current-video-title">video.mp4</p>
<button id="transcode-btn" class="play-btn hidden">Start Transcode</button> <div style="display: flex; gap: 0.5rem; margin-top: 1rem; align-items: center; flex-wrap: wrap;">
<button id="stop-transcode-btn" class="play-btn stop-btn hidden">Stop Transcode</button> <button id="transcode-btn" class="play-btn hidden">Start Transcode</button>
<button id="stop-transcode-btn" class="play-btn stop-btn hidden">Stop Transcode</button>
<button id="clear-playing-download-cache-btn" class="action-btn danger hidden">清空下载缓存</button>
<button id="clear-playing-transcode-cache-btn" class="action-btn danger hidden">清空转码缓存</button>
</div>
</div> </div>
</section> </section>
</main> </main>

View File

@@ -2,7 +2,6 @@ document.addEventListener('DOMContentLoaded', () => {
const videoListEl = document.getElementById('video-list'); const videoListEl = document.getElementById('video-list');
const loadingSpinner = document.getElementById('loading-spinner'); const loadingSpinner = document.getElementById('loading-spinner');
const refreshBtn = document.getElementById('refresh-btn'); const refreshBtn = document.getElementById('refresh-btn');
const clearDownloadCacheBtn = document.getElementById('clear-download-cache-btn');
const bucketSelect = document.getElementById('bucket-select'); const bucketSelect = document.getElementById('bucket-select');
const loginScreen = document.getElementById('login-screen'); const loginScreen = document.getElementById('login-screen');
const appContainer = document.getElementById('app-container'); const appContainer = document.getElementById('app-container');
@@ -19,8 +18,10 @@ document.addEventListener('DOMContentLoaded', () => {
const currentVideoTitle = document.getElementById('current-video-title'); const currentVideoTitle = document.getElementById('current-video-title');
const transcodeBtn = document.getElementById('transcode-btn'); const transcodeBtn = document.getElementById('transcode-btn');
const stopTranscodeBtn = document.getElementById('stop-transcode-btn'); const stopTranscodeBtn = document.getElementById('stop-transcode-btn');
const clearPlayingDownloadBtn = document.getElementById('clear-playing-download-cache-btn');
const clearPlayingTranscodeBtn = document.getElementById('clear-playing-transcode-cache-btn');
const themeSelector = document.getElementById('theme-selector'); const themeSelector = document.getElementById('theme-selector');
const currentUsername = document.getElementById('current-username'); const videoListHeader = document.getElementById('video-list-header');
const logoutBtn = document.getElementById('logout-btn'); const logoutBtn = document.getElementById('logout-btn');
const topBannerTitle = document.getElementById('top-banner-title'); const topBannerTitle = document.getElementById('top-banner-title');
const playBtn = document.getElementById('play-btn'); const playBtn = document.getElementById('play-btn');
@@ -140,7 +141,7 @@ document.addEventListener('DOMContentLoaded', () => {
const buildHlsPlaylistUrl = () => { const buildHlsPlaylistUrl = () => {
const decoder = 'auto'; const decoder = 'auto';
const encoder = encoderSelect?.value || 'h264_qsv'; const encoder = encoderSelect?.value || 'h264_rkmpp';
let streamUrl = `/api/hls/playlist.m3u8?bucket=${encodeURIComponent(selectedBucket)}&key=${encodeURIComponent(selectedKey)}&decoder=${encodeURIComponent(decoder)}&encoder=${encodeURIComponent(encoder)}`; let streamUrl = `/api/hls/playlist.m3u8?bucket=${encodeURIComponent(selectedBucket)}&key=${encodeURIComponent(selectedKey)}&decoder=${encodeURIComponent(decoder)}&encoder=${encodeURIComponent(encoder)}`;
const sessionId = localStorage.getItem('sessionId'); const sessionId = localStorage.getItem('sessionId');
if (sessionId) { if (sessionId) {
@@ -543,7 +544,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (topBannerTitle) topBannerTitle.textContent = title; if (topBannerTitle) topBannerTitle.textContent = title;
topBanner.classList.remove('hidden'); topBanner.classList.remove('hidden');
document.title = title; document.title = title;
populateSelect(encoderSelect, data.videoEncoders || [], data.defaultVideoEncoder || 'h264_qsv'); populateSelect(encoderSelect, data.videoEncoders || [], data.defaultVideoEncoder || 'h264_rkmpp');
} catch (err) { } catch (err) {
console.error('Config load failed:', err); console.error('Config load failed:', err);
} }
@@ -586,7 +587,7 @@ document.addEventListener('DOMContentLoaded', () => {
localStorage.setItem('sessionId', sessionId); localStorage.setItem('sessionId', sessionId);
if (username) { if (username) {
localStorage.setItem('username', username); localStorage.setItem('username', username);
if (currentUsername) currentUsername.textContent = username; if (videoListHeader) videoListHeader.textContent = `${username} Available Videos`;
} }
}; };
@@ -642,8 +643,8 @@ document.addEventListener('DOMContentLoaded', () => {
clearLoginError(); clearLoginError();
try { try {
const res = await fetch('/api/login', { const res = await fetch('/api/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }) body: JSON.stringify({ username, password })
}); });
@@ -674,26 +675,7 @@ document.addEventListener('DOMContentLoaded', () => {
} }
}; };
const clearDownloadCache = async () => {
if (!clearDownloadCacheBtn) return;
clearDownloadCacheBtn.disabled = true;
clearDownloadCacheBtn.textContent = '清空中...';
try {
const res = await fetch('/api/clear-download-cache', { method: 'POST' });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || '清空下载缓存失败');
}
alert('下载缓存已清空');
} catch (err) {
console.error('Clear download cache failed:', err);
alert(`清空下载缓存失败: ${err.message}`);
} finally {
clearDownloadCacheBtn.disabled = false;
clearDownloadCacheBtn.textContent = '清空下载缓存';
}
};
if (transcodeBtn) { if (transcodeBtn) {
transcodeBtn.addEventListener('click', () => { transcodeBtn.addEventListener('click', () => {
@@ -733,7 +715,8 @@ document.addEventListener('DOMContentLoaded', () => {
// Build a tree structure from S3 keys // Build a tree structure from S3 keys
const tree = {}; const tree = {};
data.videos.forEach(key => { data.videos.forEach(vid => {
const key = vid.key;
const parts = key.split('/'); const parts = key.split('/');
let current = tree; let current = tree;
parts.forEach((part, index) => { parts.forEach((part, index) => {
@@ -742,15 +725,18 @@ document.addEventListener('DOMContentLoaded', () => {
} }
if (index === parts.length - 1) { if (index === parts.length - 1) {
current[part].__file = key; current[part].__file = key;
current[part].__hasTranscodeCache = vid.hasTranscodeCache;
current[part].__hasDownloadCache = vid.hasDownloadCache;
} }
current = current[part]; current = current[part];
}); });
}); });
const createFileItem = (name, key) => { const createFileItem = (name, key, hasTranscodeCache, hasDownloadCache) => {
const li = document.createElement('li'); const li = document.createElement('li');
li.className = 'video-item file-item'; li.className = 'video-item file-item';
const ext = name.split('.').pop().toUpperCase(); const ext = name.split('.').pop().toUpperCase();
li.innerHTML = ` li.innerHTML = `
<div class="video-icon"> <div class="video-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 8-6 4 6 4V8Z"/><rect width="14" height="12" x="2" y="6" rx="2" ry="2"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 8-6 4 6 4V8Z"/><rect width="14" height="12" x="2" y="6" rx="2" ry="2"/></svg>
@@ -762,16 +748,17 @@ document.addEventListener('DOMContentLoaded', () => {
`; `;
li.addEventListener('click', (e) => { li.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
selectVideo(key, li); selectVideo(key, li, hasTranscodeCache, hasDownloadCache);
}); });
return li; return li;
}; };
const renderTree = (node, container) => { const renderTree = (node, container) => {
for (const [name, value] of Object.entries(node)) { for (const [name, value] of Object.entries(node)) {
if (name === '__file') continue; if (name === '__file' || name === '__hasTranscodeCache' || name === '__hasDownloadCache') continue;
const childKeys = Object.keys(value).filter(key => key !== '__file'); const childKeys = Object.keys(value).filter(k => k !== '__file' && k !== '__hasTranscodeCache' && k !== '__hasDownloadCache');
const hasChildren = childKeys.length > 0; const hasChildren = childKeys.length > 0;
const isFile = typeof value.__file === 'string'; const isFile = typeof value.__file === 'string';
@@ -802,13 +789,13 @@ document.addEventListener('DOMContentLoaded', () => {
li.appendChild(folderHeader); li.appendChild(folderHeader);
if (isFile) { if (isFile) {
subListContainer.appendChild(createFileItem(name, value.__file)); subListContainer.appendChild(createFileItem(name, value.__file, value.__hasTranscodeCache, value.__hasDownloadCache));
} }
renderTree(value, subListContainer); renderTree(value, subListContainer);
li.appendChild(subListContainer); li.appendChild(subListContainer);
container.appendChild(li); container.appendChild(li);
} else if (isFile) { } else if (isFile) {
container.appendChild(createFileItem(name, value.__file)); container.appendChild(createFileItem(name, value.__file, value.__hasTranscodeCache, value.__hasDownloadCache));
} }
} }
}; };
@@ -821,7 +808,7 @@ document.addEventListener('DOMContentLoaded', () => {
}; };
// Handle video selection // Handle video selection
const selectVideo = (key, listItemNode) => { const selectVideo = (key, listItemNode, hasTranscodeCache = false, hasDownloadCache = false) => {
document.querySelectorAll('.video-item').forEach(n => n.classList.remove('active')); document.querySelectorAll('.video-item').forEach(n => n.classList.remove('active'));
listItemNode.classList.add('active'); listItemNode.classList.add('active');
@@ -855,6 +842,16 @@ document.addEventListener('DOMContentLoaded', () => {
stopTranscodeBtn.disabled = false; stopTranscodeBtn.disabled = false;
stopTranscodeBtn.textContent = 'Stop Transcode'; stopTranscodeBtn.textContent = 'Stop Transcode';
} }
if (clearPlayingDownloadBtn) {
if (hasDownloadCache) clearPlayingDownloadBtn.classList.remove('hidden');
else clearPlayingDownloadBtn.classList.add('hidden');
}
if (clearPlayingTranscodeBtn) {
if (hasTranscodeCache) clearPlayingTranscodeBtn.classList.remove('hidden');
else clearPlayingTranscodeBtn.classList.add('hidden');
}
if (playBtn) { if (playBtn) {
playBtn.classList.add('hidden'); playBtn.classList.add('hidden');
} }
@@ -1099,12 +1096,56 @@ document.addEventListener('DOMContentLoaded', () => {
// Bind events // Bind events
refreshBtn.addEventListener('click', () => fetchVideos(selectedBucket)); refreshBtn.addEventListener('click', () => fetchVideos(selectedBucket));
if (clearDownloadCacheBtn) {
clearDownloadCacheBtn.addEventListener('click', clearDownloadCache);
}
if (stopTranscodeBtn) { if (stopTranscodeBtn) {
stopTranscodeBtn.addEventListener('click', stopTranscode); stopTranscodeBtn.addEventListener('click', stopTranscode);
} }
if (clearPlayingDownloadBtn) {
clearPlayingDownloadBtn.addEventListener('click', async () => {
if (!currentVideoKey) return;
clearPlayingDownloadBtn.disabled = true;
clearPlayingDownloadBtn.textContent = '清空中...';
try {
const res = await fetch('/api/clear-video-download-cache', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bucket: selectedBucket, key: currentVideoKey })
});
if (!res.ok) throw new Error('清空失败');
clearPlayingDownloadBtn.classList.add('hidden');
fetchVideos(selectedBucket);
} catch (err) {
alert('清空下载缓存失败: ' + err.message);
} finally {
clearPlayingDownloadBtn.disabled = false;
clearPlayingDownloadBtn.textContent = '清空下载缓存';
}
});
}
if (clearPlayingTranscodeBtn) {
clearPlayingTranscodeBtn.addEventListener('click', async () => {
if (!currentVideoKey) return;
clearPlayingTranscodeBtn.disabled = true;
clearPlayingTranscodeBtn.textContent = '清空中...';
try {
const res = await fetch('/api/clear-video-transcode-cache', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bucket: selectedBucket, key: currentVideoKey })
});
if (!res.ok) throw new Error('清空失败');
clearPlayingTranscodeBtn.classList.add('hidden');
fetchVideos(selectedBucket);
} catch (err) {
alert('清空转码缓存失败: ' + err.message);
} finally {
clearPlayingTranscodeBtn.disabled = false;
clearPlayingTranscodeBtn.textContent = '清空转码缓存';
}
});
}
if (loginBtn) { if (loginBtn) {
loginBtn.addEventListener('click', login); loginBtn.addEventListener('click', login);
} }
@@ -1120,11 +1161,11 @@ document.addEventListener('DOMContentLoaded', () => {
const sessionId = localStorage.getItem('sessionId'); const sessionId = localStorage.getItem('sessionId');
if (sessionId) { if (sessionId) {
try { try {
await fetch('/api/logout', { await fetch('/api/logout', {
method: 'POST', method: 'POST',
headers: { 'X-Session-ID': sessionId } headers: { 'X-Session-ID': sessionId }
}); });
} catch (e) { } } catch(e){}
} }
clearSessionAuth(); clearSessionAuth();
location.reload(); location.reload();
@@ -1143,7 +1184,7 @@ document.addEventListener('DOMContentLoaded', () => {
const savedSession = localStorage.getItem('sessionId'); const savedSession = localStorage.getItem('sessionId');
const savedUsername = localStorage.getItem('username'); const savedUsername = localStorage.getItem('username');
const savedTheme = localStorage.getItem('theme') || 'light'; const savedTheme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', savedTheme); document.documentElement.setAttribute('data-theme', savedTheme);
if (themeSelector) themeSelector.value = savedTheme; if (themeSelector) themeSelector.value = savedTheme;

393
server.js
View File

@@ -7,7 +7,7 @@ const path = require('path');
const http = require('http'); const http = require('http');
const WebSocket = require('ws'); const WebSocket = require('ws');
const ffmpeg = require('fluent-ffmpeg'); const ffmpeg = require('fluent-ffmpeg');
const { S3Client, ListBucketsCommand, ListObjectsV2Command, GetObjectCommand } = require('@aws-sdk/client-s3'); const { S3Client, ListBucketsCommand, ListObjectsV2Command, GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
const crypto = require('crypto'); const crypto = require('crypto');
const Redis = require('ioredis'); const Redis = require('ioredis');
@@ -59,10 +59,10 @@ const logger = winston.createLogger({
] ]
}); });
console.log = function (...args) { logger.info(util.format(...args)); }; console.log = function(...args) { logger.info(util.format(...args)); };
console.info = function (...args) { logger.info(util.format(...args)); }; console.info = function(...args) { logger.info(util.format(...args)); };
console.warn = function (...args) { logger.warn(util.format(...args)); }; console.warn = function(...args) { logger.warn(util.format(...args)); };
console.error = function (...args) { logger.error(util.format(...args)); }; console.error = function(...args) { logger.error(util.format(...args)); };
process.on('uncaughtException', (err) => { process.on('uncaughtException', (err) => {
logger.error(`Uncaught Exception: ${err.message}`, err); logger.error(`Uncaught Exception: ${err.message}`, err);
@@ -142,14 +142,11 @@ const createS3Client = (credentials) => {
const progressMap = {}; const progressMap = {};
const transcodeProcesses = new Map(); const transcodeProcesses = new Map();
const wsSubscriptions = new Map(); const wsSubscriptions = new Map();
const activeDownloads = new Map();
const AVAILABLE_VIDEO_ENCODERS = [ const AVAILABLE_VIDEO_ENCODERS = [
{ value: 'h264_qsv', label: 'H.264(Intel QSV)' }, { value: 'h264_rkmpp', label: 'H.264(RKMPP HighSpeed)' },
{ value: 'hevc_qsv', label: 'H.265(Intel QSV)' }, { value: 'hevc_rkmpp', label: 'H.265(RKMPP HighSpeed)' },
{ value: 'h264_nvenc', label: 'H.264(Nvidia NVENC)' },
{ value: 'hevc_nvenc', label: 'H.265(Nvidia NVENC)' },
{ value: 'h264_vaapi', label: 'H.264(VAAPI)' },
{ value: 'hevc_vaapi', label: 'H.265(VAAPI)' },
{ value: 'libx264', label: 'H.264(Software Slow)' }, { value: 'libx264', label: 'H.264(Software Slow)' },
{ value: 'libx265', label: 'H.265(Software Slow)' } { value: 'libx265', label: 'H.265(Software Slow)' }
]; ];
@@ -200,14 +197,35 @@ const createFfmpegOptions = (encoderName) => {
options.push('-preset', 'fast', '-global_quality', '23'); options.push('-preset', 'fast', '-global_quality', '23');
} else if (/_vaapi$/.test(encoderName)) { } else if (/_vaapi$/.test(encoderName)) {
options.push('-qp', '23'); options.push('-qp', '23');
} else if (/_rkmpp$/.test(encoderName)) {
options.push('-qp_init', '23', '-pix_fmt', 'nv12');
} }
return options; return options;
}; };
const isRkmppCodec = (codecName) => /_rkmpp$/.test(codecName);
const isVaapiCodec = (codecName) => /_vaapi$/.test(codecName); const isVaapiCodec = (codecName) => /_vaapi$/.test(codecName);
const availableEncoderValues = new Set(AVAILABLE_VIDEO_ENCODERS.map((item) => item.value)); const availableEncoderValues = new Set(AVAILABLE_VIDEO_ENCODERS.map((item) => item.value));
const availableDecoderValues = new Set(AVAILABLE_VIDEO_DECODERS.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) => { const parseFpsValue = (fpsText) => {
if (typeof fpsText !== 'string' || !fpsText.trim()) { if (typeof fpsText !== 'string' || !fpsText.trim()) {
return 0; return 0;
@@ -246,6 +264,8 @@ const getSeekFriendlyOutputOptions = (encoderName, metadata) => {
options.push('-forced-idr', '1', '-force_key_frames', 'expr:gte(t,n_forced*2)'); options.push('-forced-idr', '1', '-force_key_frames', 'expr:gte(t,n_forced*2)');
} else if (/_qsv$/.test(encoderName)) { } else if (/_qsv$/.test(encoderName)) {
options.push('-idr_interval', '1'); options.push('-idr_interval', '1');
} else if (/_rkmpp$/.test(encoderName)) {
options.push('-force_key_frames', 'expr:gte(t,n_forced*2)');
} }
return options; return options;
@@ -253,7 +273,7 @@ const getSeekFriendlyOutputOptions = (encoderName, metadata) => {
const shouldRetryWithSoftware = (message) => { const shouldRetryWithSoftware = (message) => {
if (!message) return false; 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) => { const probeFile = (filePath) => {
@@ -367,28 +387,131 @@ wss.on('connection', (ws) => {
ws.on('close', () => removeWsClient(ws)); ws.on('close', () => removeWsClient(ws));
}); });
const ensureS3Downloaded = async (s3Client, bucket, key, tmpInputPath, progressKey, streamSessionId) => {
const clearDownloadCache = () => { if (activeDownloads.has(progressKey)) {
const tmpDir = CACHE_DIR; try {
try { await activeDownloads.get(progressKey);
if (!fs.existsSync(tmpDir)) return; } catch (err) {
const files = fs.readdirSync(tmpDir); // Ignore error and retry if previous failed
for (const file of files) {
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 });
}
} }
}
let shouldDownload = true;
let s3Metadata = null;
if (fs.existsSync(tmpInputPath)) {
const stats = fs.statSync(tmpInputPath);
const localSize = stats.size;
try {
const headCommand = new HeadObjectCommand({ Bucket: bucket, Key: key });
s3Metadata = await s3Client.send(headCommand);
if (s3Metadata.ContentLength === localSize) {
shouldDownload = false;
console.log(`[Cache] Verified ${key}: Local size matches S3 (${localSize} bytes).`);
} else {
console.log(`[Cache] Mismatch for ${key}: Local ${localSize} vs S3 ${s3Metadata.ContentLength}. Re-downloading.`);
}
} catch (err) {
console.error(`[Cache] Failed to verify S3 metadata for ${key}:`, err.message);
}
}
if (!shouldDownload) {
progressMap[progressKey] = {
status: 'downloaded',
percent: 100,
downloadedBytes: s3Metadata.ContentLength,
totalBytes: s3Metadata.ContentLength,
streamSessionId,
details: 'Source cached locally and verified against S3...',
mp4Url: null
};
broadcastWs(progressKey, { type: 'progress', key, progress: progressMap[progressKey] });
return;
}
const downloadPromise = (async () => {
const command = new GetObjectCommand({ Bucket: bucket, Key: key });
const response = await s3Client.send(command);
const totalBytes = response.ContentLength || 0;
const s3Stream = response.Body;
let downloadedBytes = 0;
const downloadingPath = tmpInputPath + '.downloading';
progressMap[progressKey] = {
status: 'downloading',
percent: 0,
downloadedBytes: 0,
totalBytes,
streamSessionId,
details: 'Downloading full source...',
mp4Url: null
};
broadcastWs(progressKey, { type: 'progress', key, progress: progressMap[progressKey] });
await new Promise((resolve, reject) => {
const writeStream = fs.createWriteStream(downloadingPath);
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', () => {
try {
fs.renameSync(downloadingPath, tmpInputPath);
resolve();
} catch (e) {
reject(e);
}
});
s3Stream.pipe(writeStream);
});
progressMap[progressKey] = {
status: 'downloaded',
percent: 100,
downloadedBytes,
totalBytes,
streamSessionId,
details: 'Source download complete...',
mp4Url: null
};
broadcastWs(progressKey, { type: 'progress', key, progress: progressMap[progressKey] });
})();
activeDownloads.set(progressKey, downloadPromise);
try {
await downloadPromise;
} catch (err) { } catch (err) {
console.error('Failed to clear download cache:', err); if (fs.existsSync(tmpInputPath + '.downloading')) {
fs.rmSync(tmpInputPath + '.downloading', { force: true });
}
throw err; throw err;
} finally {
activeDownloads.delete(progressKey);
} }
}; };
app.get('/api/buckets', async (req, res) => { app.get('/api/buckets', async (req, res) => {
try { try {
const auth = await extractS3Credentials(req); const auth = await extractS3Credentials(req);
@@ -438,6 +561,17 @@ app.get('/api/videos', async (req, res) => {
if (!key) return false; if (!key) return false;
const lowerKey = key.toLowerCase(); const lowerKey = key.toLowerCase();
return videoExtensions.some(ext => lowerKey.endsWith(ext)); return videoExtensions.some(ext => lowerKey.endsWith(ext));
})
.map(key => {
const safeBucket = bucket.replace(/[^a-z0-9]/gi, '_');
const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-z0-9]/gi, '_'));
const hlsDir = path.join(CACHE_DIR, `hls-${safeBucket}-${safeKeySegments.join('-')}`);
const tmpInputPath = path.join(CACHE_DIR, `s3-input-${safeBucket}-${safeKeySegments.join('-')}.tmp`);
return {
key: key,
hasTranscodeCache: fs.existsSync(hlsDir),
hasDownloadCache: fs.existsSync(tmpInputPath)
};
}); });
res.json({ videos }); res.json({ videos });
@@ -480,19 +614,49 @@ app.get('/api/config', (req, res) => {
title, title,
ffmpegPath: JELLYFIN_FFMPEG_PATH, ffmpegPath: JELLYFIN_FFMPEG_PATH,
ffprobePath: JELLYFIN_FFPROBE_PATH, ffprobePath: JELLYFIN_FFPROBE_PATH,
defaultVideoEncoder: 'h264_qsv', defaultVideoEncoder: 'h264_rkmpp',
defaultVideoDecoder: 'auto', defaultVideoDecoder: 'auto',
videoEncoders: AVAILABLE_VIDEO_ENCODERS, videoEncoders: AVAILABLE_VIDEO_ENCODERS,
videoDecoders: AVAILABLE_VIDEO_DECODERS videoDecoders: AVAILABLE_VIDEO_DECODERS
}); });
}); });
app.post('/api/clear-download-cache', (req, res) => { app.post('/api/clear-video-transcode-cache', async (req, res) => {
try { try {
clearDownloadCache(); const { bucket, key } = req.body;
res.json({ message: 'Download cache cleared' }); if (!bucket || !key) {
return res.status(400).json({ error: 'Bucket and key are required' });
}
const safeBucket = bucket.replace(/[^a-z0-9]/gi, '_');
const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-z0-9]/gi, '_'));
const hlsDir = path.join(CACHE_DIR, `hls-${safeBucket}-${safeKeySegments.join('-')}`);
if (fs.existsSync(hlsDir)) {
fs.rmSync(hlsDir, { recursive: true, force: true });
}
res.json({ message: 'Transcode cache cleared for video' });
} catch (error) { } catch (error) {
console.error('Error clearing download cache:', error); console.error('Error clearing video transcode cache:', error);
res.status(500).json({ error: 'Failed to clear transcode cache', detail: error.message });
}
});
app.post('/api/clear-video-download-cache', async (req, res) => {
try {
const { bucket, key } = req.body;
if (!bucket || !key) {
return res.status(400).json({ error: 'Bucket and key are required' });
}
const safeBucket = bucket.replace(/[^a-z0-9]/gi, '_');
const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-z0-9]/gi, '_'));
const tmpInputPath = path.join(CACHE_DIR, `s3-input-${safeBucket}-${safeKeySegments.join('-')}.tmp`);
if (fs.existsSync(tmpInputPath)) {
fs.rmSync(tmpInputPath, { force: true });
}
res.json({ message: 'Download cache cleared for video' });
} catch (error) {
console.error('Error clearing video download cache:', error);
res.status(500).json({ error: 'Failed to clear download cache', detail: error.message }); res.status(500).json({ error: 'Failed to clear download cache', detail: error.message });
} }
}); });
@@ -556,84 +720,14 @@ app.get('/api/hls/playlist.m3u8', async (req, res) => {
const auth = await extractS3Credentials(req); const auth = await extractS3Credentials(req);
const s3Client = createS3Client(auth); const s3Client = createS3Client(auth);
let totalBytes = 0;
const progressKey = getProgressKey(key); const progressKey = getProgressKey(key);
const streamSessionId = createStreamSessionId(); const streamSessionId = createStreamSessionId();
let downloadedBytes = 0;
if (!fs.existsSync(tmpInputPath)) { try {
try { await ensureS3Downloaded(s3Client, bucket, key, tmpInputPath, progressKey, streamSessionId);
const command = new GetObjectCommand({ Bucket: bucket, Key: key }); } catch (err) {
const response = await s3Client.send(command); console.error('S3 Download Failed:', err);
totalBytes = response.ContentLength; return res.status(500).send('S3 Download Failed');
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; let duration = 0;
@@ -652,7 +746,7 @@ app.get('/api/hls/playlist.m3u8', async (req, res) => {
if (i === totalSegments - 1 && duration % HLS_SEGMENT_TIME !== 0) { if (i === totalSegments - 1 && duration % HLS_SEGMENT_TIME !== 0) {
segDur = (duration % HLS_SEGMENT_TIME) || HLS_SEGMENT_TIME; 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_qsv'}&decoder=${req.query.decoder || 'auto'}\n`; 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`; m3u8 += `#EXT-X-ENDLIST\n`;
@@ -684,7 +778,7 @@ app.get('/api/hls/segment.ts', async (req, res) => {
const bucket = req.query.bucket; const bucket = req.query.bucket;
const key = req.query.key; const key = req.query.key;
const seg = parseInt(req.query.seg || '0'); const seg = parseInt(req.query.seg || '0');
const requestedEncoder = req.query.encoder || 'h264_qsv'; const requestedEncoder = req.query.encoder || 'h264_rkmpp';
const requestedDecoder = req.query.decoder || 'auto'; const requestedDecoder = req.query.decoder || 'auto';
if (!bucket || !key || isNaN(seg)) return res.status(400).send('Bad Request'); if (!bucket || !key || isNaN(seg)) return res.status(400).send('Bad Request');
@@ -732,7 +826,7 @@ app.get('/api/hls/segment.ts', async (req, res) => {
let sourceMetadata = null; let sourceMetadata = null;
try { sourceMetadata = await probeFile(tmpInputPath); } catch (e) { } try { sourceMetadata = await probeFile(tmpInputPath); } catch (e) { }
const encoderName = availableEncoderValues.has(requestedEncoder) ? requestedEncoder : 'h264_qsv'; const encoderName = availableEncoderValues.has(requestedEncoder) ? requestedEncoder : 'h264_rkmpp';
const decoderName = availableDecoderValues.has(requestedDecoder) ? requestedDecoder : 'auto'; const decoderName = availableDecoderValues.has(requestedDecoder) ? requestedDecoder : 'auto';
const m3u8Path = path.join(hlsDir, `temp.m3u8`); const m3u8Path = path.join(hlsDir, `temp.m3u8`);
@@ -746,7 +840,7 @@ app.get('/api/hls/segment.ts', async (req, res) => {
if (isVaapiCodec(encoderName)) { if (isVaapiCodec(encoderName)) {
ffmpegCommand.inputOptions(['-vaapi_device', '/dev/dri/renderD128']).videoFilters('format=nv12,hwupload'); ffmpegCommand.inputOptions(['-vaapi_device', '/dev/dri/renderD128']).videoFilters('format=nv12,hwupload');
} }
const resolvedDecoderName = decoderName; const resolvedDecoderName = decoderName === 'auto' && isRkmppCodec(encoderName) ? getRkmppDecoderName(sourceMetadata) : decoderName;
if (resolvedDecoderName && resolvedDecoderName !== 'auto') ffmpegCommand.inputOptions(['-c:v', resolvedDecoderName]); if (resolvedDecoderName && resolvedDecoderName !== 'auto') ffmpegCommand.inputOptions(['-c:v', resolvedDecoderName]);
const segmentFilename = path.join(hlsDir, `segment_%d.ts`); const segmentFilename = path.join(hlsDir, `segment_%d.ts`);
@@ -822,7 +916,7 @@ app.get('/api/stream', async (req, res) => {
const bucket = req.query.bucket; const bucket = req.query.bucket;
const key = req.query.key; const key = req.query.key;
const requestedDecoder = typeof req.query.decoder === 'string' ? req.query.decoder.trim() : 'auto'; const requestedDecoder = typeof req.query.decoder === 'string' ? req.query.decoder.trim() : 'auto';
const requestedEncoder = typeof req.query.encoder === 'string' ? req.query.encoder.trim() : 'h264_qsv'; const requestedEncoder = typeof req.query.encoder === 'string' ? req.query.encoder.trim() : 'h264_rkmpp';
const startSeconds = parseFloat(req.query.ss) || 0; const startSeconds = parseFloat(req.query.ss) || 0;
const streamSessionId = typeof req.query.streamSessionId === 'string' && req.query.streamSessionId.trim() const streamSessionId = typeof req.query.streamSessionId === 'string' && req.query.streamSessionId.trim()
? req.query.streamSessionId.trim() ? req.query.streamSessionId.trim()
@@ -835,7 +929,7 @@ app.get('/api/stream', async (req, res) => {
return res.status(400).json({ error: 'Video key is required' }); return res.status(400).json({ error: 'Video key is required' });
} }
const videoEncoder = availableEncoderValues.has(requestedEncoder) ? requestedEncoder : 'h264_qsv'; const videoEncoder = availableEncoderValues.has(requestedEncoder) ? requestedEncoder : 'h264_rkmpp';
const requestedVideoDecoder = availableDecoderValues.has(requestedDecoder) ? requestedDecoder : 'auto'; const requestedVideoDecoder = availableDecoderValues.has(requestedDecoder) ? requestedDecoder : 'auto';
const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_')); const safeKeySegments = key.split('/').map(segment => segment.replace(/[^a-zA-Z0-9_\-]/g, '_'));
@@ -862,74 +956,7 @@ app.get('/api/stream', async (req, res) => {
broadcastWs(progressKey, { type: 'progress', key, progress: progressMap[progressKey] }); broadcastWs(progressKey, { type: 'progress', key, progress: progressMap[progressKey] });
} }
let totalBytes = 0; await ensureS3Downloaded(s3Client, bucket, key, tmpInputPath, progressKey, streamSessionId);
let downloadedBytes = 0;
if (!cacheExists) {
const command = new GetObjectCommand({ Bucket: bucket, Key: key });
const response = await s3Client.send(command);
const s3Stream = response.Body;
totalBytes = response.ContentLength || 0;
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, starting real-time transcode...',
mp4Url: null
};
broadcastWs(progressKey, { type: 'progress', key, progress: progressMap[progressKey] });
} 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, starting real-time transcode...',
mp4Url: null
};
broadcastWs(progressKey, { type: 'progress', key, progress: progressMap[progressKey] });
}
// Probe file for duration and broadcast to clients // Probe file for duration and broadcast to clients
let sourceMetadata = null; let sourceMetadata = null;
@@ -976,7 +1003,9 @@ app.get('/api/stream', async (req, res) => {
.videoFilters('format=nv12,hwupload'); .videoFilters('format=nv12,hwupload');
} }
const resolvedDecoderName = decoderName; const resolvedDecoderName = decoderName === 'auto' && isRkmppCodec(encoderName)
? getRkmppDecoderName(sourceMetadata)
: decoderName;
if (resolvedDecoderName && resolvedDecoderName !== 'auto') { if (resolvedDecoderName && resolvedDecoderName !== 'auto') {
ffmpegCommand.inputOptions(['-c:v', resolvedDecoderName]); ffmpegCommand.inputOptions(['-c:v', resolvedDecoderName]);