Compare commits

...

8 Commits

Author SHA1 Message Date
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 267 additions and 242 deletions

View File

@@ -1,27 +1,57 @@
# 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**:
- 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`.
## Features
2. **AWS S3 / MinIO Configuration**:
- Modify the `.env` file (copy from `.env.example`).
- Add your `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`, and `S3_BUCKET_NAME`.
- If using **MinIO**, ensure you set the `S3_ENDPOINT` (e.g., `http://127.0.0.1:9000`) and set `S3_FORCE_PATH_STYLE=true`.
- Ensure your bucket has `.mp4` video files.
- **High-Performance HLS Streaming:** Streams video using HLS for fast startup, localized buffering, native browser video controls, and frame-accurate seeking.
- **Hardware-Accelerated Encoding:** Detects and seamlessly supports Intel QSV, NVIDIA NVENC, and VAAPI hardware encoders natively via FFmpeg.
- **AWS S3 / MinIO Integration:** Securely downloads media from S3-compatible endpoints to a dedicated local cache directory before processing.
- **Persistent Sessions:** Utilizes Valkey (Redis) for stateful session management, eliminating repetitive login actions.
- **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
npm install
npm start
```
4. **Test Delivery**:
- Open your browser to `http://localhost:3000`.
- The application should display available `.mp4` items from your S3 bucket.
- 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.
- The frontend polls via `/api/status`, and once the index playlist is available, HLS playback starts!
2. **Establish Environment Variables**:
Create a pristine copy of the `.env.example` structure:
```bash
cp .env.example .env
```
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 {
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
@@ -136,35 +137,37 @@ header p {
display: flex;
align-items: 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;
padding: 2rem;
}
.login-card {
width: min(480px, 100%);
background: #ffffff;
border: 1px solid rgba(148, 163, 184, 0.3);
background: var(--panel-bg);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--panel-border);
border-radius: 24px;
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 {
margin: 0 0 0.75rem;
font-size: 1.75rem;
color: #111827;
color: var(--text-primary);
}
.login-card p {
color: #6b7280;
color: var(--text-secondary);
margin: 0 0 1.5rem;
}
.login-card label {
display: block;
margin-bottom: 1rem;
color: #4b5563;
color: var(--text-secondary);
font-size: 0.95rem;
}
@@ -175,17 +178,17 @@ header p {
.login-card input {
width: 100%;
border: 1px solid rgba(148, 163, 184, 0.5);
background: #f8fafc;
color: #111827;
border: 1px solid var(--panel-border);
background: rgba(255, 255, 255, 0.08);
color: var(--text-primary);
border-radius: 12px;
padding: 0.85rem 1rem;
outline: none;
}
.login-card input:focus {
border-color: #2563eb;
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.12);
border-color: var(--accent);
box-shadow: 0 0 0 4px var(--accent-glow);
}
.login-card .primary-btn {
@@ -300,13 +303,6 @@ header p {
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 {
display: flex;
flex-wrap: nowrap;
@@ -315,6 +311,12 @@ header p {
justify-content: flex-start;
}
.banner-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.action-btn {
border: none;
background: #2563eb;

View File

@@ -42,12 +42,14 @@
<label>页面主题:
<select id="theme-selector">
<option value="light">白天模式</option>
<option value="dark">夜模式</option>
<option value="dark">夜模式</option>
</select>
</label>
<div class="banner-actions">
<button id="refresh-btn" class="action-btn" title="刷新列表">刷新列表</button>
</div>
<div class="user-info">
<span id="current-username"></span>
<button id="logout-btn" class="action-btn">退出登录</button>
<button id="logout-btn" class="action-btn danger">退出登录</button>
</div>
</div>
</div>
@@ -55,13 +57,7 @@
<main class="dashboard">
<section class="video-list-section glass-panel">
<div class="section-header">
<h2>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>
<h2 id="video-list-header">Available Videos</h2>
</div>
<div class="bucket-panel">
<label for="bucket-select">Select Bucket</label>

View File

@@ -2,7 +2,6 @@ document.addEventListener('DOMContentLoaded', () => {
const videoListEl = document.getElementById('video-list');
const loadingSpinner = document.getElementById('loading-spinner');
const refreshBtn = document.getElementById('refresh-btn');
const clearDownloadCacheBtn = document.getElementById('clear-download-cache-btn');
const bucketSelect = document.getElementById('bucket-select');
const loginScreen = document.getElementById('login-screen');
const appContainer = document.getElementById('app-container');
@@ -20,7 +19,7 @@ document.addEventListener('DOMContentLoaded', () => {
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 videoListHeader = document.getElementById('video-list-header');
const logoutBtn = document.getElementById('logout-btn');
const topBannerTitle = document.getElementById('top-banner-title');
const playBtn = document.getElementById('play-btn');
@@ -586,7 +585,7 @@ document.addEventListener('DOMContentLoaded', () => {
localStorage.setItem('sessionId', sessionId);
if (username) {
localStorage.setItem('username', username);
if (currentUsername) currentUsername.textContent = username;
if (videoListHeader) videoListHeader.textContent = `${username} Available Videos`;
}
};
@@ -674,26 +673,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) {
transcodeBtn.addEventListener('click', () => {
@@ -733,7 +713,8 @@ document.addEventListener('DOMContentLoaded', () => {
// Build a tree structure from S3 keys
const tree = {};
data.videos.forEach(key => {
data.videos.forEach(vid => {
const key = vid.key;
const parts = key.split('/');
let current = tree;
parts.forEach((part, index) => {
@@ -742,15 +723,22 @@ document.addEventListener('DOMContentLoaded', () => {
}
if (index === parts.length - 1) {
current[part].__file = key;
current[part].__hasTranscodeCache = vid.hasTranscodeCache;
}
current = current[part];
});
});
const createFileItem = (name, key) => {
const createFileItem = (name, key, hasTranscodeCache) => {
const li = document.createElement('li');
li.className = 'video-item file-item';
const ext = name.split('.').pop().toUpperCase();
let cacheButtonHtml = '';
if (hasTranscodeCache) {
cacheButtonHtml = `<button class="action-btn danger clear-video-cache-btn" data-key="${key}" style="padding: 0.3rem 0.6rem; font-size: 0.75rem; margin-left: auto;" title="清空转码缓存">清空转码缓存</button>`;
}
li.innerHTML = `
<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>
@@ -759,19 +747,44 @@ document.addEventListener('DOMContentLoaded', () => {
<div class="video-title" title="${key}">${name}</div>
<div class="video-meta">Video / ${ext}</div>
</div>
${cacheButtonHtml}
`;
li.addEventListener('click', (e) => {
e.stopPropagation();
selectVideo(key, li);
});
const clearBtn = li.querySelector('.clear-video-cache-btn');
if (clearBtn) {
clearBtn.addEventListener('click', async (e) => {
e.stopPropagation();
clearBtn.disabled = true;
clearBtn.textContent = '清空中...';
try {
const res = await fetch('/api/clear-video-transcode-cache', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bucket: selectedBucket, key: key })
});
if (!res.ok) throw new Error('Failed to clear cache');
fetchVideos(selectedBucket);
} catch (err) {
console.error(err);
alert('清空转码缓存失败: ' + err.message);
clearBtn.disabled = false;
clearBtn.textContent = '清空转码缓存';
}
});
}
return li;
};
const renderTree = (node, container) => {
for (const [name, value] of Object.entries(node)) {
if (name === '__file') continue;
if (name === '__file' || name === '__hasTranscodeCache') continue;
const childKeys = Object.keys(value).filter(key => key !== '__file');
const childKeys = Object.keys(value).filter(k => k !== '__file' && k !== '__hasTranscodeCache');
const hasChildren = childKeys.length > 0;
const isFile = typeof value.__file === 'string';
@@ -802,13 +815,13 @@ document.addEventListener('DOMContentLoaded', () => {
li.appendChild(folderHeader);
if (isFile) {
subListContainer.appendChild(createFileItem(name, value.__file));
subListContainer.appendChild(createFileItem(name, value.__file, value.__hasTranscodeCache));
}
renderTree(value, subListContainer);
li.appendChild(subListContainer);
container.appendChild(li);
} else if (isFile) {
container.appendChild(createFileItem(name, value.__file));
container.appendChild(createFileItem(name, value.__file, value.__hasTranscodeCache));
}
}
};
@@ -1099,9 +1112,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Bind events
refreshBtn.addEventListener('click', () => fetchVideos(selectedBucket));
if (clearDownloadCacheBtn) {
clearDownloadCacheBtn.addEventListener('click', clearDownloadCache);
}
if (stopTranscodeBtn) {
stopTranscodeBtn.addEventListener('click', stopTranscode);
}

314
server.js
View File

@@ -7,7 +7,7 @@ const path = require('path');
const http = require('http');
const WebSocket = require('ws');
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 Redis = require('ioredis');
@@ -142,6 +142,7 @@ const createS3Client = (credentials) => {
const progressMap = {};
const transcodeProcesses = new Map();
const wsSubscriptions = new Map();
const activeDownloads = new Map();
const AVAILABLE_VIDEO_ENCODERS = [
{ value: 'h264_rkmpp', label: 'H.264(RKMPP HighSpeed)' },
@@ -386,28 +387,131 @@ wss.on('connection', (ws) => {
ws.on('close', () => removeWsClient(ws));
});
const clearDownloadCache = () => {
const tmpDir = CACHE_DIR;
try {
if (!fs.existsSync(tmpDir)) return;
const files = fs.readdirSync(tmpDir);
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 });
}
const ensureS3Downloaded = async (s3Client, bucket, key, tmpInputPath, progressKey, streamSessionId) => {
if (activeDownloads.has(progressKey)) {
try {
await activeDownloads.get(progressKey);
} catch (err) {
// Ignore error and retry if previous failed
}
}
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) {
console.error('Failed to clear download cache:', err);
if (fs.existsSync(tmpInputPath + '.downloading')) {
fs.rmSync(tmpInputPath + '.downloading', { force: true });
}
throw err;
} finally {
activeDownloads.delete(progressKey);
}
};
app.get('/api/buckets', async (req, res) => {
try {
const auth = await extractS3Credentials(req);
@@ -457,6 +561,15 @@ app.get('/api/videos', async (req, res) => {
if (!key) return false;
const lowerKey = key.toLowerCase();
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('-')}`);
return {
key: key,
hasTranscodeCache: fs.existsSync(hlsDir)
};
});
res.json({ videos });
@@ -506,13 +619,23 @@ app.get('/api/config', (req, res) => {
});
});
app.post('/api/clear-download-cache', (req, res) => {
app.post('/api/clear-video-transcode-cache', async (req, res) => {
try {
clearDownloadCache();
res.json({ message: 'Download cache cleared' });
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 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) {
console.error('Error clearing download cache:', error);
res.status(500).json({ error: 'Failed to clear download cache', detail: error.message });
console.error('Error clearing video transcode cache:', error);
res.status(500).json({ error: 'Failed to clear transcode cache', detail: error.message });
}
});
@@ -575,84 +698,14 @@ app.get('/api/hls/playlist.m3u8', async (req, res) => {
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] });
try {
await ensureS3Downloaded(s3Client, bucket, key, tmpInputPath, progressKey, streamSessionId);
} catch (err) {
console.error('S3 Download Failed:', err);
return res.status(500).send('S3 Download Failed');
}
let duration = 0;
@@ -881,74 +934,7 @@ app.get('/api/stream', async (req, res) => {
broadcastWs(progressKey, { type: 'progress', key, progress: progressMap[progressKey] });
}
let totalBytes = 0;
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] });
}
await ensureS3Downloaded(s3Client, bucket, key, tmpInputPath, progressKey, streamSessionId);
// Probe file for duration and broadcast to clients
let sourceMetadata = null;