| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923 |
- <!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;
- // pollTimer is declared once in the State block near the top of this script.
- /**
- * 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>
|