// Frigate Bulk Classification Labeler // Paste this into the browser console on the Frigate /classification page // while viewing a model's training images. // // Image URL pattern: /clips/{modelName}/train/{filename} // Categorize API: POST /api/classification/{modelName}/dataset/categorize // body: { category: "...", training_file: "..." } // Delete API: POST /api/classification/{modelName}/train/delete // body: { ids: ["..."] } // Dataset API: GET /api/classification/{modelName}/dataset // returns: { categories: { catName: [files...] }, training_metadata: {...} } (async () => { "use strict"; // --- Configuration --- const API_BASE = window.location.origin + "/api"; const TOOLBAR_ID = "bulk-classify-toolbar"; // Frigate's axios instance sends these headers on every request. // X-CSRF-TOKEN is required for state-modifying (POST/PUT/DELETE) requests. const API_HEADERS = { "Content-Type": "application/json", "X-CSRF-TOKEN": "1", "X-CACHE-BYPASS": "1", }; // Abort if already injected if (document.getElementById(TOOLBAR_ID)) { console.log("Bulk classifier already active. Refresh page to re-inject."); return; } // --- Extract model name from page --- // Training images use src="/clips/{modelName}/train/{filename}" let modelName = null; // Method 1: Extract from training image src on the page for (const img of document.querySelectorAll("img")) { const src = img.getAttribute("src") || ""; const m = src.match(/\/clips\/([^/]+)\/train\//); if (m) { modelName = decodeURIComponent(m[1]); break; } } // Method 2: List all custom models from config and let the user pick if (!modelName) { try { const resp = await fetch(`${API_BASE}/config`); const config = await resp.json(); // Custom classification models are under config.classification.custom const models = Object.keys(config.classification?.custom || {}); if (models.length === 1) { modelName = models[0]; } else if (models.length > 1) { modelName = prompt( `Multiple classification models found. Enter the model name:\n\n${models.join(", ")}`, ); } } catch (_) {} } if (!modelName) { alert( "Could not detect model name.\nMake sure you are on the /classification page with training images visible.", ); return; } console.log(`[bulk-classify] Detected model: "${modelName}"`); // --- Fetch categories from the dataset API --- let categories = []; try { const resp = await fetch(`${API_BASE}/classification/${encodeURIComponent(modelName)}/dataset`); const data = await resp.json(); // Dataset response: { categories: { catName: [files...] }, training_metadata: {...} } categories = Object.keys(data.categories || data); } catch (e) { console.error("Failed to fetch categories:", e); } // Deduplicate categories = [...new Set(categories)]; console.log("[bulk-classify] Categories:", categories); // --- Fetch all training filenames and build event groups --- // Frigate groups training images by eventId (first two segments of the filename). // Filename format: {timestamp}-{randomId}-{timestamp2}-{label}-{score}.webp // EventId = "{timestamp}-{randomId}" let allTrainFiles = []; const eventGroups = {}; // eventId -> [filename, ...] function parseEventId(filename) { const base = filename.replace(/\.webp$/, ""); const parts = base.split("-"); if (parts.length >= 2) return `${parts[0]}-${parts[1]}`; return filename; // fallback: treat as its own group } try { const resp = await fetch( `${API_BASE}/classification/${encodeURIComponent(modelName)}/train`, { headers: API_HEADERS }, ); allTrainFiles = await resp.json(); for (const f of allTrainFiles) { const eid = parseEventId(f); if (!eventGroups[eid]) eventGroups[eid] = []; eventGroups[eid].push(f); } console.log( `[bulk-classify] Loaded ${allTrainFiles.length} training files in ${Object.keys(eventGroups).length} event groups.`, ); } catch (e) { console.error("[bulk-classify] Failed to fetch training files:", e); } // Get all filenames in the same event group as the given filename function getGroupFiles(filename) { const eid = parseEventId(filename); return eventGroups[eid] || [filename]; } // --- State --- const selected = new Set(); // --- Inject styles --- const style = document.createElement("style"); style.textContent = ` #${TOOLBAR_ID} { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); z-index: 99999; background: #1e1e2e; border: 1px solid #444; border-radius: 12px; padding: 12px 20px; display: flex; align-items: center; gap: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.5); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 14px; color: #cdd6f4; } #${TOOLBAR_ID} button { padding: 6px 14px; border: 1px solid #555; border-radius: 6px; background: #313244; color: #cdd6f4; cursor: pointer; font-size: 13px; white-space: nowrap; } #${TOOLBAR_ID} button:hover { background: #45475a; } #${TOOLBAR_ID} button.primary { background: #89b4fa; color: #1e1e2e; border-color: #89b4fa; font-weight: 600; } #${TOOLBAR_ID} button.primary:hover { background: #74c7ec; } #${TOOLBAR_ID} button.primary:disabled { opacity: 0.5; cursor: not-allowed; } #${TOOLBAR_ID} button.danger { background: #f38ba8; color: #1e1e2e; border-color: #f38ba8; font-weight: 600; } #${TOOLBAR_ID} button.danger:hover { background: #eba0ac; } .bulk-classify-dropdown { position: relative; display: inline-block; } .bulk-classify-dropdown-btn { padding: 6px 14px; border: 1px solid #555; border-radius: 6px; background: #313244; color: #cdd6f4; cursor: pointer; font-size: 13px; white-space: nowrap; min-width: 140px; text-align: left; } .bulk-classify-dropdown-btn::after { content: " ▾"; float: right; margin-left: 8px; } .bulk-classify-dropdown-menu { display: none; position: absolute; bottom: 100%; left: 0; margin-bottom: 4px; background: #313244; border: 1px solid #555; border-radius: 6px; max-height: 250px; overflow-y: auto; min-width: 180px; box-shadow: 0 -4px 16px rgba(0,0,0,0.4); z-index: 100000; } .bulk-classify-dropdown-menu.open { display: block; } .bulk-classify-dropdown-item { padding: 8px 14px; cursor: pointer; font-size: 13px; color: #cdd6f4; white-space: nowrap; } .bulk-classify-dropdown-item:hover { background: #45475a; } .bulk-classify-dropdown-item.active { background: #89b4fa; color: #1e1e2e; } #${TOOLBAR_ID} .count { font-weight: 600; min-width: 30px; text-align: center; } #${TOOLBAR_ID} .separator { width: 1px; height: 24px; background: #555; } #${TOOLBAR_ID} .progress { font-size: 12px; color: #a6adc8; } .bulk-classify-checkbox { position: absolute; top: 6px; left: 6px; z-index: 9999; width: 22px; height: 22px; cursor: pointer; accent-color: #89b4fa; pointer-events: auto; } .bulk-classify-selected { outline: 3px solid #89b4fa !important; outline-offset: -3px; } .bulk-classify-overlay { position: fixed; inset: 0; z-index: 99998; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; } .bulk-classify-dialog { background: #1e1e2e; border: 1px solid #444; border-radius: 12px; padding: 24px; min-width: 350px; color: #cdd6f4; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } .bulk-classify-dialog h3 { margin: 0 0 16px; font-size: 16px; } .bulk-classify-dialog .progress-bar { width: 100%; height: 8px; background: #313244; border-radius: 4px; overflow: hidden; margin: 12px 0; } .bulk-classify-dialog .progress-fill { height: 100%; background: #89b4fa; transition: width 0.2s; } .bulk-classify-dialog .status { font-size: 13px; color: #a6adc8; } `; document.head.appendChild(style); // --- Helper: find all training image cards --- function getImageCards() { // Training images use src="/clips/{modelName}/train/{filename}" // Filenames are like: 1770573871.602803-in4y00-1770573889.027752-none-1.0.webp const pattern = /\/clips\/[^/]+\/train\/([^/?#]+)/; const imgs = document.querySelectorAll("img"); const cards = []; const seen = new Set(); for (const img of imgs) { const src = img.getAttribute("src") || ""; const match = src.match(pattern); if (match && !seen.has(match[1])) { seen.add(match[1]); // Walk up to find the card container (Frigate uses aspect-square divs) let card = img.closest("[class*='aspect-']") || img.closest("[class*='card']") || img.parentElement?.parentElement || img.parentElement; // Resolve the full group of filenames for this card const groupFiles = getGroupFiles(match[1]); cards.push({ element: card, filename: match[1], img, groupFiles }); } } return cards; } // --- Debug: log what images we found --- const debugImgs = document.querySelectorAll("img"); const debugSrcs = Array.from(debugImgs) .map((i) => i.getAttribute("src")) .filter(Boolean); console.log( `[bulk-classify] Found ${debugSrcs.length} elements. Sample srcs:`, debugSrcs.slice(0, 5), ); const initialCards = getImageCards(); console.log( `[bulk-classify] Matched ${initialCards.length} training image cards.`, ); // --- Add checkboxes to all cards --- function injectCheckboxes() { const cards = getImageCards(); for (const { element, filename, groupFiles } of cards) { if (element.querySelector(".bulk-classify-checkbox")) continue; // Ensure relative positioning for absolute checkbox element.style.position = "relative"; const cb = document.createElement("input"); cb.type = "checkbox"; cb.className = "bulk-classify-checkbox"; cb.dataset.filename = filename; cb.checked = selected.has(filename); // Show group count badge next to checkbox if group has >1 image let badge = null; if (groupFiles.length > 1) { badge = document.createElement("span"); badge.className = "bulk-classify-badge"; badge.textContent = groupFiles.length; badge.style.cssText = "position:absolute;top:6px;left:32px;z-index:9999;background:#89b4fa;color:#1e1e2e;" + "font-size:11px;font-weight:700;padding:1px 5px;border-radius:8px;pointer-events:none;"; } cb.addEventListener("change", (e) => { e.stopPropagation(); if (cb.checked) { // Select ALL files in this event group for (const f of groupFiles) selected.add(f); element.classList.add("bulk-classify-selected"); } else { for (const f of groupFiles) selected.delete(f); element.classList.remove("bulk-classify-selected"); } updateCount(); }); // Also allow clicking the image to toggle element.addEventListener("click", (e) => { // Don't intercept if clicking the checkbox itself or a button if ( e.target === cb || e.target.closest("button") || e.target.closest("a") ) return; e.preventDefault(); e.stopPropagation(); cb.checked = !cb.checked; cb.dispatchEvent(new Event("change")); }); element.prepend(cb); if (badge) element.appendChild(badge); } } // --- Toolbar --- const toolbar = document.createElement("div"); toolbar.id = TOOLBAR_ID; const countLabel = document.createElement("span"); countLabel.className = "count"; countLabel.textContent = "0"; const countText = document.createElement("span"); countText.textContent = "selected"; const sep1 = document.createElement("div"); sep1.className = "separator"; const selectAllBtn = document.createElement("button"); selectAllBtn.textContent = "Select All"; selectAllBtn.addEventListener("click", () => { const cards = getImageCards(); for (const { element, groupFiles } of cards) { for (const f of groupFiles) selected.add(f); element.classList.add("bulk-classify-selected"); const cb = element.querySelector(".bulk-classify-checkbox"); if (cb) cb.checked = true; } updateCount(); }); const deselectBtn = document.createElement("button"); deselectBtn.textContent = "Deselect All"; deselectBtn.addEventListener("click", () => { const cards = getImageCards(); for (const { element, groupFiles } of cards) { for (const f of groupFiles) selected.delete(f); element.classList.remove("bulk-classify-selected"); const cb = element.querySelector(".bulk-classify-checkbox"); if (cb) cb.checked = false; } updateCount(); }); const sep2 = document.createElement("div"); sep2.className = "separator"; // --- Custom dropdown (replaces native