Skip to content

Instantly share code, notes, and snippets.

@iGerman00
Last active March 28, 2025 02:59
Show Gist options
  • Save iGerman00/0e21d4b957f1a4917f5bbb817136b83a to your computer and use it in GitHub Desktop.
Save iGerman00/0e21d4b957f1a4917f5bbb817136b83a to your computer and use it in GitHub Desktop.
Buttercup cache server worker, if it works it works. Groq API key needed (in variables) for summary feature
import { escapeHtml } from './escape.js';
import template from './template.html';
async function listAllKeys(namespace, options = {}) {
let allKeys = [];
let cursor = undefined;
let listComplete = false;
while (!listComplete) {
const currentOptions = { ...options, cursor: cursor, limit: 1000 };
try {
const result = await namespace.list(currentOptions);
if (result.keys && result.keys.length > 0) {
allKeys = allKeys.concat(result.keys);
}
listComplete = result.list_complete;
cursor = result.cursor;
if (!listComplete && !cursor) {
console.error("KV list inconsistency: list_complete is false but no cursor provided. Stopping pagination.");
break; // Avoid infinite loop
}
} catch (e) {
console.error(`Error during KV list operation: ${e.message}`, e);
throw new Error(`Failed to list KV keys: ${e.message}`);
}
}
return allKeys; // Return just the array of key objects
}
async function doSummaries(env, params = {}) {
const getCache = (key) => env.EXAMPLE_TODOS.get(key);
const setCache = (key, data) => env.EXAMPLE_TODOS.put(key, data);
let keysToProcess;
if (params.key) {
console.log(`doSummaries called for specific key: ${params.key}`);
keysToProcess = [{ name: params.key }];
} else {
console.log("doSummaries called for all keys. Fetching list...");
const allKeys = await listAllKeys(env.EXAMPLE_TODOS);
keysToProcess = allKeys.filter((key) => !key.name.startsWith('rateLimit:'));
console.log(`Found ${keysToProcess.length} non-ratelimit keys to potentially summarize.`);
}
let currentKey = 0; // Counter for subrequest limiting within a single invocation
const SUBREQUEST_LIMIT = 50;
for (const key of keysToProcess) {
try {
const cache = await getCache(key.name);
if (cache) {
let { i, c, s } = JSON.parse(cache);
// Only process if summary 's' is missing
if (s === null || typeof s === 'undefined') {
currentKey++;
if (currentKey > SUBREQUEST_LIMIT) {
console.log(`Subrequest limit (${SUBREQUEST_LIMIT}) reached during summarization, stopping for this run.`);
return; // Stop processing more keys in this invocation
}
if (typeof c === 'string') {
try {
c = JSON.parse(c);
} catch (parseError) {
console.error(`Failed to parse captions for key ${key.name}: ${parseError.message}. Skipping summary.`);
await setCache(key.name, JSON.stringify({ i, c: c, s: 'Error: Failed to parse captions' }));
continue;
}
}
if (!c || !c.events || !Array.isArray(c.events)) {
console.log(`Invalid captions structure for key ${key.name}. Skipping summary.`);
await setCache(key.name, JSON.stringify({ i, c: JSON.stringify(c), s: 'Error: Invalid captions format' }));
continue;
}
// Process text for summary
let text = c.events.map((event) => event.segs?.map((seg) => seg.utf8).join('') || '').join(' ');
text = text.replace(
/\b(?:a|an|and|the|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|shall|should|may|might|must|can|could|of|in|on|at|to|for|with|as|by|from|about|into|onto|upon|out|off|over|under|through|between|among|against|before|after|during|since|throughout|within|without|above|below|underneath|behind|beside|beyond|inside|outside|but|or|nor|so|yet|if|then|there|here|when|where|why|how|what|which|who|whom|whose|this|that|these|those|each|every|either|neither|some|any)\b/gi,
''
);
text = text.replace(/\s+/g, ' ').trim();
text = text.replace(/^\s*[\r\n]/gm, '');
const words = text.split(' ');
text = words.slice(0, 4000).join(' '); // Limit words first
text = text.substring(0, 25000); // Then limit characters
if (words.length < 100) {
console.log(`Transcript too short for summary (<100 words), skipped ${key.name}`);
await setCache(key.name, JSON.stringify({ i, c: JSON.stringify(c), s: 'Transcript too short for summary' }));
continue;
}
// Call AI for summary
console.log(`Requesting summary for ${key.name}`);
const response = await fetch(env.AI_BASE_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${env.GROQ_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: env.AI_MODEL || 'llama3-8b-8192',
messages: [
{
role: 'system',
content:
'You are a YouTube video summary assistant. You will provide a detailed but concise 100-word summary of the video transcript you are given by the user. Regardless of the language, you will provide the summary in English. You will only provide a summary and nothing more, you will not mention you are an AI or that you are providing a summary. At most 100 words.',
},
{ role: 'user', content: text },
],
max_tokens: 150, // Slightly more than 100 words to allow for model verbosity
}),
});
if (response.ok) {
const data = await response.json();
const summaryContent = data.choices?.[0]?.message?.content;
if (summaryContent) {
const content = JSON.stringify({ i, c: JSON.stringify(c), s: summaryContent.trim() })
await setCache(key.name, content);
console.log(`Successfully summarized and stored ${key.name}`);
} else {
console.error(`Failed to get summary content from AI response for ${key.name}. Response: ${JSON.stringify(data)}`);
await setCache(key.name, JSON.stringify({ i, c: JSON.stringify(c), s: 'Error: AI response missing content' }));
}
} else {
const errorBody = await response.text();
console.error(`AI API request failed for ${key.name}. Status: ${response.status}. Body: ${errorBody}`);
await setCache(key.name, JSON.stringify({ i, c: JSON.stringify(c), s: `Error: AI request failed (Status ${response.status})` }));
}
} else {
console.log(`Summary already exists for ${key.name}, skipping.`);
}
} else {
console.log(`Cache miss for key ${key.name} during summarization, skipping.`);
}
} catch (err) {
console.error(`Error processing key ${key.name} in doSummaries: ${err.message}`, err);
// Attempt to mark the item as errored in KV
try {
const existingData = await getCache(key.name);
if (existingData) {
let { i, c } = JSON.parse(existingData);
await setCache(key.name, JSON.stringify({ i, c, s: `Error: Processing failed - ${err.message}` }));
}
} catch (updateErr) {
console.error(`Failed to update KV with error state for key ${key.name}: ${updateErr.message}`);
}
}
}
console.log("doSummaries run finished.");
}
async function removeSummaries(env) {
// For development, inaccessible in production
const allKeys = await listAllKeys(env.EXAMPLE_TODOS);
const keys = allKeys.filter((key) => !key.name.startsWith('rateLimit:'));
console.log(`Attempting to remove summaries from ${keys.length} videos`);
let removedCount = 0;
for (const key of keys) {
try {
const cache = await env.EXAMPLE_TODOS.get(key.name);
if (cache) {
let { i, c, s } = JSON.parse(cache);
// Remove summary if 's' exists and is not null
if (typeof s !== 'undefined' && s !== null) {
await env.EXAMPLE_TODOS.put(key.name, JSON.stringify({ i, c, s: null }));
console.log(`Removed summary from ${key.name}`);
removedCount++;
}
}
} catch (err) {
console.error(`Error removing summary for key ${key.name}: ${err.message}`, err);
}
}
console.log(`Finished removing summaries. Total removed: ${removedCount}`);
}
export default {
async scheduled(event, env, ctx) {
console.log(`Scheduled event triggered: ${event.cron}`);
try {
ctx.waitUntil(doSummaries(env));
console.log("Scheduled tasks initiated.");
} catch (e) {
console.error(`Error in scheduled handler: ${e.message}`, e);
}
},
async fetch(request, env, ctx) {
const url = new URL(request.url);
const setCache = (key, data) => env.EXAMPLE_TODOS.put(key, data);
const getCache = (key) => env.EXAMPLE_TODOS.get(key);
const corsHeaders = {
'Access-Control-Allow-Origin': 'https://www.youtube.com',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, BC-VideoID, BC-Translate',
'Access-Control-Max-Age': '86400'
};
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders });
}
if (url.pathname === '/api/status') {
try {
const allKeys = await listAllKeys(env.EXAMPLE_TODOS);
// filter all keys, old behavior had ratelimit keys
const videoKeys = allKeys.filter(key => !key.name.startsWith('rateLimit:'));
const count = videoKeys.length;
return new Response(JSON.stringify({ count: count, status: 'ok' }), {
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
} catch (e) {
console.error(`Error in /api/status: ${e.message}`, e);
return new Response(JSON.stringify({ status: 'error', message: 'Failed to retrieve count' }), {
status: 500,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
}
if (url.pathname === '/api/getSummary') {
const videoIdHeader = request.headers.get('BC-VideoID');
if (!videoIdHeader) {
return new Response('Missing BC-VideoID header', { status: 400, headers: corsHeaders });
}
let cacheKey = videoIdHeader;
if (cacheKey.length > 13 || !/^[a-zA-Z0-9_-]+$/.test(cacheKey)) {
return new Response('Invalid video ID format', { status: 400, headers: corsHeaders });
}
const translateHeader = request.headers.get('BC-Translate');
if (translateHeader === 'true') {
cacheKey = 'English-' + cacheKey;
}
try {
const cache = await getCache(cacheKey);
if (!cache) {
return new Response('Not found', { status: 404, headers: corsHeaders });
} else {
const data = JSON.parse(cache);
if (data.s) {
return new Response(data.s, {
headers: { 'Content-Type': 'text/plain', ...corsHeaders },
});
} else {
return new Response('Summary not available', { status: 404, headers: corsHeaders });
}
}
} catch (e) {
console.error(`Error fetching summary for ${cacheKey}: ${e.message}`, e);
return new Response('Error retrieving summary', { status: 500, headers: corsHeaders });
}
}
if (url.pathname === '/api/videos') {
try {
const page = parseInt(url.searchParams.get('page') || '1', 10);
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
if (isNaN(page) || page < 1 || isNaN(limit) || limit < 1 || limit > 100) {
return new Response(JSON.stringify({ error: 'Invalid page or limit parameter. Limit must be between 1 and 100.' }), {
status: 400,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
const allKeys = await listAllKeys(env.EXAMPLE_TODOS);
const videoKeys = allKeys
.filter(key => !key.name.startsWith('rateLimit:'))
// KV lists lexicographically, rely on that order.
.map((key) => ({
id: escapeHtml(key.name.replace(/^English-/, '')), // Handle potential prefix
english: key.name.startsWith('English-')
}));
const totalVideos = videoKeys.length;
const totalPages = Math.ceil(totalVideos / limit);
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const paginatedVideos = videoKeys.slice(startIndex, endIndex);
const responseData = {
videos: paginatedVideos,
totalVideos: totalVideos,
totalPages: totalPages,
currentPage: page,
limit: limit
};
return new Response(JSON.stringify(responseData), {
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
} catch (e) {
console.error(`Error in /api/videos: ${e.message}`, e);
return new Response(JSON.stringify({ error: 'Failed to retrieve video list' }), {
status: 500,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
}
const videoIdHeader = request.headers.get('BC-VideoID');
if (request.method === 'POST' && videoIdHeader) {
let cacheKey = videoIdHeader;
if (cacheKey.length > 13 || !/^[a-zA-Z0-9_-]+$/.test(cacheKey)) { // Basic validation
return new Response('Invalid video ID format', { status: 400, headers: corsHeaders });
}
const translateHeader = request.headers.get('BC-Translate');
if (translateHeader === 'true') {
cacheKey = 'English-' + cacheKey;
}
const body = await request.text();
if (body.length > 2000000) { // 2MB
return new Response('Captions too big to cache', { status: 413, headers: corsHeaders });
}
try {
let parsed = JSON.parse(body);
if (parsed.id && typeof parsed.id === 'string' && parsed.captions && typeof parsed.captions === 'string') {
// Prepare data for storage, 's' (summary) is null initially
const dataToStore = JSON.stringify({
i: parsed.id, // Store the original ID from payload
c: parsed.captions, // Store captions string
s: null // Initialize summary as null
});
await setCache(cacheKey, dataToStore);
console.log(`Stored captions for key: ${cacheKey}`);
ctx.waitUntil(doSummaries(env, { key: cacheKey }));
return new Response('Stored', { status: 200, headers: corsHeaders });
} else {
return new Response('Invalid input structure: requires id (string) and captions (string)', { status: 400, headers: corsHeaders });
}
} catch (err) {
console.error(`Error processing setCaptions for ${cacheKey}: ${err.message}`, err);
return new Response('Invalid JSON input or server error', { status: err instanceof SyntaxError ? 400 : 500, headers: corsHeaders });
}
}
if (request.method === 'GET' && videoIdHeader) {
let cacheKey = videoIdHeader;
if (cacheKey.length > 13 || !/^[a-zA-Z0-9_-]+$/.test(cacheKey)) {
return new Response('Invalid video ID format', { status: 400, headers: corsHeaders });
}
const translateHeader = request.headers.get('BC-Translate');
if (translateHeader === 'true') {
cacheKey = 'English-' + cacheKey;
}
try {
const cache = await getCache(cacheKey);
if (!cache) {
return new Response('Not found', { status: 404, headers: corsHeaders });
} else {
// Return the full stored object {i, c, s}
const data = JSON.parse(cache); // Assume stored data is valid JSON
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
} catch (e) {
console.error(`Error fetching captions/summary for ${cacheKey}: ${e.message}`, e);
return new Response('Error retrieving data', { status: 500, headers: corsHeaders });
}
}
if (request.method === 'GET' && url.pathname === '/') {
return new Response(template, {
headers: { 'Content-Type': 'text/html', ...corsHeaders },
});
}
return new Response("Not Found", { status: 404, headers: corsHeaders });
},
};
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Buttercup Cache</title>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/full.min.css" rel="stylesheet" type="text/css" />
<script src="https://cdn.tailwindcss.com"></script>
<style>
/* Add some explicit height control for the table container */
.table-container {
height: clamp(300px, 75svh, 600px); /* Responsive height */
max-height: 600px; /* Explicit max height */
overflow-y: auto; /* Enable vertical scroll */
display: block; /* Needed for overflow */
}
/* Style for loading state */
.loading-placeholder td {
height: 48px; /* Match typical row height */
background-color: rgba(128, 128, 128, 0.1);
animation: pulse 1.5s infinite ease-in-out;
}
@keyframes pulse {
0%, 100% { opacity: 0.1; }
50% { opacity: 0.2; }
}
</style>
</head>
<body>
<div class="flex flex-col min-h-screen justify-between">
<div class="navbar bg-primary text-primary-content">
<div class="flex-1">
<a class="btn btn-ghost text-xl">Buttercup Cache
<div class="badge badge-accent badge-lg" id="videoCount">
<span class="loading loading-spinner loading-xs"></span>
</div>
<span class="text-sm ml-2 hidden" id="pageIndicator">(Page <span id="currentPage">1</span> / <span id="totalPages">1</span>)</span>
</a>
</div>
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn m-1 btn-ghost">Theme
<svg width="12px" height="12px" class="h-2 w-2 fill-current opacity-60 inline-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2048 2048"><path d="M1799 349l242 241-1017 1017L7 590l242-241 775 775 775-775z"></path></svg>
</div>
<ul tabindex="0" class="dropdown-content z-[1] p-2 shadow-2xl bg-base-100 rounded-box w-52 mt-4 max-h-72 overflow-y-auto">
<li><input type="radio" name="theme-dropdown" class="theme-controller btn btn-sm btn-block btn-ghost justify-start" aria-label="Light" value="light"/></li>
<li><input type="radio" name="theme-dropdown" class="theme-controller btn btn-sm btn-block btn-ghost justify-start" aria-label="Dark" value="dark"/></li>
<li><input type="radio" name="theme-dropdown" class="theme-controller btn btn-sm btn-block btn-ghost justify-start" aria-label="Cupcake" value="cupcake"/></li>
<li><input type="radio" name="theme-dropdown" class="theme-controller btn btn-sm btn-block btn-ghost justify-start" aria-label="Bumblebee" value="bumblebee"/></li>
<li><input type="radio" name="theme-dropdown" class="theme-controller btn btn-sm btn-block btn-ghost justify-start" aria-label="Emerald" value="emerald"/></li>
<li><input type="radio" name="theme-dropdown" class="theme-controller btn btn-sm btn-block btn-ghost justify-start" aria-label="Corporate" value="corporate"/></li>
<li><input type="radio" name="theme-dropdown" class="theme-controller btn btn-sm btn-block btn-ghost justify-start" aria-label="Synthwave" value="synthwave"/></li>
<li><input type="radio" name="theme-dropdown" class="theme-controller btn btn-sm btn-block btn-ghost justify-start" aria-label="Retro" value="retro"/></li>
<li><input type="radio" name="theme-dropdown" class="theme-controller btn btn-sm btn-block btn-ghost justify-start" aria-label="Cyberpunk" value="cyberpunk"/></li>
<li><input type="radio" name="theme-dropdown" class="theme-controller btn btn-sm btn-block btn-ghost justify-start" aria-label="Valentine" value="valentine"/></li>
<li><input type="radio" name="theme-dropdown" class="theme-controller btn btn-sm btn-block btn-ghost justify-start" aria-label="Halloween" value="halloween"/></li>
<li><input type="radio" name="theme-dropdown" class="theme-controller btn btn-sm btn-block btn-ghost justify-start" aria-label="Garden" value="garden"/></li>
<li><input type="radio" name="theme-dropdown" class="theme-controller btn btn-sm btn-block btn-ghost justify-start" aria-label="Forest" value="forest"/></li>
<li><input type="radio" name="theme-dropdown" class="theme-controller btn btn-sm btn-block btn-ghost justify-start" aria-label="Aqua" value="aqua"/></li>
<li><input type="radio" name="theme-dropdown" class="theme-controller btn btn-sm btn-block btn-ghost justify-start" aria-label="Lofi" value="lofi"/></li>
<li><input type="radio" name="theme-dropdown" class="theme-controller btn btn-sm btn-block btn-ghost justify-start" aria-label="Pastel" value="pastel"/></li>
<li><input type="radio" name="theme-dropdown" class="theme-controller btn btn-sm btn-block btn-ghost justify-start" aria-label="Fantasy" value="fantasy"/></li>
<li><input type="radio" name="theme-dropdown" class="theme-controller btn btn-sm btn-block btn-ghost justify-start" aria-label="Wireframe" value="wireframe"/></li>
<li><input type="radio" name="theme-dropdown" class="theme-controller btn btn-sm btn-block btn-ghost justify-start" aria-label="Black" value="black"/></li>
<li><input type="radio" name="theme-dropdown" class="theme-controller btn btn-sm btn-block btn-ghost justify-start" aria-label="Luxury" value="luxury"/></li>
<li><input type="radio" name="theme-dropdown" class="theme-controller btn btn-sm btn-block btn-ghost justify-start" aria-label="Dracula" value="dracula"/></li>
<li><input type="radio" name="theme-dropdown" class="theme-controller btn btn-sm btn-block btn-ghost justify-start" aria-label="Cmyk" value="cmyk"/></li>
<li><input type="radio" name="theme-dropdown" class="theme-controller btn btn-sm btn-block btn-ghost justify-start" aria-label="Autumn" value="autumn"/></li>
<li><input type="radio" name="theme-dropdown" class="theme-controller btn btn-sm btn-block btn-ghost justify-start" aria-label="Business" value="business"/></li>
<li><input type="radio" name="theme-dropdown" class="theme-controller btn btn-sm btn-block btn-ghost justify-start" aria-label="Acid" value="acid"/></li>
<li><input type="radio" name="theme-dropdown" class="theme-controller btn btn-sm btn-block btn-ghost justify-start" aria-label="Lemonade" value="lemonade"/></li>
<li><input type="radio" name="theme-dropdown" class="theme-controller btn btn-sm btn-block btn-ghost justify-start" aria-label="Night" value="night"/></li>
<li><input type="radio" name="theme-dropdown" class="theme-controller btn btn-sm btn-block btn-ghost justify-start" value="coffee" aria-label="Coffee"/></li>
<li><input type="radio" name="theme-dropdown" class="theme-controller btn btn-sm btn-block btn-ghost justify-start" value="winter" aria-label="Winter"/></li>
<li><input type="radio" name="theme-dropdown" class="theme-controller btn btn-sm btn-block btn-ghost justify-start" value="dim" aria-label="Dim"/></li>
<li><input type="radio" name="theme-dropdown" class="theme-controller btn btn-sm btn-block btn-ghost justify-start" value="nord" aria-label="Nord"/></li>
<li><input type="radio" name="theme-dropdown" class="theme-controller btn btn-sm btn-block btn-ghost justify-start" value="sunset" aria-label="Sunset"/></li>
</ul>
</div>
</div>
<main class="w-full flex-grow flex items-center justify-center mt-8 pb-10 px-4">
<div class="bg-base-200 shadow-lg rounded-lg p-5 w-full max-w-4xl flex flex-col">
<h2 class="text-2xl font-bold mb-4 text-center">Cached Videos</h2>
<div class="table-container mb-4">
<table class="table table-pin-rows table-sm w-full">
<thead class="sticky top-0 bg-base-300 z-10">
<tr>
<th>ID</th>
<th>Language</th>
<th>Link</th>
<th>Summary</th>
</tr>
</thead>
<tbody id="videosTableBody">
<!-- Rows will be populated by JS -->
<tr><td colspan="4" class="text-center p-4">Loading videos...</td></tr>
</tbody>
</table>
</div>
<div class="flex justify-center items-center space-x-2" id="paginationControls">
<!-- Pagination buttons will be populated by JS -->
</div>
</div>
</main>
<dialog id="modal" class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4" id="modalTitle">Video Summary</h3>
<div id="modalContent" class="py-4">
<span class="loading loading-infinity loading-lg"></span> Fetching summary...
</div>
<div class="modal-action">
<form method="dialog">
<button class="btn">Close</button>
</form>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button> {{-- Allows closing by clicking backdrop --}}
</form>
</dialog>
<footer class="footer items-center p-4 bg-neutral text-neutral-content flex-wrap justify-between">
<aside class="items-center grid-flow-col">
<div>
<p>Please don't abuse my services</p>
<p>- G</p>
</div>
</aside>
<nav class="grid-flow-col gap-4 md:place-self-center md:justify-self-end">
<a href="https://igerman.cc/" target="_blank" class="btn btn-primary btn-sm">
Contact
</a>
<a href="https://ko-fi.com/D1D2BJ5D6" target="_blank" class="btn btn-secondary btn-sm">
Donate
</a>
<a href="https://github.com/iGerman00/buttercup-chrome" target="_blank" class="btn btn-ghost btn-sm">
Github
</a>
</nav>
</footer>
</div>
<script>
const API_BASE_URL = '/'; // Assuming worker runs at the root
const VIDEOS_PER_PAGE = 100; // How many videos to show per page
let currentPageGlobal = 1;
// --- Theme Handling ---
function applyTheme(theme) {
document.querySelector('html').setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
// Ensure the correct radio button is checked
const currentThemeInput = document.querySelector(`.theme-controller[value="${theme}"]`);
if (currentThemeInput) {
currentThemeInput.checked = true;
}
}
document.addEventListener('DOMContentLoaded', () => {
const savedTheme = localStorage.getItem('theme') || 'business'; // Default theme
applyTheme(savedTheme);
const themeControllers = document.querySelectorAll('.theme-controller');
themeControllers.forEach(controller => {
controller.addEventListener('change', function () { // Use change event for radios
applyTheme(this.value);
});
});
// --- Initial Data Load ---
fetchVideoCount();
fetchAndDisplayVideos(currentPageGlobal);
});
// --- API Fetching Functions ---
async function fetchVideoCount() {
const videoCountElement = document.querySelector('#videoCount');
try {
const response = await fetch(`${API_BASE_URL}api/status`);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json();
videoCountElement.innerHTML = data.count ?? 'Error'; // Update count, handle potential missing count
} catch (error) {
console.error('Error fetching video count:', error);
videoCountElement.innerHTML = 'Error';
}
}
async function fetchAndDisplayVideos(page = 1) {
const tableBody = document.querySelector('#videosTableBody');
const paginationControls = document.querySelector('#paginationControls');
const pageIndicator = document.querySelector('#pageIndicator');
const currentPageElement = document.querySelector('#currentPage');
const totalPagesElement = document.querySelector('#totalPages');
// Show loading state
tableBody.innerHTML = `<tr><td colspan="4" class="loading-placeholder"></td></tr>`.repeat(5); // Placeholder rows
paginationControls.innerHTML = '<span class="loading loading-dots loading-md"></span>';
pageIndicator.classList.add('hidden'); // Hide page indicator during load
try {
const response = await fetch(`${API_BASE_URL}api/videos?page=${page}&limit=${VIDEOS_PER_PAGE}`);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json();
currentPageGlobal = data.currentPage; // Update global page state
// Clear loading state
tableBody.innerHTML = '';
// Populate table
if (data.videos && data.videos.length > 0) {
data.videos.forEach(video => {
const row = createVideoRow(video);
tableBody.appendChild(row);
});
} else {
tableBody.innerHTML = '<tr><td colspan="4" class="text-center p-4">No videos found for this page.</td></tr>';
}
// Update pagination controls
renderPagination(data.currentPage, data.totalPages);
// Update header indicator
currentPageElement.textContent = data.currentPage;
totalPagesElement.textContent = data.totalPages;
if (data.totalPages > 0) {
pageIndicator.classList.remove('hidden');
} else {
pageIndicator.classList.add('hidden');
}
} catch(error) {
console.error('Error fetching videos:', error);
tableBody.innerHTML = `<tr><td colspan="4" class="text-center p-4 text-error">Error loading videos. Please try again later.</td></tr>`;
paginationControls.innerHTML = '<span class="text-error">Failed to load pagination</span>';
pageIndicator.classList.add('hidden');
}
}
async function getSummaryFromId(videoId, isEnglish) {
const modalContent = document.querySelector('#modalContent');
const modalTitle = document.querySelector('#modalTitle');
modalTitle.textContent = `Summary for ${videoId}`;
modalContent.innerHTML = '<span class="loading loading-infinity loading-lg"></span> Fetching summary...';
document.getElementById("modal").showModal();
try {
const headers = { 'BC-VideoID': videoId };
if (isEnglish) {
headers['BC-Translate'] = 'true';
}
const response = await fetch(API_BASE_URL, { headers: headers });
if (!response.ok) {
if(response.status === 404) {
throw new Error('Video data not found in cache.');
} else {
throw new Error(`Failed to fetch video data (Status: ${response.status})`);
}
}
const data = await response.json();
if (data.s === null || typeof data.s === 'undefined') {
modalContent.innerHTML = 'Summary has not been generated yet. Please check back later.';
} else if (data.s.startsWith('Error:') || data.s.includes('too short')) {
modalContent.innerHTML = `<span class="text-warning">${data.s}</span>`;
}
else {
modalContent.textContent = data.s;
}
} catch (error) {
console.error('Error fetching summary:', error);
modalContent.innerHTML = `<span class="text-error">Error fetching summary: ${error.message}</span>`;
}
}
function createVideoRow(video) {
const row = document.createElement('tr');
const idCell = document.createElement('td');
idCell.textContent = video.id;
const langCell = document.createElement('td');
if (video.english) {
const badge = document.createElement('div');
badge.className = 'badge badge-primary badge-sm';
badge.textContent = "English";
langCell.appendChild(badge);
} else {
const badge = document.createElement('div');
badge.className = 'badge badge-ghost badge-sm';
badge.textContent = "Original";
langCell.appendChild(badge);
}
const linkCell = document.createElement('td');
const link = document.createElement('a');
link.className = 'btn btn-secondary btn-xs';
link.textContent = 'Watch';
link.href = 'https://youtu.be/' + video.id;
link.target = '_blank';
link.rel = 'noopener noreferrer';
linkCell.appendChild(link);
const summaryCell = document.createElement('td');
const summaryButton = document.createElement('button');
summaryButton.className = 'btn btn-primary btn-xs';
summaryButton.textContent = 'Get';
summaryButton.onclick = () => getSummaryFromId(video.id, video.english)
summaryCell.appendChild(summaryButton);
row.appendChild(idCell);
row.appendChild(langCell);
row.appendChild(linkCell);
row.appendChild(summaryCell);
return row;
}
function renderPagination(currentPage, totalPages) {
const paginationControls = document.querySelector('#paginationControls');
paginationControls.innerHTML = '';
if (totalPages <= 1) {
return; // No pagination needed if 0 or 1 page
}
const createButton = (text, page, isDisabled = false, isCurrent = false) => {
const button = document.createElement('button');
button.className = `btn btn-sm join-item ${isCurrent ? 'btn-active' : ''}`;
button.textContent = text;
button.disabled = isDisabled;
button.onclick = () => {
if (!isDisabled && !isCurrent) {
fetchAndDisplayVideos(page);
}
};
return button;
};
const paginationGroup = document.createElement('div');
paginationGroup.className = 'join';
// Previous
paginationGroup.appendChild(createButton('«', currentPage - 1, currentPage === 1));
// Page Number
paginationGroup.appendChild(createButton(`Page ${currentPage}`, currentPage, false, true));
// Next
paginationGroup.appendChild(createButton('»', currentPage + 1, currentPage === totalPages));
paginationControls.appendChild(paginationGroup);
}
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment