diff --git a/scripts/frigate-bulk-classify.js b/scripts/frigate-bulk-classify.js
new file mode 100644
index 00000000..32b594e5
--- /dev/null
+++ b/scripts/frigate-bulk-classify.js
@@ -0,0 +1,698 @@
+// 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