Last active
March 28, 2025 02:59
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }); | |
| }, | |
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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