index.html 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939
  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>YAAR — YouTube Auto-Archiver and Retagger</title>
  7. <style>
  8. *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  9. :root {
  10. --bg: #0d0f12;
  11. --bg2: #13161b;
  12. --bg3: #1a1e26;
  13. --border: #252932;
  14. --border2: #313846;
  15. --text: #d4d8e2;
  16. --muted: #636c7e;
  17. --accent: #4f8ef7;
  18. --accent-bg: #1a2540;
  19. --accent-dim:#2c3f6a;
  20. --green: #3dd68c;
  21. --green-bg: #0e2419;
  22. --amber: #f5a623;
  23. --amber-bg: #2a1f0a;
  24. --red: #f05252;
  25. --red-bg: #2a0e0e;
  26. --mono: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
  27. --sans: 'Inter', system-ui, sans-serif;
  28. --radius: 6px;
  29. }
  30. body { background:var(--bg); color:var(--text); font-family:var(--sans); font-size:14px; line-height:1.6; min-height:100vh; }
  31. header {
  32. border-bottom:1px solid var(--border); padding:0 2rem; height:52px;
  33. display:flex; align-items:center; gap:1rem;
  34. position:sticky; top:0; background:var(--bg); z-index:10;
  35. }
  36. .logo { font-family:var(--mono); font-size:15px; font-weight:600; color:var(--accent); letter-spacing:.05em; }
  37. .logo span { color:var(--muted); font-weight:400; font-size:12px; margin-left:.5rem; }
  38. .header-status { margin-left:auto; font-size:12px; color:var(--muted); font-family:var(--mono); }
  39. .dot { display:inline-block; width:6px; height:6px; border-radius:50%; background:var(--green); margin-right:6px; animation:pulse 2s infinite; }
  40. @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
  41. .layout { display:grid; grid-template-columns:440px 1fr; height:calc(100vh - 52px); }
  42. .panel-left { border-right:1px solid var(--border); overflow-y:auto; padding:1.5rem; display:flex; flex-direction:column; gap:1.25rem; }
  43. .panel-right { overflow-y:auto; padding:1.5rem; }
  44. .section-label { font-size:10px; font-family:var(--mono); letter-spacing:.12em; color:var(--muted); text-transform:uppercase; margin-bottom:.6rem; }
  45. .url-row { display:flex; gap:.5rem; }
  46. input[type="text"], input[type="number"], select, textarea {
  47. background:var(--bg2); border:1px solid var(--border2); color:var(--text);
  48. border-radius:var(--radius); padding:.45rem .7rem; font-size:13px;
  49. font-family:var(--sans); width:100%; outline:none; transition:border-color .15s;
  50. }
  51. input:focus, select:focus, textarea:focus { border-color:var(--accent-dim); }
  52. input::placeholder, textarea::placeholder { color:var(--muted); }
  53. select option { background:var(--bg2); }
  54. .btn {
  55. padding:.45rem 1rem; border-radius:var(--radius); font-size:13px; font-weight:500;
  56. cursor:pointer; border:none; white-space:nowrap; transition:opacity .15s,transform .1s; font-family:var(--sans);
  57. }
  58. .btn:active { transform:scale(.97); }
  59. .btn:disabled { opacity:.4; cursor:not-allowed; }
  60. .btn-probe { background:var(--bg3); color:var(--text); border:1px solid var(--border2); min-width:32px; }
  61. .btn-probe:hover:not(:disabled) { border-color:var(--accent-dim); }
  62. .btn-primary { background:var(--accent); color:#fff; width:100%; }
  63. .btn-primary:hover:not(:disabled) { opacity:.88; }
  64. /* Preview card */
  65. #preview-card { background:var(--bg2); border:1px solid var(--border); border-radius:var(--radius); padding:.85rem; display:none; gap:.75rem; }
  66. #preview-card.visible { display:flex; }
  67. #preview-thumb { width:100px; height:56px; object-fit:cover; border-radius:4px; flex-shrink:0; background:var(--bg3); }
  68. .preview-meta { flex:1; min-width:0; }
  69. .preview-title { font-size:13px; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-bottom:3px; }
  70. .preview-sub { font-size:11px; color:var(--muted); font-family:var(--mono); }
  71. /* Type tabs */
  72. .type-tabs { display:flex; background:var(--bg2); border:1px solid var(--border); border-radius:var(--radius); padding:3px; }
  73. .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; }
  74. .type-tab.active { background:var(--bg3); color:var(--text); border:1px solid var(--border2); }
  75. /* Fields */
  76. .field { display:flex; flex-direction:column; gap:.3rem; }
  77. .field label { font-size:11px; color:var(--muted); }
  78. .row-2 { display:grid; grid-template-columns:1fr 1fr; gap:.6rem; }
  79. .row-3 { display:grid; grid-template-columns:2fr 1fr 1fr; gap:.6rem; }
  80. .type-section { display:none; flex-direction:column; gap:.75rem; }
  81. .type-section.active { display:flex; }
  82. hr { border:none; border-top:1px solid var(--border); }
  83. /* Folder picker */
  84. .folder-picker { display:flex; flex-direction:column; gap:.5rem; }
  85. .folder-row { display:flex; gap:.5rem; align-items:flex-end; }
  86. .folder-row select { flex:1; }
  87. .folder-row input { flex:1; }
  88. .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; }
  89. .folder-hint.visible { display:block; }
  90. /* Mount banner */
  91. #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; }
  92. #mount-banner.ok { background:var(--green-bg); color:var(--green); border-color:#1a4030; }
  93. #mount-banner.warn { background:var(--amber-bg); color:var(--amber); border-color:#4a350a; }
  94. #mount-banner.error { background:var(--red-bg); color:var(--red); border-color:#4a1414; }
  95. #mount-banner.visible { display:block; }
  96. /* Path preview */
  97. #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; }
  98. #path-preview.visible { display:block; }
  99. /* Probe status indicator inside URL row */
  100. #probe-status { font-size:11px; font-family:var(--mono); color:var(--muted); white-space:nowrap; align-self:center; min-width:60px; text-align:right; }
  101. /* Jobs panel */
  102. .jobs-header { display:flex; align-items:center; gap:.75rem; margin-bottom:1rem; }
  103. .jobs-title { font-size:13px; font-weight:500; font-family:var(--mono); color:var(--muted); }
  104. .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); }
  105. .jobs-list { display:flex; flex-direction:column; gap:.5rem; }
  106. .job-card { background:var(--bg2); border:1px solid var(--border); border-radius:var(--radius); padding:.8rem 1rem; transition:border-color .2s; }
  107. .job-card:hover { border-color:var(--border2); }
  108. .job-top { display:flex; align-items:flex-start; gap:.75rem; margin-bottom:.5rem; }
  109. .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; }
  110. .icon-tv { background:#0b1a2a; color:#4f8ef7; }
  111. .icon-film { background:#0c1f14; color:#3dd68c; }
  112. .icon-music { background:#1a0c1f; color:#c084fc; }
  113. .job-info { flex:1; min-width:0; }
  114. .job-title { font-size:13px; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-bottom:2px; }
  115. .job-sub { font-size:11px; color:var(--muted); font-family:var(--mono); }
  116. .job-status { font-size:10px; font-family:var(--mono); padding:2px 8px; border-radius:999px; font-weight:500; flex-shrink:0; }
  117. .status-queued { background:var(--bg3); color:var(--muted); border:1px solid var(--border2); }
  118. .status-running { background:var(--accent-bg); color:var(--accent); border:1px solid var(--accent-dim); }
  119. .status-done { background:var(--green-bg); color:var(--green); border:1px solid #1a4030; }
  120. .status-error { background:var(--red-bg); color:var(--red); border:1px solid #4a1414; }
  121. .progress-bar { height:2px; background:var(--border); border-radius:999px; overflow:hidden; margin-top:.5rem; }
  122. .progress-fill { height:100%; background:var(--accent); border-radius:999px; transition:width .5s ease; }
  123. .progress-fill.done { background:var(--green); }
  124. .progress-fill.error { background:var(--red); }
  125. .job-speed { font-size:10px; color:var(--muted); font-family:var(--mono); margin-top:.35rem; }
  126. .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; }
  127. .job-actions { display:flex; gap:.4rem; margin-top:.5rem; justify-content:flex-end; }
  128. .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; }
  129. .btn-sm:hover { color:var(--text); border-color:var(--accent-dim); }
  130. .btn-sm.danger:hover { color:var(--red); border-color:var(--red); }
  131. .empty-state { text-align:center; color:var(--muted); padding:3rem 1rem; font-family:var(--mono); font-size:13px; }
  132. #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; }
  133. #toast.show { opacity:1; transform:translateY(0); }
  134. #toast.success { border-color:var(--green); color:var(--green); }
  135. #toast.error { border-color:var(--red); color:var(--red); }
  136. .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; }
  137. @keyframes spin { to{transform:rotate(360deg)} }
  138. ::-webkit-scrollbar { width:5px; }
  139. ::-webkit-scrollbar-track { background:transparent; }
  140. ::-webkit-scrollbar-thumb { background:var(--border2); border-radius:999px; }
  141. </style>
  142. </head>
  143. <body>
  144. <header>
  145. <div class="logo">YAAR<span>YouTube Auto-Archiver and Retagger</span></div>
  146. <div class="header-status" id="header-status"><span class="dot"></span>checking…</div>
  147. </header>
  148. <div class="layout">
  149. <div class="panel-left">
  150. <div id="mount-banner"></div>
  151. <!-- URL input -->
  152. <div>
  153. <div class="section-label">YouTube URL</div>
  154. <div class="url-row">
  155. <input type="text" id="url-input" placeholder="Paste a YouTube URL…" autocomplete="off" spellcheck="false">
  156. <button class="btn btn-probe" id="probe-btn" onclick="probeUrl()" title="Re-fetch metadata">↻</button>
  157. </div>
  158. <div id="probe-status" style="margin-top:.35rem;font-size:11px;font-family:var(--mono);color:var(--muted);min-height:16px;"></div>
  159. </div>
  160. <!-- Preview card -->
  161. <div id="preview-card">
  162. <img id="preview-thumb" src="" alt="">
  163. <div class="preview-meta">
  164. <div class="preview-title" id="preview-title"></div>
  165. <div class="preview-sub" id="preview-sub"></div>
  166. </div>
  167. </div>
  168. <hr>
  169. <!-- Archive type -->
  170. <div>
  171. <div class="section-label">Archive as</div>
  172. <div class="type-tabs">
  173. <div class="type-tab active" data-type="series" onclick="setType('series')">TV series episode</div>
  174. <div class="type-tab" data-type="movie" onclick="setType('movie')">Movie</div>
  175. <div class="type-tab" data-type="music" onclick="setType('music')">Music track</div>
  176. </div>
  177. </div>
  178. <!-- Common fields -->
  179. <div class="field">
  180. <label><span id="title-label">Video title</span></label>
  181. <input type="text" id="title" placeholder="Auto-filled from URL">
  182. </div>
  183. <div class="row-2">
  184. <div class="field">
  185. <label><span id="author-label">Author / Channel</span></label>
  186. <input type="text" id="author" placeholder="Auto-filled">
  187. </div>
  188. <div class="field">
  189. <label>Upload date</label>
  190. <input type="text" id="upload_date" placeholder="YYYY-MM-DD">
  191. </div>
  192. </div>
  193. <!-- ── Series type fields ── -->
  194. <div class="type-section active" id="series-fields">
  195. <hr>
  196. <div class="field">
  197. <label>Series name <span style="color:var(--muted);font-weight:400">(Jellyfin show folder)</span></label>
  198. <div class="folder-row">
  199. <select id="series-folder-select" onchange="onSeriesFolderSelect()">
  200. <option value="">— choose existing —</option>
  201. </select>
  202. <input type="text" id="series" placeholder="or type a new series name" oninput="updatePathPreview()">
  203. </div>
  204. </div>
  205. <div class="row-3">
  206. <div class="field">
  207. <label>Episode title</label>
  208. <input type="text" id="episode_title" placeholder="Defaults to video title">
  209. </div>
  210. <div class="field">
  211. <label>Season</label>
  212. <input type="number" id="season" value="1" min="1" oninput="updatePathPreview()">
  213. </div>
  214. <div class="field">
  215. <label>Episode</label>
  216. <input type="number" id="episode" value="1" min="1">
  217. </div>
  218. </div>
  219. </div>
  220. <!-- ── Movie type fields ── -->
  221. <div class="type-section" id="movie-fields">
  222. <hr>
  223. <div class="field">
  224. <label>Year <span style="color:var(--muted);font-weight:400">(appended to filename for Jellyfin matching)</span></label>
  225. <input type="text" id="year" placeholder="e.g. 2024" oninput="updatePathPreview()">
  226. </div>
  227. </div>
  228. <!-- ── Music type fields ── -->
  229. <div class="type-section" id="music-fields">
  230. <hr>
  231. <div class="field">
  232. <label>Artist <span style="color:var(--muted);font-weight:400">(embedded tag; used for the Singles folder when no album)</span></label>
  233. <input type="text" id="artist" placeholder="e.g. Nujabes" oninput="updatePathPreview()">
  234. </div>
  235. <div class="row-2">
  236. <div class="field">
  237. <label>Album <span style="color:var(--muted);font-weight:400">(folder name in Music/)</span></label>
  238. <input type="text" id="album" placeholder="Blank → &lt;Artist&gt; - Singles" oninput="updatePathPreview()">
  239. </div>
  240. <div class="field">
  241. <label>Track number <span style="color:var(--muted);font-weight:400">(optional)</span></label>
  242. <input type="number" id="track" min="1" placeholder="e.g. 3" oninput="updatePathPreview()">
  243. </div>
  244. </div>
  245. <div class="row-2">
  246. <div class="field">
  247. <label>Year</label>
  248. <input type="text" id="music_year" placeholder="e.g. 2024" oninput="updatePathPreview()">
  249. </div>
  250. <div class="field">
  251. <label>Genre <span style="color:var(--muted);font-weight:400">(optional)</span></label>
  252. <input type="text" id="genre" placeholder="e.g. Electronic">
  253. </div>
  254. </div>
  255. <div class="field">
  256. <label>
  257. Audio format
  258. <span style="color:var(--muted);font-weight:400" id="format-hint">
  259. (M4A/AAC — universal Jellyfin direct-play)
  260. </span>
  261. </label>
  262. <select id="format" onchange="onFormatChange()">
  263. <option value="m4a" selected>M4A (AAC in MP4 container, lossy — best Jellyfin compatibility)</option>
  264. <option value="aac">AAC (raw ADTS, lossy — same codec as M4A, leaner container)</option>
  265. <option value="opus">Opus (lossy, smaller files at same quality)</option>
  266. <option value="mp3">MP3 (lossy, universal compatibility incl. old hardware)</option>
  267. <option value="wav">WAV (uncompressed PCM — massive files, archival use only)</option>
  268. <option value="flac">FLAC (lossless container — see note)</option>
  269. </select>
  270. </div>
  271. </div>
  272. <!-- Path preview -->
  273. <div id="path-preview"></div>
  274. <hr>
  275. <button class="btn btn-primary" id="archive-btn" onclick="submitJob()">Archive video</button>
  276. </div>
  277. <!-- Right panel: queue -->
  278. <div class="panel-right">
  279. <div class="jobs-header">
  280. <div class="jobs-title">archive queue</div>
  281. <div class="badge" id="job-count">0 jobs</div>
  282. <div style="margin-left:auto; display:flex; gap:.4rem">
  283. <button class="btn-sm" id="clear-finished-btn" onclick="clearFinished()">✕ clear finished</button>
  284. <button class="btn-sm" onclick="refreshJobs()">↻ refresh</button>
  285. </div>
  286. </div>
  287. <div class="jobs-list" id="jobs-list">
  288. <div class="empty-state">no jobs yet — paste a URL to get started</div>
  289. </div>
  290. </div>
  291. </div>
  292. <div id="toast"></div>
  293. <script>
  294. // ── State ─────────────────────────────────────────────────────────────────────
  295. let currentType = 'series';
  296. let probeData = {};
  297. let pollTimer = null;
  298. let mountInfo = { root: '/nas/jellyfin', mounted: null, writable: null, subdirs: {} };
  299. let browseData = { Movies: [], Shows: [], Music: [] };
  300. // ── Init ──────────────────────────────────────────────────────────────────────
  301. (async function init() {
  302. await Promise.all([checkMount(), loadBrowseData()]);
  303. loadJobs();
  304. })();
  305. // ── Mount check ───────────────────────────────────────────────────────────────
  306. async function checkMount() {
  307. try {
  308. const data = await fetch('/api/mounts').then(r => r.json());
  309. mountInfo = data;
  310. const banner = document.getElementById('mount-banner');
  311. const hdr = document.getElementById('header-status');
  312. if (!data.mounted) {
  313. banner.className = 'visible error';
  314. banner.textContent = `✕ ${data.root} not found — attach Proxmox bind-mount (mp0) and restart.`;
  315. hdr.innerHTML = '<span class="dot" style="background:var(--red)"></span>mount missing';
  316. document.getElementById('archive-btn').disabled = true;
  317. } else if (!data.writable) {
  318. banner.className = 'visible warn';
  319. banner.textContent = `⚠ ${data.root} is read-only — check LXC mount permissions.`;
  320. hdr.innerHTML = '<span class="dot" style="background:var(--amber)"></span>read-only';
  321. document.getElementById('archive-btn').disabled = true;
  322. } else {
  323. const missing = ['Movies','Shows','Music'].filter(d => !data.subdirs[d]?.exists);
  324. if (missing.length) {
  325. banner.className = 'visible warn';
  326. banner.textContent = `⚠ ${data.root} writable. Missing subdirs (will be created): ${missing.join(', ')}`;
  327. hdr.innerHTML = '<span class="dot" style="background:var(--amber)"></span>' + data.root;
  328. } else {
  329. banner.className = 'visible ok';
  330. banner.textContent = `✓ ${data.root} — Movies/ Shows/ Music/ detected`;
  331. hdr.innerHTML = '<span class="dot"></span>' + data.root;
  332. }
  333. document.getElementById('archive-btn').disabled = false;
  334. }
  335. updatePathPreview();
  336. } catch(e) { console.warn('Mount check failed:', e); }
  337. }
  338. // ── Browse (folder population) ────────────────────────────────────────────────
  339. async function loadBrowseData() {
  340. try {
  341. browseData = await fetch('/api/browse').then(r => r.json());
  342. populateFolderSelects();
  343. } catch(e) { console.warn('Browse failed:', e); }
  344. }
  345. function populateFolderSelects() {
  346. populateSelect('series-folder-select', browseData.Shows || [], '— choose existing —');
  347. }
  348. function populateSelect(id, items, placeholder) {
  349. const sel = document.getElementById(id);
  350. if (!sel) return;
  351. const first = sel.options[0]?.text || placeholder;
  352. sel.innerHTML = `<option value="">${first}</option>` +
  353. items.map(name => `<option value="${escAttr(name)}">${escHtml(name)}</option>`).join('');
  354. }
  355. function onSeriesFolderSelect() {
  356. const val = document.getElementById('series-folder-select').value;
  357. document.getElementById('series').value = val;
  358. updatePathPreview();
  359. }
  360. // ── Audio format selector ─────────────────────────────────────────────────────
  361. const FORMAT_HINTS = {
  362. m4a: '(M4A/AAC — universal Jellyfin direct-play)',
  363. aac: '(AAC in raw ADTS container — same codec as M4A, smaller container overhead)',
  364. opus: '(Opus — smaller files, well supported)',
  365. mp3: '(MP3 — universal compatibility)',
  366. wav: '⚠ Note: WAV is uncompressed PCM and produces very large files. YouTube source is already lossy, so WAV provides no quality gain over m4a.',
  367. flac: '⚠ Note: YouTube source is already lossy. FLAC just makes the file bigger; it cannot restore audio quality.',
  368. };
  369. function onFormatChange() {
  370. const fmt = document.getElementById('format').value;
  371. const hint = document.getElementById('format-hint');
  372. if (hint) {
  373. hint.textContent = FORMAT_HINTS[fmt] || '';
  374. hint.style.color = (fmt === 'flac' || fmt === 'wav')
  375. ? 'var(--amber, #f5a623)' : 'var(--muted)';
  376. }
  377. updatePathPreview();
  378. }
  379. // ── Type selector ─────────────────────────────────────────────────────────────
  380. function setType(type) {
  381. currentType = type;
  382. document.querySelectorAll('.type-tab').forEach(t =>
  383. t.classList.toggle('active', t.dataset.type === type));
  384. ['series-fields','movie-fields','music-fields'].forEach(id =>
  385. document.getElementById(id).classList.toggle('active', id.startsWith(type)));
  386. // Repurpose the shared title/author labels for the music context
  387. const titleLabel = document.getElementById('title-label');
  388. const authorLabel = document.getElementById('author-label');
  389. const archiveBtn = document.getElementById('archive-btn');
  390. if (type === 'music') {
  391. titleLabel.textContent = 'Track title';
  392. authorLabel.textContent = 'Channel (not the artist)';
  393. archiveBtn.textContent = 'Archive track';
  394. } else {
  395. titleLabel.textContent = 'Video title';
  396. authorLabel.textContent = 'Author / Channel';
  397. archiveBtn.textContent = 'Archive video';
  398. }
  399. updatePathPreview();
  400. }
  401. // ── URL probe — auto-fetch on paste only ─────────────────────────────────────
  402. /**
  403. * Clean tracking params from a YouTube URL.
  404. * Only rewrites the field when we have a COMPLETE, parseable URL —
  405. * never on partial input to avoid the re-trigger loop.
  406. */
  407. function cleanYouTubeUrl(raw) {
  408. try {
  409. const u = new URL(raw.trim());
  410. const host = u.hostname.replace(/^www\./, '');
  411. if (host === 'youtube.com' && u.pathname === '/watch') {
  412. const v = u.searchParams.get('v') || '';
  413. return `https://www.youtube.com/watch?v=${v}`;
  414. }
  415. if (host === 'youtu.be' || host === 'youtube.com') {
  416. return `${u.origin}${u.pathname}`;
  417. }
  418. return raw.trim();
  419. } catch { return raw.trim(); }
  420. }
  421. function looksLikeYouTube(url) {
  422. try {
  423. const u = new URL(url);
  424. const host = u.hostname.replace(/^www\./, '');
  425. if (host === 'youtu.be' && u.pathname.length > 1) return true;
  426. if (host === 'youtube.com' && u.searchParams.get('v')) return true;
  427. if (host === 'youtube.com' && /^\/(shorts|live)\/\w/.test(u.pathname)) return true;
  428. return false;
  429. } catch { return false; }
  430. }
  431. function setProbeStatus(msg, cls='') {
  432. const el = document.getElementById('probe-status');
  433. el.textContent = msg;
  434. el.style.color = cls === 'ok' ? 'var(--green)' :
  435. cls === 'err' ? 'var(--red)' : 'var(--muted)';
  436. }
  437. async function probeUrl(urlOverride) {
  438. const inputEl = document.getElementById('url-input');
  439. const url = urlOverride !== undefined ? urlOverride : inputEl.value.trim();
  440. if (!url) { toast('Paste a YouTube URL first', 'error'); return; }
  441. const btn = document.getElementById('probe-btn');
  442. btn.disabled = true;
  443. btn.innerHTML = '<span class="spinner"></span>';
  444. setProbeStatus('fetching…');
  445. try {
  446. const res = await fetch('/api/probe', {
  447. method: 'POST',
  448. headers: {'Content-Type': 'application/json'},
  449. body: JSON.stringify({ url })
  450. });
  451. const data = await res.json();
  452. if (!res.ok) throw new Error(data.error || 'Probe failed');
  453. probeData = data;
  454. document.getElementById('title').value = data.title || '';
  455. document.getElementById('author').value = data.author || '';
  456. document.getElementById('upload_date').value = data.upload_date || '';
  457. document.getElementById('year').value = data.year || '';
  458. document.getElementById('preview-thumb').src = data.thumbnail || '';
  459. document.getElementById('preview-title').textContent = data.title || '(untitled)';
  460. document.getElementById('preview-sub').textContent =
  461. `${data.author || ''} · ${data.upload_date || ''} · ${data.duration || ''}`;
  462. document.getElementById('preview-card').classList.add('visible');
  463. setProbeStatus('✓ fetched', 'ok');
  464. updatePathPreview();
  465. } catch(e) {
  466. setProbeStatus('fetch failed', 'err');
  467. toast(e.message, 'error');
  468. } finally {
  469. btn.disabled = false;
  470. btn.textContent = '↻';
  471. }
  472. }
  473. // Paste handler — robust cross-browser approach:
  474. // We let the browser complete the paste first (no preventDefault), then read
  475. // the field value in a microtask. This works on desktop, mobile, and all
  476. // browsers regardless of clipboardData availability.
  477. document.getElementById('url-input').addEventListener('paste', function() {
  478. const el = this;
  479. setTimeout(function() {
  480. const raw = el.value;
  481. const cleaned = cleanYouTubeUrl(raw);
  482. // Update field with cleaned URL only if it changed
  483. if (cleaned !== raw) el.value = cleaned;
  484. if (looksLikeYouTube(cleaned)) {
  485. setProbeStatus('fetching…');
  486. probeUrl(cleaned);
  487. }
  488. }, 0);
  489. });
  490. // Enter key as manual fallback
  491. document.getElementById('url-input').addEventListener('keydown', function(e) {
  492. if (e.key === 'Enter') probeUrl();
  493. });
  494. // ── Path preview ──────────────────────────────────────────────────────────────
  495. function getSeriesName() {
  496. return document.getElementById('series-folder-select').value ||
  497. document.getElementById('series').value.trim() ||
  498. 'Series Name';
  499. }
  500. function getArtistName() {
  501. return document.getElementById('artist').value.trim() || 'Artist Name';
  502. }
  503. function updatePathPreview() {
  504. const root = mountInfo.root || '/nas/jellyfin';
  505. let path = '';
  506. if (currentType === 'movie') {
  507. // Flat layout: Movies/<Title> (Year).mkv — no enclosing folder
  508. const title = sanitize(document.getElementById('title').value || 'Title');
  509. const year = document.getElementById('year').value || 'YYYY';
  510. const base = year ? `${title} (${year})` : title;
  511. path = `${root}/Movies/${base}.mkv`;
  512. } else if (currentType === 'music') {
  513. // Music/<Album>/[NN - ]<Track>.<ext>, or Music/<Artist> - Singles/... when
  514. // no album is given. Mirrors resolve_output_dir() in app.py.
  515. const artist = sanitize(getArtistName());
  516. const album = sanitize(document.getElementById('album').value || '');
  517. const track = document.getElementById('track').value;
  518. const title = sanitize(document.getElementById('title').value || 'Track');
  519. const fmtEl = document.getElementById('format');
  520. const ext = (fmtEl && fmtEl.value) || 'm4a';
  521. const trackPrefix = track ? `${String(track).padStart(2,'0')} - ` : '';
  522. const dir = album ? `${root}/Music/${album}` : `${root}/Music/${artist} - Singles`;
  523. path = `${dir}/${trackPrefix}${title}.${ext}`;
  524. } else { // series
  525. const series = sanitize(getSeriesName());
  526. const season = String(document.getElementById('season').value || '1').padStart(2,'0');
  527. const episode = String(document.getElementById('episode').value || '1').padStart(2,'0');
  528. const epTitle = sanitize(document.getElementById('episode_title').value ||
  529. document.getElementById('title').value || 'Episode Title');
  530. path = `${root}/Shows/${series}/Season ${season}/S${season}E${episode} - ${epTitle}.mkv`;
  531. }
  532. const el = document.getElementById('path-preview');
  533. el.textContent = `→ ${path}`;
  534. el.classList.add('visible');
  535. }
  536. function sanitize(s) {
  537. return String(s || '').replace(/[<>:"/\\|?*]/g, '').trim() || '…';
  538. }
  539. // Live preview on common fields
  540. ['title','author','upload_date','series','episode_title','season','episode','year',
  541. 'artist','album','track','music_year','genre'].forEach(id => {
  542. const el = document.getElementById(id);
  543. if (el) el.addEventListener('input', updatePathPreview);
  544. });
  545. // ── Submit ─────────────────────────────────────────────────────────────────────
  546. async function submitJob() {
  547. const url = document.getElementById('url-input').value.trim();
  548. if (!url) { toast('URL is required', 'error'); return; }
  549. // Year input is shared between movie ('year' field) and music ('music_year' field)
  550. const year = currentType === 'music'
  551. ? document.getElementById('music_year').value.trim()
  552. : document.getElementById('year').value.trim();
  553. const payload = {
  554. url,
  555. media_type: currentType,
  556. title: document.getElementById('title').value.trim(),
  557. author: document.getElementById('author').value.trim(),
  558. upload_date: document.getElementById('upload_date').value.trim(),
  559. year: year,
  560. description: probeData.description || '',
  561. // Series fields
  562. folder: currentType === 'series' ? getSeriesName() : '',
  563. series: document.getElementById('series').value.trim(),
  564. season: parseInt(document.getElementById('season').value) || 1,
  565. episode: parseInt(document.getElementById('episode').value) || 1,
  566. episode_title: document.getElementById('episode_title').value.trim(),
  567. // Music fields
  568. artist: currentType === 'music' ? getArtistName() : '',
  569. album: document.getElementById('album').value.trim(),
  570. track: document.getElementById('track').value.trim(),
  571. genre: document.getElementById('genre').value.trim(),
  572. format: currentType === 'music' ? document.getElementById('format').value : '',
  573. };
  574. const btn = document.getElementById('archive-btn');
  575. const originalText = btn.textContent;
  576. btn.disabled = true;
  577. btn.innerHTML = '<span class="spinner"></span> Queuing…';
  578. try {
  579. const res = await fetch('/api/archive', {
  580. method: 'POST',
  581. headers: {'Content-Type': 'application/json'},
  582. body: JSON.stringify(payload)
  583. });
  584. const data = await res.json();
  585. if (!res.ok) throw new Error(data.error || 'Failed to queue');
  586. toast(`Job ${data.job_id} queued`, 'success');
  587. clearForm();
  588. await loadBrowseData(); // refresh folder lists after potential new folder
  589. applySnapshot(data); // the new job is in data.jobs with a fresh version
  590. startPolling();
  591. // A fast job may already have advanced past the queued snapshot above.
  592. // Kick an immediate refresh so we catch running/done state without waiting
  593. // for the first poll interval.
  594. loadJobs();
  595. } catch(e) {
  596. toast(e.message, 'error');
  597. } finally {
  598. btn.disabled = false;
  599. btn.textContent = originalText;
  600. }
  601. }
  602. function clearForm() {
  603. ['url-input','title','author','upload_date','series','episode_title','year',
  604. 'artist','album','track','music_year','genre'].forEach(id => {
  605. const el = document.getElementById(id);
  606. if (el) el.value = '';
  607. });
  608. document.getElementById('season').value = '1';
  609. document.getElementById('episode').value = '1';
  610. const seriesSelect = document.getElementById('series-folder-select');
  611. if (seriesSelect) seriesSelect.value = '';
  612. const formatSel = document.getElementById('format');
  613. if (formatSel) {
  614. formatSel.value = 'm4a';
  615. onFormatChange(); // reset hint text
  616. }
  617. document.getElementById('preview-card').classList.remove('visible');
  618. document.getElementById('path-preview').classList.remove('visible');
  619. setProbeStatus('');
  620. probeData = {};
  621. }
  622. // ── Job list ──────────────────────────────────────────────────────────────────
  623. //
  624. // Race-free rendering — backed by a server-side action_version counter.
  625. //
  626. // Every response from /api/jobs and from mutating endpoints carries an
  627. // `action_version`. We keep the highest version we've applied locally and
  628. // refuse to render anything older. This makes the order of fetch responses
  629. // irrelevant: a stale poll cannot overwrite the result of a click, and a
  630. // click's response (which always carries the freshest version, since
  631. // mutations bump the counter before responding) always wins.
  632. //
  633. // Mutating endpoints also include the new job list in their response,
  634. // so we never have to issue a follow-up GET that could race with polling.
  635. let lastAppliedVersion = -1;
  636. let lastServerInstance = null;
  637. // pollTimer is declared once in the State block near the top of this script.
  638. /**
  639. * Apply a server snapshot to the DOM, but only if it's strictly newer than
  640. * what's currently rendered. Stale snapshots are silently dropped.
  641. *
  642. * Accepts the shape: { server_instance: string, action_version: number, jobs: [...] }
  643. *
  644. * `server_instance` is a per-process UUID issued by the backend. Whenever it
  645. * changes (server restart, code reload, container redeployment), we reset
  646. * `lastAppliedVersion` so the fresh state is accepted even though the
  647. * counter started over at 0. Without this, the queue would silently freeze
  648. * after any backend restart until enough new actions occurred to push the
  649. * counter past the client's stale watermark.
  650. */
  651. function applySnapshot(snapshot) {
  652. if (!snapshot || typeof snapshot.action_version !== 'number') return;
  653. // Detect server restart and reset the version watermark.
  654. if (snapshot.server_instance && snapshot.server_instance !== lastServerInstance) {
  655. lastServerInstance = snapshot.server_instance;
  656. lastAppliedVersion = -1;
  657. }
  658. if (snapshot.action_version < lastAppliedVersion) return;
  659. lastAppliedVersion = snapshot.action_version;
  660. const list = Array.isArray(snapshot.jobs) ? snapshot.jobs : [];
  661. renderJobsList(list);
  662. }
  663. async function loadJobs() {
  664. try {
  665. const res = await fetch('/api/jobs');
  666. const data = await res.json();
  667. applySnapshot(data);
  668. } catch(e) {
  669. // Network blip — leave the current view alone; next call retries.
  670. }
  671. }
  672. function refreshJobs() {
  673. loadJobs().then(() => toast('refreshed', ''));
  674. }
  675. function renderJobsList(jobs) {
  676. const list = document.getElementById('jobs-list');
  677. // Update count badge
  678. document.getElementById('job-count').textContent =
  679. `${jobs.length} job${jobs.length !== 1 ? 's' : ''}`;
  680. // Build new content in a fragment so the swap is atomic (no flash)
  681. if (!jobs.length) {
  682. const placeholder = document.createElement('div');
  683. placeholder.className = 'empty-state';
  684. placeholder.textContent = 'no jobs yet — paste a URL to get started';
  685. list.replaceChildren(placeholder);
  686. stopPolling();
  687. return;
  688. }
  689. const frag = document.createDocumentFragment();
  690. for (const job of jobs) {
  691. const wrapper = document.createElement('div');
  692. wrapper.innerHTML = renderJob(job).trim();
  693. const card = wrapper.firstChild;
  694. if (card) frag.appendChild(card);
  695. }
  696. list.replaceChildren(frag);
  697. // Poll only when work is in progress. When jobs exist but none are active,
  698. // coast through a short grace period before stopping (see requestStopPolling)
  699. // so a just-completed job's final state can't strand the queue.
  700. const hasActive = jobs.some(j => j.status === 'running' || j.status === 'queued');
  701. if (hasActive) startPolling(); else requestStopPolling();
  702. }
  703. function renderJob(j) {
  704. const iconClass = j.media_type === 'movie' ? 'icon-film'
  705. : j.media_type === 'music' ? 'icon-music'
  706. : 'icon-tv';
  707. const iconLabel = j.media_type === 'movie' ? '🎬'
  708. : j.media_type === 'music' ? '♪'
  709. : 'TV';
  710. const pct = Math.round(j.progress || 0);
  711. const fillCls = j.status === 'done' ? 'done' : j.status === 'error' ? 'error' : '';
  712. let sub;
  713. if (j.media_type === 'movie') {
  714. sub = `movie ${j.year || ''}`;
  715. } else if (j.media_type === 'music') {
  716. sub = `${j.artist || j.author || '—'}${j.album ? ' · ' + j.album : ''}`;
  717. } else {
  718. sub = `${j.series || j.folder || '—'} S${String(j.season||1).padStart(2,'0')}E${String(j.episode||1).padStart(2,'0')}`;
  719. }
  720. const speed = j.status === 'running' && j.speed
  721. ? `<div class="job-speed">${escHtml(j.speed)}${j.eta ? ' · ETA ' + escHtml(j.eta) : ''}</div>` : '';
  722. const outPath = j.output_path
  723. ? `<div class="job-speed" style="word-break:break-all;margin-top:3px">${escHtml(j.output_path)}</div>` : '';
  724. const error = j.status === 'error' && j.error
  725. ? `<div class="job-error">${escHtml(String(j.error).slice(0, 400))}</div>` : '';
  726. const retryBtn = j.status === 'error'
  727. ? `<button class="btn-sm" onclick="retryJob('${escAttr(j.id)}')">↺ retry</button>` : '';
  728. return `<div class="job-card" data-job-id="${escAttr(j.id)}">
  729. <div class="job-top">
  730. <div class="job-icon ${iconClass}">${iconLabel}</div>
  731. <div class="job-info">
  732. <div class="job-title">${escHtml(j.title || j.url)}</div>
  733. <div class="job-sub">${escHtml(sub)} · <span style="font-size:10px;font-family:monospace">${escHtml(j.id)}</span></div>
  734. </div>
  735. <div class="job-status status-${j.status}">${j.status}</div>
  736. </div>
  737. <div class="progress-bar"><div class="progress-fill ${fillCls}" style="width:${pct}%"></div></div>
  738. ${speed}${outPath}${error}
  739. <div class="job-actions">${retryBtn}<button class="btn-sm danger" onclick="deleteJob('${escAttr(j.id)}')">✕ remove</button></div>
  740. </div>`;
  741. }
  742. async function retryJob(id) {
  743. try {
  744. const res = await fetch(`/api/jobs/${id}/retry`, { method: 'POST' });
  745. const data = await res.json();
  746. toast(`Job ${id} retrying`, 'success');
  747. applySnapshot(data);
  748. } catch(e) {
  749. toast('Retry failed', 'error');
  750. return;
  751. }
  752. startPolling();
  753. }
  754. async function deleteJob(id) {
  755. // Optimistic: remove the card immediately so the click feels responsive.
  756. // The server's response then becomes the authoritative state via applySnapshot.
  757. const card = document.querySelector(`.job-card[data-job-id="${cssEsc(id)}"]`);
  758. if (card) card.remove();
  759. try {
  760. const res = await fetch(`/api/jobs/${id}`, { method: 'DELETE' });
  761. const data = await res.json();
  762. applySnapshot(data);
  763. } catch(e) {
  764. // Network blip — fall back to fetching fresh state
  765. await loadJobs();
  766. }
  767. }
  768. async function clearFinished() {
  769. const btn = document.getElementById('clear-finished-btn');
  770. btn.disabled = true;
  771. try {
  772. const res = await fetch('/api/jobs', { method: 'DELETE' });
  773. const data = await res.json();
  774. toast(`Cleared ${data.count || 0} finished job${data.count === 1 ? '' : 's'}`, 'success');
  775. applySnapshot(data);
  776. } catch(e) {
  777. toast('Clear failed', 'error');
  778. } finally {
  779. btn.disabled = false;
  780. }
  781. }
  782. // ── Polling ───────────────────────────────────────────────────────────────────
  783. // `stopPolling` doesn't halt immediately. When no job is active we keep polling
  784. // for a few extra cycles (the grace period). This protects against a race where
  785. // a fast job's snapshot arrives showing everything done, polling stops, but a
  786. // later state change (or an out-of-order response) would otherwise be missed —
  787. // which manifested as completed jobs vanishing from the queue.
  788. let pollGrace = 0;
  789. const POLL_GRACE_CYCLES = 3;
  790. function startPolling() {
  791. pollGrace = 0;
  792. if (!pollTimer) pollTimer = setInterval(loadJobs, 2500);
  793. }
  794. function requestStopPolling() {
  795. // Called by renderJobsList when nothing is active. Coast for a few cycles
  796. // before actually stopping, so late updates still land.
  797. if (pollGrace < POLL_GRACE_CYCLES) {
  798. pollGrace += 1;
  799. } else {
  800. stopPolling();
  801. }
  802. }
  803. function stopPolling() {
  804. if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
  805. pollGrace = 0;
  806. }
  807. // ── Toast ─────────────────────────────────────────────────────────────────────
  808. function toast(msg, type='') {
  809. const el = document.getElementById('toast');
  810. el.textContent = msg;
  811. el.className = 'show ' + type;
  812. setTimeout(() => el.classList.remove('show'), 3500);
  813. }
  814. // ── Utils ─────────────────────────────────────────────────────────────────────
  815. function escHtml(s) {
  816. return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;')
  817. .replace(/>/g,'&gt;').replace(/"/g,'&quot;');
  818. }
  819. function escAttr(s) { return escHtml(s); }
  820. /** Escape a string for safe use in a CSS attribute selector value. */
  821. function cssEsc(s) {
  822. if (window.CSS && CSS.escape) return CSS.escape(String(s ?? ''));
  823. return String(s ?? '').replace(/[^a-zA-Z0-9_-]/g, '\\$&');
  824. }
  825. </script>
  826. </body>
  827. </html>