使用VALKEY缓存登录装唐
This commit is contained in:
@@ -11,3 +11,7 @@ S3_FORCE_PATH_STYLE=true
|
|||||||
|
|
||||||
# Application Title
|
# Application Title
|
||||||
APP_TITLE=S3 Media Transcoder
|
APP_TITLE=S3 Media Transcoder
|
||||||
|
|
||||||
|
# Session Cache (Valkey / Redis)
|
||||||
|
VALKEY_URL=redis://localhost:6379
|
||||||
|
VALKEY_DB=0
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ module.exports = {
|
|||||||
S3_BUCKET_ADDRESS: 'https://s3.example.com/your_bucket_name',
|
S3_BUCKET_ADDRESS: 'https://s3.example.com/your_bucket_name',
|
||||||
S3_ENDPOINT: 'http://127.0.0.1:9000',
|
S3_ENDPOINT: 'http://127.0.0.1:9000',
|
||||||
S3_FORCE_PATH_STYLE: 'true',
|
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",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"fluent-ffmpeg": "^2.1.3",
|
"fluent-ffmpeg": "^2.1.3",
|
||||||
|
"ioredis": "^5.3.0",
|
||||||
"ws": "^8.13.0"
|
"ws": "^8.13.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,17 @@
|
|||||||
--font-main: 'Inter', sans-serif;
|
--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;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -109,7 +120,7 @@ header p {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard > * {
|
.dashboard>* {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,10 +258,37 @@ header p {
|
|||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 700;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
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 {
|
.top-banner.hidden {
|
||||||
@@ -699,8 +737,15 @@ header p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes statusPulse {
|
@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 {
|
.control-btn {
|
||||||
@@ -907,8 +952,15 @@ header p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes phaseIn {
|
@keyframes phaseIn {
|
||||||
from { opacity: 0; transform: translateY(12px); }
|
from {
|
||||||
to { opacity: 1; transform: translateY(0); }
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.phase-container h3 {
|
.phase-container h3 {
|
||||||
@@ -940,8 +992,15 @@ header p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse-download {
|
@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 {
|
.phase-icon.transcode-icon {
|
||||||
@@ -951,8 +1010,15 @@ header p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse-transcode {
|
@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 */
|
/* Transcode stats */
|
||||||
@@ -1089,4 +1155,4 @@ header p {
|
|||||||
.control-seek {
|
.control-seek {
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh">
|
<html lang="zh">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
@@ -10,6 +11,7 @@
|
|||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;800&display=swap" rel="stylesheet">
|
<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?v=2">
|
<link rel="stylesheet" href="/css/style.css?v=2">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div class="background-effects">
|
<div class="background-effects">
|
||||||
<div class="glow-orb orb-1"></div>
|
<div class="glow-orb orb-1"></div>
|
||||||
@@ -34,7 +36,21 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container hidden" id="app-container">
|
<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">
|
<main class="dashboard">
|
||||||
<section class="video-list-section glass-panel">
|
<section class="video-list-section glass-panel">
|
||||||
@@ -75,7 +91,13 @@
|
|||||||
<!-- Download Phase -->
|
<!-- Download Phase -->
|
||||||
<div id="download-phase" class="phase-container">
|
<div id="download-phase" class="phase-container">
|
||||||
<div class="phase-icon download-icon">
|
<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>
|
</div>
|
||||||
<h3>正在从 S3 下载源文件...</h3>
|
<h3>正在从 S3 下载源文件...</h3>
|
||||||
<p id="download-size-text" class="phase-detail">准备下载...</p>
|
<p id="download-size-text" class="phase-detail">准备下载...</p>
|
||||||
@@ -89,7 +111,12 @@
|
|||||||
<!-- Transcode Phase -->
|
<!-- Transcode Phase -->
|
||||||
<div id="transcode-phase" class="phase-container hidden">
|
<div id="transcode-phase" class="phase-container hidden">
|
||||||
<div class="phase-icon transcode-icon">
|
<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>
|
</div>
|
||||||
<h3>正在转码并流式传输...</h3>
|
<h3>正在转码并流式传输...</h3>
|
||||||
<p id="transcode-detail-text" class="phase-detail">FFmpeg 转码中...</p>
|
<p id="transcode-detail-text" class="phase-detail">FFmpeg 转码中...</p>
|
||||||
@@ -106,7 +133,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<video id="video-player" preload="auto" controls playsinline webkit-playsinline 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 id="seeking-overlay" class="seeking-overlay hidden">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
<span>跳转中...</span>
|
<span>跳转中...</span>
|
||||||
@@ -125,4 +153,5 @@
|
|||||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
|
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
|
||||||
<script src="/js/main.js?v=2"></script>
|
<script src="/js/main.js?v=2"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
|
||||||
|
</html>
|
||||||
@@ -19,6 +19,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 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 playBtn = document.getElementById('play-btn');
|
||||||
const topBanner = document.getElementById('top-banner');
|
const topBanner = document.getElementById('top-banner');
|
||||||
const customControls = document.getElementById('custom-controls');
|
const customControls = document.getElementById('custom-controls');
|
||||||
@@ -138,8 +142,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const decoder = 'auto';
|
const decoder = 'auto';
|
||||||
const encoder = encoderSelect?.value || 'h264_rkmpp';
|
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)}`;
|
||||||
if (s3Username) streamUrl += `&username=${encodeURIComponent(s3Username)}`;
|
const sessionId = localStorage.getItem('sessionId');
|
||||||
if (s3Password) streamUrl += `&password=${encodeURIComponent(s3Password)}`;
|
if (sessionId) {
|
||||||
|
streamUrl += `&sessionId=${encodeURIComponent(sessionId)}`;
|
||||||
|
} else {
|
||||||
|
if (s3Username) streamUrl += `&username=${encodeURIComponent(s3Username)}`;
|
||||||
|
if (s3Password) streamUrl += `&password=${encodeURIComponent(s3Password)}`;
|
||||||
|
}
|
||||||
return streamUrl;
|
return streamUrl;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -531,7 +540,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (!res.ok) throw new Error('Failed to load config');
|
if (!res.ok) throw new Error('Failed to load config');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const title = data.title || 'S3 Media Transcoder';
|
const title = data.title || 'S3 Media Transcoder';
|
||||||
topBanner.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_rkmpp');
|
populateSelect(encoderSelect, data.videoEncoders || [], data.defaultVideoEncoder || 'h264_rkmpp');
|
||||||
@@ -572,6 +581,21 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (s3Password) s3AuthHeaders['X-S3-Password'] = s3Password;
|
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 = () => {
|
const showLogin = () => {
|
||||||
if (loginScreen) loginScreen.classList.remove('hidden');
|
if (loginScreen) loginScreen.classList.remove('hidden');
|
||||||
if (appContainer) appContainer.classList.add('hidden');
|
if (appContainer) appContainer.classList.add('hidden');
|
||||||
@@ -618,16 +642,19 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
clearLoginError();
|
clearLoginError();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setAuthHeaders(username, password);
|
const res = await fetch('/api/login', {
|
||||||
const res = await fetch('/api/buckets', { headers: s3AuthHeaders });
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}));
|
|
||||||
throw new Error(data.error || 'Login failed');
|
throw new Error(data.error || 'Login failed');
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
|
||||||
if (!Array.isArray(data.buckets) || data.buckets.length === 0) {
|
if (!Array.isArray(data.buckets) || data.buckets.length === 0) {
|
||||||
throw new Error('No buckets available for this account');
|
throw new Error('No buckets available for this account');
|
||||||
}
|
}
|
||||||
|
setSessionAuth(data.sessionId, data.username);
|
||||||
renderBuckets(data.buckets);
|
renderBuckets(data.buckets);
|
||||||
showApp();
|
showApp();
|
||||||
selectedBucket = data.buckets[0].Name;
|
selectedBucket = data.buckets[0].Name;
|
||||||
@@ -638,7 +665,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Login error:', err);
|
console.error('Login error:', err);
|
||||||
showLoginError(err.message);
|
showLoginError(err.message);
|
||||||
setAuthHeaders('', '');
|
clearSessionAuth();
|
||||||
} finally {
|
} finally {
|
||||||
if (loginBtn) {
|
if (loginBtn) {
|
||||||
loginBtn.disabled = false;
|
loginBtn.disabled = false;
|
||||||
@@ -1088,6 +1115,61 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial state: require login before loading data
|
if (logoutBtn) {
|
||||||
showLogin();
|
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();
|
||||||
});
|
});
|
||||||
|
|||||||
138
server.js
138
server.js
@@ -8,6 +8,8 @@ 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 } = require('@aws-sdk/client-s3');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const Redis = require('ioredis');
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
@@ -29,6 +31,12 @@ if (typeof ffmpeg.setFfprobePath === 'function') {
|
|||||||
ffmpeg.setFfprobePath(JELLYFIN_FFPROBE_PATH);
|
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(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.static('public'));
|
app.use(express.static('public'));
|
||||||
@@ -264,8 +272,20 @@ const stopActiveTranscode = (progressKey) => {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const extractS3Credentials = (req) => {
|
const extractS3Credentials = async (req) => {
|
||||||
const query = req.query || {};
|
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 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 || '';
|
const password = req.headers['x-s3-password'] || req.body?.password || query.password || query.secretAccessKey || '';
|
||||||
return {
|
return {
|
||||||
@@ -328,10 +348,9 @@ const clearDownloadCache = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// Endpoint to list available buckets
|
|
||||||
app.get('/api/buckets', async (req, res) => {
|
app.get('/api/buckets', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const auth = extractS3Credentials(req);
|
const auth = await extractS3Credentials(req);
|
||||||
const s3Client = createS3Client(auth);
|
const s3Client = createS3Client(auth);
|
||||||
const command = new ListBucketsCommand({});
|
const command = new ListBucketsCommand({});
|
||||||
const response = await s3Client.send(command);
|
const response = await s3Client.send(command);
|
||||||
@@ -351,7 +370,7 @@ app.get('/api/videos', async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Bucket name is required' });
|
return res.status(400).json({ error: 'Bucket name is required' });
|
||||||
}
|
}
|
||||||
const allObjects = [];
|
const allObjects = [];
|
||||||
const auth = extractS3Credentials(req);
|
const auth = await extractS3Credentials(req);
|
||||||
const s3Client = createS3Client(auth);
|
const s3Client = createS3Client(auth);
|
||||||
let continuationToken;
|
let continuationToken;
|
||||||
|
|
||||||
@@ -387,6 +406,33 @@ 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) => {
|
app.get('/api/config', (req, res) => {
|
||||||
const title = process.env.APP_TITLE || 'S3 Media Transcoder';
|
const title = process.env.APP_TITLE || 'S3 Media Transcoder';
|
||||||
res.json({
|
res.json({
|
||||||
@@ -441,7 +487,7 @@ const waitForSegment = async (hlsDir, segIndex, timeoutMs = 45000) => {
|
|||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const segPath = path.join(hlsDir, `segment_${segIndex}.ts`);
|
const segPath = path.join(hlsDir, `segment_${segIndex}.ts`);
|
||||||
const m3u8Path = path.join(hlsDir, `temp.m3u8`);
|
const m3u8Path = path.join(hlsDir, `temp.m3u8`);
|
||||||
|
|
||||||
while (Date.now() - start < timeoutMs) {
|
while (Date.now() - start < timeoutMs) {
|
||||||
if (fs.existsSync(m3u8Path)) {
|
if (fs.existsSync(m3u8Path)) {
|
||||||
const m3u8Content = fs.readFileSync(m3u8Path, 'utf8');
|
const m3u8Content = fs.readFileSync(m3u8Path, 'utf8');
|
||||||
@@ -450,7 +496,7 @@ const waitForSegment = async (hlsDir, segIndex, timeoutMs = 45000) => {
|
|||||||
}
|
}
|
||||||
if (m3u8Content.includes(`#EXT-X-ENDLIST`)) {
|
if (m3u8Content.includes(`#EXT-X-ENDLIST`)) {
|
||||||
if (fs.existsSync(segPath)) return true;
|
if (fs.existsSync(segPath)) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await new Promise(r => setTimeout(r, 200));
|
await new Promise(r => setTimeout(r, 200));
|
||||||
@@ -462,18 +508,18 @@ app.get('/api/hls/playlist.m3u8', async (req, res) => {
|
|||||||
const bucket = req.query.bucket;
|
const bucket = req.query.bucket;
|
||||||
const key = req.query.key;
|
const key = req.query.key;
|
||||||
if (!bucket || !key) return res.status(400).send('Bad Request');
|
if (!bucket || !key) return res.status(400).send('Bad Request');
|
||||||
|
|
||||||
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, '_'));
|
||||||
const safeBucket = bucket.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 tmpInputPath = path.join(CACHE_DIR, `s3-input-${safeBucket}-${safeKeySegments.join('-')}.tmp`);
|
||||||
|
|
||||||
const auth = extractS3Credentials(req);
|
const auth = await extractS3Credentials(req);
|
||||||
const s3Client = createS3Client(auth);
|
const s3Client = createS3Client(auth);
|
||||||
let totalBytes = 0;
|
let totalBytes = 0;
|
||||||
const progressKey = getProgressKey(key);
|
const progressKey = getProgressKey(key);
|
||||||
const streamSessionId = createStreamSessionId();
|
const streamSessionId = createStreamSessionId();
|
||||||
let downloadedBytes = 0;
|
let downloadedBytes = 0;
|
||||||
|
|
||||||
if (!fs.existsSync(tmpInputPath)) {
|
if (!fs.existsSync(tmpInputPath)) {
|
||||||
try {
|
try {
|
||||||
const command = new GetObjectCommand({ Bucket: bucket, Key: key });
|
const command = new GetObjectCommand({ Bucket: bucket, Key: key });
|
||||||
@@ -494,11 +540,11 @@ app.get('/api/hls/playlist.m3u8', async (req, res) => {
|
|||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
const writeStream = fs.createWriteStream(tmpInputPath);
|
const writeStream = fs.createWriteStream(tmpInputPath);
|
||||||
|
|
||||||
s3Stream.on('data', (chunk) => {
|
s3Stream.on('data', (chunk) => {
|
||||||
downloadedBytes += chunk.length;
|
downloadedBytes += chunk.length;
|
||||||
const percent = totalBytes ? Math.min(100, Math.round((downloadedBytes / totalBytes) * 100)) : 0;
|
const percent = totalBytes ? Math.min(100, Math.round((downloadedBytes / totalBytes) * 100)) : 0;
|
||||||
|
|
||||||
const downloadState = {
|
const downloadState = {
|
||||||
status: 'downloading',
|
status: 'downloading',
|
||||||
percent,
|
percent,
|
||||||
@@ -529,7 +575,7 @@ app.get('/api/hls/playlist.m3u8', async (req, res) => {
|
|||||||
};
|
};
|
||||||
broadcastWs(progressKey, { type: 'progress', key, progress: progressMap[progressKey] });
|
broadcastWs(progressKey, { type: 'progress', key, progress: progressMap[progressKey] });
|
||||||
|
|
||||||
} catch(err) {
|
} catch (err) {
|
||||||
console.error('S3 Download Failed:', err);
|
console.error('S3 Download Failed:', err);
|
||||||
return res.status(500).send('S3 Download Failed');
|
return res.status(500).send('S3 Download Failed');
|
||||||
}
|
}
|
||||||
@@ -548,27 +594,27 @@ app.get('/api/hls/playlist.m3u8', async (req, res) => {
|
|||||||
};
|
};
|
||||||
broadcastWs(progressKey, { type: 'progress', key, progress: progressMap[progressKey] });
|
broadcastWs(progressKey, { type: 'progress', key, progress: progressMap[progressKey] });
|
||||||
}
|
}
|
||||||
|
|
||||||
let duration = 0;
|
let duration = 0;
|
||||||
try {
|
try {
|
||||||
const metadata = await probeFile(tmpInputPath);
|
const metadata = await probeFile(tmpInputPath);
|
||||||
duration = parseFloat(metadata.format?.duration || 0);
|
duration = parseFloat(metadata.format?.duration || 0);
|
||||||
} catch(err) {}
|
} catch (err) { }
|
||||||
|
|
||||||
if (duration <= 0) duration = 3600;
|
if (duration <= 0) duration = 3600;
|
||||||
|
|
||||||
const totalSegments = Math.ceil(duration / HLS_SEGMENT_TIME);
|
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`;
|
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++) {
|
for (let i = 0; i < totalSegments; i++) {
|
||||||
let segDur = HLS_SEGMENT_TIME;
|
let segDur = HLS_SEGMENT_TIME;
|
||||||
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_rkmpp'}&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`;
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'application/vnd.apple.mpegurl');
|
res.setHeader('Content-Type', 'application/vnd.apple.mpegurl');
|
||||||
res.setHeader('Cache-Control', 'no-cache');
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
res.send(m3u8);
|
res.send(m3u8);
|
||||||
@@ -582,17 +628,17 @@ app.get('/api/hls/segment.ts', async (req, res) => {
|
|||||||
const seg = parseInt(req.query.seg || '0');
|
const seg = parseInt(req.query.seg || '0');
|
||||||
const requestedEncoder = req.query.encoder || 'h264_rkmpp';
|
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');
|
||||||
|
|
||||||
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, '_'));
|
||||||
const safeBucket = bucket.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 tmpInputPath = path.join(CACHE_DIR, `s3-input-${safeBucket}-${safeKeySegments.join('-')}.tmp`);
|
||||||
|
|
||||||
const progressKey = safeKeySegments.join('/');
|
const progressKey = safeKeySegments.join('/');
|
||||||
const hlsDir = path.join(CACHE_DIR, `hls-${safeBucket}-${progressKey}`);
|
const hlsDir = path.join(CACHE_DIR, `hls-${safeBucket}-${progressKey}`);
|
||||||
if (!fs.existsSync(hlsDir)) fs.mkdirSync(hlsDir, { recursive: true });
|
if (!fs.existsSync(hlsDir)) fs.mkdirSync(hlsDir, { recursive: true });
|
||||||
|
|
||||||
const targetSegPath = path.join(hlsDir, `segment_${seg}.ts`);
|
const targetSegPath = path.join(hlsDir, `segment_${seg}.ts`);
|
||||||
let currentProcess = hlsProcesses.get(progressKey);
|
let currentProcess = hlsProcesses.get(progressKey);
|
||||||
|
|
||||||
@@ -616,36 +662,36 @@ app.get('/api/hls/segment.ts', async (req, res) => {
|
|||||||
|
|
||||||
if (needsNewProcess) {
|
if (needsNewProcess) {
|
||||||
if (currentProcess && currentProcess.command) {
|
if (currentProcess && currentProcess.command) {
|
||||||
try { currentProcess.command.kill('SIGKILL'); } catch(e){}
|
try { currentProcess.command.kill('SIGKILL'); } catch (e) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
const startTime = Math.max(0, seg * HLS_SEGMENT_TIME);
|
const startTime = Math.max(0, seg * HLS_SEGMENT_TIME);
|
||||||
|
|
||||||
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_rkmpp';
|
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`);
|
||||||
if (fs.existsSync(m3u8Path)) fs.unlinkSync(m3u8Path);
|
if (fs.existsSync(m3u8Path)) fs.unlinkSync(m3u8Path);
|
||||||
|
|
||||||
const ffmpegCommand = ffmpeg().input(tmpInputPath);
|
const ffmpegCommand = ffmpeg().input(tmpInputPath);
|
||||||
if (startTime > 0) ffmpegCommand.seekInput(startTime);
|
if (startTime > 0) ffmpegCommand.seekInput(startTime);
|
||||||
|
|
||||||
ffmpegCommand.videoCodec(encoderName).audioCodec('aac');
|
ffmpegCommand.videoCodec(encoderName).audioCodec('aac');
|
||||||
|
|
||||||
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 === 'auto' && isRkmppCodec(encoderName) ? getRkmppDecoderName(sourceMetadata) : 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`);
|
||||||
const hlsOptions = createFfmpegOptions(encoderName).concat([
|
const hlsOptions = createFfmpegOptions(encoderName).concat([
|
||||||
'-f', 'hls',
|
'-f', 'hls',
|
||||||
'-hls_time', HLS_SEGMENT_TIME.toString(),
|
'-hls_time', HLS_SEGMENT_TIME.toString(),
|
||||||
'-hls_list_size', '0',
|
'-hls_list_size', '0',
|
||||||
'-hls_segment_filename', segmentFilename,
|
'-hls_segment_filename', segmentFilename,
|
||||||
'-start_number', seg.toString(),
|
'-start_number', seg.toString(),
|
||||||
'-copyts',
|
'-copyts',
|
||||||
@@ -653,22 +699,22 @@ app.get('/api/hls/segment.ts', async (req, res) => {
|
|||||||
'-muxdelay', '0',
|
'-muxdelay', '0',
|
||||||
'-muxpreload', '0'
|
'-muxpreload', '0'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
ffmpegCommand.outputOptions(hlsOptions).output(m3u8Path);
|
ffmpegCommand.outputOptions(hlsOptions).output(m3u8Path);
|
||||||
ffmpegCommand.on('error', (err) => {
|
ffmpegCommand.on('error', (err) => {
|
||||||
console.error('HLS FFmpeg Error:', err.message);
|
console.error('HLS FFmpeg Error:', err.message);
|
||||||
});
|
});
|
||||||
|
|
||||||
ffmpegCommand.on('progress', (progress) => {
|
ffmpegCommand.on('progress', (progress) => {
|
||||||
const timemarkSeconds = parseTimemarkToSeconds(progress.timemark || '0');
|
const timemarkSeconds = parseTimemarkToSeconds(progress.timemark || '0');
|
||||||
const absoluteSeconds = startTime + (isFinite(timemarkSeconds) ? timemarkSeconds : 0);
|
const absoluteSeconds = startTime + (isFinite(timemarkSeconds) ? timemarkSeconds : 0);
|
||||||
const totalDuration = parseFloat(sourceMetadata?.format?.duration || 0);
|
const totalDuration = parseFloat(sourceMetadata?.format?.duration || 0);
|
||||||
|
|
||||||
let percent = 0;
|
let percent = 0;
|
||||||
if (totalDuration > 0) {
|
if (totalDuration > 0) {
|
||||||
percent = Math.min(Math.max(Math.round((absoluteSeconds / totalDuration) * 100), 0), 100);
|
percent = Math.min(Math.max(Math.round((absoluteSeconds / totalDuration) * 100), 0), 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
const progressState = {
|
const progressState = {
|
||||||
status: 'transcoding',
|
status: 'transcoding',
|
||||||
percent,
|
percent,
|
||||||
@@ -684,26 +730,26 @@ app.get('/api/hls/segment.ts', async (req, res) => {
|
|||||||
};
|
};
|
||||||
progressMap[progressKey] = progressState;
|
progressMap[progressKey] = progressState;
|
||||||
broadcastWs(progressKey, { type: 'progress', key, progress: progressState });
|
broadcastWs(progressKey, { type: 'progress', key, progress: progressState });
|
||||||
|
|
||||||
console.log(`[FFmpeg] ${progressKey} | ${progress.timemark} | ${progress.currentFps}fps | ${progress.currentKbps}kbps | ${percent}%`);
|
console.log(`[FFmpeg] ${progressKey} | ${progress.timemark} | ${progress.currentFps}fps | ${progress.currentKbps}kbps | ${percent}%`);
|
||||||
});
|
});
|
||||||
|
|
||||||
ffmpegCommand.on('end', () => {
|
ffmpegCommand.on('end', () => {
|
||||||
console.log(`[FFmpeg] ${progressKey} HLS transcode completed.`);
|
console.log(`[FFmpeg] ${progressKey} HLS transcode completed.`);
|
||||||
});
|
});
|
||||||
|
|
||||||
ffmpegCommand.run();
|
ffmpegCommand.run();
|
||||||
currentProcess = { command: ffmpegCommand, currentSeg: seg };
|
currentProcess = { command: ffmpegCommand, currentSeg: seg };
|
||||||
hlsProcesses.set(progressKey, currentProcess);
|
hlsProcesses.set(progressKey, currentProcess);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ready = await waitForSegment(hlsDir, seg);
|
const ready = await waitForSegment(hlsDir, seg);
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
return res.status(500).send('Segment generation timeout');
|
return res.status(500).send('Segment generation timeout');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentProcess) currentProcess.currentSeg = Math.max(currentProcess.currentSeg, seg);
|
if (currentProcess) currentProcess.currentSeg = Math.max(currentProcess.currentSeg, seg);
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'video/MP2T');
|
res.setHeader('Content-Type', 'video/MP2T');
|
||||||
res.sendFile(targetSegPath);
|
res.sendFile(targetSegPath);
|
||||||
});
|
});
|
||||||
@@ -734,7 +780,7 @@ app.get('/api/stream', async (req, res) => {
|
|||||||
const tmpInputPath = path.join(CACHE_DIR, `s3-input-${safeBucket}-${safeKeySegments.join('-')}.tmp`);
|
const tmpInputPath = path.join(CACHE_DIR, `s3-input-${safeBucket}-${safeKeySegments.join('-')}.tmp`);
|
||||||
const cacheExists = fs.existsSync(tmpInputPath);
|
const cacheExists = fs.existsSync(tmpInputPath);
|
||||||
|
|
||||||
const auth = extractS3Credentials(req);
|
const auth = await extractS3Credentials(req);
|
||||||
const s3Client = createS3Client(auth);
|
const s3Client = createS3Client(auth);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -956,7 +1002,7 @@ app.get('/api/stream', async (req, res) => {
|
|||||||
if (ffmpegCommand && typeof ffmpegCommand.kill === 'function') {
|
if (ffmpegCommand && typeof ffmpegCommand.kill === 'function') {
|
||||||
try {
|
try {
|
||||||
ffmpegCommand.kill('SIGKILL');
|
ffmpegCommand.kill('SIGKILL');
|
||||||
} catch (_) {}
|
} catch (_) { }
|
||||||
}
|
}
|
||||||
if (transcodeProcesses.get(progressKey)?.streamSessionId === streamSessionId) {
|
if (transcodeProcesses.get(progressKey)?.streamSessionId === streamSessionId) {
|
||||||
transcodeProcesses.delete(progressKey);
|
transcodeProcesses.delete(progressKey);
|
||||||
|
|||||||
Reference in New Issue
Block a user