1942 lines
52 KiB
JavaScript
1942 lines
52 KiB
JavaScript
/**
|
||
* 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';
|
||
}
|