Skip to content

Instantly share code, notes, and snippets.

@adam137016
Last active April 24, 2026 11:17
Show Gist options
  • Select an option

  • Save adam137016/8d5e405e05050098a656f240d03c7d25 to your computer and use it in GitHub Desktop.

Select an option

Save adam137016/8d5e405e05050098a656f240d03c7d25 to your computer and use it in GitHub Desktop.
Template for UI
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sonic Adventure</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0e0e10;
--surface: #1a1a1e;
--surface2: #25252b;
--border: #333340;
--accent: #7f77dd;
--accent2: #534ab7;
--text: #e8e8f0;
--muted: #888899;
--green: #1d9e75;
--red: #e24b4a;
--amber: #ef9f27;
--radius: 10px;
}
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 15px;
line-height: 1.5;
min-height: 100vh;
}
header {
padding: 1.25rem 2rem;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 1rem;
}
header h1 { font-size: 20px; font-weight: 600; letter-spacing: -0.3px; }
header .sub { color: var(--muted); font-size: 13px; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
/* Tabs */
.tabs {
display: flex;
border-bottom: 1px solid var(--border);
padding: 0 2rem;
gap: 0;
}
.tab {
padding: 0.75rem 1.5rem;
font-size: 13px;
font-weight: 500;
color: var(--muted);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: all 0.15s;
}
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.tab:hover:not(.active) { color: var(--text); }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
main { max-width: 900px; margin: 0 auto; padding: 2rem; }
section { margin-bottom: 2rem; }
label.section-label {
display: block;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.8px;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 0.75rem;
}
/* Waypoints */
#waypoints { display: flex; flex-direction: column; gap: 0.75rem; }
.waypoint {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem 1.25rem;
display: grid;
grid-template-columns: 90px 1fr 1fr auto;
gap: 0.75rem;
align-items: center;
}
.waypoint-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.5px;
color: var(--accent);
text-transform: uppercase;
}
select {
background: var(--surface2);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.45rem 0.75rem;
font-size: 13px;
width: 100%;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath fill='%23888899' d='M6 8L0 0h12z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
padding-right: 28px;
cursor: pointer;
}
select:focus { outline: none; border-color: var(--accent); }
.remove-btn {
background: none;
border: 1px solid var(--border);
border-radius: 6px;
color: var(--muted);
cursor: pointer;
padding: 0.4rem 0.6rem;
font-size: 14px;
transition: all 0.15s;
line-height: 1;
}
.remove-btn:hover { background: var(--surface2); color: var(--red); border-color: var(--red); }
.remove-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.connector {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0 1.25rem;
color: var(--muted);
font-size: 12px;
}
.connector-line {
flex: 1;
height: 1px;
background: repeating-linear-gradient(90deg, var(--border) 0, var(--border) 4px, transparent 4px, transparent 8px);
}
.controls-row {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
align-items: center;
}
button.btn {
padding: 0.55rem 1.1rem;
border-radius: 7px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: 1px solid var(--border);
transition: all 0.15s;
line-height: 1.4;
}
button.btn-ghost { background: var(--surface); color: var(--text); }
button.btn-ghost:hover { background: var(--surface2); border-color: var(--accent); }
button.btn-primary {
background: var(--accent2);
border-color: var(--accent);
color: #fff;
font-size: 14px;
padding: 0.65rem 1.5rem;
}
button.btn-primary:hover { background: var(--accent); }
button.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
button.btn-green {
background: var(--green);
border-color: var(--green);
color: #fff;
font-size: 14px;
padding: 0.65rem 1.5rem;
}
button.btn-green:hover { opacity: 0.85; }
button.btn-green:disabled { opacity: 0.4; cursor: not-allowed; }
.options-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.option-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem 1.25rem;
}
.option-card label {
display: block;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.5px;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 0.5rem;
}
input[type="text"], input[type="number"] {
background: var(--surface2);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.45rem 0.75rem;
font-size: 13px;
width: 100%;
}
input[type="text"]:focus, input[type="number"]:focus { outline: none; border-color: var(--accent); }
input[type="text"]::placeholder { color: var(--muted); }
.threshold-btns { display: flex; gap: 0.5rem; }
.threshold-btn {
flex: 1;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--muted);
cursor: pointer;
padding: 0.45rem;
font-size: 13px;
text-align: center;
transition: all 0.15s;
}
.threshold-btn.active { background: var(--accent2); border-color: var(--accent); color: #fff; }
.threshold-btn:hover:not(.active) { border-color: var(--accent); color: var(--text); }
/* Log */
.log-section { display: none; margin-top: 1.5rem; }
.log-box {
background: #0a0a0c;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem 1.25rem;
font-family: "Consolas", "Courier New", monospace;
font-size: 12px;
line-height: 1.7;
max-height: 300px;
overflow-y: auto;
color: #b0b0c0;
white-space: pre-wrap;
}
.log-ok { color: var(--green); }
.log-warn { color: var(--amber); }
.log-err { color: var(--red); }
.log-head { color: var(--accent); font-weight: bold; }
.result-banner {
display: none;
margin-top: 1rem;
background: var(--surface);
border: 1px solid var(--green);
border-radius: var(--radius);
padding: 1rem 1.25rem;
color: var(--green);
font-weight: 500;
font-size: 14px;
}
.spinner {
display: inline-block;
width: 14px; height: 14px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
vertical-align: middle;
margin-right: 6px;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Genre grid */
.genre-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.5rem;
}
.genre-toggle {
display: flex;
align-items: center;
gap: 0.6rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 7px;
padding: 0.55rem 0.85rem;
cursor: pointer;
transition: all 0.15s;
user-select: none;
}
.genre-toggle.on { border-color: var(--accent); background: #1e1c30; }
.genre-toggle.on .toggle-dot { background: var(--accent); }
.genre-toggle.off { opacity: 0.5; }
.toggle-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--border);
flex-shrink: 0;
transition: background 0.15s;
}
.genre-name { font-size: 12px; line-height: 1.3; }
/* Schedule status card */
.schedule-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.schedule-info { font-size: 13px; color: var(--muted); }
.schedule-info strong { color: var(--text); font-weight: 500; }
.schedule-info .next { color: var(--accent); font-size: 12px; margin-top: 2px; }
.counter-row { display: flex; gap: 0.5rem; align-items: center; }
.counter-btn {
width: 30px; height: 30px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 16px;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: all 0.15s;
line-height: 1;
}
.counter-btn:hover { border-color: var(--accent); color: var(--accent); }
.counter-val { min-width: 28px; text-align: center; font-size: 15px; font-weight: 500; }
.auto-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
@media (max-width: 600px) {
.waypoint { grid-template-columns: 1fr; }
.options-grid { grid-template-columns: 1fr; }
.auto-grid { grid-template-columns: 1fr; }
.genre-grid { grid-template-columns: 1fr 1fr; }
}
</style>
</head>
<body>
<header>
<div class="dot"></div>
<div>
<h1>Sonic Adventure</h1>
<div class="sub">Playlist generator — Plex Music</div>
</div>
</header>
<div class="tabs">
<div class="tab active" onclick="switchTab('manual')">Manual</div>
<div class="tab" onclick="switchTab('auto')">Auto</div>
</div>
<main>
<!-- ===================== MANUAL TAB ===================== -->
<div id="tab-manual" class="tab-panel active">
<section>
<label class="section-label">Genre Waypoints</label>
<div id="waypoints"></div>
<div style="margin-top: 0.75rem;" class="controls-row">
<button class="btn btn-ghost" onclick="addWaypoint()">+ Add waypoint</button>
<span style="color: var(--muted); font-size: 12px;" id="waypoint-count"></span>
</div>
</section>
<section>
<label class="section-label">Options</label>
<div class="options-grid">
<div class="option-card">
<label>Genre tag threshold</label>
<div class="threshold-btns">
<div class="threshold-btn" data-val="1" onclick="setThreshold(1)">1 — broad</div>
<div class="threshold-btn active" data-val="2" onclick="setThreshold(2)">2 — balanced</div>
<div class="threshold-btn" data-val="3" onclick="setThreshold(3)">3 — strict</div>
</div>
</div>
<div class="option-card">
<label>Playlist name (optional)</label>
<input type="text" id="playlist-name" placeholder="Auto-generated if blank">
</div>
</div>
</section>
<section>
<button class="btn btn-primary" id="generate-btn" onclick="generate()">Generate playlist</button>
</section>
<div id="manual-log-section" class="log-section">
<label class="section-label">Progress</label>
<div id="manual-log-box" class="log-box"></div>
</div>
<div id="manual-result-banner" class="result-banner"></div>
</div>
<!-- ===================== AUTO TAB ===================== -->
<div id="tab-auto" class="tab-panel">
<section>
<label class="section-label">Schedule</label>
<div class="schedule-card">
<div class="schedule-info">
<strong id="schedule-status">Loading...</strong>
<div class="next" id="schedule-next"></div>
</div>
<div class="controls-row">
<select id="schedule-interval" onchange="scheduleChanged()" style="width: auto;">
<option value="0">Off</option>
<option value="1">Every day</option>
<option value="2" selected>Every 2 days</option>
<option value="3">Every 3 days</option>
<option value="7">Every week</option>
</select>
<button class="btn btn-ghost" onclick="saveSchedule()">Save schedule</button>
</div>
</div>
</section>
<section>
<label class="section-label">Run settings</label>
<div class="auto-grid">
<div class="option-card">
<label>Playlists per run</label>
<div style="font-size: 15px; font-weight: 500; padding-top: 4px;" id="playlists-display">All enabled genres</div>
<div style="font-size: 11px; color: var(--muted); margin-top: 4px;" id="playlists-count-sub"></div>
</div>
<div class="option-card">
<label>Seeds per playlist</label>
<div class="counter-row">
<button class="counter-btn" onclick="adjustCounter('seeds', -1)">−</button>
<div class="counter-val" id="seeds-val">3</div>
<button class="counter-btn" onclick="adjustCounter('seeds', 1)">+</button>
</div>
</div>
<div class="option-card">
<label>Style threshold</label>
<div class="counter-row">
<button class="counter-btn" onclick="adjustCounter('threshold_auto', -1)">−</button>
<div class="counter-val" id="threshold_auto-val">2</div>
<button class="counter-btn" onclick="adjustCounter('threshold_auto', 1)">+</button>
</div>
</div>
</div>
</section>
<section>
<label class="section-label">
Enabled genres
<span style="font-weight: 400; text-transform: none; letter-spacing: 0; margin-left: 0.5rem;">
<span id="genre-count" style="color: var(--accent);"></span>
&nbsp;·&nbsp;
<span onclick="toggleAllGenres(true)" style="color: var(--muted); cursor: pointer; font-size: 11px;">all on</span>
&nbsp;/&nbsp;
<span onclick="toggleAllGenres(false)" style="color: var(--muted); cursor: pointer; font-size: 11px;">all off</span>
</span>
</label>
<div class="genre-grid" id="genre-grid"></div>
</section>
<section class="controls-row">
<button class="btn btn-green" id="auto-run-btn" onclick="runAuto()">Run now</button>
<button class="btn btn-primary" id="auto-save-btn" onclick="saveAutoSettings()">Save settings</button>
<span style="color: var(--muted); font-size: 12px;" id="auto-save-status"></span>
</section>
<div id="auto-log-section" class="log-section">
<label class="section-label">Progress</label>
<div id="auto-log-box" class="log-box"></div>
</div>
<div id="auto-result-banner" class="result-banner"></div>
</div>
</main>
<script>
const PRESETS = {{ presets | tojson }};
const MODES = {{ modes | tojson }};
// ── Tab switching ────────────────────────────────────────
function switchTab(name) {
document.querySelectorAll('.tab').forEach((t, i) => {
t.classList.toggle('active', ['manual','auto'][i] === name);
});
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
document.getElementById('tab-' + name).classList.add('active');
}
// ── Manual tab ───────────────────────────────────────────
let threshold = 2;
let waypoints = [];
let manualPolling = null;
function setThreshold(val) {
threshold = val;
document.querySelectorAll(".threshold-btn").forEach(b => {
b.classList.toggle("active", parseInt(b.dataset.val) === val);
});
}
function renderWaypoints() {
const container = document.getElementById("waypoints");
container.innerHTML = "";
waypoints.forEach((wp, i) => {
const label = i === 0 ? "Start" : i === waypoints.length - 1 ? "End" : `Middle ${i}`;
const presetOpts = PRESETS.map(p => `<option value="${p.index}">${p.name}</option>`).join("");
const modeOpts = MODES.map(m => `<option value="${m.key}">${m.key}. ${m.label}</option>`).join("");
if (i > 0) {
const conn = document.createElement("div");
conn.className = "connector";
conn.innerHTML = `<div class="connector-line"></div><span>↓</span><div class="connector-line"></div>`;
container.appendChild(conn);
}
const div = document.createElement("div");
div.className = "waypoint";
div.innerHTML = `
<div class="waypoint-label">${label}</div>
<select onchange="wpChange(${i},'preset',this.value)">${presetOpts}</select>
<select onchange="wpChange(${i},'mode',this.value)">${modeOpts}</select>
<button class="remove-btn" onclick="removeWaypoint(${i})" ${waypoints.length<=2?'disabled':''} title="Remove">✕</button>
`;
container.appendChild(div);
div.querySelector("select:nth-of-type(1)").value = wp.preset_index;
div.querySelector("select:nth-of-type(2)").value = wp.mode;
});
document.getElementById("waypoint-count").textContent =
`${waypoints.length} waypoints · ${waypoints.length-1} segment${waypoints.length>2?"s":""}`;
}
function wpChange(i, key, val) {
if (key === "preset") waypoints[i].preset_index = parseInt(val);
if (key === "mode") waypoints[i].mode = val;
}
function addWaypoint() {
if (waypoints.length >= 6) return;
waypoints.splice(waypoints.length - 1, 0, { preset_index: 0, mode: "1" });
renderWaypoints();
}
function removeWaypoint(i) {
if (waypoints.length <= 2) return;
waypoints.splice(i, 1);
renderWaypoints();
}
function logLine(text, boxId) {
const box = document.getElementById(boxId);
const span = document.createElement("span");
if (text.includes("ERROR") || text.includes("FATAL") || text.includes("Aborting"))
span.className = "log-err";
else if (text.includes("WARNING"))
span.className = "log-warn";
else if (text.includes("Done!") || text.includes("Created") || text.includes("created"))
span.className = "log-ok";
else if (text.startsWith("Loading") || text.startsWith("Building") || text.startsWith("Checking") || text.startsWith("Fetching"))
span.className = "log-head";
span.textContent = text + "\n";
box.appendChild(span);
box.scrollTop = box.scrollHeight;
}
async function generate() {
const btn = document.getElementById("generate-btn");
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Generating...';
document.getElementById("manual-log-section").style.display = "block";
document.getElementById("manual-log-box").innerHTML = "";
document.getElementById("manual-result-banner").style.display = "none";
const payload = {
waypoints: waypoints,
min_style_matches: threshold,
playlist_name: document.getElementById("playlist-name").value.trim(),
};
let job_id;
try {
const resp = await fetch("/api/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await resp.json();
if (data.error) throw new Error(data.error);
job_id = data.job_id;
} catch (e) {
logLine("ERROR: " + e.message, "manual-log-box");
btn.disabled = false;
btn.textContent = "Generate playlist";
return;
}
pollJob(job_id, "manual-log-box", "manual-result-banner", btn, "Generate playlist");
}
function pollJob(job_id, logBoxId, bannerId, btn, btnLabel) {
let lastLogLen = 0;
const iv = setInterval(async () => {
try {
const resp = await fetch(`/api/status/${job_id}`);
const data = await resp.json();
data.log.slice(lastLogLen).forEach(l => logLine(l, logBoxId));
lastLogLen = data.log.length;
if (data.done) {
clearInterval(iv);
if (btn) { btn.disabled = false; btn.textContent = btnLabel; }
if (data.status === "done") {
const banner = document.getElementById(bannerId);
banner.style.display = "block";
banner.textContent = `✓ "${data.playlist_name}" — ${data.track_count} tracks. Open Plex to listen.`;
}
}
} catch (e) {
clearInterval(iv);
logLine("ERROR polling: " + e.message, logBoxId);
if (btn) { btn.disabled = false; btn.textContent = btnLabel; }
}
}, 1500);
}
// ── Auto tab ─────────────────────────────────────────────
let genreEnabled = {};
let autoCounters = { playlists: 3, seeds: 3, threshold_auto: 2 };
let autoCounterLimits = { playlists: [1,10], seeds: [2,6], threshold_auto: [1,3] };
function adjustCounter(key, delta) {
const [min, max] = autoCounterLimits[key];
autoCounters[key] = Math.max(min, Math.min(max, autoCounters[key] + delta));
document.getElementById(key + "-val").textContent = autoCounters[key];
}
function buildGenreGrid() {
const grid = document.getElementById("genre-grid");
grid.innerHTML = "";
PRESETS.forEach(p => {
if (!(p.name in genreEnabled)) genreEnabled[p.name] = true;
const el = document.createElement("div");
el.className = "genre-toggle " + (genreEnabled[p.name] ? "on" : "off");
el.id = "genre-" + p.index;
el.innerHTML = `<div class="toggle-dot"></div><div class="genre-name">${p.name}</div>`;
el.onclick = () => toggleGenre(p.name, p.index);
grid.appendChild(el);
});
updateGenreCount();
}
function toggleGenre(name, index) {
genreEnabled[name] = !genreEnabled[name];
const el = document.getElementById("genre-" + index);
el.classList.toggle("on", genreEnabled[name]);
el.classList.toggle("off", !genreEnabled[name]);
el.querySelector(".toggle-dot").style.background = genreEnabled[name] ? "var(--accent)" : "var(--border)";
updateGenreCount();
}
function toggleAllGenres(state) {
PRESETS.forEach(p => {
genreEnabled[p.name] = state;
const el = document.getElementById("genre-" + p.index);
if (el) {
el.classList.toggle("on", state);
el.classList.toggle("off", !state);
el.querySelector(".toggle-dot").style.background = state ? "var(--accent)" : "var(--border)";
}
});
updateGenreCount();
}
function updateGenreCount() {
const on = Object.values(genreEnabled).filter(Boolean).length;
document.getElementById("genre-count").textContent = `${on} of ${PRESETS.length} enabled`;
const sub = document.getElementById("playlists-count-sub");
if (sub) sub.textContent = `${on} playlist${on !== 1 ? "s" : ""} will be created`;
}
async function loadAutoSettings() {
try {
const resp = await fetch("/api/auto/settings");
const data = await resp.json();
if (data.num_seeds) { autoCounters.seeds = data.num_seeds; document.getElementById("seeds-val").textContent = data.num_seeds; }
if (data.style_matches) { autoCounters.threshold_auto = data.style_matches; document.getElementById("threshold_auto-val").textContent = data.style_matches; }
if (data.genre_enabled) { genreEnabled = data.genre_enabled; buildGenreGrid(); }
const interval = (data.interval_days !== undefined) ? data.interval_days : 2;
document.getElementById("schedule-interval").value = interval;
updateScheduleDisplay(data);
} catch(e) {
console.log("Could not load auto settings, using defaults:", e);
document.getElementById("schedule-interval").value = 2;
updateScheduleDisplay({ interval_days: 2, next_run: null });
}
}
function scheduleChanged() {}
async function saveSchedule() {
const interval = parseInt(document.getElementById("schedule-interval").value);
await fetch("/api/auto/schedule", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ interval_days: interval }),
});
await loadAutoSettings();
}
async function saveAutoSettings() {
const btn = document.getElementById("auto-save-btn");
btn.disabled = true;
const payload = {
num_seeds: autoCounters.seeds,
style_matches: autoCounters.threshold_auto,
genre_enabled: genreEnabled,
interval_days: parseInt(document.getElementById("schedule-interval").value),
};
try {
await fetch("/api/auto/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
document.getElementById("auto-save-status").textContent = "Saved!";
setTimeout(() => document.getElementById("auto-save-status").textContent = "", 2000);
await loadAutoSettings();
} catch(e) {
document.getElementById("auto-save-status").textContent = "Error saving.";
}
btn.disabled = false;
}
function updateScheduleDisplay(data) {
const interval = data.interval_days || 0;
const statusEl = document.getElementById("schedule-status");
const nextEl = document.getElementById("schedule-next");
if (!interval) {
statusEl.textContent = "Scheduling off";
nextEl.textContent = "";
return;
}
statusEl.textContent = interval === 1 ? "Runs every day" : `Runs every ${interval} days`;
if (data.next_run) {
const d = new Date(data.next_run);
nextEl.textContent = "Next run: " + d.toLocaleString();
} else {
nextEl.textContent = "Will run on next scheduled interval";
}
}
async function runAuto() {
const btn = document.getElementById("auto-run-btn");
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Running...';
document.getElementById("auto-log-section").style.display = "block";
document.getElementById("auto-log-box").innerHTML = "";
document.getElementById("auto-result-banner").style.display = "none";
const payload = {
num_seeds: autoCounters.seeds,
style_matches: autoCounters.threshold_auto,
genre_enabled: genreEnabled,
};
try {
const resp = await fetch("/api/auto/run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await resp.json();
if (data.error) throw new Error(data.error);
pollJob(data.job_id, "auto-log-box", "auto-result-banner", btn, "Run now");
} catch(e) {
logLine("ERROR: " + e.message, "auto-log-box");
btn.disabled = false;
btn.textContent = "Run now";
}
}
// ── Init ─────────────────────────────────────────────────
waypoints = [
{ preset_index: 0, mode: "1" },
{ preset_index: 10, mode: "1" },
{ preset_index: 5, mode: "1" },
];
renderWaypoints();
buildGenreGrid();
loadAutoSettings();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment