|
@@ -0,0 +1,923 @@
|
|
|
|
|
+<!DOCTYPE html>
|
|
|
|
|
+<html lang="en">
|
|
|
|
|
+<head>
|
|
|
|
|
+<meta charset="UTF-8">
|
|
|
|
|
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
|
+<title>YAAR — YouTube Auto-Archiver and Retagger</title>
|
|
|
|
|
+<style>
|
|
|
|
|
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
|
+
|
|
|
|
|
+ :root {
|
|
|
|
|
+ --bg: #0d0f12;
|
|
|
|
|
+ --bg2: #13161b;
|
|
|
|
|
+ --bg3: #1a1e26;
|
|
|
|
|
+ --border: #252932;
|
|
|
|
|
+ --border2: #313846;
|
|
|
|
|
+ --text: #d4d8e2;
|
|
|
|
|
+ --muted: #636c7e;
|
|
|
|
|
+ --accent: #4f8ef7;
|
|
|
|
|
+ --accent-bg: #1a2540;
|
|
|
|
|
+ --accent-dim:#2c3f6a;
|
|
|
|
|
+ --green: #3dd68c;
|
|
|
|
|
+ --green-bg: #0e2419;
|
|
|
|
|
+ --amber: #f5a623;
|
|
|
|
|
+ --amber-bg: #2a1f0a;
|
|
|
|
|
+ --red: #f05252;
|
|
|
|
|
+ --red-bg: #2a0e0e;
|
|
|
|
|
+ --mono: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
|
|
|
|
+ --sans: 'Inter', system-ui, sans-serif;
|
|
|
|
|
+ --radius: 6px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ body { background:var(--bg); color:var(--text); font-family:var(--sans); font-size:14px; line-height:1.6; min-height:100vh; }
|
|
|
|
|
+
|
|
|
|
|
+ header {
|
|
|
|
|
+ border-bottom:1px solid var(--border); padding:0 2rem; height:52px;
|
|
|
|
|
+ display:flex; align-items:center; gap:1rem;
|
|
|
|
|
+ position:sticky; top:0; background:var(--bg); z-index:10;
|
|
|
|
|
+ }
|
|
|
|
|
+ .logo { font-family:var(--mono); font-size:15px; font-weight:600; color:var(--accent); letter-spacing:.05em; }
|
|
|
|
|
+ .logo span { color:var(--muted); font-weight:400; font-size:12px; margin-left:.5rem; }
|
|
|
|
|
+ .header-status { margin-left:auto; font-size:12px; color:var(--muted); font-family:var(--mono); }
|
|
|
|
|
+ .dot { display:inline-block; width:6px; height:6px; border-radius:50%; background:var(--green); margin-right:6px; animation:pulse 2s infinite; }
|
|
|
|
|
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
|
|
|
|
|
+
|
|
|
|
|
+ .layout { display:grid; grid-template-columns:440px 1fr; height:calc(100vh - 52px); }
|
|
|
|
|
+
|
|
|
|
|
+ .panel-left { border-right:1px solid var(--border); overflow-y:auto; padding:1.5rem; display:flex; flex-direction:column; gap:1.25rem; }
|
|
|
|
|
+ .panel-right { overflow-y:auto; padding:1.5rem; }
|
|
|
|
|
+
|
|
|
|
|
+ .section-label { font-size:10px; font-family:var(--mono); letter-spacing:.12em; color:var(--muted); text-transform:uppercase; margin-bottom:.6rem; }
|
|
|
|
|
+
|
|
|
|
|
+ .url-row { display:flex; gap:.5rem; }
|
|
|
|
|
+
|
|
|
|
|
+ input[type="text"], input[type="number"], select, textarea {
|
|
|
|
|
+ background:var(--bg2); border:1px solid var(--border2); color:var(--text);
|
|
|
|
|
+ border-radius:var(--radius); padding:.45rem .7rem; font-size:13px;
|
|
|
|
|
+ font-family:var(--sans); width:100%; outline:none; transition:border-color .15s;
|
|
|
|
|
+ }
|
|
|
|
|
+ input:focus, select:focus, textarea:focus { border-color:var(--accent-dim); }
|
|
|
|
|
+ input::placeholder, textarea::placeholder { color:var(--muted); }
|
|
|
|
|
+ select option { background:var(--bg2); }
|
|
|
|
|
+
|
|
|
|
|
+ .btn {
|
|
|
|
|
+ padding:.45rem 1rem; border-radius:var(--radius); font-size:13px; font-weight:500;
|
|
|
|
|
+ cursor:pointer; border:none; white-space:nowrap; transition:opacity .15s,transform .1s; font-family:var(--sans);
|
|
|
|
|
+ }
|
|
|
|
|
+ .btn:active { transform:scale(.97); }
|
|
|
|
|
+ .btn:disabled { opacity:.4; cursor:not-allowed; }
|
|
|
|
|
+ .btn-probe { background:var(--bg3); color:var(--text); border:1px solid var(--border2); min-width:32px; }
|
|
|
|
|
+ .btn-probe:hover:not(:disabled) { border-color:var(--accent-dim); }
|
|
|
|
|
+ .btn-primary { background:var(--accent); color:#fff; width:100%; }
|
|
|
|
|
+ .btn-primary:hover:not(:disabled) { opacity:.88; }
|
|
|
|
|
+
|
|
|
|
|
+ /* Preview card */
|
|
|
|
|
+ #preview-card { background:var(--bg2); border:1px solid var(--border); border-radius:var(--radius); padding:.85rem; display:none; gap:.75rem; }
|
|
|
|
|
+ #preview-card.visible { display:flex; }
|
|
|
|
|
+ #preview-thumb { width:100px; height:56px; object-fit:cover; border-radius:4px; flex-shrink:0; background:var(--bg3); }
|
|
|
|
|
+ .preview-meta { flex:1; min-width:0; }
|
|
|
|
|
+ .preview-title { font-size:13px; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-bottom:3px; }
|
|
|
|
|
+ .preview-sub { font-size:11px; color:var(--muted); font-family:var(--mono); }
|
|
|
|
|
+
|
|
|
|
|
+ /* Type tabs */
|
|
|
|
|
+ .type-tabs { display:flex; background:var(--bg2); border:1px solid var(--border); border-radius:var(--radius); padding:3px; }
|
|
|
|
|
+ .type-tab { flex:1; text-align:center; padding:.35rem 0; font-size:12px; cursor:pointer; border-radius:4px; color:var(--muted); transition:all .15s; user-select:none; }
|
|
|
|
|
+ .type-tab.active { background:var(--bg3); color:var(--text); border:1px solid var(--border2); }
|
|
|
|
|
+
|
|
|
|
|
+ /* Fields */
|
|
|
|
|
+ .field { display:flex; flex-direction:column; gap:.3rem; }
|
|
|
|
|
+ .field label { font-size:11px; color:var(--muted); }
|
|
|
|
|
+ .row-2 { display:grid; grid-template-columns:1fr 1fr; gap:.6rem; }
|
|
|
|
|
+ .row-3 { display:grid; grid-template-columns:2fr 1fr 1fr; gap:.6rem; }
|
|
|
|
|
+
|
|
|
|
|
+ .type-section { display:none; flex-direction:column; gap:.75rem; }
|
|
|
|
|
+ .type-section.active { display:flex; }
|
|
|
|
|
+
|
|
|
|
|
+ hr { border:none; border-top:1px solid var(--border); }
|
|
|
|
|
+
|
|
|
|
|
+ /* Folder picker */
|
|
|
|
|
+ .folder-picker { display:flex; flex-direction:column; gap:.5rem; }
|
|
|
|
|
+ .folder-row { display:flex; gap:.5rem; align-items:flex-end; }
|
|
|
|
|
+ .folder-row select { flex:1; }
|
|
|
|
|
+ .folder-row input { flex:1; }
|
|
|
|
|
+ .folder-hint { font-size:10px; color:var(--muted); font-family:var(--mono); padding:.3rem .4rem; background:var(--bg2); border:1px solid var(--border); border-radius:4px; word-break:break-all; display:none; }
|
|
|
|
|
+ .folder-hint.visible { display:block; }
|
|
|
|
|
+
|
|
|
|
|
+ /* Mount banner */
|
|
|
|
|
+ #mount-banner { display:none; font-size:11px; font-family:var(--mono); padding:.45rem .7rem; border-radius:var(--radius); border:1px solid; line-height:1.5; }
|
|
|
|
|
+ #mount-banner.ok { background:var(--green-bg); color:var(--green); border-color:#1a4030; }
|
|
|
|
|
+ #mount-banner.warn { background:var(--amber-bg); color:var(--amber); border-color:#4a350a; }
|
|
|
|
|
+ #mount-banner.error { background:var(--red-bg); color:var(--red); border-color:#4a1414; }
|
|
|
|
|
+ #mount-banner.visible { display:block; }
|
|
|
|
|
+
|
|
|
|
|
+ /* Path preview */
|
|
|
|
|
+ #path-preview { font-size:11px; font-family:var(--mono); color:var(--muted); background:var(--bg2); border:1px solid var(--border); border-radius:4px; padding:.4rem .6rem; word-break:break-all; display:none; }
|
|
|
|
|
+ #path-preview.visible { display:block; }
|
|
|
|
|
+
|
|
|
|
|
+ /* Probe status indicator inside URL row */
|
|
|
|
|
+ #probe-status { font-size:11px; font-family:var(--mono); color:var(--muted); white-space:nowrap; align-self:center; min-width:60px; text-align:right; }
|
|
|
|
|
+
|
|
|
|
|
+ /* Jobs panel */
|
|
|
|
|
+ .jobs-header { display:flex; align-items:center; gap:.75rem; margin-bottom:1rem; }
|
|
|
|
|
+ .jobs-title { font-size:13px; font-weight:500; font-family:var(--mono); color:var(--muted); }
|
|
|
|
|
+ .badge { font-size:10px; font-family:var(--mono); padding:1px 7px; border-radius:999px; background:var(--bg3); border:1px solid var(--border2); color:var(--muted); }
|
|
|
|
|
+ .jobs-list { display:flex; flex-direction:column; gap:.5rem; }
|
|
|
|
|
+
|
|
|
|
|
+ .job-card { background:var(--bg2); border:1px solid var(--border); border-radius:var(--radius); padding:.8rem 1rem; transition:border-color .2s; }
|
|
|
|
|
+ .job-card:hover { border-color:var(--border2); }
|
|
|
|
|
+ .job-top { display:flex; align-items:flex-start; gap:.75rem; margin-bottom:.5rem; }
|
|
|
|
|
+ .job-icon { width:28px; height:28px; border-radius:4px; display:flex; align-items:center; justify-content:center; font-size:12px; flex-shrink:0; font-family:var(--mono); font-weight:600; }
|
|
|
|
|
+ .icon-tv { background:#0b1a2a; color:#4f8ef7; }
|
|
|
|
|
+ .icon-film { background:#0c1f14; color:#3dd68c; }
|
|
|
|
|
+ .icon-music { background:#1a0c1f; color:#c084fc; }
|
|
|
|
|
+ .job-info { flex:1; min-width:0; }
|
|
|
|
|
+ .job-title { font-size:13px; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-bottom:2px; }
|
|
|
|
|
+ .job-sub { font-size:11px; color:var(--muted); font-family:var(--mono); }
|
|
|
|
|
+ .job-status { font-size:10px; font-family:var(--mono); padding:2px 8px; border-radius:999px; font-weight:500; flex-shrink:0; }
|
|
|
|
|
+ .status-queued { background:var(--bg3); color:var(--muted); border:1px solid var(--border2); }
|
|
|
|
|
+ .status-running { background:var(--accent-bg); color:var(--accent); border:1px solid var(--accent-dim); }
|
|
|
|
|
+ .status-done { background:var(--green-bg); color:var(--green); border:1px solid #1a4030; }
|
|
|
|
|
+ .status-error { background:var(--red-bg); color:var(--red); border:1px solid #4a1414; }
|
|
|
|
|
+ .progress-bar { height:2px; background:var(--border); border-radius:999px; overflow:hidden; margin-top:.5rem; }
|
|
|
|
|
+ .progress-fill { height:100%; background:var(--accent); border-radius:999px; transition:width .5s ease; }
|
|
|
|
|
+ .progress-fill.done { background:var(--green); }
|
|
|
|
|
+ .progress-fill.error { background:var(--red); }
|
|
|
|
|
+ .job-speed { font-size:10px; color:var(--muted); font-family:var(--mono); margin-top:.35rem; }
|
|
|
|
|
+ .job-error { font-size:11px; color:var(--red); font-family:var(--mono); margin-top:.4rem; background:var(--red-bg); padding:.4rem .6rem; border-radius:4px; word-break:break-all; }
|
|
|
|
|
+ .job-actions { display:flex; gap:.4rem; margin-top:.5rem; justify-content:flex-end; }
|
|
|
|
|
+ .btn-sm { font-size:11px; font-family:var(--mono); padding:2px 10px; border-radius:4px; cursor:pointer; border:1px solid var(--border2); background:var(--bg3); color:var(--muted); transition:color .15s,border-color .15s; }
|
|
|
|
|
+ .btn-sm:hover { color:var(--text); border-color:var(--accent-dim); }
|
|
|
|
|
+ .btn-sm.danger:hover { color:var(--red); border-color:var(--red); }
|
|
|
|
|
+
|
|
|
|
|
+ .empty-state { text-align:center; color:var(--muted); padding:3rem 1rem; font-family:var(--mono); font-size:13px; }
|
|
|
|
|
+
|
|
|
|
|
+ #toast { position:fixed; bottom:1.5rem; right:1.5rem; background:var(--bg3); border:1px solid var(--border2); color:var(--text); padding:.6rem 1rem; border-radius:var(--radius); font-size:13px; font-family:var(--mono); opacity:0; transform:translateY(8px); transition:all .2s; pointer-events:none; z-index:100; }
|
|
|
|
|
+ #toast.show { opacity:1; transform:translateY(0); }
|
|
|
|
|
+ #toast.success { border-color:var(--green); color:var(--green); }
|
|
|
|
|
+ #toast.error { border-color:var(--red); color:var(--red); }
|
|
|
|
|
+
|
|
|
|
|
+ .spinner { display:inline-block; width:12px; height:12px; border:1.5px solid var(--border2); border-top-color:var(--accent); border-radius:50%; animation:spin .7s linear infinite; vertical-align:middle; }
|
|
|
|
|
+ @keyframes spin { to{transform:rotate(360deg)} }
|
|
|
|
|
+
|
|
|
|
|
+ ::-webkit-scrollbar { width:5px; }
|
|
|
|
|
+ ::-webkit-scrollbar-track { background:transparent; }
|
|
|
|
|
+ ::-webkit-scrollbar-thumb { background:var(--border2); border-radius:999px; }
|
|
|
|
|
+</style>
|
|
|
|
|
+</head>
|
|
|
|
|
+<body>
|
|
|
|
|
+
|
|
|
|
|
+<header>
|
|
|
|
|
+ <div class="logo">YAAR<span>YouTube Auto-Archiver and Retagger</span></div>
|
|
|
|
|
+ <div class="header-status" id="header-status"><span class="dot"></span>checking…</div>
|
|
|
|
|
+</header>
|
|
|
|
|
+
|
|
|
|
|
+<div class="layout">
|
|
|
|
|
+
|
|
|
|
|
+ <div class="panel-left">
|
|
|
|
|
+
|
|
|
|
|
+ <div id="mount-banner"></div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- URL input -->
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <div class="section-label">YouTube URL</div>
|
|
|
|
|
+ <div class="url-row">
|
|
|
|
|
+ <input type="text" id="url-input" placeholder="Paste a YouTube URL…" autocomplete="off" spellcheck="false">
|
|
|
|
|
+ <button class="btn btn-probe" id="probe-btn" onclick="probeUrl()" title="Re-fetch metadata">↻</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div id="probe-status" style="margin-top:.35rem;font-size:11px;font-family:var(--mono);color:var(--muted);min-height:16px;"></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Preview card -->
|
|
|
|
|
+ <div id="preview-card">
|
|
|
|
|
+ <img id="preview-thumb" src="" alt="">
|
|
|
|
|
+ <div class="preview-meta">
|
|
|
|
|
+ <div class="preview-title" id="preview-title"></div>
|
|
|
|
|
+ <div class="preview-sub" id="preview-sub"></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <hr>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Archive type -->
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <div class="section-label">Archive as</div>
|
|
|
|
|
+ <div class="type-tabs">
|
|
|
|
|
+ <div class="type-tab active" data-type="series" onclick="setType('series')">TV series episode</div>
|
|
|
|
|
+ <div class="type-tab" data-type="movie" onclick="setType('movie')">Movie</div>
|
|
|
|
|
+ <div class="type-tab" data-type="music" onclick="setType('music')">Music track</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Common fields -->
|
|
|
|
|
+ <div class="field">
|
|
|
|
|
+ <label><span id="title-label">Video title</span></label>
|
|
|
|
|
+ <input type="text" id="title" placeholder="Auto-filled from URL">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="row-2">
|
|
|
|
|
+ <div class="field">
|
|
|
|
|
+ <label><span id="author-label">Author / Channel</span></label>
|
|
|
|
|
+ <input type="text" id="author" placeholder="Auto-filled">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="field">
|
|
|
|
|
+ <label>Upload date</label>
|
|
|
|
|
+ <input type="text" id="upload_date" placeholder="YYYY-MM-DD">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- ── Series type fields ── -->
|
|
|
|
|
+ <div class="type-section active" id="series-fields">
|
|
|
|
|
+ <hr>
|
|
|
|
|
+ <div class="field">
|
|
|
|
|
+ <label>Series name <span style="color:var(--muted);font-weight:400">(Jellyfin show folder)</span></label>
|
|
|
|
|
+ <div class="folder-row">
|
|
|
|
|
+ <select id="series-folder-select" onchange="onSeriesFolderSelect()">
|
|
|
|
|
+ <option value="">— choose existing —</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ <input type="text" id="series" placeholder="or type a new series name" oninput="updatePathPreview()">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="row-3">
|
|
|
|
|
+ <div class="field">
|
|
|
|
|
+ <label>Episode title</label>
|
|
|
|
|
+ <input type="text" id="episode_title" placeholder="Defaults to video title">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="field">
|
|
|
|
|
+ <label>Season</label>
|
|
|
|
|
+ <input type="number" id="season" value="1" min="1" oninput="updatePathPreview()">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="field">
|
|
|
|
|
+ <label>Episode</label>
|
|
|
|
|
+ <input type="number" id="episode" value="1" min="1">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- ── Movie type fields ── -->
|
|
|
|
|
+ <div class="type-section" id="movie-fields">
|
|
|
|
|
+ <hr>
|
|
|
|
|
+ <div class="field">
|
|
|
|
|
+ <label>Year <span style="color:var(--muted);font-weight:400">(appended to filename for Jellyfin matching)</span></label>
|
|
|
|
|
+ <input type="text" id="year" placeholder="e.g. 2024" oninput="updatePathPreview()">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- ── Music type fields ── -->
|
|
|
|
|
+ <div class="type-section" id="music-fields">
|
|
|
|
|
+ <hr>
|
|
|
|
|
+ <div class="field">
|
|
|
|
|
+ <label>Artist <span style="color:var(--muted);font-weight:400">(Jellyfin artist folder)</span></label>
|
|
|
|
|
+ <div class="folder-row">
|
|
|
|
|
+ <select id="artist-folder-select" onchange="onArtistFolderSelect()">
|
|
|
|
|
+ <option value="">— choose existing —</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ <input type="text" id="artist" placeholder="or type a new artist name" oninput="updatePathPreview()">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="row-2">
|
|
|
|
|
+ <div class="field">
|
|
|
|
|
+ <label>Album <span style="color:var(--muted);font-weight:400">(optional)</span></label>
|
|
|
|
|
+ <input type="text" id="album" placeholder="Leave blank for flat artist folder" oninput="updatePathPreview()">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="field">
|
|
|
|
|
+ <label>Track number <span style="color:var(--muted);font-weight:400">(optional)</span></label>
|
|
|
|
|
+ <input type="number" id="track" min="1" placeholder="e.g. 3" oninput="updatePathPreview()">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="row-2">
|
|
|
|
|
+ <div class="field">
|
|
|
|
|
+ <label>Year</label>
|
|
|
|
|
+ <input type="text" id="music_year" placeholder="e.g. 2024" oninput="updatePathPreview()">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="field">
|
|
|
|
|
+ <label>Genre <span style="color:var(--muted);font-weight:400">(optional)</span></label>
|
|
|
|
|
+ <input type="text" id="genre" placeholder="e.g. Electronic">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="field">
|
|
|
|
|
+ <label>
|
|
|
|
|
+ Audio format
|
|
|
|
|
+ <span style="color:var(--muted);font-weight:400" id="format-hint">
|
|
|
|
|
+ (M4A/AAC — universal Jellyfin direct-play)
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </label>
|
|
|
|
|
+ <select id="format" onchange="onFormatChange()">
|
|
|
|
|
+ <option value="m4a" selected>M4A (AAC in MP4 container, lossy — best Jellyfin compatibility)</option>
|
|
|
|
|
+ <option value="aac">AAC (raw ADTS, lossy — same codec as M4A, leaner container)</option>
|
|
|
|
|
+ <option value="opus">Opus (lossy, smaller files at same quality)</option>
|
|
|
|
|
+ <option value="mp3">MP3 (lossy, universal compatibility incl. old hardware)</option>
|
|
|
|
|
+ <option value="wav">WAV (uncompressed PCM — massive files, archival use only)</option>
|
|
|
|
|
+ <option value="flac">FLAC (lossless container — see note)</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Path preview -->
|
|
|
|
|
+ <div id="path-preview"></div>
|
|
|
|
|
+
|
|
|
|
|
+ <hr>
|
|
|
|
|
+ <button class="btn btn-primary" id="archive-btn" onclick="submitJob()">Archive video</button>
|
|
|
|
|
+
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Right panel: queue -->
|
|
|
|
|
+ <div class="panel-right">
|
|
|
|
|
+ <div class="jobs-header">
|
|
|
|
|
+ <div class="jobs-title">archive queue</div>
|
|
|
|
|
+ <div class="badge" id="job-count">0 jobs</div>
|
|
|
|
|
+ <div style="margin-left:auto; display:flex; gap:.4rem">
|
|
|
|
|
+ <button class="btn-sm" id="clear-finished-btn" onclick="clearFinished()">✕ clear finished</button>
|
|
|
|
|
+ <button class="btn-sm" onclick="refreshJobs()">↻ refresh</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="jobs-list" id="jobs-list">
|
|
|
|
|
+ <div class="empty-state">no jobs yet — paste a URL to get started</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+</div>
|
|
|
|
|
+
|
|
|
|
|
+<div id="toast"></div>
|
|
|
|
|
+
|
|
|
|
|
+<script>
|
|
|
|
|
+// ── State ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
+let currentType = 'series';
|
|
|
|
|
+let probeData = {};
|
|
|
|
|
+let pollTimer = null;
|
|
|
|
|
+let mountInfo = { root: '/nas/jellyfin', mounted: null, writable: null, subdirs: {} };
|
|
|
|
|
+let browseData = { Movies: [], Shows: [], Music: [] };
|
|
|
|
|
+
|
|
|
|
|
+// ── Init ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
+(async function init() {
|
|
|
|
|
+ await Promise.all([checkMount(), loadBrowseData()]);
|
|
|
|
|
+ loadJobs();
|
|
|
|
|
+})();
|
|
|
|
|
+
|
|
|
|
|
+// ── Mount check ───────────────────────────────────────────────────────────────
|
|
|
|
|
+async function checkMount() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const data = await fetch('/api/mounts').then(r => r.json());
|
|
|
|
|
+ mountInfo = data;
|
|
|
|
|
+ const banner = document.getElementById('mount-banner');
|
|
|
|
|
+ const hdr = document.getElementById('header-status');
|
|
|
|
|
+
|
|
|
|
|
+ if (!data.mounted) {
|
|
|
|
|
+ banner.className = 'visible error';
|
|
|
|
|
+ banner.textContent = `✕ ${data.root} not found — attach Proxmox bind-mount (mp0) and restart.`;
|
|
|
|
|
+ hdr.innerHTML = '<span class="dot" style="background:var(--red)"></span>mount missing';
|
|
|
|
|
+ document.getElementById('archive-btn').disabled = true;
|
|
|
|
|
+ } else if (!data.writable) {
|
|
|
|
|
+ banner.className = 'visible warn';
|
|
|
|
|
+ banner.textContent = `⚠ ${data.root} is read-only — check LXC mount permissions.`;
|
|
|
|
|
+ hdr.innerHTML = '<span class="dot" style="background:var(--amber)"></span>read-only';
|
|
|
|
|
+ document.getElementById('archive-btn').disabled = true;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ const missing = ['Movies','Shows','Music'].filter(d => !data.subdirs[d]?.exists);
|
|
|
|
|
+ if (missing.length) {
|
|
|
|
|
+ banner.className = 'visible warn';
|
|
|
|
|
+ banner.textContent = `⚠ ${data.root} writable. Missing subdirs (will be created): ${missing.join(', ')}`;
|
|
|
|
|
+ hdr.innerHTML = '<span class="dot" style="background:var(--amber)"></span>' + data.root;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ banner.className = 'visible ok';
|
|
|
|
|
+ banner.textContent = `✓ ${data.root} — Movies/ Shows/ Music/ detected`;
|
|
|
|
|
+ hdr.innerHTML = '<span class="dot"></span>' + data.root;
|
|
|
|
|
+ }
|
|
|
|
|
+ document.getElementById('archive-btn').disabled = false;
|
|
|
|
|
+ }
|
|
|
|
|
+ updatePathPreview();
|
|
|
|
|
+ } catch(e) { console.warn('Mount check failed:', e); }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ── Browse (folder population) ────────────────────────────────────────────────
|
|
|
|
|
+async function loadBrowseData() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ browseData = await fetch('/api/browse').then(r => r.json());
|
|
|
|
|
+ populateFolderSelects();
|
|
|
|
|
+ } catch(e) { console.warn('Browse failed:', e); }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function populateFolderSelects() {
|
|
|
|
|
+ populateSelect('series-folder-select', browseData.Shows || [], '— choose existing —');
|
|
|
|
|
+ populateSelect('artist-folder-select', browseData.Music || [], '— choose existing —');
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function populateSelect(id, items, placeholder) {
|
|
|
|
|
+ const sel = document.getElementById(id);
|
|
|
|
|
+ if (!sel) return;
|
|
|
|
|
+ const first = sel.options[0]?.text || placeholder;
|
|
|
|
|
+ sel.innerHTML = `<option value="">${first}</option>` +
|
|
|
|
|
+ items.map(name => `<option value="${escAttr(name)}">${escHtml(name)}</option>`).join('');
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function onSeriesFolderSelect() {
|
|
|
|
|
+ const val = document.getElementById('series-folder-select').value;
|
|
|
|
|
+ document.getElementById('series').value = val;
|
|
|
|
|
+ updatePathPreview();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function onArtistFolderSelect() {
|
|
|
|
|
+ const val = document.getElementById('artist-folder-select').value;
|
|
|
|
|
+ document.getElementById('artist').value = val;
|
|
|
|
|
+ updatePathPreview();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ── Audio format selector ─────────────────────────────────────────────────────
|
|
|
|
|
+const FORMAT_HINTS = {
|
|
|
|
|
+ m4a: '(M4A/AAC — universal Jellyfin direct-play)',
|
|
|
|
|
+ aac: '(AAC in raw ADTS container — same codec as M4A, smaller container overhead)',
|
|
|
|
|
+ opus: '(Opus — smaller files, well supported)',
|
|
|
|
|
+ mp3: '(MP3 — universal compatibility)',
|
|
|
|
|
+ wav: '⚠ Note: WAV is uncompressed PCM and produces very large files. YouTube source is already lossy, so WAV provides no quality gain over m4a.',
|
|
|
|
|
+ flac: '⚠ Note: YouTube source is already lossy. FLAC just makes the file bigger; it cannot restore audio quality.',
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+function onFormatChange() {
|
|
|
|
|
+ const fmt = document.getElementById('format').value;
|
|
|
|
|
+ const hint = document.getElementById('format-hint');
|
|
|
|
|
+ if (hint) {
|
|
|
|
|
+ hint.textContent = FORMAT_HINTS[fmt] || '';
|
|
|
|
|
+ hint.style.color = (fmt === 'flac' || fmt === 'wav')
|
|
|
|
|
+ ? 'var(--amber, #f5a623)' : 'var(--muted)';
|
|
|
|
|
+ }
|
|
|
|
|
+ updatePathPreview();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ── Type selector ─────────────────────────────────────────────────────────────
|
|
|
|
|
+function setType(type) {
|
|
|
|
|
+ currentType = type;
|
|
|
|
|
+ document.querySelectorAll('.type-tab').forEach(t =>
|
|
|
|
|
+ t.classList.toggle('active', t.dataset.type === type));
|
|
|
|
|
+ ['series-fields','movie-fields','music-fields'].forEach(id =>
|
|
|
|
|
+ document.getElementById(id).classList.toggle('active', id.startsWith(type)));
|
|
|
|
|
+
|
|
|
|
|
+ // Repurpose the shared title/author labels for the music context
|
|
|
|
|
+ const titleLabel = document.getElementById('title-label');
|
|
|
|
|
+ const authorLabel = document.getElementById('author-label');
|
|
|
|
|
+ const archiveBtn = document.getElementById('archive-btn');
|
|
|
|
|
+ if (type === 'music') {
|
|
|
|
|
+ titleLabel.textContent = 'Track title';
|
|
|
|
|
+ authorLabel.textContent = 'Channel (not the artist)';
|
|
|
|
|
+ archiveBtn.textContent = 'Archive track';
|
|
|
|
|
+ } else {
|
|
|
|
|
+ titleLabel.textContent = 'Video title';
|
|
|
|
|
+ authorLabel.textContent = 'Author / Channel';
|
|
|
|
|
+ archiveBtn.textContent = 'Archive video';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ updatePathPreview();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ── URL probe — auto-fetch on paste only ─────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Clean tracking params from a YouTube URL.
|
|
|
|
|
+ * Only rewrites the field when we have a COMPLETE, parseable URL —
|
|
|
|
|
+ * never on partial input to avoid the re-trigger loop.
|
|
|
|
|
+ */
|
|
|
|
|
+function cleanYouTubeUrl(raw) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const u = new URL(raw.trim());
|
|
|
|
|
+ const host = u.hostname.replace(/^www\./, '');
|
|
|
|
|
+ if (host === 'youtube.com' && u.pathname === '/watch') {
|
|
|
|
|
+ const v = u.searchParams.get('v') || '';
|
|
|
|
|
+ return `https://www.youtube.com/watch?v=${v}`;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (host === 'youtu.be' || host === 'youtube.com') {
|
|
|
|
|
+ return `${u.origin}${u.pathname}`;
|
|
|
|
|
+ }
|
|
|
|
|
+ return raw.trim();
|
|
|
|
|
+ } catch { return raw.trim(); }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function looksLikeYouTube(url) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const u = new URL(url);
|
|
|
|
|
+ const host = u.hostname.replace(/^www\./, '');
|
|
|
|
|
+ if (host === 'youtu.be' && u.pathname.length > 1) return true;
|
|
|
|
|
+ if (host === 'youtube.com' && u.searchParams.get('v')) return true;
|
|
|
|
|
+ if (host === 'youtube.com' && /^\/(shorts|live)\/\w/.test(u.pathname)) return true;
|
|
|
|
|
+ return false;
|
|
|
|
|
+ } catch { return false; }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function setProbeStatus(msg, cls='') {
|
|
|
|
|
+ const el = document.getElementById('probe-status');
|
|
|
|
|
+ el.textContent = msg;
|
|
|
|
|
+ el.style.color = cls === 'ok' ? 'var(--green)' :
|
|
|
|
|
+ cls === 'err' ? 'var(--red)' : 'var(--muted)';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function probeUrl(urlOverride) {
|
|
|
|
|
+ const inputEl = document.getElementById('url-input');
|
|
|
|
|
+ const url = urlOverride !== undefined ? urlOverride : inputEl.value.trim();
|
|
|
|
|
+ if (!url) { toast('Paste a YouTube URL first', 'error'); return; }
|
|
|
|
|
+
|
|
|
|
|
+ const btn = document.getElementById('probe-btn');
|
|
|
|
|
+ btn.disabled = true;
|
|
|
|
|
+ btn.innerHTML = '<span class="spinner"></span>';
|
|
|
|
|
+ setProbeStatus('fetching…');
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await fetch('/api/probe', {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: {'Content-Type': 'application/json'},
|
|
|
|
|
+ body: JSON.stringify({ url })
|
|
|
|
|
+ });
|
|
|
|
|
+ const data = await res.json();
|
|
|
|
|
+ if (!res.ok) throw new Error(data.error || 'Probe failed');
|
|
|
|
|
+
|
|
|
|
|
+ probeData = data;
|
|
|
|
|
+ document.getElementById('title').value = data.title || '';
|
|
|
|
|
+ document.getElementById('author').value = data.author || '';
|
|
|
|
|
+ document.getElementById('upload_date').value = data.upload_date || '';
|
|
|
|
|
+ document.getElementById('year').value = data.year || '';
|
|
|
|
|
+
|
|
|
|
|
+ document.getElementById('preview-thumb').src = data.thumbnail || '';
|
|
|
|
|
+ document.getElementById('preview-title').textContent = data.title || '(untitled)';
|
|
|
|
|
+ document.getElementById('preview-sub').textContent =
|
|
|
|
|
+ `${data.author || ''} · ${data.upload_date || ''} · ${data.duration || ''}`;
|
|
|
|
|
+ document.getElementById('preview-card').classList.add('visible');
|
|
|
|
|
+
|
|
|
|
|
+ setProbeStatus('✓ fetched', 'ok');
|
|
|
|
|
+ updatePathPreview();
|
|
|
|
|
+ } catch(e) {
|
|
|
|
|
+ setProbeStatus('fetch failed', 'err');
|
|
|
|
|
+ toast(e.message, 'error');
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ btn.disabled = false;
|
|
|
|
|
+ btn.textContent = '↻';
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Paste handler — robust cross-browser approach:
|
|
|
|
|
+// We let the browser complete the paste first (no preventDefault), then read
|
|
|
|
|
+// the field value in a microtask. This works on desktop, mobile, and all
|
|
|
|
|
+// browsers regardless of clipboardData availability.
|
|
|
|
|
+document.getElementById('url-input').addEventListener('paste', function() {
|
|
|
|
|
+ const el = this;
|
|
|
|
|
+ setTimeout(function() {
|
|
|
|
|
+ const raw = el.value;
|
|
|
|
|
+ const cleaned = cleanYouTubeUrl(raw);
|
|
|
|
|
+
|
|
|
|
|
+ // Update field with cleaned URL only if it changed
|
|
|
|
|
+ if (cleaned !== raw) el.value = cleaned;
|
|
|
|
|
+
|
|
|
|
|
+ if (looksLikeYouTube(cleaned)) {
|
|
|
|
|
+ setProbeStatus('fetching…');
|
|
|
|
|
+ probeUrl(cleaned);
|
|
|
|
|
+ }
|
|
|
|
|
+ }, 0);
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// Enter key as manual fallback
|
|
|
|
|
+document.getElementById('url-input').addEventListener('keydown', function(e) {
|
|
|
|
|
+ if (e.key === 'Enter') probeUrl();
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// ── Path preview ──────────────────────────────────────────────────────────────
|
|
|
|
|
+function getSeriesName() {
|
|
|
|
|
+ return document.getElementById('series-folder-select').value ||
|
|
|
|
|
+ document.getElementById('series').value.trim() ||
|
|
|
|
|
+ 'Series Name';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function getArtistName() {
|
|
|
|
|
+ return document.getElementById('artist-folder-select').value ||
|
|
|
|
|
+ document.getElementById('artist').value.trim() ||
|
|
|
|
|
+ 'Artist Name';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function updatePathPreview() {
|
|
|
|
|
+ const root = mountInfo.root || '/nas/jellyfin';
|
|
|
|
|
+ let path = '';
|
|
|
|
|
+
|
|
|
|
|
+ if (currentType === 'movie') {
|
|
|
|
|
+ // Flat layout: Movies/<Title> (Year).mkv — no enclosing folder
|
|
|
|
|
+ const title = sanitize(document.getElementById('title').value || 'Title');
|
|
|
|
|
+ const year = document.getElementById('year').value || 'YYYY';
|
|
|
|
|
+ const base = year ? `${title} (${year})` : title;
|
|
|
|
|
+ path = `${root}/Movies/${base}.mkv`;
|
|
|
|
|
+
|
|
|
|
|
+ } else if (currentType === 'music') {
|
|
|
|
|
+ // Music/<Artist>/<Album>/[NN - ]<Track>.<ext> (album & number optional)
|
|
|
|
|
+ const artist = sanitize(getArtistName());
|
|
|
|
|
+ const album = sanitize(document.getElementById('album').value || '');
|
|
|
|
|
+ const track = document.getElementById('track').value;
|
|
|
|
|
+ const title = sanitize(document.getElementById('title').value || 'Track');
|
|
|
|
|
+ const fmtEl = document.getElementById('format');
|
|
|
|
|
+ const ext = (fmtEl && fmtEl.value) || 'm4a';
|
|
|
|
|
+ const trackPrefix = track ? `${String(track).padStart(2,'0')} - ` : '';
|
|
|
|
|
+ const dir = album ? `${root}/Music/${artist}/${album}` : `${root}/Music/${artist}`;
|
|
|
|
|
+ path = `${dir}/${trackPrefix}${title}.${ext}`;
|
|
|
|
|
+
|
|
|
|
|
+ } else { // series
|
|
|
|
|
+ const series = sanitize(getSeriesName());
|
|
|
|
|
+ const season = String(document.getElementById('season').value || '1').padStart(2,'0');
|
|
|
|
|
+ const episode = String(document.getElementById('episode').value || '1').padStart(2,'0');
|
|
|
|
|
+ const epTitle = sanitize(document.getElementById('episode_title').value ||
|
|
|
|
|
+ document.getElementById('title').value || 'Episode Title');
|
|
|
|
|
+ path = `${root}/Shows/${series}/Season ${season}/S${season}E${episode} - ${epTitle}.mkv`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const el = document.getElementById('path-preview');
|
|
|
|
|
+ el.textContent = `→ ${path}`;
|
|
|
|
|
+ el.classList.add('visible');
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function sanitize(s) {
|
|
|
|
|
+ return String(s || '').replace(/[<>:"/\\|?*]/g, '').trim() || '…';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Live preview on common fields
|
|
|
|
|
+['title','author','upload_date','series','episode_title','season','episode','year',
|
|
|
|
|
+ 'artist','album','track','music_year','genre'].forEach(id => {
|
|
|
|
|
+ const el = document.getElementById(id);
|
|
|
|
|
+ if (el) el.addEventListener('input', updatePathPreview);
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// ── Submit ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
+async function submitJob() {
|
|
|
|
|
+ const url = document.getElementById('url-input').value.trim();
|
|
|
|
|
+ if (!url) { toast('URL is required', 'error'); return; }
|
|
|
|
|
+
|
|
|
|
|
+ // Year input is shared between movie ('year' field) and music ('music_year' field)
|
|
|
|
|
+ const year = currentType === 'music'
|
|
|
|
|
+ ? document.getElementById('music_year').value.trim()
|
|
|
|
|
+ : document.getElementById('year').value.trim();
|
|
|
|
|
+
|
|
|
|
|
+ const payload = {
|
|
|
|
|
+ url,
|
|
|
|
|
+ media_type: currentType,
|
|
|
|
|
+ title: document.getElementById('title').value.trim(),
|
|
|
|
|
+ author: document.getElementById('author').value.trim(),
|
|
|
|
|
+ upload_date: document.getElementById('upload_date').value.trim(),
|
|
|
|
|
+ year: year,
|
|
|
|
|
+ description: probeData.description || '',
|
|
|
|
|
+ // Series fields
|
|
|
|
|
+ folder: currentType === 'series' ? getSeriesName() : '',
|
|
|
|
|
+ series: document.getElementById('series').value.trim(),
|
|
|
|
|
+ season: parseInt(document.getElementById('season').value) || 1,
|
|
|
|
|
+ episode: parseInt(document.getElementById('episode').value) || 1,
|
|
|
|
|
+ episode_title: document.getElementById('episode_title').value.trim(),
|
|
|
|
|
+ // Music fields
|
|
|
|
|
+ artist: currentType === 'music' ? getArtistName() : '',
|
|
|
|
|
+ album: document.getElementById('album').value.trim(),
|
|
|
|
|
+ track: document.getElementById('track').value.trim(),
|
|
|
|
|
+ genre: document.getElementById('genre').value.trim(),
|
|
|
|
|
+ format: currentType === 'music' ? document.getElementById('format').value : '',
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const btn = document.getElementById('archive-btn');
|
|
|
|
|
+ const originalText = btn.textContent;
|
|
|
|
|
+ btn.disabled = true;
|
|
|
|
|
+ btn.innerHTML = '<span class="spinner"></span> Queuing…';
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await fetch('/api/archive', {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: {'Content-Type': 'application/json'},
|
|
|
|
|
+ body: JSON.stringify(payload)
|
|
|
|
|
+ });
|
|
|
|
|
+ const data = await res.json();
|
|
|
|
|
+ if (!res.ok) throw new Error(data.error || 'Failed to queue');
|
|
|
|
|
+
|
|
|
|
|
+ toast(`Job ${data.job_id} queued`, 'success');
|
|
|
|
|
+ clearForm();
|
|
|
|
|
+ await loadBrowseData(); // refresh folder lists after potential new folder
|
|
|
|
|
+ applySnapshot(data); // the new job is in data.jobs with a fresh version
|
|
|
|
|
+ startPolling();
|
|
|
|
|
+ } catch(e) {
|
|
|
|
|
+ toast(e.message, 'error');
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ btn.disabled = false;
|
|
|
|
|
+ btn.textContent = originalText;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function clearForm() {
|
|
|
|
|
+ ['url-input','title','author','upload_date','series','episode_title','year',
|
|
|
|
|
+ 'artist','album','track','music_year','genre'].forEach(id => {
|
|
|
|
|
+ const el = document.getElementById(id);
|
|
|
|
|
+ if (el) el.value = '';
|
|
|
|
|
+ });
|
|
|
|
|
+ document.getElementById('season').value = '1';
|
|
|
|
|
+ document.getElementById('episode').value = '1';
|
|
|
|
|
+ const seriesSelect = document.getElementById('series-folder-select');
|
|
|
|
|
+ if (seriesSelect) seriesSelect.value = '';
|
|
|
|
|
+ const artistSelect = document.getElementById('artist-folder-select');
|
|
|
|
|
+ if (artistSelect) artistSelect.value = '';
|
|
|
|
|
+ const formatSel = document.getElementById('format');
|
|
|
|
|
+ if (formatSel) {
|
|
|
|
|
+ formatSel.value = 'm4a';
|
|
|
|
|
+ onFormatChange(); // reset hint text
|
|
|
|
|
+ }
|
|
|
|
|
+ document.getElementById('preview-card').classList.remove('visible');
|
|
|
|
|
+ document.getElementById('path-preview').classList.remove('visible');
|
|
|
|
|
+ setProbeStatus('');
|
|
|
|
|
+ probeData = {};
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ── Job list ──────────────────────────────────────────────────────────────────
|
|
|
|
|
+//
|
|
|
|
|
+// Race-free rendering — backed by a server-side action_version counter.
|
|
|
|
|
+//
|
|
|
|
|
+// Every response from /api/jobs and from mutating endpoints carries an
|
|
|
|
|
+// `action_version`. We keep the highest version we've applied locally and
|
|
|
|
|
+// refuse to render anything older. This makes the order of fetch responses
|
|
|
|
|
+// irrelevant: a stale poll cannot overwrite the result of a click, and a
|
|
|
|
|
+// click's response (which always carries the freshest version, since
|
|
|
|
|
+// mutations bump the counter before responding) always wins.
|
|
|
|
|
+//
|
|
|
|
|
+// Mutating endpoints also include the new job list in their response,
|
|
|
|
|
+// so we never have to issue a follow-up GET that could race with polling.
|
|
|
|
|
+
|
|
|
|
|
+let lastAppliedVersion = -1;
|
|
|
|
|
+let lastServerInstance = null;
|
|
|
|
|
+let pollTimer = null;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Apply a server snapshot to the DOM, but only if it's strictly newer than
|
|
|
|
|
+ * what's currently rendered. Stale snapshots are silently dropped.
|
|
|
|
|
+ *
|
|
|
|
|
+ * Accepts the shape: { server_instance: string, action_version: number, jobs: [...] }
|
|
|
|
|
+ *
|
|
|
|
|
+ * `server_instance` is a per-process UUID issued by the backend. Whenever it
|
|
|
|
|
+ * changes (server restart, code reload, container redeployment), we reset
|
|
|
|
|
+ * `lastAppliedVersion` so the fresh state is accepted even though the
|
|
|
|
|
+ * counter started over at 0. Without this, the queue would silently freeze
|
|
|
|
|
+ * after any backend restart until enough new actions occurred to push the
|
|
|
|
|
+ * counter past the client's stale watermark.
|
|
|
|
|
+ */
|
|
|
|
|
+function applySnapshot(snapshot) {
|
|
|
|
|
+ if (!snapshot || typeof snapshot.action_version !== 'number') return;
|
|
|
|
|
+
|
|
|
|
|
+ // Detect server restart and reset the version watermark.
|
|
|
|
|
+ if (snapshot.server_instance && snapshot.server_instance !== lastServerInstance) {
|
|
|
|
|
+ lastServerInstance = snapshot.server_instance;
|
|
|
|
|
+ lastAppliedVersion = -1;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (snapshot.action_version < lastAppliedVersion) return;
|
|
|
|
|
+ lastAppliedVersion = snapshot.action_version;
|
|
|
|
|
+ const list = Array.isArray(snapshot.jobs) ? snapshot.jobs : [];
|
|
|
|
|
+ renderJobsList(list);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function loadJobs() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await fetch('/api/jobs');
|
|
|
|
|
+ const data = await res.json();
|
|
|
|
|
+ applySnapshot(data);
|
|
|
|
|
+ } catch(e) {
|
|
|
|
|
+ // Network blip — leave the current view alone; next call retries.
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function refreshJobs() {
|
|
|
|
|
+ loadJobs().then(() => toast('refreshed', ''));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderJobsList(jobs) {
|
|
|
|
|
+ const list = document.getElementById('jobs-list');
|
|
|
|
|
+
|
|
|
|
|
+ // Update count badge
|
|
|
|
|
+ document.getElementById('job-count').textContent =
|
|
|
|
|
+ `${jobs.length} job${jobs.length !== 1 ? 's' : ''}`;
|
|
|
|
|
+
|
|
|
|
|
+ // Build new content in a fragment so the swap is atomic (no flash)
|
|
|
|
|
+ if (!jobs.length) {
|
|
|
|
|
+ const placeholder = document.createElement('div');
|
|
|
|
|
+ placeholder.className = 'empty-state';
|
|
|
|
|
+ placeholder.textContent = 'no jobs yet — paste a URL to get started';
|
|
|
|
|
+ list.replaceChildren(placeholder);
|
|
|
|
|
+ stopPolling();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const frag = document.createDocumentFragment();
|
|
|
|
|
+ for (const job of jobs) {
|
|
|
|
|
+ const wrapper = document.createElement('div');
|
|
|
|
|
+ wrapper.innerHTML = renderJob(job).trim();
|
|
|
|
|
+ const card = wrapper.firstChild;
|
|
|
|
|
+ if (card) frag.appendChild(card);
|
|
|
|
|
+ }
|
|
|
|
|
+ list.replaceChildren(frag);
|
|
|
|
|
+
|
|
|
|
|
+ // Poll only when work is in progress
|
|
|
|
|
+ const hasActive = jobs.some(j => j.status === 'running' || j.status === 'queued');
|
|
|
|
|
+ if (hasActive) startPolling(); else stopPolling();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderJob(j) {
|
|
|
|
|
+ const iconClass = j.media_type === 'movie' ? 'icon-film'
|
|
|
|
|
+ : j.media_type === 'music' ? 'icon-music'
|
|
|
|
|
+ : 'icon-tv';
|
|
|
|
|
+ const iconLabel = j.media_type === 'movie' ? '🎬'
|
|
|
|
|
+ : j.media_type === 'music' ? '♪'
|
|
|
|
|
+ : 'TV';
|
|
|
|
|
+ const pct = Math.round(j.progress || 0);
|
|
|
|
|
+ const fillCls = j.status === 'done' ? 'done' : j.status === 'error' ? 'error' : '';
|
|
|
|
|
+
|
|
|
|
|
+ let sub;
|
|
|
|
|
+ if (j.media_type === 'movie') {
|
|
|
|
|
+ sub = `movie ${j.year || ''}`;
|
|
|
|
|
+ } else if (j.media_type === 'music') {
|
|
|
|
|
+ sub = `${j.artist || j.author || '—'}${j.album ? ' · ' + j.album : ''}`;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ sub = `${j.series || j.folder || '—'} S${String(j.season||1).padStart(2,'0')}E${String(j.episode||1).padStart(2,'0')}`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const speed = j.status === 'running' && j.speed
|
|
|
|
|
+ ? `<div class="job-speed">${escHtml(j.speed)}${j.eta ? ' · ETA ' + escHtml(j.eta) : ''}</div>` : '';
|
|
|
|
|
+ const outPath = j.output_path
|
|
|
|
|
+ ? `<div class="job-speed" style="word-break:break-all;margin-top:3px">${escHtml(j.output_path)}</div>` : '';
|
|
|
|
|
+ const error = j.status === 'error' && j.error
|
|
|
|
|
+ ? `<div class="job-error">${escHtml(String(j.error).slice(0, 400))}</div>` : '';
|
|
|
|
|
+
|
|
|
|
|
+ const retryBtn = j.status === 'error'
|
|
|
|
|
+ ? `<button class="btn-sm" onclick="retryJob('${escAttr(j.id)}')">↺ retry</button>` : '';
|
|
|
|
|
+
|
|
|
|
|
+ return `<div class="job-card" data-job-id="${escAttr(j.id)}">
|
|
|
|
|
+ <div class="job-top">
|
|
|
|
|
+ <div class="job-icon ${iconClass}">${iconLabel}</div>
|
|
|
|
|
+ <div class="job-info">
|
|
|
|
|
+ <div class="job-title">${escHtml(j.title || j.url)}</div>
|
|
|
|
|
+ <div class="job-sub">${escHtml(sub)} · <span style="font-size:10px;font-family:monospace">${escHtml(j.id)}</span></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="job-status status-${j.status}">${j.status}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="progress-bar"><div class="progress-fill ${fillCls}" style="width:${pct}%"></div></div>
|
|
|
|
|
+ ${speed}${outPath}${error}
|
|
|
|
|
+ <div class="job-actions">${retryBtn}<button class="btn-sm danger" onclick="deleteJob('${escAttr(j.id)}')">✕ remove</button></div>
|
|
|
|
|
+</div>`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function retryJob(id) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await fetch(`/api/jobs/${id}/retry`, { method: 'POST' });
|
|
|
|
|
+ const data = await res.json();
|
|
|
|
|
+ toast(`Job ${id} retrying`, 'success');
|
|
|
|
|
+ applySnapshot(data);
|
|
|
|
|
+ } catch(e) {
|
|
|
|
|
+ toast('Retry failed', 'error');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ startPolling();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function deleteJob(id) {
|
|
|
|
|
+ // Optimistic: remove the card immediately so the click feels responsive.
|
|
|
|
|
+ // The server's response then becomes the authoritative state via applySnapshot.
|
|
|
|
|
+ const card = document.querySelector(`.job-card[data-job-id="${cssEsc(id)}"]`);
|
|
|
|
|
+ if (card) card.remove();
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await fetch(`/api/jobs/${id}`, { method: 'DELETE' });
|
|
|
|
|
+ const data = await res.json();
|
|
|
|
|
+ applySnapshot(data);
|
|
|
|
|
+ } catch(e) {
|
|
|
|
|
+ // Network blip — fall back to fetching fresh state
|
|
|
|
|
+ await loadJobs();
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+async function clearFinished() {
|
|
|
|
|
+ const btn = document.getElementById('clear-finished-btn');
|
|
|
|
|
+ btn.disabled = true;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const res = await fetch('/api/jobs', { method: 'DELETE' });
|
|
|
|
|
+ const data = await res.json();
|
|
|
|
|
+ toast(`Cleared ${data.count || 0} finished job${data.count === 1 ? '' : 's'}`, 'success');
|
|
|
|
|
+ applySnapshot(data);
|
|
|
|
|
+ } catch(e) {
|
|
|
|
|
+ toast('Clear failed', 'error');
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ btn.disabled = false;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ── Polling ───────────────────────────────────────────────────────────────────
|
|
|
|
|
+function startPolling() { if (!pollTimer) pollTimer = setInterval(loadJobs, 2500); }
|
|
|
|
|
+function stopPolling() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } }
|
|
|
|
|
+
|
|
|
|
|
+// ── Toast ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
+function toast(msg, type='') {
|
|
|
|
|
+ const el = document.getElementById('toast');
|
|
|
|
|
+ el.textContent = msg;
|
|
|
|
|
+ el.className = 'show ' + type;
|
|
|
|
|
+ setTimeout(() => el.classList.remove('show'), 3500);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ── Utils ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
+function escHtml(s) {
|
|
|
|
|
+ return String(s ?? '').replace(/&/g,'&').replace(/</g,'<')
|
|
|
|
|
+ .replace(/>/g,'>').replace(/"/g,'"');
|
|
|
|
|
+}
|
|
|
|
|
+function escAttr(s) { return escHtml(s); }
|
|
|
|
|
+
|
|
|
|
|
+/** Escape a string for safe use in a CSS attribute selector value. */
|
|
|
|
|
+function cssEsc(s) {
|
|
|
|
|
+ if (window.CSS && CSS.escape) return CSS.escape(String(s ?? ''));
|
|
|
|
|
+ return String(s ?? '').replace(/[^a-zA-Z0-9_-]/g, '\\$&');
|
|
|
|
|
+}
|
|
|
|
|
+</script>
|
|
|
|
|
+</body>
|
|
|
|
|
+</html>
|