obs-r2-uploader/worker/video-server.js

1942 lines
52 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Enhanced Cloudflare Worker for serving videos from R2 storage
* Features:
* - Admin panel with authentication
* - Video visibility controls (private, shareable, clip_shareable)
* - Clip generation and serving
* - Permission-based access control
*/
// Admin login HTML
const LOGIN_HTML = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Login</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f0f;
color: #fff;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 20px;
}
.login-container {
background: #1a1a1a;
padding: 40px;
border-radius: 12px;
max-width: 400px;
width: 100%;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
}
h1 {
text-align: center;
margin-bottom: 30px;
font-size: 2rem;
}
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
color: #aaa;
font-size: 0.9rem;
}
input {
width: 100%;
padding: 12px;
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 6px;
color: white;
font-size: 1rem;
}
input:focus {
outline: none;
border-color: #3ea6ff;
}
button {
width: 100%;
padding: 12px;
background: #3ea6ff;
border: none;
border-radius: 6px;
color: white;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
}
button:hover {
background: #2988d8;
}
.error {
background: #e74c3c;
color: white;
padding: 12px;
border-radius: 6px;
margin-bottom: 20px;
display: none;
}
.error.show {
display: block;
}
</style>
</head>
<body>
<div class="login-container">
<h1>🎥 Admin Login</h1>
<div id="error" class="error"></div>
<form id="loginForm">
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required autofocus>
</div>
<button type="submit">Login</button>
</form>
</div>
<script>
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const password = document.getElementById('password').value;
const error = document.getElementById('error');
try {
const response = await fetch('/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
if (response.ok) {
window.location.href = '/admin';
} else {
error.textContent = 'Invalid password';
error.classList.add('show');
}
} catch (err) {
error.textContent = 'Login failed';
error.classList.add('show');
}
});
</script>
</body>
</html>
`;
export default {
async fetch(request, env) {
const url = new URL(request.url);
const path = url.pathname;
// CORS headers
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, HEAD, POST, DELETE, OPTIONS',
'Access-Control-Allow-Headers': '*',
};
if (request.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
try {
// Admin routes
if (path.startsWith('/admin')) {
return await handleAdminRoutes(request, env, path, corsHeaders);
}
// Clip serving
if (path.startsWith('/clip/')) {
return await handleClip(request, env, path, corsHeaders);
}
// Public gallery (only shows shareable videos)
if (path === '/gallery') {
return await handlePublicGallery(env.R2_BUCKET, env.VIDEO_METADATA, corsHeaders);
}
// Public API (only lists shareable videos)
if (path === '/' || path === '/api/list') {
return await handlePublicList(env.R2_BUCKET, env.VIDEO_METADATA, corsHeaders);
}
// Handle HLS live streams (/live/stream-name/file.m3u8 or file.ts)
if (path.startsWith('/live/')) {
return await handleHLSStream(request, env, path, corsHeaders);
}
// Serve video file (with permission check)
const filename = path.substring(1);
if (filename) {
return await handleVideoFile(request, env, filename, corsHeaders);
}
return new Response('Not found', { status: 404, headers: corsHeaders });
} catch (error) {
console.error('Error:', error);
return new Response(`Error: ${error.message}`, { status: 500, headers: corsHeaders });
}
},
};
/**
* Handle admin routes
*/
async function handleAdminRoutes(request, env, path, corsHeaders) {
// Login page
if (path === '/admin/login' && request.method === 'GET') {
return new Response(LOGIN_HTML, {
headers: { 'Content-Type': 'text/html; charset=utf-8' }
});
}
// Login POST
if (path === '/admin/login' && request.method === 'POST') {
const { password } = await request.json();
const correctPassword = env.ADMIN_PASSWORD || 'changeme';
if (password === correctPassword) {
// Create session token
const token = await generateToken(password);
return new Response(JSON.stringify({ success: true }), {
headers: {
'Content-Type': 'application/json',
'Set-Cookie': `admin_auth=${token}; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=86400`
}
});
}
return new Response(JSON.stringify({ error: 'Invalid password' }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
}
// Check authentication for other admin routes
const isAuthenticated = await verifyAuth(request, env);
if (!isAuthenticated) {
return new Response('Unauthorized', {
status: 401,
headers: { 'Location': '/admin/login' }
});
}
// Admin panel
if (path === '/admin' || path === '/admin/') {
const adminHTML = await getAdminHTML();
return new Response(adminHTML, {
headers: { 'Content-Type': 'text/html; charset=utf-8' }
});
}
// API: List all videos with metadata
if (path === '/admin/api/videos') {
return await handleAdminListVideos(env.R2_BUCKET, env.VIDEO_METADATA, corsHeaders);
}
// API: Update video visibility
if (path === '/admin/api/videos/visibility' && request.method === 'POST') {
const { filename, visibility } = await request.json();
await env.VIDEO_METADATA.put(filename, JSON.stringify({ visibility }));
return new Response(JSON.stringify({ success: true }), {
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
// API: Delete video
if (path.startsWith('/admin/api/videos/') && request.method === 'DELETE') {
const filename = decodeURIComponent(path.replace('/admin/api/videos/', ''));
await env.R2_BUCKET.delete(filename);
await env.VIDEO_METADATA.delete(filename);
return new Response(JSON.stringify({ success: true }), {
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
// API: Upload video
if (path === '/admin/api/upload' && request.method === 'POST') {
return await handleVideoUpload(request, env, corsHeaders);
}
return new Response('Not found', { status: 404 });
}
/**
* Get admin HTML (reads from the admin.html file embedded as string or fetched)
*/
async function getAdminHTML() {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Video Admin Panel</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #0f0f0f;
color: #fff;
padding: 20px;
}
header {
background: #1a1a1a;
padding: 20px;
border-radius: 12px;
margin-bottom: 30px;
display: flex;
justify-content: space-between;
align-items: center;
}
h1 {
font-size: 1.8rem;
}
.subtitle {
color: #aaa;
font-size: 0.9rem;
}
.header-buttons {
display: flex;
gap: 10px;
}
.upload-btn {
background: #2ecc71;
color: white;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
}
.upload-btn:hover {
background: #27ae60;
}
.logout-btn {
background: #e74c3c;
color: white;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
}
.logout-btn:hover {
background: #c0392b;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: #1a1a1a;
padding: 20px;
border-radius: 12px;
text-align: center;
}
.stat-value {
font-size: 2.5rem;
font-weight: bold;
color: #3ea6ff;
margin-bottom: 5px;
}
.stat-label {
color: #aaa;
font-size: 0.9rem;
}
.video-list {
background: #1a1a1a;
border-radius: 12px;
overflow: hidden;
}
.video-list-header {
padding: 20px;
border-bottom: 1px solid #2a2a2a;
}
.search-bar {
width: 100%;
padding: 12px;
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 6px;
color: white;
font-size: 1rem;
}
.video-item {
display: grid;
grid-template-columns: 120px 1fr auto;
gap: 20px;
padding: 20px;
border-bottom: 1px solid #2a2a2a;
align-items: center;
}
.video-item:hover {
background: #222;
}
.video-item:last-child {
border-bottom: none;
}
.video-thumbnail {
width: 120px;
height: 68px;
background: #000;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
cursor: pointer;
transition: transform 0.2s;
}
.video-thumbnail:hover {
transform: scale(1.05);
}
.video-thumbnail::after {
content: '▶';
position: absolute;
font-size: 2rem;
color: white;
text-shadow: 0 2px 8px rgba(0,0,0,0.8);
pointer-events: none;
}
.video-thumbnail video {
width: 100%;
height: 100%;
object-fit: cover;
}
.video-info {
flex: 1;
}
.video-name {
font-size: 1.1rem;
margin-bottom: 8px;
word-break: break-word;
}
.video-meta {
color: #aaa;
font-size: 0.85rem;
display: flex;
gap: 15px;
flex-wrap: wrap;
}
.video-controls {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 200px;
}
.visibility-select {
padding: 8px 12px;
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 6px;
color: white;
font-size: 0.9rem;
cursor: pointer;
}
.visibility-select:focus {
outline: none;
border-color: #3ea6ff;
}
.visibility-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: bold;
text-transform: uppercase;
}
.visibility-private {
background: #e74c3c;
color: white;
}
.visibility-shareable {
background: #2ecc71;
color: white;
}
.visibility-clip {
background: #f39c12;
color: white;
}
.action-buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.btn {
padding: 6px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: background 0.2s;
text-decoration: none;
display: inline-block;
}
.btn-primary {
background: #3ea6ff;
color: white;
}
.btn-primary:hover {
background: #2988d8;
}
.btn-secondary {
background: #555;
color: white;
}
.btn-secondary:hover {
background: #666;
}
.btn-danger {
background: #e74c3c;
color: white;
}
.btn-danger:hover {
background: #c0392b;
}
.clip-generator {
margin-top: 10px;
padding: 15px;
background: #2a2a2a;
border-radius: 6px;
display: none;
}
.clip-generator.active {
display: block;
}
.clip-inputs {
display: flex;
gap: 10px;
margin-bottom: 10px;
align-items: center;
}
.clip-inputs input {
padding: 6px 10px;
background: #1a1a1a;
border: 1px solid #3a3a3a;
border-radius: 4px;
color: white;
width: 80px;
font-size: 0.85rem;
}
.clip-inputs label {
font-size: 0.85rem;
color: #aaa;
}
.toast {
position: fixed;
bottom: 20px;
right: 20px;
background: #3ea6ff;
color: white;
padding: 15px 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
opacity: 0;
transition: opacity 0.3s;
z-index: 1000;
max-width: 400px;
}
.toast.show {
opacity: 1;
}
.toast.error {
background: #e74c3c;
}
.toast.success {
background: #2ecc71;
}
.loading {
text-align: center;
padding: 40px;
color: #aaa;
}
.spinner {
border: 3px solid #2a2a2a;
border-top: 3px solid #3ea6ff;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
z-index: 2000;
justify-content: center;
align-items: center;
}
.modal.active {
display: flex;
}
.modal-content {
background: #1a1a1a;
padding: 30px;
border-radius: 12px;
max-width: 500px;
width: 90%;
}
.modal-header {
margin-bottom: 20px;
}
.modal-body {
margin-bottom: 20px;
}
.modal-footer {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.upload-area {
border: 2px dashed #3a3a3a;
border-radius: 8px;
padding: 40px;
text-align: center;
margin-bottom: 20px;
cursor: pointer;
transition: all 0.3s;
}
.upload-area:hover,
.upload-area.drag-over {
border-color: #3ea6ff;
background: #1a1a1a;
}
.upload-area.uploading {
pointer-events: none;
opacity: 0.6;
}
.upload-icon {
font-size: 3rem;
margin-bottom: 10px;
}
.file-input {
display: none;
}
.upload-progress {
display: none;
margin-top: 20px;
}
.upload-progress.active {
display: block;
}
.progress-bar {
width: 100%;
height: 8px;
background: #2a2a2a;
border-radius: 4px;
overflow: hidden;
margin-bottom: 10px;
}
.progress-fill {
height: 100%;
background: #3ea6ff;
width: 0%;
transition: width 0.3s;
}
.progress-text {
color: #aaa;
font-size: 0.9rem;
}
.empty-state {
text-align: center;
padding: 80px 40px;
color: #aaa;
background: linear-gradient(135deg, #1a1a1a 0%, #0f0f0f 100%);
border-radius: 12px;
margin: 20px;
}
.empty-state-icon {
font-size: 6rem;
margin-bottom: 30px;
opacity: 0.7;
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-20px); }
}
.empty-state h3 {
font-size: 2rem;
margin-bottom: 15px;
color: #fff;
font-weight: 600;
}
.empty-state p {
margin-bottom: 30px;
font-size: 1.1rem;
line-height: 1.6;
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
.empty-state-actions {
display: flex;
gap: 15px;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 40px;
}
.empty-state-actions .btn {
padding: 12px 24px;
font-size: 1rem;
min-width: 160px;
}
.upload-methods {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
max-width: 900px;
margin: 40px auto 0;
text-align: left;
}
.upload-method {
background: #2a2a2a;
padding: 25px;
border-radius: 10px;
transition: all 0.3s;
border: 2px solid transparent;
}
.upload-method:hover {
border-color: #3ea6ff;
transform: translateY(-5px);
box-shadow: 0 8px 20px rgba(62, 166, 255, 0.2);
}
.upload-method-icon {
font-size: 2.5rem;
margin-bottom: 15px;
}
.upload-method h4 {
color: #fff;
margin-bottom: 10px;
font-size: 1.1rem;
}
.upload-method p {
color: #aaa;
font-size: 0.9rem;
line-height: 1.5;
margin: 0;
}
.upload-method code {
display: block;
background: #1a1a1a;
padding: 8px 12px;
border-radius: 4px;
margin-top: 10px;
font-size: 0.85rem;
color: #3ea6ff;
overflow-x: auto;
}
.video-player-modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.95);
z-index: 3000;
justify-content: center;
align-items: center;
padding: 20px;
}
.video-player-modal.active {
display: flex;
}
.video-player-container {
position: relative;
width: 100%;
max-width: 1200px;
background: #000;
border-radius: 8px;
overflow: hidden;
}
.video-player-container video {
width: 100%;
height: auto;
display: block;
}
.video-player-close {
position: absolute;
top: 20px;
right: 20px;
background: rgba(0, 0, 0, 0.8);
color: white;
border: none;
width: 40px;
height: 40px;
border-radius: 50%;
font-size: 1.5rem;
cursor: pointer;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
}
.video-player-close:hover {
background: rgba(255, 255, 255, 0.2);
}
.video-player-title {
position: absolute;
top: 20px;
left: 20px;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 10px 15px;
border-radius: 6px;
font-size: 0.9rem;
max-width: calc(100% - 100px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 768px) {
.video-item {
grid-template-columns: 1fr;
}
.video-controls {
min-width: auto;
}
.action-buttons {
justify-content: flex-start;
}
.header-buttons {
flex-direction: column;
}
}
</style>
</head>
<body>
<header>
<div>
<h1>🎥 Video Admin Panel</h1>
<p class="subtitle">Manage video visibility and sharing</p>
</div>
<div class="header-buttons">
<button class="upload-btn" onclick="openUploadModal()">📤 Upload Video</button>
<button class="logout-btn" onclick="logout()">Logout</button>
</div>
</header>
<div class="stats">
<div class="stat-card">
<div class="stat-value" id="totalVideos">0</div>
<div class="stat-label">Total Videos</div>
</div>
<div class="stat-card">
<div class="stat-value" id="privateCount">0</div>
<div class="stat-label">Private</div>
</div>
<div class="stat-card">
<div class="stat-value" id="shareableCount">0</div>
<div class="stat-label">Shareable</div>
</div>
<div class="stat-card">
<div class="stat-value" id="clipCount">0</div>
<div class="stat-label">Clip Shareable</div>
</div>
</div>
<div class="video-list">
<div class="video-list-header">
<input type="text" class="search-bar" id="searchBar" placeholder="Search videos...">
</div>
<div id="videoContainer">
<div class="loading">
<div class="spinner"></div>
<p>Loading videos...</p>
</div>
</div>
</div>
<div id="toast" class="toast"></div>
<!-- Upload Modal -->
<div id="uploadModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Upload Video</h2>
</div>
<div class="modal-body">
<div class="upload-area" id="uploadArea" onclick="document.getElementById('fileInput').click()">
<div class="upload-icon">📁</div>
<h3>Drop video file here</h3>
<p>or click to browse</p>
<p style="color: #666; font-size: 0.85rem; margin-top: 10px;">Supports: MP4, MKV, MOV, AVI, WebM</p>
</div>
<input type="file" id="fileInput" class="file-input" accept="video/*" onchange="handleFileSelect(event)">
<div id="uploadProgress" class="upload-progress">
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
<div class="progress-text" id="progressText">Uploading...</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeUploadModal()">Close</button>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div id="deleteModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Delete Video?</h2>
</div>
<div class="modal-body">
<p>Are you sure you want to delete <strong id="deleteVideoName"></strong>?</p>
<p style="color: #e74c3c; margin-top: 10px;">This action cannot be undone.</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeDeleteModal()">Cancel</button>
<button class="btn btn-danger" onclick="confirmDelete()">Delete</button>
</div>
</div>
</div>
<!-- Video Player Modal -->
<div id="videoPlayerModal" class="video-player-modal" onclick="closeVideoPlayer(event)">
<div class="video-player-container" onclick="event.stopPropagation()">
<button class="video-player-close" onclick="closeVideoPlayer()">×</button>
<div class="video-player-title" id="videoPlayerTitle">Video</div>
<video id="videoPlayer" controls autoplay>
<source id="videoPlayerSource" src="" type="video/mp4">
Your browser does not support the video tag.
</video>
</div>
</div>
<script>
let videos = [];
let videoToDelete = null;
// Load videos on page load
document.addEventListener('DOMContentLoaded', () => {
loadVideos();
// Search functionality
document.getElementById('searchBar').addEventListener('input', (e) => {
filterVideos(e.target.value);
});
});
async function loadVideos() {
try {
const response = await fetch('/admin/api/videos', {
credentials: 'include'
});
if (!response.ok) {
if (response.status === 401) {
window.location.href = '/admin/login';
return;
}
throw new Error('Failed to load videos');
}
const data = await response.json();
videos = data.videos;
renderVideos(videos);
updateStats(videos);
} catch (error) {
console.error('Error loading videos:', error);
showToast('Failed to load videos', 'error');
}
}
function renderVideos(videosToRender) {
const container = document.getElementById('videoContainer');
if (videosToRender.length === 0) {
container.innerHTML = \`
<div class="empty-state">
<div class="empty-state-icon">🎬</div>
<h3>Welcome to Your Video Library!</h3>
<p>Your video collection is empty. Start by uploading your first video and share it with the world.</p>
<div class="empty-state-actions">
<button class="btn btn-primary" onclick="openUploadModal()" style="background: #2ecc71;">
📤 Upload Video
</button>
</div>
<div class="upload-methods">
<div class="upload-method">
<div class="upload-method-icon">🖱️</div>
<h4>Browser Upload</h4>
<p>Click the upload button above to drag & drop videos directly from your browser. Perfect for quick uploads!</p>
</div>
<div class="upload-method">
<div class="upload-method-icon">⌨️</div>
<h4>Command Line</h4>
<p>Upload videos using the terminal for larger files and automation.</p>
<code>./scripts/upload.sh video.mp4</code>
</div>
<div class="upload-method">
<div class="upload-method-icon">👁️</div>
<h4>Auto-Upload</h4>
<p>Watch a folder and automatically upload new OBS recordings as they're created.</p>
<code>./scripts/start-watcher.sh</code>
</div>
</div>
</div>
\`;
return;
}
container.innerHTML = videosToRender.map(video => \`
<div class="video-item">
<div class="video-thumbnail" onclick="playVideo('\${video.name}')">
<video preload="metadata" muted>
<source src="/\${video.name}#t=1" type="\${getContentType(video.name)}">
</video>
</div>
<div class="video-info">
<div class="video-name">\${video.name}</div>
<div class="video-meta">
<span>📦 \${formatSize(video.size)}</span>
<span>📅 \${formatDate(video.uploaded)}</span>
<span class="visibility-badge visibility-\${video.visibility || 'shareable'}">
\${video.visibility || 'shareable'}
</span>
</div>
</div>
<div class="video-controls">
<select class="visibility-select" onchange="updateVisibility('\${video.name}', this.value)">
<option value="private" \${video.visibility === 'private' ? 'selected' : ''}>🔒 Private</option>
<option value="shareable" \${!video.visibility || video.visibility === 'shareable' ? 'selected' : ''}>🔗 Shareable</option>
<option value="clip_shareable" \${video.visibility === 'clip_shareable' ? 'selected' : ''}>✂️ Clip Shareable</option>
</select>
<div class="action-buttons">
<a class="btn btn-primary" href="/\${video.name}" target="_blank">▶ Watch</a>
<button class="btn btn-primary" onclick="copyLink('\${video.name}')">Copy Link</button>
<button class="btn btn-secondary" onclick="toggleClipGenerator('\${video.name}')">Create Clip</button>
<button class="btn btn-danger" onclick="deleteVideo('\${video.name}')">Delete</button>
</div>
<div id="clip-\${video.name}" class="clip-generator">
<div class="clip-inputs">
<label>Start:</label>
<input type="text" id="start-\${video.name}" placeholder="00:00" />
<label>End:</label>
<input type="text" id="end-\${video.name}" placeholder="00:30" />
</div>
<button class="btn btn-primary" onclick="generateClipLink('\${video.name}')">Generate Clip Link</button>
</div>
</div>
</div>
\`).join('');
}
function filterVideos(query) {
const filtered = videos.filter(video =>
video.name.toLowerCase().includes(query.toLowerCase())
);
renderVideos(filtered);
}
function updateStats(videos) {
document.getElementById('totalVideos').textContent = videos.length;
const counts = videos.reduce((acc, video) => {
const vis = video.visibility || 'shareable';
acc[vis] = (acc[vis] || 0) + 1;
return acc;
}, {});
document.getElementById('privateCount').textContent = counts.private || 0;
document.getElementById('shareableCount').textContent = counts.shareable || 0;
document.getElementById('clipCount').textContent = counts.clip_shareable || 0;
}
async function updateVisibility(filename, visibility) {
try {
const response = await fetch('/admin/api/videos/visibility', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify({ filename, visibility })
});
if (!response.ok) throw new Error('Failed to update visibility');
showToast(\`Updated \${filename} to \${visibility}\`, 'success');
// Reload videos to update stats
await loadVideos();
} catch (error) {
console.error('Error updating visibility:', error);
showToast('Failed to update visibility', 'error');
}
}
function copyLink(filename) {
const url = \`\${window.location.origin}/\${filename}\`;
navigator.clipboard.writeText(url).then(() => {
showToast('Link copied to clipboard!', 'success');
}).catch(() => {
showToast('Failed to copy link', 'error');
});
}
function toggleClipGenerator(filename) {
const clipGen = document.getElementById(\`clip-\${filename}\`);
clipGen.classList.toggle('active');
}
function generateClipLink(filename) {
const start = document.getElementById(\`start-\${filename}\`).value;
const end = document.getElementById(\`end-\${filename}\`).value;
if (!start || !end) {
showToast('Please enter start and end times', 'error');
return;
}
const url = \`\${window.location.origin}/clip/\${filename}?start=\${start}&end=\${end}\`;
navigator.clipboard.writeText(url).then(() => {
showToast('Clip link copied to clipboard!', 'success');
}).catch(() => {
showToast('Failed to copy clip link', 'error');
});
}
function deleteVideo(filename) {
videoToDelete = filename;
document.getElementById('deleteVideoName').textContent = filename;
document.getElementById('deleteModal').classList.add('active');
}
function closeDeleteModal() {
document.getElementById('deleteModal').classList.remove('active');
videoToDelete = null;
}
async function confirmDelete() {
if (!videoToDelete) return;
try {
const response = await fetch(\`/admin/api/videos/\${encodeURIComponent(videoToDelete)}\`, {
method: 'DELETE',
credentials: 'include'
});
if (!response.ok) throw new Error('Failed to delete video');
showToast(\`Deleted \${videoToDelete}\`, 'success');
closeDeleteModal();
await loadVideos();
} catch (error) {
console.error('Error deleting video:', error);
showToast('Failed to delete video', 'error');
}
}
function openUploadModal() {
document.getElementById('uploadModal').classList.add('active');
setupDragAndDrop();
}
function closeUploadModal() {
document.getElementById('uploadModal').classList.remove('active');
resetUploadUI();
}
function setupDragAndDrop() {
const uploadArea = document.getElementById('uploadArea');
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('drag-over');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('drag-over');
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('drag-over');
const files = e.dataTransfer.files;
if (files.length > 0) {
handleFileUpload(files[0]);
}
});
}
function handleFileSelect(event) {
const file = event.target.files[0];
if (file) {
handleFileUpload(file);
}
}
async function handleFileUpload(file) {
// Validate file type
const validTypes = ['video/mp4', 'video/x-matroska', 'video/quicktime', 'video/x-msvideo', 'video/webm'];
if (!validTypes.includes(file.type) && !file.name.match(/\\.(mp4|mkv|mov|avi|webm)$/i)) {
showToast('Invalid file type. Please upload a video file.', 'error');
return;
}
// Show progress
const uploadArea = document.getElementById('uploadArea');
const uploadProgress = document.getElementById('uploadProgress');
const progressFill = document.getElementById('progressFill');
const progressText = document.getElementById('progressText');
uploadArea.classList.add('uploading');
uploadProgress.classList.add('active');
try {
// Create form data
const formData = new FormData();
formData.append('video', file);
// Upload with progress tracking
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percentComplete = (e.loaded / e.total) * 100;
progressFill.style.width = percentComplete + '%';
progressText.textContent = \`Uploading \${file.name}... \${Math.round(percentComplete)}%\`;
}
});
xhr.addEventListener('load', async () => {
if (xhr.status === 200) {
showToast(\`Successfully uploaded \${file.name}!\`, 'success');
closeUploadModal();
await loadVideos(); // Reload video list
} else {
showToast('Upload failed. Please try again.', 'error');
resetUploadUI();
}
});
xhr.addEventListener('error', () => {
showToast('Upload failed. Please try again.', 'error');
resetUploadUI();
});
xhr.open('POST', '/admin/api/upload');
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.withCredentials = true;
xhr.send(formData);
} catch (error) {
console.error('Upload error:', error);
showToast('Upload failed. Please try again.', 'error');
resetUploadUI();
}
}
function resetUploadUI() {
const uploadArea = document.getElementById('uploadArea');
const uploadProgress = document.getElementById('uploadProgress');
const progressFill = document.getElementById('progressFill');
const progressText = document.getElementById('progressText');
const fileInput = document.getElementById('fileInput');
uploadArea.classList.remove('uploading');
uploadProgress.classList.remove('active');
progressFill.style.width = '0%';
progressText.textContent = 'Uploading...';
fileInput.value = '';
}
function logout() {
document.cookie = 'admin_auth=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
window.location.href = '/admin/login';
}
function showToast(message, type = 'info') {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.className = \`toast show \${type}\`;
setTimeout(() => {
toast.classList.remove('show');
}, 3000);
}
function formatSize(bytes) {
if (bytes < 1024 * 1024) {
return (bytes / 1024).toFixed(1) + ' KB';
} else if (bytes < 1024 * 1024 * 1024) {
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
} else {
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
}
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
}
function getContentType(filename) {
const ext = filename.split('.').pop().toLowerCase();
const types = {
'mp4': 'video/mp4',
'mkv': 'video/x-matroska',
'mov': 'video/quicktime',
'avi': 'video/x-msvideo',
'webm': 'video/webm'
};
return types[ext] || 'video/mp4';
}
function playVideo(filename) {
const modal = document.getElementById('videoPlayerModal');
const player = document.getElementById('videoPlayer');
const source = document.getElementById('videoPlayerSource');
const title = document.getElementById('videoPlayerTitle');
// Set video source and title
source.src = \`/\${filename}\`;
source.type = getContentType(filename);
title.textContent = filename;
// Reload video and show modal
player.load();
modal.classList.add('active');
// Pause video when clicking outside
modal.onclick = (e) => {
if (e.target === modal) {
closeVideoPlayer();
}
};
// Support ESC key to close
document.addEventListener('keydown', handleEscKey);
}
function closeVideoPlayer(event) {
if (event && event.target !== document.getElementById('videoPlayerModal')) {
return;
}
const modal = document.getElementById('videoPlayerModal');
const player = document.getElementById('videoPlayer');
// Pause and reset video
player.pause();
player.currentTime = 0;
// Hide modal
modal.classList.remove('active');
// Remove ESC key listener
document.removeEventListener('keydown', handleEscKey);
}
function handleEscKey(e) {
if (e.key === 'Escape') {
closeVideoPlayer();
}
}
</script>
</body>
</html>
`;
}
/**
* List all videos for admin (includes metadata)
*/
async function handleAdminListVideos(bucket, kv, corsHeaders) {
const objects = await bucket.list();
const videos = await Promise.all(
objects.objects.map(async (obj) => {
const metadataStr = await kv.get(obj.key);
const metadata = metadataStr ? JSON.parse(metadataStr) : {};
return {
name: obj.key,
size: obj.size,
uploaded: obj.uploaded,
visibility: metadata.visibility || 'shareable',
url: `/${obj.key}`,
};
})
);
return new Response(JSON.stringify({ count: videos.length, videos }), {
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
/**
* List public videos only (shareable ones)
*/
async function handlePublicList(bucket, kv, corsHeaders) {
const objects = await bucket.list();
const videos = await Promise.all(
objects.objects.map(async (obj) => {
const metadataStr = await kv.get(obj.key);
const metadata = metadataStr ? JSON.parse(metadataStr) : {};
return {
name: obj.key,
size: obj.size,
uploaded: obj.uploaded,
visibility: metadata.visibility || 'shareable',
url: `/${obj.key}`,
};
})
);
// Filter to only shareable videos
const shareableVideos = videos.filter(v => v.visibility === 'shareable');
return new Response(JSON.stringify({ count: shareableVideos.length, videos: shareableVideos }), {
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
/**
* Serve video file with permission check
*/
async function handleVideoFile(request, env, filename, corsHeaders) {
// Check permissions
const metadataStr = await env.VIDEO_METADATA.get(filename);
const metadata = metadataStr ? JSON.parse(metadataStr) : {};
const visibility = metadata.visibility || 'shareable';
// If private, require authentication
if (visibility === 'private') {
const isAuth = await verifyAuth(request, env);
if (!isAuth) {
return new Response('This video is private', {
status: 403,
headers: corsHeaders
});
}
}
// If clip_shareable, only allow clips
if (visibility === 'clip_shareable') {
const isAuth = await verifyAuth(request, env);
if (!isAuth) {
return new Response('Full video not available. Only clips can be shared.', {
status: 403,
headers: corsHeaders
});
}
}
// Serve the video
return await serveVideo(request, env.R2_BUCKET, filename, corsHeaders);
}
/**
* Handle clip requests
*/
async function handleClip(request, env, path, corsHeaders) {
const filename = path.replace('/clip/', '').split('?')[0];
const url = new URL(request.url);
const start = url.searchParams.get('start') || '0';
const end = url.searchParams.get('end');
// Check permissions
const metadataStr = await env.VIDEO_METADATA.get(filename);
const metadata = metadataStr ? JSON.parse(metadataStr) : {};
const visibility = metadata.visibility || 'shareable';
// Clips are allowed for clip_shareable and shareable videos
if (visibility === 'private') {
const isAuth = await verifyAuth(request, env);
if (!isAuth) {
return new Response('This video is private', {
status: 403,
headers: corsHeaders
});
}
}
// For actual clip generation, you'd need to:
// 1. Use ffmpeg in a separate service, or
// 2. Use byte-range requests to approximate clips, or
// 3. Pre-generate clips on upload
// For now, we'll serve with Content-Range headers as an approximation
// This won't give exact frame-accurate clips but will seek to the right position
return new Response(`
<html>
<head><title>Video Clip</title></head>
<body style="margin: 0; background: #000;">
<video controls autoplay style="width: 100%; height: 100vh;" preload="auto">
<source src="/${filename}#t=${start}${end ? ',' + end : ''}" type="${getContentType(filename)}">
</video>
</body>
</html>
`, {
headers: { 'Content-Type': 'text/html; charset=utf-8' }
});
}
/**
* Serve video with range support
*/
async function serveVideo(request, bucket, filename, corsHeaders) {
const range = request.headers.get('Range');
let object;
if (range) {
const rangeMatch = range.match(/bytes=(\d+)-(\d*)/);
if (rangeMatch) {
const start = parseInt(rangeMatch[1]);
const end = rangeMatch[2] ? parseInt(rangeMatch[2]) : undefined;
object = await bucket.get(filename, {
range: { offset: start, length: end ? end - start + 1 : undefined }
});
} else {
object = await bucket.get(filename);
}
} else {
object = await bucket.get(filename);
}
if (!object) {
return new Response('Video not found', { status: 404, headers: corsHeaders });
}
const contentType = getContentType(filename);
const headers = {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000',
'Accept-Ranges': 'bytes',
...corsHeaders,
};
if (range && object.range) {
headers['Content-Range'] = `bytes ${object.range.offset}-${object.range.offset + object.range.length - 1}/${object.size}`;
headers['Content-Length'] = object.range.length;
return new Response(object.body, { status: 206, headers });
} else {
headers['Content-Length'] = object.size;
return new Response(object.body, { status: 200, headers });
}
}
/**
* Public gallery (only shareable videos)
*/
async function handlePublicGallery(bucket, kv, corsHeaders) {
const objects = await bucket.list();
const videos = await Promise.all(
objects.objects.map(async (obj) => {
const metadataStr = await kv.get(obj.key);
const metadata = metadataStr ? JSON.parse(metadataStr) : {};
return {
...obj,
visibility: metadata.visibility || 'shareable'
};
})
);
const shareableVideos = videos.filter(v => v.visibility === 'shareable');
const videoItems = shareableVideos
.map(obj => {
const sizeInMB = (obj.size / (1024 * 1024)).toFixed(2);
const uploadDate = new Date(obj.uploaded).toLocaleDateString();
return `
<div class="video-item">
<video controls preload="metadata">
<source src="/${obj.key}" type="${getContentType(obj.key)}">
</video>
<div class="video-info">
<h3>${obj.key}</h3>
<p>Size: ${sizeInMB} MB | Uploaded: ${uploadDate}</p>
<button onclick="copyLink('${obj.key}')">Copy Link</button>
</div>
</div>
`;
})
.join('\n');
const emptyState = shareableVideos.length === 0 ? `
<div style="text-align: center; padding: 100px 40px; background: linear-gradient(135deg, #1a1a1a 0%, #0f0f0f 100%); border-radius: 12px; margin: 20px;">
<div style="font-size: 6rem; margin-bottom: 30px; opacity: 0.7; animation: float 3s ease-in-out infinite;">🎬</div>
<h2 style="font-size: 2rem; margin-bottom: 15px; color: #fff;">No Videos Available Yet</h2>
<p style="color: #aaa; font-size: 1.1rem; line-height: 1.6; max-width: 600px; margin: 0 auto 30px;">
This gallery is currently empty. Check back soon for new content!
</p>
<div style="background: #2a2a2a; padding: 30px; border-radius: 10px; max-width: 500px; margin: 40px auto; border: 2px solid #3a3a3a;">
<div style="font-size: 2rem; margin-bottom: 15px;">📢</div>
<h3 style="color: #fff; margin-bottom: 10px;">For Content Creators</h3>
<p style="color: #aaa; font-size: 0.95rem;">
Upload videos through the admin panel to make them available here.
</p>
</div>
</div>
<style>
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-20px); }
}
</style>
` : videoItems;
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Video Gallery</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f0f;
color: #fff;
padding: 20px;
min-height: 100vh;
}
header { text-align: center; margin-bottom: 40px; padding: 20px; }
h1 { font-size: 2.5rem; margin-bottom: 10px; }
.subtitle { color: #aaa; font-size: 1.1rem; }
.video-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 30px;
max-width: 1400px;
margin: 0 auto;
}
.video-item {
background: #1a1a1a;
border-radius: 12px;
overflow: hidden;
transition: transform 0.2s;
}
.video-item:hover { transform: translateY(-4px); }
video { width: 100%; display: block; background: #000; }
.video-info { padding: 15px; }
.video-info h3 { font-size: 1rem; margin-bottom: 8px; word-break: break-word; }
.video-info p { color: #aaa; font-size: 0.9rem; margin-bottom: 12px; }
button {
padding: 8px 16px;
background: #3ea6ff;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
}
button:hover { background: #2988d8; }
@media (max-width: 768px) {
.video-grid { grid-template-columns: 1fr; }
h1 { font-size: 2rem; }
}
</style>
</head>
<body>
<header>
<h1>🎥 Video Gallery</h1>
<p class="subtitle">${shareableVideos.length} video${shareableVideos.length === 1 ? '' : 's'} available</p>
</header>
<div class="video-grid">
${emptyState}
</div>
<script>
function copyLink(filename) {
const url = window.location.origin + '/' + filename;
navigator.clipboard.writeText(url).then(() => alert('Link copied!'));
}
</script>
</body>
</html>`;
return new Response(html, {
headers: { 'Content-Type': 'text/html; charset=utf-8', ...corsHeaders }
});
}
/**
* Handle video upload from admin panel
*/
async function handleVideoUpload(request, env, corsHeaders) {
try {
// Parse multipart form data
const formData = await request.formData();
const videoFile = formData.get('video');
if (!videoFile) {
return new Response(JSON.stringify({ error: 'No video file provided' }), {
status: 400,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
// Get filename
const filename = videoFile.name;
// Validate file type
const validExtensions = ['.mp4', '.mkv', '.mov', '.avi', '.webm', '.flv', '.wmv'];
const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase();
if (!validExtensions.includes(ext)) {
return new Response(JSON.stringify({ error: 'Invalid file type' }), {
status: 400,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
// Upload to R2
await env.R2_BUCKET.put(filename, videoFile.stream(), {
httpMetadata: {
contentType: getContentType(filename)
}
});
// Set default visibility to shareable
await env.VIDEO_METADATA.put(filename, JSON.stringify({
visibility: 'shareable',
uploadedAt: new Date().toISOString(),
uploadMethod: 'admin_panel'
}));
return new Response(JSON.stringify({
success: true,
filename: filename,
url: `/${filename}`
}), {
status: 200,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
} catch (error) {
console.error('Upload error:', error);
return new Response(JSON.stringify({
error: 'Upload failed',
details: error.message
}), {
status: 500,
headers: { 'Content-Type': 'application/json', ...corsHeaders }
});
}
}
/**
* Verify admin authentication
*/
async function verifyAuth(request, env) {
const cookie = request.headers.get('Cookie') || '';
const match = cookie.match(/admin_auth=([^;]+)/);
if (!match) return false;
const token = match[1];
const correctPassword = env.ADMIN_PASSWORD || 'changeme';
const expectedToken = await generateToken(correctPassword);
return token === expectedToken;
}
/**
* Generate auth token
*/
async function generateToken(password) {
const encoder = new TextEncoder();
const data = encoder.encode(password + 'salt123');
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
/**
* Handle HLS live stream requests
*/
async function handleHLSStream(request, env, path, corsHeaders) {
// Path format: /live/stream-name/file.m3u8 or /live/stream-name/file.ts
const filename = path.substring(1); // Remove leading slash
try {
const object = await env.R2_BUCKET.get(filename);
if (!object) {
return new Response('Stream not found', {
status: 404,
headers: corsHeaders
});
}
// Determine content type based on extension
const contentType = filename.endsWith('.m3u8')
? 'application/vnd.apple.mpegurl'
: filename.endsWith('.ts')
? 'video/MP2T'
: 'application/octet-stream';
// Set appropriate caching headers
const cacheControl = filename.endsWith('.m3u8')
? 'no-cache, no-store, must-revalidate' // Playlist changes frequently
: 'public, max-age=31536000'; // Chunks are immutable
const headers = {
'Content-Type': contentType,
'Cache-Control': cacheControl,
...corsHeaders,
};
return new Response(object.body, { headers });
} catch (error) {
console.error('Error serving HLS stream:', error);
return new Response('Error serving stream', {
status: 500,
headers: corsHeaders
});
}
}
/**
* Get content type
*/
function getContentType(filename) {
const ext = filename.split('.').pop().toLowerCase();
const types = {
'mp4': 'video/mp4',
'mkv': 'video/x-matroska',
'mov': 'video/quicktime',
'avi': 'video/x-msvideo',
'webm': 'video/webm',
'flv': 'video/x-flv',
'wmv': 'video/x-ms-wmv',
'm3u8': 'application/vnd.apple.mpegurl',
'ts': 'video/MP2T',
};
return types[ext] || 'application/octet-stream';
}