discarr/public/index.html

2041 lines
87 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Discarr</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='14' fill='%237c6af7'/%3E%3Ccircle cx='16' cy='16' r='5' fill='%230f1117'/%3E%3Ccircle cx='16' cy='16' r='2' fill='%237c6af7'/%3E%3C/svg%3E" />
<style>
/* ── Theme (shared with recovarr) ───────────────────────────────────── */
:root {
--bg:#0f1117; --surface:#1a1d27; --surface2:#252836;
--border:#2e3245; --accent:#7c6af7; --text:#e2e4ef;
--muted:#7880a0; --success:#4caf7d; --warning:#e8a93a;
--error:#e85858; --info:#4ab8e8; --disc:#f7a26a;
--radius:10px;
--mono:'JetBrains Mono','Fira Code','Consolas',monospace;
--ui:system-ui,-apple-system,sans-serif;
}
@media(prefers-color-scheme:light){
:root{--bg:#f0f2f8;--surface:#fff;--surface2:#e8eaf2;
--border:#cdd0e0;--accent:#5a4fcf;--text:#1a1d2e;--muted:#6068a0}
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:var(--ui);background:var(--bg);color:var(--text);min-height:100vh;padding:1rem 1rem 4rem}
/* ── Layout ─────────────────────────────────────────────────────────── */
.wrap{max-width:1200px;margin:0 auto}
h1{font-size:1.25rem;font-weight:600;margin-bottom:1.25rem;display:flex;align-items:center;gap:.5rem}
h1 .badge{font-size:.65rem;font-weight:600;background:#3d2a10;color:#f7a26a;border-radius:20px;padding:.2rem .6rem;text-transform:uppercase;letter-spacing:.05em}
.section{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:1.1rem;margin-bottom:1rem}
.section-title{font-size:.75rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.07em;margin-bottom:.9rem;display:flex;align-items:center;gap:.5rem}
.step-num{width:20px;height:20px;border-radius:50%;background:var(--accent);color:#fff;font-size:.65rem;font-weight:700;display:flex;align-items:center;justify-content:center;flex-shrink:0}
.step-num.done{background:var(--success)}
/* ── Inputs ──────────────────────────────────────────────────────────── */
label{display:block;font-size:.75rem;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:.4rem}
input[type=text]{width:100%;background:var(--surface2);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:var(--mono);font-size:.85rem;padding:.55rem .75rem;outline:none}
input[type=text]:focus{border-color:var(--accent)}
input[type=text]::placeholder{color:var(--muted);opacity:.6}
select{background:var(--surface2);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:.85rem;padding:.5rem .75rem;outline:none;cursor:pointer}
select:focus{border-color:var(--accent)}
.row{display:flex;gap:.6rem;margin-top:.75rem;flex-wrap:wrap;align-items:center}
/* ── Buttons ─────────────────────────────────────────────────────────── */
button{font-family:var(--ui);font-size:.85rem;font-weight:600;border:none;border-radius:6px;padding:.5rem 1.1rem;cursor:pointer;transition:opacity .15s,transform .1s;white-space:nowrap}
button:active{transform:scale(.97)}
button:disabled{opacity:.4;cursor:not-allowed}
.btn-primary{background:var(--accent);color:#fff}
.btn-primary:hover:not(:disabled){opacity:.85}
.btn-ghost{background:var(--surface2);color:var(--muted);border:1px solid var(--border)}
.btn-ghost:hover:not(:disabled){color:var(--text);border-color:var(--muted)}
.btn-success{background:color-mix(in srgb,var(--success) 20%,var(--surface));color:var(--success);border:1px solid var(--success)}
.btn-success:hover:not(:disabled){background:color-mix(in srgb,var(--success) 30%,var(--surface))}
.btn-sm{font-size:.75rem;padding:.3rem .7rem}
.btn-xs{font-size:.7rem;padding:.2rem .5rem}
/* ── Scan log ────────────────────────────────────────────────────────── */
.log{background:var(--surface2);border:1px solid var(--border);border-radius:6px;font-family:var(--mono);font-size:.75rem;color:var(--muted);padding:.6rem .8rem;max-height:120px;overflow-y:auto;line-height:1.6;white-space:pre-wrap;word-break:break-all}
/* ── Map panel ───────────────────────────────────────────────────────── */
.map-layout{display:grid;grid-template-columns:1fr 1fr;gap:1rem}
@media(max-width:700px){.map-layout{grid-template-columns:1fr}}
.panel-head{font-size:.75rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:.65rem;display:flex;align-items:center;justify-content:space-between;gap:.5rem}
.disk-label{font-size:.7rem;font-weight:700;color:var(--disc);text-transform:uppercase;letter-spacing:.06em;padding:.2rem .6rem;background:color-mix(in srgb,var(--disc) 12%,var(--surface2));border-radius:4px;margin-bottom:.5rem;display:inline-block}
/* ── Title cards ─────────────────────────────────────────────────────── */
.title-card{background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:.6rem .8rem;margin-bottom:.4rem;cursor:grab;user-select:none;transition:border-color .15s,opacity .15s}
.title-card:hover{border-color:var(--accent)}
.title-card.dragging{opacity:.4;cursor:grabbing}
.title-card.assigned{opacity:.5;border-style:dashed}
.tc-title{font-size:.8rem;font-weight:600;color:var(--text)}
.tc-meta{font-size:.7rem;color:var(--muted);margin-top:.2rem;display:flex;gap:.75rem;flex-wrap:wrap}
/* ── Episode slots ───────────────────────────────────────────────────── */
.ep-slot{background:var(--surface2);border:2px dashed var(--border);border-radius:8px;padding:.55rem .75rem;margin-bottom:.4rem;transition:border-color .15s,background .15s;min-height:52px;display:flex;align-items:center;gap:.6rem}
.ep-slot.drag-over{border-color:var(--accent);background:color-mix(in srgb,var(--accent) 8%,var(--surface2))}
.ep-slot.assigned{border-style:solid;border-color:var(--success);background:color-mix(in srgb,var(--success) 6%,var(--surface2))}
.ep-num{font-size:.7rem;font-weight:700;color:var(--muted);min-width:32px;flex-shrink:0}
.ep-info{flex:1;min-width:0}
.ep-title-text{font-size:.8rem;font-weight:600;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ep-assigned-text{font-size:.7rem;color:var(--success);margin-top:.15rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
/* ── Series search ───────────────────────────────────────────────────── */
.series-results{margin-top:.5rem;display:flex;flex-direction:column;gap:.35rem;max-height:220px;overflow-y:auto}
.series-result{display:flex;align-items:center;gap:.65rem;padding:.5rem .65rem;border-radius:7px;cursor:pointer;transition:background .12s;border:1px solid transparent}
.series-result:hover{background:var(--surface2);border-color:var(--border)}
.series-result.active{background:color-mix(in srgb,var(--accent) 12%,var(--surface2));border-color:var(--accent)}
.sr-poster{width:34px;height:50px;object-fit:cover;border-radius:3px;background:var(--surface2);flex-shrink:0}
.sr-title-text{font-size:.85rem;font-weight:600}
.sr-meta-text{font-size:.7rem;color:var(--muted);margin-top:.1rem}
.sr-badge{font-size:.65rem;padding:.1rem .4rem;border-radius:4px;background:color-mix(in srgb,var(--info) 15%,var(--surface));color:var(--info);border:1px solid var(--info);flex-shrink:0;margin-left:auto}
/* ── Season pills ────────────────────────────────────────────────────── */
.season-pills{display:flex;flex-wrap:wrap;gap:.35rem;margin-top:.5rem}
.season-pill{font-size:.75rem;padding:.3rem .7rem;border-radius:20px;border:1px solid var(--border);background:var(--surface2);color:var(--muted);cursor:pointer;transition:all .12s}
.season-pill:hover{border-color:var(--accent);color:var(--text)}
.season-pill.active{background:var(--accent);border-color:var(--accent);color:#fff}
/* ── Review table ────────────────────────────────────────────────────── */
.review-table{width:100%;border-collapse:collapse;font-size:.8rem}
.review-table th{text-align:left;font-size:.7rem;color:var(--muted);font-weight:600;text-transform:uppercase;letter-spacing:.05em;padding:.4rem .6rem;border-bottom:1px solid var(--border)}
.review-table td{padding:.45rem .6rem;border-bottom:1px solid color-mix(in srgb,var(--border) 50%,transparent);vertical-align:middle}
.review-table tr:last-child td{border-bottom:none}
.path-input{width:100%;background:transparent;border:none;color:var(--text);font-family:var(--mono);font-size:.75rem;outline:none;padding:.2rem .3rem;border-radius:4px}
.path-input:focus{background:var(--surface2);border:1px solid var(--accent)}
.dur{color:var(--muted);white-space:nowrap}
.rm-btn{background:none;border:none;color:var(--error);cursor:pointer;font-size:.85rem;opacity:.6;padding:.2rem}
.rm-btn:hover{opacity:1}
/* ── Encode bar ──────────────────────────────────────────────────────── */
.encode-bar{display:flex;gap:.75rem;align-items:flex-end;flex-wrap:wrap}
.encode-field{display:flex;flex-direction:column;gap:.35rem;min-width:160px}
.encode-field label{margin-bottom:0}
/* ── Status chips ────────────────────────────────────────────────────── */
.chip{display:inline-flex;align-items:center;gap:.3rem;font-size:.7rem;font-weight:600;padding:.2rem .55rem;border-radius:20px;border:1px solid}
.chip.ok{color:var(--success);border-color:var(--success);background:color-mix(in srgb,var(--success) 10%,transparent)}
.chip.warn{color:var(--warning);border-color:var(--warning);background:color-mix(in srgb,var(--warning) 10%,transparent)}
.chip.off{color:var(--muted);border-color:var(--border)}
/* ── Activity panel ──────────────────────────────────────────────────── */
.act-tabs{display:flex;gap:.35rem;margin-bottom:.75rem}
.act-tab{font-size:.75rem;font-weight:600;padding:.3rem .75rem;border-radius:20px;border:1px solid var(--border);background:var(--surface2);color:var(--muted);cursor:pointer;transition:all .12s}
.act-tab.active{background:var(--accent);border-color:var(--accent);color:#fff}
.act-sub{font-size:.7rem;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.07em;margin:.6rem 0 .3rem;display:flex;align-items:center;gap:.5rem}
.act-item{display:flex;align-items:flex-start;gap:.6rem;padding:.45rem .55rem;border-radius:6px;border:1px solid var(--border);background:var(--surface2);margin-bottom:.3rem;font-size:.78rem}
.act-icon{font-size:.9rem;flex-shrink:0;margin-top:.05rem}
.act-body{flex:1;min-width:0}
.act-title{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.act-meta{font-size:.7rem;color:var(--muted);margin-top:.1rem;display:flex;gap:.5rem;flex-wrap:wrap;align-items:center}
.act-release{font-size:.68rem;color:var(--muted);font-family:var(--mono);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:280px}
.act-progress{height:3px;border-radius:2px;background:var(--border);margin-top:.3rem;overflow:hidden}
.act-progress-bar{height:100%;background:var(--info);border-radius:2px;transition:width .4s}
.act-age{font-size:.68rem;color:var(--muted);white-space:nowrap;flex-shrink:0}
.evt-grabbed{color:var(--info)}.evt-imported{color:var(--success)}.evt-deleted{color:var(--error)}.evt-ignored{color:var(--muted)}.evt-warning{color:var(--warning)}
/* ── Job strip ───────────────────────────────────────────────────────── */
.job-card{background:var(--surface2);border:1px solid var(--border);border-radius:8px;margin-bottom:.5rem;overflow:hidden}
.job-card-head{display:flex;align-items:center;gap:.6rem;padding:.6rem .75rem;cursor:pointer;user-select:none}
.job-card-head:hover{background:color-mix(in srgb,var(--accent) 4%,var(--surface2))}
.jr-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.jr-dot.done{background:var(--success)}.jr-dot.failed{background:var(--error)}
.jr-dot.running{background:var(--info);animation:pulse 1.4s ease-in-out infinite}
.jr-dot.queued{background:var(--muted)}
.jr-path{flex:1;min-width:0;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:.8rem;font-weight:600}
.jr-meta{font-size:.7rem;color:var(--muted);white-space:nowrap}
.jr-progress-wrap{padding:0 .75rem .5rem;display:flex;flex-direction:column;gap:.25rem}
.jr-progress-bar-bg{height:4px;background:var(--border);border-radius:2px;overflow:hidden}
.jr-progress-bar{height:100%;background:var(--accent);border-radius:2px;transition:width .3s}
.jr-progress-bar.failed{background:var(--error)}
.jr-current-file{font-size:.7rem;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.job-log{display:none;border-top:1px solid var(--border);background:var(--bg);padding:.5rem .75rem;max-height:220px;overflow-y:auto;font-family:var(--mono);font-size:.7rem;line-height:1.5}
.job-log.open{display:block}
.jl-ok{color:var(--success)}.jl-err{color:var(--error)}.jl-cmd{color:var(--muted)}.jl-info{color:var(--text)}
/* ── Misc ────────────────────────────────────────────────────────────── */
.hidden{display:none!important}
.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
.divider{border:none;border-top:1px solid var(--border);margin:.75rem 0}
.hint{font-size:.75rem;color:var(--muted)}
.scroll-list{max-height:340px;overflow-y:auto;padding-right:2px}
.empty-msg{text-align:center;padding:2rem;color:var(--muted);font-size:.85rem}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
/* ── Motion reduction ────────────────────────────────────────────────── */
@media(prefers-reduced-motion:reduce){
*,*::before,*::after{animation:none!important;transition:none!important}
}
/* ── Keyboard focus ring ─────────────────────────────────────────────── */
:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:3px}
/* suppress default outline only where we provide a visible alternative */
input:focus-visible,select:focus-visible{outline:none}
/* ── Keyboard-mode title select ──────────────────────────────────────── */
.kbd-select{background:var(--surface2);border:1px solid var(--border);border-radius:6px;
color:var(--text);font-size:.78rem;padding:.3rem .5rem;outline:none;flex:1;cursor:pointer}
.kbd-select:focus-visible{border-color:var(--accent);outline:2px solid var(--accent);outline-offset:2px}
/* ── Settings panel ──────────────────────────────────────────────────── */
.settings-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:.65rem}
.settings-group-title{font-size:.7rem;font-weight:700;color:var(--muted);text-transform:uppercase;
letter-spacing:.07em;grid-column:1/-1;margin-top:.4rem}
.settings-group-title:first-child{margin-top:0}
.settings-field{display:flex;flex-direction:column;gap:.3rem}
.settings-field label{font-size:.72rem;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.05em}
.settings-footer{display:flex;align-items:center;gap:.75rem;margin-top:.85rem;flex-wrap:wrap}
.save-status{font-size:.78rem}
.save-status.ok{color:var(--success)}.save-status.err{color:var(--error)}
</style>
</head>
<body>
<div class="wrap">
<h1>
Discarr
<span class="badge">disc → arr</span>
<span id="cfg-chips" style="margin-left:auto;display:flex;gap:.4rem;align-items:center"></span>
<button class="btn-ghost btn-xs" id="btn-settings" aria-expanded="false"
aria-controls="sec-settings" title="Settings" style="padding:.35rem .55rem;font-size:.95rem"></button>
</h1>
<!-- Settings panel (hidden by default) -->
<div class="section hidden" id="sec-settings" aria-label="Discarr settings">
<div class="section-title">Settings</div>
<div class="settings-grid">
<div class="settings-group-title">Sonarr</div>
<div class="settings-field">
<label for="cfg-sonarr-url">Sonarr URL</label>
<input type="text" id="cfg-sonarr-url" name="SONARR_URL"
placeholder="http://10.1.10.71:8989" autocomplete="off" />
</div>
<div class="settings-field">
<label for="cfg-sonarr-key">Sonarr API Key</label>
<input type="password" id="cfg-sonarr-key" name="SONARR_API_KEY"
placeholder="32-char hex key" autocomplete="new-password" />
</div>
<div class="settings-group-title">Radarr</div>
<div class="settings-field">
<label for="cfg-radarr-url">Radarr URL</label>
<input type="text" id="cfg-radarr-url" name="RADARR_URL"
placeholder="http://10.1.10.71:7878" autocomplete="off" />
</div>
<div class="settings-field">
<label for="cfg-radarr-key">Radarr API Key</label>
<input type="password" id="cfg-radarr-key" name="RADARR_API_KEY"
placeholder="32-char hex key" autocomplete="new-password" />
</div>
<div class="settings-group-title">TMDB (fallback metadata)</div>
<div class="settings-field">
<label for="cfg-tmdb-key">TMDB API Key</label>
<input type="password" id="cfg-tmdb-key" name="TMDB_API_KEY"
placeholder="v3 API key" autocomplete="new-password" />
</div>
<div class="settings-group-title">Tdarr (optional post-encode hook)</div>
<div class="settings-field">
<label for="cfg-tdarr-url">Tdarr URL</label>
<input type="text" id="cfg-tdarr-url" name="TDARR_URL"
placeholder="http://10.1.10.71:8265" autocomplete="off" />
</div>
<div class="settings-field">
<label for="cfg-tdarr-lib">Tdarr Library ID</label>
<input type="text" id="cfg-tdarr-lib" name="TDARR_LIBRARY_ID"
placeholder="Library ID from Tdarr UI" autocomplete="off" />
</div>
<div class="settings-group-title">Encode</div>
<div class="settings-field">
<label for="cfg-ssh-host">SSH Encode Host</label>
<input type="text" id="cfg-ssh-host" name="ENCODE_SSH_HOST"
placeholder="strahl (leave blank for local)" autocomplete="off" />
</div>
<div class="settings-field">
<label for="cfg-ffmpeg">FFmpeg binary</label>
<input type="text" id="cfg-ffmpeg" name="FFMPEG_BIN"
placeholder="/usr/bin/ffmpeg" autocomplete="off" />
</div>
<div class="settings-field">
<label for="cfg-ffprobe">FFprobe binary</label>
<input type="text" id="cfg-ffprobe" name="FFPROBE_BIN"
placeholder="/usr/bin/ffprobe" autocomplete="off" />
</div>
<div class="settings-group-title">Output</div>
<div class="settings-field" style="grid-column:1/-1">
<label for="cfg-output-base">Output base path</label>
<input type="text" id="cfg-output-base" name="OUTPUT_BASE"
placeholder="/Library/Series" autocomplete="off" />
</div>
</div>
<div class="settings-footer">
<button class="btn-primary btn-sm" id="btn-save-settings">Save Settings</button>
<button class="btn-ghost btn-sm" id="btn-close-settings">Cancel</button>
<span class="save-status hidden" id="settings-status" role="status" aria-live="polite"></span>
</div>
</div>
<!-- Step 1: Scan -->
<div class="section" id="sec-scan">
<div class="section-title">
<span class="step-num" id="s1">1</span> Scan Source
</div>
<label for="scan-path">Source path</label>
<input type="text" id="scan-path"
placeholder="/Library/Downloads/Series/Show.Name.S02 — folder with VIDEO_TS/, BDMV/, or Disk1/ Disk2/ subdirs" />
<div class="row">
<button class="btn-primary" id="btn-scan">Scan Disc</button>
<span class="hint">Supports multi-disk: Disk1/VIDEO_TS, Disk2/VIDEO_TS …</span>
</div>
<div id="scan-log-wrap" class="hidden" style="margin-top:.75rem">
<div class="log" id="scan-log" role="log" aria-label="Scan progress" aria-live="polite"></div>
</div>
</div>
<!-- Step 2: Map -->
<div class="section hidden" id="sec-map">
<div class="section-title">
<span class="step-num" id="s2">2</span> Map Titles → Episodes
</div>
<div class="map-layout">
<!-- Left: disc titles -->
<div>
<div class="panel-head">
Disc Titles
<div style="display:flex;gap:.4rem">
<button class="btn-ghost btn-sm" id="btn-kbd-mode" aria-pressed="false"
title="Switch to keyboard-accessible select mode">⌨ Keyboard</button>
<button class="btn-ghost btn-sm" id="btn-autofill">Auto-fill in order</button>
</div>
</div>
<div id="title-list" class="scroll-list"></div>
</div>
<!-- Right: lookup + slots -->
<div>
<div class="panel-head">Episodes</div>
<input type="text" id="series-search" placeholder="Search series name…" />
<div style="display:flex;gap:.35rem;margin-top:.4rem;flex-wrap:wrap" id="browse-btns">
<button class="btn-ghost btn-xs" id="btn-browse-sonarr"
title="Browse your full Sonarr library">📺 Browse Sonarr</button>
<button class="btn-ghost btn-xs" id="btn-browse-radarr"
title="Browse your full Radarr library">🎬 Browse Radarr</button>
</div>
<div id="series-results" class="series-results hidden"></div>
<div id="season-pills" class="season-pills hidden"></div>
<div id="ep-list" class="scroll-list" style="margin-top:.65rem">
<div class="empty-msg">Search for a series and select a season</div>
</div>
</div>
</div>
<hr class="divider">
<div style="display:flex;gap:.5rem;align-items:center;flex-wrap:wrap">
<span class="hint" id="map-count" role="status" aria-live="polite">0 titles mapped</span>
<button class="btn-ghost btn-sm" id="btn-clear-map">Clear all</button>
<button class="btn-primary" style="margin-left:auto" id="btn-review" disabled>Review &amp; Queue →</button>
</div>
</div>
<!-- Step 3: Review -->
<div class="section hidden" id="sec-review">
<div class="section-title">
<span class="step-num" id="s3">3</span> Review &amp; Encode
</div>
<table class="review-table">
<thead>
<tr><th scope="col">Title</th><th scope="col">Duration</th><th scope="col">Episode</th><th scope="col">Output path</th><th scope="col"><span class="sr-only">Actions</span></th></tr>
</thead>
<tbody id="review-body"></tbody>
</table>
<hr class="divider">
<div class="encode-bar">
<div class="encode-field">
<label for="preset-select">Encode preset</label>
<select id="preset-select">
<optgroup label="ffmpeg">
<option value="hevc-nvenc">HEVC NVENC (NVIDIA GPU)</option>
<option value="hevc-qsv">HEVC QSV (Intel GPU)</option>
<option value="hevc-vaapi">HEVC VAAPI (AMD GPU)</option>
<option value="x265-software" selected>x265 Software (CPU)</option>
<option value="h264-nvenc">H.264 NVENC (NVIDIA, fast)</option>
</optgroup>
<optgroup label="HandBrakeCLI (recommended for DVD/Blu-ray)">
<option value="handbrake-hevc">HandBrake x265 HEVC</option>
<option value="handbrake-h264">HandBrake x264 H.264</option>
<option value="handbrake-nvenc">HandBrake NVENC HEVC</option>
</optgroup>
<optgroup label="Custom">
<option value="custom">Custom script…</option>
</optgroup>
</select>
</div>
<div class="encode-field hidden" id="custom-script-wrap" style="flex:1">
<label for="custom-script">Script — use {input} and {output}</label>
<input type="text" id="custom-script"
placeholder="python3 /path/to/convert.py {input} -o {output}" />
</div>
<div class="encode-field">
<label for="encode-host">Encode host (SSH)</label>
<input type="text" id="encode-host" placeholder="local" style="width:150px" />
</div>
<button class="btn-success" id="btn-encode" style="margin-top:1.35rem">Queue Encode Jobs</button>
<button class="btn-ghost" id="btn-back" style="margin-top:1.35rem">← Back</button>
</div>
</div>
<!-- Job history -->
<div class="section">
<div class="section-title" style="margin-bottom:.5rem">
Encode History
<button class="btn-ghost btn-xs" id="btn-refresh" style="margin-left:auto">Refresh</button>
</div>
<div id="job-strip"><div class="empty-msg">No jobs yet</div></div>
</div>
<!-- Arr activity -->
<div class="section" id="sec-activity">
<div class="section-title" style="margin-bottom:.6rem">
Arr Activity
<button class="btn-ghost btn-xs" id="btn-refresh-activity" style="margin-left:auto"
aria-label="Refresh activity">Refresh</button>
</div>
<div class="act-tabs" role="tablist">
<button class="act-tab active" id="act-tab-sonarr" role="tab"
aria-selected="true" aria-controls="act-panel">Sonarr</button>
<button class="act-tab" id="act-tab-radarr" role="tab"
aria-selected="false" aria-controls="act-panel">Radarr</button>
</div>
<div id="act-panel" role="tabpanel">
<div class="empty-msg" id="act-loading">Loading…</div>
</div>
</div>
</div><!-- /wrap -->
<script>
// ---------------------------------------------------------------------------
// Security: always escape untrusted strings before inserting into DOM
// ---------------------------------------------------------------------------
function esc(str) {
return String(str ?? '')
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
const S = {
scanJob: null, // job object from POST /api/scan
titles: [], // flat array enriched with diskNum/diskType/diskPath
series: null, // selected series
season: null,
episodes: [],
mappings: [], // {titleIdx, episodeIdx, outputPath, audioTrack, subTrack}
config: {},
};
// ---------------------------------------------------------------------------
// API
// ---------------------------------------------------------------------------
const api = {
get: url => fetch(url).then(r=>r.json()),
post: (url, b) => fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b)}).then(r=>r.json()),
del: url => fetch(url,{method:'DELETE'}).then(r=>r.json()),
sse(url, onLine, onEvent) {
const es = new EventSource(url);
es.onmessage = e => onLine(JSON.parse(e.data));
['scan-done','done','progress','status','mapping-done'].forEach(ev =>
es.addEventListener(ev, e => onEvent(ev, JSON.parse(e.data))));
return es;
},
};
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
async function init() {
S.config = await api.get('/api/config').catch(()=>({}));
renderChips();
const host = S.config.encodeHost;
if (host) document.getElementById('encode-host').value = host;
// Show/hide tabs based on what's configured
document.getElementById('act-tab-sonarr').style.display = S.config.sonarr ? '' : 'none';
document.getElementById('act-tab-radarr').style.display = S.config.radarr ? '' : 'none';
// Default to first configured source
if (!S.config.sonarr && S.config.radarr) actSource = 'radarr';
loadJobs();
loadActivity(true);
startActivityRefresh();
}
function renderChips() {
const c = S.config;
const items = [
c.sonarr ? ['Sonarr','ok'] : ['Sonarr','off'],
c.radarr ? ['Radarr','ok'] : null,
c.tmdb ? ['TMDB','ok'] : ['TMDB','warn'],
c.tdarr ? ['Tdarr','ok'] : null,
c.encodeHost ? [`SSH: ${esc(c.encodeHost)}`,'ok'] : ['Local encode','warn'],
].filter(Boolean);
const el = document.getElementById('cfg-chips');
el.innerHTML = '';
for (const [label, cls] of items) {
const span = document.createElement('span');
span.className = `chip ${cls}`;
span.textContent = label;
el.appendChild(span);
}
}
// ---------------------------------------------------------------------------
// Step 1 — Scan
// ---------------------------------------------------------------------------
document.getElementById('btn-scan').addEventListener('click', startScan);
document.getElementById('scan-path').addEventListener('keydown', e => { if(e.key==='Enter') startScan(); });
async function startScan() {
const pathVal = document.getElementById('scan-path').value.trim();
if (!pathVal) return;
const btn = document.getElementById('btn-scan');
btn.disabled = true; btn.textContent = 'Scanning…';
const logWrap = document.getElementById('scan-log-wrap');
const logEl = document.getElementById('scan-log');
logWrap.classList.remove('hidden');
logEl.textContent = '';
try {
const job = await api.post('/api/scan', { path: pathVal });
S.scanJob = job;
await new Promise((resolve, reject) => {
const es = api.sse(`/api/jobs/${job.id}/stream`,
line => { logEl.textContent += line + '\n'; logEl.scrollTop = logEl.scrollHeight; },
(evt, data) => {
es.close();
if (evt === 'scan-done') { S.scanJob.scanResult = data.result; resolve(); }
else reject(new Error(data.error || 'Scan failed'));
}
);
});
buildTitleList(S.scanJob.scanResult);
document.getElementById('sec-map').classList.remove('hidden');
document.getElementById('s1').classList.add('done');
document.getElementById('sec-map').scrollIntoView({behavior:'smooth',block:'start'});
} catch(err) {
logEl.textContent += `\nError: ${err.message}`;
} finally {
btn.disabled = false; btn.textContent = 'Re-scan';
}
}
// ---------------------------------------------------------------------------
// Build title cards
// ---------------------------------------------------------------------------
// Populate S.titles from fresh scan data, then render.
// Call this only on initial scan. For re-renders after splits, call renderTitleList().
function buildTitleList(scanResult) {
S.titles = [];
for (const disk of scanResult.disks) {
for (const title of disk.titles) {
S.titles.push({ ...title, diskNum: disk.diskNum, diskType: disk.type, diskPath: disk.path });
}
}
renderTitleList();
}
// Render title cards from the current S.titles array (preserves virtual split titles).
function renderTitleList() {
const container = document.getElementById('title-list');
container.innerHTML = '';
let lastDisk = null;
for (let idx = 0; idx < S.titles.length; idx++) {
const title = S.titles[idx];
// Disk section header — only for real (non-virtual) titles
if (!title.splitFrom && title.diskNum !== lastDisk) {
const lbl = document.createElement('div');
lbl.className = 'disk-label';
lbl.textContent = `Disk ${title.diskNum} · ${title.diskType.toUpperCase()}`;
container.appendChild(lbl);
lastDisk = title.diskNum;
}
const card = document.createElement('div');
card.className = 'title-card';
if (title.splitFrom !== undefined) card.style.cssText = 'margin-left:1.5rem;border-left:3px solid var(--accent)';
card.draggable = true;
card.dataset.idx = idx;
const titleEl = document.createElement('div');
titleEl.className = 'tc-title';
if (title.splitFrom !== undefined) {
titleEl.textContent = `↳ Part ${title.splitPart}/${title.splitTotal} · ${title.duration}`;
} else {
titleEl.textContent = `Disk ${title.diskNum} · Title ${title.titleSet}`;
}
const metaEl = document.createElement('div');
metaEl.className = 'tc-meta';
if (title.splitFrom !== undefined) {
// Virtual split title: show time range only
const span = document.createElement('span');
span.textContent = `${formatSecs(title.startSec)}${formatSecs(title.endSec)}`;
metaEl.appendChild(span);
} else {
const sizeMb = Math.round(title.fileSizeBytes / 1024 / 1024);
const audioStr = title.audioTracks.length
? title.audioTracks.map(a => `${a.language.toUpperCase()} ${a.codec}`).join(', ')
: 'no audio';
const subStr = title.subtitleTracks.length
? `Sub: ${title.subtitleTracks.map(s => s.language.toUpperCase()).join(', ')}`
: '';
[title.duration,
title.chapterCount ? `${title.chapterCount} ch` : null,
audioStr, subStr || null,
`${sizeMb} MB`
].filter(Boolean).forEach(txt => {
const span = document.createElement('span');
span.textContent = txt;
metaEl.appendChild(span);
});
}
card.appendChild(titleEl);
card.appendChild(metaEl);
// ✂ Split button — only on real (non-virtual) titles, appended after meta
if (title.splitFrom === undefined) {
const splitBtn = document.createElement('button');
splitBtn.className = 'btn-ghost btn-xs';
splitBtn.style.cssText = 'margin-top:.4rem;width:100%';
splitBtn.textContent = title.chapterCount
? `✂ Split (${title.chapterCount} chapters detected)`
: '✂ Split into segments';
splitBtn.setAttribute('aria-label', `Split title ${title.titleSet} into segments`);
const splitPanel = document.createElement('div');
splitPanel.style.cssText = 'display:none;margin-top:.5rem;padding:.5rem;background:var(--surface);border:1px solid var(--border);border-radius:6px;font-size:.8rem';
splitBtn.addEventListener('click', (e) => {
e.stopPropagation();
splitPanel.style.display = splitPanel.style.display === 'none' ? 'block' : 'none';
if (splitPanel.style.display === 'block') buildSplitEditor(splitPanel, idx, title);
});
card.appendChild(splitBtn);
card.appendChild(splitPanel);
}
card.addEventListener('dragstart', onDragStart);
card.addEventListener('dragend', onDragEnd);
container.appendChild(card);
}
updateMapCount();
}
// ---------------------------------------------------------------------------
// Series search
// ---------------------------------------------------------------------------
let searchTimer = null;
document.getElementById('series-search').addEventListener('input', e => {
clearTimeout(searchTimer);
const q = e.target.value.trim();
if (q.length < 2) { document.getElementById('series-results').classList.add('hidden'); return; }
searchTimer = setTimeout(() => searchSeries(q), 400);
});
// ---------------------------------------------------------------------------
// Title splitting
// ---------------------------------------------------------------------------
function formatSecs(s) {
s = Math.round(s);
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), ss = s % 60;
return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(ss).padStart(2,'0')}`;
}
function parseSecs(str) {
const parts = str.trim().split(':').map(Number);
if (parts.length === 3) return parts[0]*3600 + parts[1]*60 + parts[2];
if (parts.length === 2) return parts[0]*60 + parts[1];
return Number(str) || 0;
}
function buildSplitEditor(panel, titleIdx, title) {
panel.innerHTML = '';
const totalSecs = title.durationSecs || 0;
// Build initial segments list — prefer chapter marks, else equal halves
let segs = [];
if (title.chapters && title.chapters.length > 1) {
segs = title.chapters.map((ch, i) => ({
startSec: ch.startSec,
endSec: title.chapters[i+1] ? title.chapters[i+1].startSec : totalSecs,
label: ch.title || `Ch ${i+1}`,
}));
} else {
segs = [
{ startSec: 0, endSec: Math.round(totalSecs/2), label: 'Part 1' },
{ startSec: Math.round(totalSecs/2), endSec: totalSecs, label: 'Part 2' },
];
}
function renderSegs() {
panel.innerHTML = '';
// "Extract from IFO" button — inside renderSegs so it survives re-renders
if (!title.chapters || title.chapters.length <= 1) {
const ifoRow = document.createElement('div');
ifoRow.style.cssText = 'margin-bottom:.5rem';
const ifoBtn = document.createElement('button');
ifoBtn.className = 'btn-ghost btn-xs';
ifoBtn.style.width = '100%';
ifoBtn.textContent = '📀 Extract chapter timestamps from DVD IFO';
ifoBtn.setAttribute('aria-label', 'Read chapter timestamps from DVD IFO file');
ifoBtn.addEventListener('click', async () => {
ifoBtn.disabled = true;
ifoBtn.textContent = 'Reading IFO…';
try {
const videoTsPath = `${title.diskPath}/VIDEO_TS`;
const data = await api.get(
`/api/chapters?videoTsPath=${encodeURIComponent(videoTsPath)}&titleSet=${title.titleSet}`
);
if (data.chapters && data.chapters.length > 1) {
title.chapters = data.chapters;
title.chapterCount = data.chapters.length;
ifoBtn.textContent = `✓ Found ${data.chapters.length} chapters — rebuilding…`;
setTimeout(() => buildSplitEditor(panel, titleIdx, title), 400);
} else {
ifoBtn.textContent = 'No chapters found in IFO — use manual split below';
ifoBtn.disabled = false;
}
} catch (err) {
ifoBtn.textContent = `Error: ${err.message}`;
ifoBtn.disabled = false;
}
});
ifoRow.appendChild(ifoBtn);
panel.appendChild(ifoRow);
}
// Preset row
const presetRow = document.createElement('div');
presetRow.style.cssText = 'display:flex;gap:.4rem;align-items:center;margin-bottom:.5rem;flex-wrap:wrap';
const nLabel = document.createElement('label');
nLabel.textContent = 'Split into';
nLabel.style.fontSize = '.78rem';
const nInput = document.createElement('input');
nInput.type = 'number'; nInput.min = 2; nInput.max = 99;
nInput.value = segs.length;
nInput.style.cssText = 'width:3.5rem;padding:.2rem .3rem;font-size:.78rem;border-radius:4px;border:1px solid var(--border);background:var(--surface2);color:var(--text)';
nInput.setAttribute('aria-label', 'Number of equal segments');
const equalBtn = document.createElement('button');
equalBtn.className = 'btn-ghost btn-xs';
equalBtn.textContent = 'equal parts';
equalBtn.addEventListener('click', () => {
const n = Math.max(2, Math.min(99, parseInt(nInput.value) || 2));
const segLen = totalSecs / n;
segs = Array.from({ length: n }, (_, i) => ({
startSec: Math.round(i * segLen),
endSec: Math.round((i+1) * segLen),
label: `Part ${i+1}`,
}));
renderSegs();
});
if (title.chapters && title.chapters.length > 1) {
const chBtn = document.createElement('button');
chBtn.className = 'btn-ghost btn-xs';
chBtn.textContent = `by ${title.chapters.length} chapters`;
chBtn.addEventListener('click', () => {
segs = title.chapters.map((ch, i) => ({
startSec: ch.startSec,
endSec: title.chapters[i+1] ? title.chapters[i+1].startSec : totalSecs,
label: ch.title || `Ch ${i+1}`,
}));
renderSegs();
});
presetRow.appendChild(chBtn);
}
presetRow.appendChild(nLabel);
presetRow.appendChild(nInput);
presetRow.appendChild(equalBtn);
panel.appendChild(presetRow);
// Segment rows
const segList = document.createElement('div');
segList.style.cssText = 'display:flex;flex-direction:column;gap:.25rem;margin-bottom:.5rem';
segs.forEach((seg, si) => {
const row = document.createElement('div');
row.style.cssText = 'display:flex;gap:.3rem;align-items:center;font-size:.75rem';
const segLabel = document.createElement('span');
segLabel.style.cssText = 'min-width:3rem;color:var(--muted)';
segLabel.textContent = seg.label;
const startIn = document.createElement('input');
startIn.type = 'text'; startIn.value = formatSecs(seg.startSec);
startIn.style.cssText = 'width:6rem;padding:.15rem .3rem;font-size:.75rem;border-radius:4px;border:1px solid var(--border);background:var(--surface2);color:var(--text);font-family:var(--mono)';
startIn.setAttribute('aria-label', `Segment ${si+1} start time`);
startIn.addEventListener('change', () => { segs[si].startSec = parseSecs(startIn.value); });
const arrow = document.createElement('span');
arrow.textContent = '→'; arrow.style.color = 'var(--muted)';
const endIn = document.createElement('input');
endIn.type = 'text'; endIn.value = formatSecs(seg.endSec);
endIn.style.cssText = startIn.style.cssText;
endIn.setAttribute('aria-label', `Segment ${si+1} end time`);
endIn.addEventListener('change', () => { segs[si].endSec = parseSecs(endIn.value); });
const dur = document.createElement('span');
dur.style.cssText = 'color:var(--muted);min-width:4.5rem';
dur.textContent = `(${formatSecs(Math.max(0, seg.endSec - seg.startSec))})`;
row.appendChild(segLabel);
row.appendChild(startIn);
row.appendChild(arrow);
row.appendChild(endIn);
row.appendChild(dur);
segList.appendChild(row);
});
panel.appendChild(segList);
// Apply button
const applyBtn = document.createElement('button');
applyBtn.className = 'btn-primary btn-xs';
applyBtn.style.width = '100%';
applyBtn.textContent = `Apply — create ${segs.length} virtual titles`;
applyBtn.addEventListener('click', () => applySplits(titleIdx, segs, title));
panel.appendChild(applyBtn);
}
renderSegs();
}
function applySplits(originalIdx, segs, originalTitle) {
// Remove any existing splits for this title, then insert new virtual titles
S.titles = S.titles.filter(t => t.splitFrom !== originalIdx);
// Clear mappings that referenced the original or old splits
S.mappings = S.mappings.filter(m => m.titleIdx !== originalIdx
&& !S.titles.some((t, i) => i === m.titleIdx && t.splitFrom === originalIdx));
const virtualTitles = segs.map((seg, i) => ({
...originalTitle,
id: `${originalTitle.id}_split_${i}`,
splitFrom: originalIdx,
splitPart: i + 1,
splitTotal: segs.length,
startSec: seg.startSec,
endSec: seg.endSec,
duration: formatSecs(seg.endSec - seg.startSec),
durationSecs: seg.endSec - seg.startSec,
chapters: [],
chapterCount: 0,
}));
// Insert virtual titles right after the original
S.titles.splice(originalIdx + 1, 0, ...virtualTitles);
// Re-render from S.titles (preserves virtual titles — don't use buildTitleList here)
renderTitleList();
// Auto-assign virtual titles to episodes if count matches (convenience)
if (S.episodes.length >= segs.length) {
virtualTitles.forEach((vt, vi) => {
const newIdx = S.titles.indexOf(vt);
if (newIdx >= 0) assignTitle(newIdx, vi);
});
}
}
// ---------------------------------------------------------------------------
// Series search
// ---------------------------------------------------------------------------
async function searchSeries(q) {
const results = await api.get(`/api/lookup?q=${encodeURIComponent(q)}&type=series`);
const container = document.getElementById('series-results');
container.innerHTML = '';
container.classList.remove('hidden');
// Reset browse button states when doing a manual search
document.getElementById('btn-browse-sonarr').classList.remove('btn-primary');
document.getElementById('btn-browse-sonarr').classList.add('btn-ghost');
document.getElementById('btn-browse-radarr').classList.remove('btn-primary');
document.getElementById('btn-browse-radarr').classList.add('btn-ghost');
if (!results.length) {
const msg = document.createElement('div');
msg.className = 'empty-msg';
msg.style.padding = '.75rem';
msg.textContent = 'No results';
container.appendChild(msg);
return;
}
renderSeriesResults(results, container, false);
container._results = results;
}
// Browse full Sonarr/Radarr library
document.getElementById('btn-browse-sonarr').addEventListener('click', () => browseLibrary('sonarr'));
document.getElementById('btn-browse-radarr').addEventListener('click', () => browseLibrary('radarr'));
async function browseLibrary(source) {
const container = document.getElementById('series-results');
container.innerHTML = '';
container.classList.remove('hidden');
const loading = document.createElement('div');
loading.className = 'empty-msg';
loading.style.padding = '.75rem';
loading.textContent = `Loading ${source} library…`;
container.appendChild(loading);
// Mark active browse button
document.getElementById('btn-browse-sonarr').classList.toggle('btn-primary', source === 'sonarr');
document.getElementById('btn-browse-sonarr').classList.toggle('btn-ghost', source !== 'sonarr');
document.getElementById('btn-browse-radarr').classList.toggle('btn-primary', source === 'radarr');
document.getElementById('btn-browse-radarr').classList.toggle('btn-ghost', source !== 'radarr');
try {
const results = await api.get(`/api/library?source=${source}`);
container.innerHTML = '';
if (!results.length) {
const msg = document.createElement('div');
msg.className = 'empty-msg';
msg.style.padding = '.75rem';
msg.textContent = `No items found in ${source}`;
container.appendChild(msg);
return;
}
// Inline filter so users can find items with 0 missing (unmonitored, etc.)
const filterWrap = document.createElement('div');
filterWrap.style.cssText = 'padding:.3rem .1rem .3rem;position:sticky;top:0;background:var(--surface2);z-index:1';
const filterInput = document.createElement('input');
filterInput.type = 'text';
filterInput.placeholder = `Filter ${results.length} items…`;
filterInput.style.cssText = 'width:100%;font-size:.8rem;padding:.25rem .4rem;border-radius:4px;border:1px solid var(--border);background:var(--surface);color:var(--text)';
filterInput.setAttribute('aria-label', `Filter ${source} library results`);
filterWrap.appendChild(filterInput);
container.appendChild(filterWrap);
const listWrap = document.createElement('div');
container.appendChild(listWrap);
container._results = results;
function applyFilter(q) {
listWrap.innerHTML = '';
const filtered = q
? results.filter(s => s.title.toLowerCase().includes(q.toLowerCase()))
: results;
if (!filtered.length) {
const none = document.createElement('div');
none.className = 'empty-msg';
none.style.padding = '.5rem';
none.textContent = 'No matches';
listWrap.appendChild(none);
listWrap._results = [];
} else {
renderSeriesResults(filtered, listWrap, /* showMissing= */ true);
listWrap._results = filtered;
}
}
filterInput.addEventListener('input', e => applyFilter(e.target.value));
filterInput.addEventListener('keydown', e => {
// Enter selects first result
if (e.key === 'Enter' && listWrap._results?.length) {
selectSeries(listWrap._results[0], listWrap);
}
});
applyFilter('');
// Auto-focus the filter so user can type immediately
setTimeout(() => filterInput.focus(), 50);
} catch(err) {
container.innerHTML = '';
const msg = document.createElement('div');
msg.className = 'empty-msg';
msg.style.padding = '.75rem';
msg.textContent = `Error: ${err.message}`;
container.appendChild(msg);
}
}
function renderSeriesResults(results, container, showMissing = false) {
results.forEach((s) => {
const el = document.createElement('div');
el.className = 'series-result';
el.setAttribute('role', 'option');
const img = document.createElement('img');
img.className = 'sr-poster';
img.alt = '';
if (s.poster) { img.src = s.poster; img.onerror = () => { img.style.display='none'; }; }
else img.style.display = 'none';
const info = document.createElement('div');
info.style.flex = '1';
const titleDiv = document.createElement('div');
titleDiv.className = 'sr-title-text';
titleDiv.textContent = s.title;
const metaDiv = document.createElement('div');
metaDiv.className = 'sr-meta-text';
let metaParts = [];
if (s.year) metaParts.push(s.year);
if (s.source) metaParts.push(s.source);
metaDiv.textContent = metaParts.join(' · ');
info.appendChild(titleDiv);
info.appendChild(metaDiv);
el.appendChild(img);
el.appendChild(info);
if (s.inLibrary) {
const badge = document.createElement('span');
if (showMissing && s.missing > 0) {
badge.className = 'sr-badge';
badge.style.borderColor = 'var(--warning)';
badge.style.color = 'var(--warning)';
badge.style.background = 'color-mix(in srgb,var(--warning) 12%,var(--surface))';
badge.textContent = `${s.missing} missing`;
} else if (showMissing && s.missing === 0) {
badge.className = 'sr-badge';
badge.style.borderColor = 'var(--success)';
badge.style.color = 'var(--success)';
badge.style.background = 'color-mix(in srgb,var(--success) 12%,var(--surface))';
badge.textContent = 'complete';
} else {
badge.className = 'sr-badge';
badge.textContent = 'In Library';
}
el.appendChild(badge);
}
el.addEventListener('click', () => selectSeries(s, container));
container.appendChild(el);
});
}
function selectSeries(series, container) {
S.series = series;
container.querySelectorAll('.series-result').forEach((el, idx) => {
el.classList.toggle('active', container._results?.[idx] === series);
});
const pillsEl = document.getElementById('season-pills');
pillsEl.innerHTML = '';
// Radarr movies have no seasons — create a single synthetic "movie slot" directly
if (series.source === 'radarr') {
pillsEl.classList.add('hidden');
S.season = 0;
S.episodes = [{
episode: 1, season: 0,
title: series.title,
airDate: String(series.year || ''),
hasFile: series.hasFile || false,
}];
renderEpSlots();
return;
}
pillsEl.classList.remove('hidden');
const seasons = series.seasons?.length ? series.seasons : [...Array(10)].map((_,i)=>i+1);
for (const sNum of seasons) {
const pill = document.createElement('button');
pill.className = 'season-pill';
pill.textContent = `S${String(sNum).padStart(2,'0')}`;
pill.addEventListener('click', () => {
document.querySelectorAll('.season-pill').forEach(p => p.classList.remove('active'));
pill.classList.add('active');
selectSeason(sNum);
});
pillsEl.appendChild(pill);
}
if (pillsEl.firstChild) pillsEl.firstChild.click();
}
async function selectSeason(seasonNum) {
S.season = seasonNum;
const epList = document.getElementById('ep-list');
epList.innerHTML = '';
const loading = document.createElement('div');
loading.className = 'empty-msg';
loading.textContent = 'Loading episodes…';
epList.appendChild(loading);
try {
S.episodes = await api.get(
`/api/lookup/${S.series.source}/${S.series.id}/episodes?season=${seasonNum}`);
renderEpSlots();
} catch(err) {
epList.innerHTML = '';
const msg = document.createElement('div');
msg.className = 'empty-msg';
msg.textContent = err.message;
epList.appendChild(msg);
}
}
// ---------------------------------------------------------------------------
// Episode slots
// ---------------------------------------------------------------------------
function renderEpSlots() {
const container = document.getElementById('ep-list');
container.innerHTML = '';
if (!S.episodes.length) {
const msg = document.createElement('div');
msg.className = 'empty-msg';
msg.textContent = 'No episodes found for this season';
container.appendChild(msg);
return;
}
S.episodes.forEach((ep, i) => {
const slot = document.createElement('div');
slot.className = 'ep-slot';
slot.dataset.epIdx = i;
const numSpan = document.createElement('span');
numSpan.className = 'ep-num';
// For movies (season 0) show a film icon instead of episode number
numSpan.textContent = S.season === 0 ? '🎬' : `E${String(ep.episode).padStart(2,'0')}`;
const info = document.createElement('div');
info.className = 'ep-info';
const titleDiv = document.createElement('div');
titleDiv.className = 'ep-title-text';
titleDiv.textContent = ep.title;
if (ep.airDate) {
const sub = document.createElement('div');
sub.style.cssText = 'font-size:.75rem;opacity:.6';
sub.textContent = ep.airDate;
info.appendChild(titleDiv);
info.appendChild(sub);
} else {
info.appendChild(titleDiv);
}
const existing = S.mappings.find(m => m.episodeIdx === i);
if (kbdMode) {
// Keyboard mode: select dropdown replaces drag target
const sel = document.createElement('select');
sel.className = 'kbd-select';
sel.setAttribute('aria-label', `Title for E${String(ep.episode).padStart(2,'0')} ${ep.title}`);
const blank = document.createElement('option');
blank.value = '';
blank.textContent = '— unassigned —';
sel.appendChild(blank);
S.titles.forEach((t, ti) => {
const opt = document.createElement('option');
opt.value = ti;
opt.textContent = titleLabel(t);
// Disable titles already assigned to other episodes
const usedBy = S.mappings.find(m => m.titleIdx === ti);
if (usedBy && usedBy.episodeIdx !== i) opt.disabled = true;
sel.appendChild(opt);
});
if (existing) sel.value = existing.titleIdx;
sel.addEventListener('change', e => {
const val = e.target.value;
if (val === '') unassign(i);
else assignTitle(parseInt(val, 10), i);
});
slot.appendChild(numSpan);
slot.appendChild(info);
slot.appendChild(sel);
if (existing) slot.classList.add('assigned');
} else if (existing) {
slot.classList.add('assigned');
const aDiv = document.createElement('div');
aDiv.className = 'ep-assigned-text';
aDiv.textContent = `${titleLabel(S.titles[existing.titleIdx])}`;
info.appendChild(aDiv);
const unBtn = document.createElement('button');
unBtn.className = 'btn-ghost btn-xs';
unBtn.textContent = '✕';
unBtn.style.marginLeft = 'auto';
unBtn.title = 'Unassign';
unBtn.setAttribute('aria-label', `Unassign ${ep.title}`);
unBtn.addEventListener('click', e => { e.stopPropagation(); unassign(i); });
slot.appendChild(numSpan);
slot.appendChild(info);
slot.appendChild(unBtn);
} else {
slot.appendChild(numSpan);
slot.appendChild(info);
}
if (!kbdMode) {
// Drag handle for reordering episode slots
const handle = document.createElement('span');
handle.textContent = '⠿';
handle.title = 'Drag to reorder assignment';
handle.style.cssText = 'cursor:grab;color:var(--muted);font-size:1rem;padding:0 .3rem;flex-shrink:0;touch-action:none';
handle.draggable = true;
handle.addEventListener('dragstart', onEpDragStart);
handle.addEventListener('dragend', onEpDragEnd);
slot.insertBefore(handle, slot.firstChild);
slot.addEventListener('dragover', onDragOver);
slot.addEventListener('dragleave', onDragLeave);
slot.addEventListener('drop', onDrop);
}
container.appendChild(slot);
});
}
function titleLabel(t) {
return t ? `Disk ${t.diskNum} · Title ${t.titleSet} (${t.duration})` : '?';
}
// ---------------------------------------------------------------------------
// Drag and drop
// ---------------------------------------------------------------------------
let draggingIdx = null; // title card drag
let draggingEpIdx = null; // episode slot drag
function onDragStart(e) {
draggingIdx = parseInt(e.currentTarget.dataset.idx);
e.currentTarget.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
}
function onDragEnd(e) {
e.currentTarget.classList.remove('dragging');
draggingIdx = null;
}
function onEpDragStart(e) {
draggingEpIdx = parseInt(e.currentTarget.closest('.ep-slot').dataset.epIdx);
e.currentTarget.closest('.ep-slot').classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.stopPropagation();
}
function onEpDragEnd(e) {
document.querySelectorAll('.ep-slot.dragging').forEach(el => el.classList.remove('dragging'));
draggingEpIdx = null;
}
function onDragOver(e) {
e.preventDefault();
e.currentTarget.classList.add('drag-over');
}
function onDragLeave(e) {
e.currentTarget.classList.remove('drag-over');
}
function onDrop(e) {
e.preventDefault();
e.currentTarget.classList.remove('drag-over');
const targetEpIdx = parseInt(e.currentTarget.dataset.epIdx);
if (draggingEpIdx !== null && draggingEpIdx !== targetEpIdx) {
// Swap title assignments between two episode slots
const srcMap = S.mappings.find(m => m.episodeIdx === draggingEpIdx);
const dstMap = S.mappings.find(m => m.episodeIdx === targetEpIdx);
if (srcMap && dstMap) {
[srcMap.titleIdx, dstMap.titleIdx] = [dstMap.titleIdx, srcMap.titleIdx];
[srcMap.startSec, dstMap.startSec] = [dstMap.startSec, srcMap.startSec];
[srcMap.endSec, dstMap.endSec] = [dstMap.endSec, srcMap.endSec];
srcMap.outputPath = buildOutputPath(S.episodes[draggingEpIdx]);
dstMap.outputPath = buildOutputPath(S.episodes[targetEpIdx]);
} else if (srcMap) {
srcMap.episodeIdx = targetEpIdx;
srcMap.outputPath = buildOutputPath(S.episodes[targetEpIdx]);
} else if (dstMap) {
dstMap.episodeIdx = draggingEpIdx;
dstMap.outputPath = buildOutputPath(S.episodes[draggingEpIdx]);
}
const assignedIdxs = new Set(S.mappings.map(m => m.titleIdx));
document.querySelectorAll('.title-card').forEach(c =>
c.classList.toggle('assigned', assignedIdxs.has(parseInt(c.dataset.idx))));
renderEpSlots();
updateMapCount();
draggingEpIdx = null;
} else if (draggingIdx !== null) {
// Title card dropped onto episode slot
assignTitle(draggingIdx, targetEpIdx);
}
}
function assignTitle(titleIdx, epIdx) {
S.mappings = S.mappings.filter(m => m.titleIdx !== titleIdx && m.episodeIdx !== epIdx);
const ep = S.episodes[epIdx];
const t = S.titles[titleIdx];
S.mappings.push({
titleIdx, episodeIdx: epIdx, audioTrack: 0, subTrack: -1,
outputPath: buildOutputPath(ep),
// Carry split bounds if this is a virtual split title
startSec: t?.startSec ?? 0,
endSec: t?.endSec ?? 0,
});
document.querySelectorAll('.title-card').forEach(c => {
c.classList.toggle('assigned', parseInt(c.dataset.idx) === titleIdx);
});
renderEpSlots();
updateMapCount();
document.getElementById('btn-review').disabled = S.mappings.length === 0;
}
function unassign(epIdx) {
const m = S.mappings.find(m => m.episodeIdx === epIdx);
if (!m) return;
document.querySelectorAll('.title-card').forEach(c => {
if (parseInt(c.dataset.idx) === m.titleIdx) c.classList.remove('assigned');
});
S.mappings = S.mappings.filter(x => x.episodeIdx !== epIdx);
renderEpSlots();
updateMapCount();
document.getElementById('btn-review').disabled = S.mappings.length === 0;
}
function buildOutputPath(ep) {
if (!S.series) return '';
const safe = s => String(s).replace(/[<>:"/\\|?*]/g,'').replace(/\s+/g,' ').trim();
const title = safe(S.series.title);
const year = S.series.year || '';
const dir = year ? `${title} (${year})` : title;
// Radarr movie: flat Movies/<dir>/<dir>.mkv
if (S.series.source === 'radarr') {
const base = (S.config.movieBase || S.config.outputBase || '/Library/Movies').replace(/\/$/, '');
return `${base}/${dir}/${dir}.mkv`;
}
// Sonarr / TV episode
const sn = String(S.season).padStart(2,'0');
const en = String(ep.episode).padStart(2,'0');
const epTitle = safe(ep.title || '');
const base = (S.config.outputBase || '/Library/Series').replace(/\/$/, '');
return `${base}/${dir}/Season ${parseInt(sn)}/${title} - S${sn}E${en} - ${epTitle}.mkv`;
}
document.getElementById('btn-autofill').addEventListener('click', () => {
if (!S.episodes.length) return;
S.mappings = [];
// Skip parent titles that have been split — virtual children are the assignable units
const hasSplitChildren = new Set(S.titles.filter(t => t.splitFrom != null).map(t => t.splitFrom));
const assignable = S.titles.map((t, i) => ({ t, i })).filter(({ i }) => !hasSplitChildren.has(i));
const limit = Math.min(assignable.length, S.episodes.length);
for (let ei = 0; ei < limit; ei++) {
const { t, i: titleIdx } = assignable[ei];
const ep = S.episodes[ei];
S.mappings.push({
titleIdx, episodeIdx: ei, audioTrack: 0, subTrack: -1,
outputPath: buildOutputPath(ep),
startSec: t?.startSec ?? 0,
endSec: t?.endSec ?? 0,
});
}
// Batch DOM update — mark all assigned title cards at once
const assignedIdxs = new Set(S.mappings.map(m => m.titleIdx));
document.querySelectorAll('.title-card').forEach(c =>
c.classList.toggle('assigned', assignedIdxs.has(parseInt(c.dataset.idx))));
renderEpSlots();
updateMapCount();
document.getElementById('btn-review').disabled = S.mappings.length === 0;
});
document.getElementById('btn-clear-map').addEventListener('click', () => {
S.mappings = [];
document.querySelectorAll('.title-card').forEach(c => c.classList.remove('assigned'));
renderEpSlots();
updateMapCount();
document.getElementById('btn-review').disabled = true;
});
function updateMapCount() {
document.getElementById('map-count').textContent =
`${S.mappings.length} of ${S.titles.length} title(s) mapped`;
}
// ---------------------------------------------------------------------------
// Step 3 — Review
// ---------------------------------------------------------------------------
document.getElementById('btn-review').addEventListener('click', buildReview);
document.getElementById('btn-back').addEventListener('click', () => {
document.getElementById('sec-review').classList.add('hidden');
});
function buildReview() {
const tbody = document.getElementById('review-body');
tbody.innerHTML = '';
[...S.mappings].sort((a,b) => a.episodeIdx - b.episodeIdx).forEach(m => {
const t = S.titles[m.titleIdx];
const ep = S.episodes[m.episodeIdx];
const tr = document.createElement('tr');
// Title cell
const tdTitle = document.createElement('td');
tdTitle.style.whiteSpace = 'nowrap';
tdTitle.textContent = `Disk ${t.diskNum} · T${t.titleSet}`;
// Duration
const tdDur = document.createElement('td');
tdDur.className = 'dur';
tdDur.textContent = t.duration;
// Episode
const tdEp = document.createElement('td');
tdEp.style.whiteSpace = 'nowrap';
tdEp.textContent = `S${String(S.season).padStart(2,'0')}E${String(ep.episode).padStart(2,'0')} ${ep.title}`;
// Output path (editable)
const tdPath = document.createElement('td');
const pathInput = document.createElement('input');
pathInput.className = 'path-input';
pathInput.type = 'text';
pathInput.value = m.outputPath;
pathInput.addEventListener('change', e => { m.outputPath = e.target.value; });
tdPath.appendChild(pathInput);
// Remove
const tdRm = document.createElement('td');
const rmBtn = document.createElement('button');
rmBtn.className = 'rm-btn';
rmBtn.textContent = '✕';
rmBtn.title = 'Remove';
rmBtn.setAttribute('aria-label', `Remove ${ep.title} from queue`);
rmBtn.addEventListener('click', () => { unassign(m.episodeIdx); buildReview(); });
tdRm.appendChild(rmBtn);
tr.appendChild(tdTitle); tr.appendChild(tdDur);
tr.appendChild(tdEp); tr.appendChild(tdPath); tr.appendChild(tdRm);
tbody.appendChild(tr);
});
document.getElementById('sec-review').classList.remove('hidden');
document.getElementById('s3').classList.add('done');
document.getElementById('sec-review').scrollIntoView({behavior:'smooth',block:'start'});
}
document.getElementById('preset-select').addEventListener('change', e => {
document.getElementById('custom-script-wrap').classList.toggle('hidden', e.target.value !== 'custom');
});
document.getElementById('btn-encode').addEventListener('click', submitEncode);
async function submitEncode() {
const preset = document.getElementById('preset-select').value;
const customScript = document.getElementById('custom-script').value.trim();
const encodeHost = document.getElementById('encode-host').value.trim();
const mappings = S.mappings.map(m => {
const t = S.titles[m.titleIdx];
const videoTsBase = `${t.diskPath}/VIDEO_TS`;
return {
diskPath: t.diskPath,
diskType: t.diskType,
titleSet: t.titleSet,
hbTitle: t.hbTitle ?? null,
vobPaths: t.hbScan ? [] : (t.vobFiles ? t.vobFiles.map(f => `${videoTsBase}/${f}`) : []),
filePath: t.file
? (t.diskType === 'video'
? `${t.diskPath}/${t.file}`
: `${t.diskPath}/BDMV/STREAM/${t.file}`)
: null,
outputPath: m.outputPath,
preset: preset === 'custom' ? null : preset,
customScript: preset === 'custom' ? customScript : null,
audioTrack: m.audioTrack ?? 0,
subTrack: m.subTrack ?? -1,
// Split title bounds — 0 means no trimming
startSec: m.startSec || 0,
endSec: m.endSec || 0,
};
});
const btn = document.getElementById('btn-encode');
btn.disabled = true; btn.textContent = 'Queuing…';
try {
await api.post('/api/encode', {
sourcePath: S.scanJob?.scanResult?.sourcePath || '',
mappings,
preset,
...(encodeHost && encodeHost !== 'local' ? { encodeHost } : {}),
});
document.getElementById('sec-review').classList.add('hidden');
document.getElementById('sec-map').classList.add('hidden');
document.getElementById('s2').classList.add('done');
loadJobs();
document.querySelector('.section:last-child').scrollIntoView({behavior:'smooth'});
} catch(err) {
alert(`Failed to queue: ${err.message}`);
} finally {
btn.disabled = false; btn.textContent = 'Queue Encode Jobs';
}
}
// ---------------------------------------------------------------------------
// Job history
// ---------------------------------------------------------------------------
// Active SSE connections keyed by job id — closed when job finishes or card removed
const jobSseMap = new Map();
function fmtElapsed(ms) {
if (ms < 60000) return `${Math.round(ms/1000)}s`;
const m = Math.floor(ms/60000), s = Math.round((ms%60000)/1000);
return `${m}m ${s}s`;
}
function appendLogLine(logEl, text) {
const line = document.createElement('div');
if (text.includes('✓') || text.includes('Done:')) line.className = 'jl-ok';
else if (text.includes('✗') || text.includes('FAILED')) line.className = 'jl-err';
else if (text.startsWith('[encode] HandBrake:') || text.startsWith('[encode] ffmpeg'))
line.className = 'jl-cmd';
else line.className = 'jl-info';
line.textContent = text;
logEl.appendChild(line);
logEl.scrollTop = logEl.scrollHeight;
}
function buildJobCard(j) {
const total = j.mappings?.length || 0;
const prog = j.progress || { current: 0, total, currentFile: null, failed: 0 };
const done = prog.current;
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
const anyFail = (prog.failed || 0) > 0;
const elapsed = j.startedAt ? fmtElapsed((j.finishedAt || Date.now()) - j.startedAt) : null;
const card = document.createElement('div');
card.className = 'job-card';
card.dataset.jobId = j.id;
// ── Header row ──────────────────────────────────────────────────────
const head = document.createElement('div');
head.className = 'job-card-head';
const dot = document.createElement('div');
dot.className = `jr-dot ${j.status}`;
const nameEl = document.createElement('div');
nameEl.className = 'jr-path';
nameEl.textContent = j.sourcePath ? j.sourcePath.split('/').pop() : j.id;
nameEl.title = j.sourcePath || j.id;
const metaEl = document.createElement('div');
metaEl.className = 'jr-meta';
metaEl.textContent = [
j.status === 'running' ? `${done}/${total}` : `${total} file${total !== 1 ? 's' : ''}`,
elapsed ? `· ${elapsed}` : null,
j.status !== 'running' ? `· ${j.status}` : null,
].filter(Boolean).join(' ');
const delBtn = document.createElement('button');
delBtn.className = 'btn-ghost btn-xs';
delBtn.textContent = '✕';
delBtn.setAttribute('aria-label', `Delete job ${j.id}`);
delBtn.addEventListener('click', async e => {
e.stopPropagation();
const sse = jobSseMap.get(j.id);
if (sse) { sse.close(); jobSseMap.delete(j.id); }
await api.del(`/api/jobs/${j.id}?force=true`);
loadJobs();
});
head.appendChild(dot); head.appendChild(nameEl);
head.appendChild(metaEl); head.appendChild(delBtn);
card.appendChild(head);
// ── Progress bar (running or finished with partial failure) ─────────
if (total > 0 && (j.status === 'running' || j.status === 'done' || j.status === 'failed')) {
const wrap = document.createElement('div');
wrap.className = 'jr-progress-wrap';
const barBg = document.createElement('div');
barBg.className = 'jr-progress-bar-bg';
const bar = document.createElement('div');
bar.className = `jr-progress-bar${anyFail ? ' failed' : ''}`;
bar.style.width = `${pct}%`;
bar.dataset.bar = '';
barBg.appendChild(bar);
wrap.appendChild(barBg);
if (prog.currentFile) {
const cf = document.createElement('div');
cf.className = 'jr-current-file';
cf.textContent = prog.currentFile;
cf.dataset.currentFile = '';
wrap.appendChild(cf);
}
card.appendChild(wrap);
}
// ── Log panel (collapsed by default) ────────────────────────────────
const logEl = document.createElement('div');
logEl.className = 'job-log';
// Fetch full lines on demand
head.addEventListener('click', async () => {
logEl.classList.toggle('open');
if (logEl.classList.contains('open') && logEl.children.length === 0) {
const full = await api.get(`/api/jobs/${j.id}`).catch(() => null);
if (full?.lines) full.lines.forEach(l => appendLogLine(logEl, l));
}
});
card.appendChild(logEl);
// ── Live SSE for running jobs ────────────────────────────────────────
if (j.status === 'running' && !jobSseMap.has(j.id)) {
const sse = api.sse(`/api/jobs/${j.id}/stream`,
line => {
if (logEl.classList.contains('open')) appendLogLine(logEl, line);
},
(event, data) => {
if (event === 'progress') {
const total = data.total || 1;
const pct = Math.round((data.current / total) * 100);
const bar = card.querySelector('[data-bar]');
if (bar) bar.style.width = `${pct}%`;
const cf = card.querySelector('[data-current-file]');
if (cf && data.currentFile) cf.textContent = data.currentFile;
metaEl.textContent = `${data.current}/${total} · ${fmtElapsed(Date.now() - j.startedAt)}`;
}
if (event === 'done') {
sse.close(); jobSseMap.delete(j.id);
setTimeout(loadJobs, 300);
}
}
);
// Also listen for progress events on the SSE object
sse.addEventListener('progress', e => {
const data = JSON.parse(e.data);
const total = data.total || 1;
const pct = Math.round((data.current / total) * 100);
const bar = card.querySelector('[data-bar]');
if (bar) bar.style.width = `${pct}%`;
const cf = card.querySelector('[data-current-file]');
if (cf && data.currentFile) cf.textContent = data.currentFile;
metaEl.textContent = `${data.current}/${total} · ${fmtElapsed(Date.now() - (j.startedAt||Date.now()))}`;
});
jobSseMap.set(j.id, sse);
}
return card;
}
async function loadJobs() {
const jobs = await api.get('/api/jobs').catch(() => []);
const strip = document.getElementById('job-strip');
const encJobs = jobs.filter(j => j.type === 'encode').slice(0, 20);
strip.innerHTML = '';
if (!encJobs.length) {
const msg = document.createElement('div');
msg.className = 'empty-msg';
msg.textContent = 'No encode jobs yet';
strip.appendChild(msg);
return;
}
for (const j of encJobs) strip.appendChild(buildJobCard(j));
}
document.getElementById('btn-refresh').addEventListener('click', loadJobs);
// ---------------------------------------------------------------------------
// Settings panel
// ---------------------------------------------------------------------------
const settingsFieldMap = {
'SONARR_URL': 'cfg-sonarr-url',
'SONARR_API_KEY': 'cfg-sonarr-key',
'RADARR_URL': 'cfg-radarr-url',
'RADARR_API_KEY': 'cfg-radarr-key',
'TMDB_API_KEY': 'cfg-tmdb-key',
'TDARR_URL': 'cfg-tdarr-url',
'TDARR_LIBRARY_ID':'cfg-tdarr-lib',
'ENCODE_SSH_HOST': 'cfg-ssh-host',
'FFMPEG_BIN': 'cfg-ffmpeg',
'FFPROBE_BIN': 'cfg-ffprobe',
'OUTPUT_BASE': 'cfg-output-base',
};
document.getElementById('btn-settings').addEventListener('click', async () => {
const panel = document.getElementById('sec-settings');
const btn = document.getElementById('btn-settings');
const open = panel.classList.contains('hidden');
panel.classList.toggle('hidden', !open);
btn.setAttribute('aria-expanded', String(open));
if (open) await loadSettingsPanel();
});
document.getElementById('btn-close-settings').addEventListener('click', () => {
document.getElementById('sec-settings').classList.add('hidden');
document.getElementById('btn-settings').setAttribute('aria-expanded', 'false');
});
async function loadSettingsPanel() {
const settings = await api.get('/api/settings').catch(() => ({}));
for (const [key, elId] of Object.entries(settingsFieldMap)) {
const el = document.getElementById(elId);
if (!el) continue;
const val = settings[key] || '';
// For masked placeholders use the input placeholder text, not the value
el.value = val === '[configured]' ? '' : val;
if (val === '[configured]') el.placeholder = '[configured — leave blank to keep]';
}
}
document.getElementById('btn-save-settings').addEventListener('click', async () => {
const btn = document.getElementById('btn-save-settings');
const status = document.getElementById('settings-status');
btn.disabled = true;
status.textContent = '';
status.className = 'save-status hidden';
const body = {};
for (const [key, elId] of Object.entries(settingsFieldMap)) {
const el = document.getElementById(elId);
if (el) body[key] = el.value.trim();
}
try {
await api.post('/api/settings', body);
// Re-probe capabilities and refresh chips
S.config = await api.get('/api/config').catch(() => S.config);
renderChips();
// Update encode-host field if changed
if (body.ENCODE_SSH_HOST !== undefined) {
const encEl = document.getElementById('encode-host');
if (encEl && body.ENCODE_SSH_HOST) encEl.value = body.ENCODE_SSH_HOST;
}
status.textContent = '✓ Saved';
status.className = 'save-status ok';
} catch(err) {
status.textContent = `Error: ${err.message}`;
status.className = 'save-status err';
} finally {
btn.disabled = false;
status.classList.remove('hidden');
}
});
// ---------------------------------------------------------------------------
// Keyboard mode (WCAG 2.1.1 — keyboard alternative to drag-and-drop)
// ---------------------------------------------------------------------------
let kbdMode = false;
document.getElementById('btn-kbd-mode').addEventListener('click', () => {
kbdMode = !kbdMode;
const btn = document.getElementById('btn-kbd-mode');
btn.setAttribute('aria-pressed', String(kbdMode));
btn.classList.toggle('btn-primary', kbdMode);
btn.classList.toggle('btn-ghost', !kbdMode);
// Re-render title cards (enable/disable draggable)
document.querySelectorAll('.title-card').forEach(c => {
c.draggable = !kbdMode;
c.style.cursor = kbdMode ? 'default' : 'grab';
});
// Re-render episode slots to add/remove selects
if (S.episodes.length) renderEpSlots();
});
// ---------------------------------------------------------------------------
// Arr Activity panel
// ---------------------------------------------------------------------------
let actSource = 'sonarr';
let actRefreshTimer = null;
document.getElementById('act-tab-sonarr').addEventListener('click', () => switchActTab('sonarr'));
document.getElementById('act-tab-radarr').addEventListener('click', () => switchActTab('radarr'));
document.getElementById('btn-refresh-activity').addEventListener('click', () => loadActivity(true));
function switchActTab(source) {
actSource = source;
document.getElementById('act-tab-sonarr').classList.toggle('active', source === 'sonarr');
document.getElementById('act-tab-sonarr').setAttribute('aria-selected', String(source === 'sonarr'));
document.getElementById('act-tab-radarr').classList.toggle('active', source === 'radarr');
document.getElementById('act-tab-radarr').setAttribute('aria-selected', String(source === 'radarr'));
loadActivity(true);
}
async function loadActivity(showLoading = false) {
const panel = document.getElementById('act-panel');
if (showLoading) {
panel.innerHTML = '<div class="empty-msg">Loading…</div>';
}
try {
const data = await api.get(`/api/activity?source=${actSource}`);
renderActivity(data);
} catch(err) {
panel.innerHTML = `<div class="empty-msg" style="color:var(--error)">Error: ${esc(err.message)}</div>`;
}
}
function relativeTime(isoDate) {
if (!isoDate) return '';
const diff = Date.now() - new Date(isoDate).getTime();
const m = Math.floor(diff / 60000);
if (m < 1) return 'just now';
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
const EVT_ICON = {
grabbed: ['↓', 'evt-grabbed'],
downloadFolderImported: ['✓', 'evt-imported'],
downloadIgnored: ['', 'evt-ignored'],
episodeFileDeleted: ['✕', 'evt-deleted'],
movieFileDeleted: ['✕', 'evt-deleted'],
episodeFileRenamed: ['✎', 'evt-ignored'],
movieFileRenamed: ['✎', 'evt-ignored'],
downloadFailed: ['✕', 'evt-warning'],
};
function renderActivity({ queue, history }) {
const panel = document.getElementById('act-panel');
panel.innerHTML = '';
// ── Queue ────────────────────────────────────────────────────────────
const qHead = document.createElement('div');
qHead.className = 'act-sub';
qHead.textContent = `Queue (${queue.length})`;
panel.appendChild(qHead);
if (queue.length === 0) {
const empty = document.createElement('div');
empty.className = 'empty-msg';
empty.style.cssText = 'padding:.5rem;font-size:.78rem';
empty.textContent = 'Nothing in queue';
panel.appendChild(empty);
} else {
for (const item of queue) {
const el = buildQueueItem(item);
panel.appendChild(el);
}
}
// ── History ──────────────────────────────────────────────────────────
const hHead = document.createElement('div');
hHead.className = 'act-sub';
hHead.style.marginTop = '.75rem';
hHead.textContent = `Recent History (${history.length})`;
panel.appendChild(hHead);
if (history.length === 0) {
const empty = document.createElement('div');
empty.className = 'empty-msg';
empty.style.cssText = 'padding:.5rem;font-size:.78rem';
empty.textContent = 'No history yet';
panel.appendChild(empty);
} else {
for (const item of history) {
const el = buildHistoryItem(item);
panel.appendChild(el);
}
}
}
function buildQueueItem(item) {
const el = document.createElement('div');
el.className = 'act-item';
const statusIcon = item.trackStatus === 'warning' ? '⚠' :
item.status === 'downloading' ? '↓' :
item.status === 'completed' ? '✓' :
item.status === 'paused' ? '⏸' :
item.status === 'failed' ? '✕' : '…';
const iconCls = item.trackStatus === 'warning' || item.status === 'failed'
? 'evt-warning' : item.status === 'completed' ? 'evt-imported' : 'evt-grabbed';
const icon = document.createElement('span');
icon.className = `act-icon ${iconCls}`;
icon.textContent = statusIcon;
const body = document.createElement('div');
body.className = 'act-body';
const title = document.createElement('div');
title.className = 'act-title';
title.textContent = item.title
? `${item.title}${item.epInfo ? ' · ' + item.epInfo : ''}${item.epTitle ? ' — ' + item.epTitle : ''}`
: item.release || '—';
const meta = document.createElement('div');
meta.className = 'act-meta';
const parts = [item.status, item.quality, item.client].filter(Boolean);
parts.forEach(p => {
const s = document.createElement('span');
s.textContent = p;
meta.appendChild(s);
});
if (item.pct !== null) {
const pctSpan = document.createElement('span');
pctSpan.textContent = `${item.pct}%`;
pctSpan.style.fontWeight = '600';
pctSpan.style.color = 'var(--info)';
meta.appendChild(pctSpan);
}
const rel = document.createElement('div');
rel.className = 'act-release';
rel.textContent = item.release || '';
rel.title = item.release || '';
body.appendChild(title);
body.appendChild(meta);
if (item.release) body.appendChild(rel);
if (item.pct !== null && item.pct < 100) {
const bar = document.createElement('div');
bar.className = 'act-progress';
const fill = document.createElement('div');
fill.className = 'act-progress-bar';
fill.style.width = `${item.pct}%`;
bar.appendChild(fill);
body.appendChild(bar);
}
// Ignore button — removes from arr queue, keeps seeding
const ignBtn = document.createElement('button');
ignBtn.className = 'btn-ghost btn-xs';
ignBtn.textContent = 'Ignore';
ignBtn.title = 'Remove from arr queue — torrent keeps seeding';
ignBtn.setAttribute('aria-label', `Ignore ${item.title || item.release} — keep seeding`);
ignBtn.style.flexShrink = '0';
ignBtn.addEventListener('click', async () => {
ignBtn.disabled = true;
ignBtn.textContent = '…';
try {
await api.del(`/api/activity/${item.id}?source=${actSource}`);
ignBtn.textContent = 'Ignored';
ignBtn.style.color = 'var(--success)';
// Refresh after a short delay so the item disappears
setTimeout(() => loadActivity(false), 1500);
} catch(err) {
ignBtn.disabled = false;
ignBtn.textContent = 'Error';
ignBtn.title = err.message;
}
});
const btns = document.createElement('div');
btns.style.cssText = 'display:flex;flex-direction:column;gap:.25rem;flex-shrink:0';
// Disc-ready: Sonarr/Radarr couldn't auto-import — offer to scan in Discarr
if (item.discReady) {
const scanBtn = document.createElement('button');
scanBtn.className = 'btn-primary btn-xs';
scanBtn.textContent = '📀 Scan';
scanBtn.title = item.outputPath
? `Scan disc at: ${item.outputPath}`
: 'Pre-fill scan path and scan this disc';
scanBtn.setAttribute('aria-label', `Scan disc for ${item.title || item.release} in Discarr`);
scanBtn.addEventListener('click', () => {
const pathInput = document.getElementById('scan-path');
if (pathInput && item.outputPath) {
pathInput.value = item.outputPath;
}
// Scroll to scan section and focus the path input (or trigger scan)
const scanSection = document.getElementById('sec-scan');
if (scanSection) scanSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
if (pathInput) {
pathInput.focus();
// If we have the path, auto-trigger the scan
if (item.outputPath) startScan();
}
});
btns.appendChild(scanBtn);
}
btns.appendChild(ignBtn);
el.appendChild(icon);
el.appendChild(body);
el.appendChild(btns);
return el;
}
function buildHistoryItem(item) {
const el = document.createElement('div');
el.className = 'act-item';
const [iconChar, iconCls] = EVT_ICON[item.eventType] || ['·', 'evt-ignored'];
const icon = document.createElement('span');
icon.className = `act-icon ${iconCls}`;
icon.textContent = iconChar;
const body = document.createElement('div');
body.className = 'act-body';
const title = document.createElement('div');
title.className = 'act-title';
title.textContent = item.title
? `${item.title}${item.epInfo ? ' · ' + item.epInfo : ''}${item.epTitle ? ' — ' + item.epTitle : ''}`
: item.release || '—';
const meta = document.createElement('div');
meta.className = 'act-meta';
const evtLabel = {
grabbed: 'grabbed', downloadFolderImported: 'imported',
downloadIgnored: 'ignored', episodeFileDeleted: 'deleted',
movieFileDeleted: 'deleted', downloadFailed: 'failed',
episodeFileRenamed: 'renamed', movieFileRenamed: 'renamed',
}[item.eventType] || item.eventType;
const evtSpan = document.createElement('span');
evtSpan.className = iconCls;
evtSpan.textContent = evtLabel;
meta.appendChild(evtSpan);
[item.quality, item.client].filter(Boolean).forEach(p => {
const s = document.createElement('span'); s.textContent = p; meta.appendChild(s);
});
body.appendChild(title);
body.appendChild(meta);
if (item.release) {
const rel = document.createElement('div');
rel.className = 'act-release';
rel.textContent = item.release;
rel.title = item.release;
body.appendChild(rel);
}
const age = document.createElement('span');
age.className = 'act-age';
age.textContent = relativeTime(item.date);
age.title = item.date || '';
el.appendChild(icon);
el.appendChild(body);
el.appendChild(age);
return el;
}
// Auto-refresh activity every 60 seconds
function startActivityRefresh() {
if (actRefreshTimer) clearInterval(actRefreshTimer);
actRefreshTimer = setInterval(() => loadActivity(false), 60_000);
}
// ---------------------------------------------------------------------------
// Boot
// ---------------------------------------------------------------------------
init();
</script>
</body>
</html>