index.html 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923
  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">(Jellyfin artist folder)</span></label>
  233. <div class="folder-row">
  234. <select id="artist-folder-select" onchange="onArtistFolderSelect()">
  235. <option value="">— choose existing —</option>
  236. </select>
  237. <input type="text" id="artist" placeholder="or type a new artist name" oninput="updatePathPreview()">
  238. </div>
  239. </div>
  240. <div class="row-2">
  241. <div class="field">
  242. <label>Album <span style="color:var(--muted);font-weight:400">(optional)</span></label>
  243. <input type="text" id="album" placeholder="Leave blank for flat artist folder" oninput="updatePathPreview()">
  244. </div>
  245. <div class="field">
  246. <label>Track number <span style="color:var(--muted);font-weight:400">(optional)</span></label>
  247. <input type="number" id="track" min="1" placeholder="e.g. 3" oninput="updatePathPreview()">
  248. </div>
  249. </div>
  250. <div class="row-2">
  251. <div class="field">
  252. <label>Year</label>
  253. <input type="text" id="music_year" placeholder="e.g. 2024" oninput="updatePathPreview()">
  254. </div>
  255. <div class="field">
  256. <label>Genre <span style="color:var(--muted);font-weight:400">(optional)</span></label>
  257. <input type="text" id="genre" placeholder="e.g. Electronic">
  258. </div>
  259. </div>
  260. <div class="field">
  261. <label>
  262. Audio format
  263. <span style="color:var(--muted);font-weight:400" id="format-hint">
  264. (M4A/AAC — universal Jellyfin direct-play)
  265. </span>
  266. </label>
  267. <select id="format" onchange="onFormatChange()">
  268. <option value="m4a" selected>M4A (AAC in MP4 container, lossy — best Jellyfin compatibility)</option>
  269. <option value="aac">AAC (raw ADTS, lossy — same codec as M4A, leaner container)</option>
  270. <option value="opus">Opus (lossy, smaller files at same quality)</option>
  271. <option value="mp3">MP3 (lossy, universal compatibility incl. old hardware)</option>
  272. <option value="wav">WAV (uncompressed PCM — massive files, archival use only)</option>
  273. <option value="flac">FLAC (lossless container — see note)</option>
  274. </select>
  275. </div>
  276. </div>
  277. <!-- Path preview -->
  278. <div id="path-preview"></div>
  279. <hr>
  280. <button class="btn btn-primary" id="archive-btn" onclick="submitJob()">Archive video</button>
  281. </div>
  282. <!-- Right panel: queue -->
  283. <div class="panel-right">
  284. <div class="jobs-header">
  285. <div class="jobs-title">archive queue</div>
  286. <div class="badge" id="job-count">0 jobs</div>
  287. <div style="margin-left:auto; display:flex; gap:.4rem">
  288. <button class="btn-sm" id="clear-finished-btn" onclick="clearFinished()">✕ clear finished</button>
  289. <button class="btn-sm" onclick="refreshJobs()">↻ refresh</button>
  290. </div>
  291. </div>
  292. <div class="jobs-list" id="jobs-list">
  293. <div class="empty-state">no jobs yet — paste a URL to get started</div>
  294. </div>
  295. </div>
  296. </div>
  297. <div id="toast"></div>
  298. <script>
  299. // ── State ─────────────────────────────────────────────────────────────────────
  300. let currentType = 'series';
  301. let probeData = {};
  302. let pollTimer = null;
  303. let mountInfo = { root: '/nas/jellyfin', mounted: null, writable: null, subdirs: {} };
  304. let browseData = { Movies: [], Shows: [], Music: [] };
  305. // ── Init ──────────────────────────────────────────────────────────────────────
  306. (async function init() {
  307. await Promise.all([checkMount(), loadBrowseData()]);
  308. loadJobs();
  309. })();
  310. // ── Mount check ───────────────────────────────────────────────────────────────
  311. async function checkMount() {
  312. try {
  313. const data = await fetch('/api/mounts').then(r => r.json());
  314. mountInfo = data;
  315. const banner = document.getElementById('mount-banner');
  316. const hdr = document.getElementById('header-status');
  317. if (!data.mounted) {
  318. banner.className = 'visible error';
  319. banner.textContent = `✕ ${data.root} not found — attach Proxmox bind-mount (mp0) and restart.`;
  320. hdr.innerHTML = '<span class="dot" style="background:var(--red)"></span>mount missing';
  321. document.getElementById('archive-btn').disabled = true;
  322. } else if (!data.writable) {
  323. banner.className = 'visible warn';
  324. banner.textContent = `⚠ ${data.root} is read-only — check LXC mount permissions.`;
  325. hdr.innerHTML = '<span class="dot" style="background:var(--amber)"></span>read-only';
  326. document.getElementById('archive-btn').disabled = true;
  327. } else {
  328. const missing = ['Movies','Shows','Music'].filter(d => !data.subdirs[d]?.exists);
  329. if (missing.length) {
  330. banner.className = 'visible warn';
  331. banner.textContent = `⚠ ${data.root} writable. Missing subdirs (will be created): ${missing.join(', ')}`;
  332. hdr.innerHTML = '<span class="dot" style="background:var(--amber)"></span>' + data.root;
  333. } else {
  334. banner.className = 'visible ok';
  335. banner.textContent = `✓ ${data.root} — Movies/ Shows/ Music/ detected`;
  336. hdr.innerHTML = '<span class="dot"></span>' + data.root;
  337. }
  338. document.getElementById('archive-btn').disabled = false;
  339. }
  340. updatePathPreview();
  341. } catch(e) { console.warn('Mount check failed:', e); }
  342. }
  343. // ── Browse (folder population) ────────────────────────────────────────────────
  344. async function loadBrowseData() {
  345. try {
  346. browseData = await fetch('/api/browse').then(r => r.json());
  347. populateFolderSelects();
  348. } catch(e) { console.warn('Browse failed:', e); }
  349. }
  350. function populateFolderSelects() {
  351. populateSelect('series-folder-select', browseData.Shows || [], '— choose existing —');
  352. populateSelect('artist-folder-select', browseData.Music || [], '— choose existing —');
  353. }
  354. function populateSelect(id, items, placeholder) {
  355. const sel = document.getElementById(id);
  356. if (!sel) return;
  357. const first = sel.options[0]?.text || placeholder;
  358. sel.innerHTML = `<option value="">${first}</option>` +
  359. items.map(name => `<option value="${escAttr(name)}">${escHtml(name)}</option>`).join('');
  360. }
  361. function onSeriesFolderSelect() {
  362. const val = document.getElementById('series-folder-select').value;
  363. document.getElementById('series').value = val;
  364. updatePathPreview();
  365. }
  366. function onArtistFolderSelect() {
  367. const val = document.getElementById('artist-folder-select').value;
  368. document.getElementById('artist').value = val;
  369. updatePathPreview();
  370. }
  371. // ── Audio format selector ─────────────────────────────────────────────────────
  372. const FORMAT_HINTS = {
  373. m4a: '(M4A/AAC — universal Jellyfin direct-play)',
  374. aac: '(AAC in raw ADTS container — same codec as M4A, smaller container overhead)',
  375. opus: '(Opus — smaller files, well supported)',
  376. mp3: '(MP3 — universal compatibility)',
  377. wav: '⚠ Note: WAV is uncompressed PCM and produces very large files. YouTube source is already lossy, so WAV provides no quality gain over m4a.',
  378. flac: '⚠ Note: YouTube source is already lossy. FLAC just makes the file bigger; it cannot restore audio quality.',
  379. };
  380. function onFormatChange() {
  381. const fmt = document.getElementById('format').value;
  382. const hint = document.getElementById('format-hint');
  383. if (hint) {
  384. hint.textContent = FORMAT_HINTS[fmt] || '';
  385. hint.style.color = (fmt === 'flac' || fmt === 'wav')
  386. ? 'var(--amber, #f5a623)' : 'var(--muted)';
  387. }
  388. updatePathPreview();
  389. }
  390. // ── Type selector ─────────────────────────────────────────────────────────────
  391. function setType(type) {
  392. currentType = type;
  393. document.querySelectorAll('.type-tab').forEach(t =>
  394. t.classList.toggle('active', t.dataset.type === type));
  395. ['series-fields','movie-fields','music-fields'].forEach(id =>
  396. document.getElementById(id).classList.toggle('active', id.startsWith(type)));
  397. // Repurpose the shared title/author labels for the music context
  398. const titleLabel = document.getElementById('title-label');
  399. const authorLabel = document.getElementById('author-label');
  400. const archiveBtn = document.getElementById('archive-btn');
  401. if (type === 'music') {
  402. titleLabel.textContent = 'Track title';
  403. authorLabel.textContent = 'Channel (not the artist)';
  404. archiveBtn.textContent = 'Archive track';
  405. } else {
  406. titleLabel.textContent = 'Video title';
  407. authorLabel.textContent = 'Author / Channel';
  408. archiveBtn.textContent = 'Archive video';
  409. }
  410. updatePathPreview();
  411. }
  412. // ── URL probe — auto-fetch on paste only ─────────────────────────────────────
  413. /**
  414. * Clean tracking params from a YouTube URL.
  415. * Only rewrites the field when we have a COMPLETE, parseable URL —
  416. * never on partial input to avoid the re-trigger loop.
  417. */
  418. function cleanYouTubeUrl(raw) {
  419. try {
  420. const u = new URL(raw.trim());
  421. const host = u.hostname.replace(/^www\./, '');
  422. if (host === 'youtube.com' && u.pathname === '/watch') {
  423. const v = u.searchParams.get('v') || '';
  424. return `https://www.youtube.com/watch?v=${v}`;
  425. }
  426. if (host === 'youtu.be' || host === 'youtube.com') {
  427. return `${u.origin}${u.pathname}`;
  428. }
  429. return raw.trim();
  430. } catch { return raw.trim(); }
  431. }
  432. function looksLikeYouTube(url) {
  433. try {
  434. const u = new URL(url);
  435. const host = u.hostname.replace(/^www\./, '');
  436. if (host === 'youtu.be' && u.pathname.length > 1) return true;
  437. if (host === 'youtube.com' && u.searchParams.get('v')) return true;
  438. if (host === 'youtube.com' && /^\/(shorts|live)\/\w/.test(u.pathname)) return true;
  439. return false;
  440. } catch { return false; }
  441. }
  442. function setProbeStatus(msg, cls='') {
  443. const el = document.getElementById('probe-status');
  444. el.textContent = msg;
  445. el.style.color = cls === 'ok' ? 'var(--green)' :
  446. cls === 'err' ? 'var(--red)' : 'var(--muted)';
  447. }
  448. async function probeUrl(urlOverride) {
  449. const inputEl = document.getElementById('url-input');
  450. const url = urlOverride !== undefined ? urlOverride : inputEl.value.trim();
  451. if (!url) { toast('Paste a YouTube URL first', 'error'); return; }
  452. const btn = document.getElementById('probe-btn');
  453. btn.disabled = true;
  454. btn.innerHTML = '<span class="spinner"></span>';
  455. setProbeStatus('fetching…');
  456. try {
  457. const res = await fetch('/api/probe', {
  458. method: 'POST',
  459. headers: {'Content-Type': 'application/json'},
  460. body: JSON.stringify({ url })
  461. });
  462. const data = await res.json();
  463. if (!res.ok) throw new Error(data.error || 'Probe failed');
  464. probeData = data;
  465. document.getElementById('title').value = data.title || '';
  466. document.getElementById('author').value = data.author || '';
  467. document.getElementById('upload_date').value = data.upload_date || '';
  468. document.getElementById('year').value = data.year || '';
  469. document.getElementById('preview-thumb').src = data.thumbnail || '';
  470. document.getElementById('preview-title').textContent = data.title || '(untitled)';
  471. document.getElementById('preview-sub').textContent =
  472. `${data.author || ''} · ${data.upload_date || ''} · ${data.duration || ''}`;
  473. document.getElementById('preview-card').classList.add('visible');
  474. setProbeStatus('✓ fetched', 'ok');
  475. updatePathPreview();
  476. } catch(e) {
  477. setProbeStatus('fetch failed', 'err');
  478. toast(e.message, 'error');
  479. } finally {
  480. btn.disabled = false;
  481. btn.textContent = '↻';
  482. }
  483. }
  484. // Paste handler — robust cross-browser approach:
  485. // We let the browser complete the paste first (no preventDefault), then read
  486. // the field value in a microtask. This works on desktop, mobile, and all
  487. // browsers regardless of clipboardData availability.
  488. document.getElementById('url-input').addEventListener('paste', function() {
  489. const el = this;
  490. setTimeout(function() {
  491. const raw = el.value;
  492. const cleaned = cleanYouTubeUrl(raw);
  493. // Update field with cleaned URL only if it changed
  494. if (cleaned !== raw) el.value = cleaned;
  495. if (looksLikeYouTube(cleaned)) {
  496. setProbeStatus('fetching…');
  497. probeUrl(cleaned);
  498. }
  499. }, 0);
  500. });
  501. // Enter key as manual fallback
  502. document.getElementById('url-input').addEventListener('keydown', function(e) {
  503. if (e.key === 'Enter') probeUrl();
  504. });
  505. // ── Path preview ──────────────────────────────────────────────────────────────
  506. function getSeriesName() {
  507. return document.getElementById('series-folder-select').value ||
  508. document.getElementById('series').value.trim() ||
  509. 'Series Name';
  510. }
  511. function getArtistName() {
  512. return document.getElementById('artist-folder-select').value ||
  513. document.getElementById('artist').value.trim() ||
  514. 'Artist Name';
  515. }
  516. function updatePathPreview() {
  517. const root = mountInfo.root || '/nas/jellyfin';
  518. let path = '';
  519. if (currentType === 'movie') {
  520. // Flat layout: Movies/<Title> (Year).mkv — no enclosing folder
  521. const title = sanitize(document.getElementById('title').value || 'Title');
  522. const year = document.getElementById('year').value || 'YYYY';
  523. const base = year ? `${title} (${year})` : title;
  524. path = `${root}/Movies/${base}.mkv`;
  525. } else if (currentType === 'music') {
  526. // Music/<Artist>/<Album>/[NN - ]<Track>.<ext> (album & number optional)
  527. const artist = sanitize(getArtistName());
  528. const album = sanitize(document.getElementById('album').value || '');
  529. const track = document.getElementById('track').value;
  530. const title = sanitize(document.getElementById('title').value || 'Track');
  531. const fmtEl = document.getElementById('format');
  532. const ext = (fmtEl && fmtEl.value) || 'm4a';
  533. const trackPrefix = track ? `${String(track).padStart(2,'0')} - ` : '';
  534. const dir = album ? `${root}/Music/${artist}/${album}` : `${root}/Music/${artist}`;
  535. path = `${dir}/${trackPrefix}${title}.${ext}`;
  536. } else { // series
  537. const series = sanitize(getSeriesName());
  538. const season = String(document.getElementById('season').value || '1').padStart(2,'0');
  539. const episode = String(document.getElementById('episode').value || '1').padStart(2,'0');
  540. const epTitle = sanitize(document.getElementById('episode_title').value ||
  541. document.getElementById('title').value || 'Episode Title');
  542. path = `${root}/Shows/${series}/Season ${season}/S${season}E${episode} - ${epTitle}.mkv`;
  543. }
  544. const el = document.getElementById('path-preview');
  545. el.textContent = `→ ${path}`;
  546. el.classList.add('visible');
  547. }
  548. function sanitize(s) {
  549. return String(s || '').replace(/[<>:"/\\|?*]/g, '').trim() || '…';
  550. }
  551. // Live preview on common fields
  552. ['title','author','upload_date','series','episode_title','season','episode','year',
  553. 'artist','album','track','music_year','genre'].forEach(id => {
  554. const el = document.getElementById(id);
  555. if (el) el.addEventListener('input', updatePathPreview);
  556. });
  557. // ── Submit ─────────────────────────────────────────────────────────────────────
  558. async function submitJob() {
  559. const url = document.getElementById('url-input').value.trim();
  560. if (!url) { toast('URL is required', 'error'); return; }
  561. // Year input is shared between movie ('year' field) and music ('music_year' field)
  562. const year = currentType === 'music'
  563. ? document.getElementById('music_year').value.trim()
  564. : document.getElementById('year').value.trim();
  565. const payload = {
  566. url,
  567. media_type: currentType,
  568. title: document.getElementById('title').value.trim(),
  569. author: document.getElementById('author').value.trim(),
  570. upload_date: document.getElementById('upload_date').value.trim(),
  571. year: year,
  572. description: probeData.description || '',
  573. // Series fields
  574. folder: currentType === 'series' ? getSeriesName() : '',
  575. series: document.getElementById('series').value.trim(),
  576. season: parseInt(document.getElementById('season').value) || 1,
  577. episode: parseInt(document.getElementById('episode').value) || 1,
  578. episode_title: document.getElementById('episode_title').value.trim(),
  579. // Music fields
  580. artist: currentType === 'music' ? getArtistName() : '',
  581. album: document.getElementById('album').value.trim(),
  582. track: document.getElementById('track').value.trim(),
  583. genre: document.getElementById('genre').value.trim(),
  584. format: currentType === 'music' ? document.getElementById('format').value : '',
  585. };
  586. const btn = document.getElementById('archive-btn');
  587. const originalText = btn.textContent;
  588. btn.disabled = true;
  589. btn.innerHTML = '<span class="spinner"></span> Queuing…';
  590. try {
  591. const res = await fetch('/api/archive', {
  592. method: 'POST',
  593. headers: {'Content-Type': 'application/json'},
  594. body: JSON.stringify(payload)
  595. });
  596. const data = await res.json();
  597. if (!res.ok) throw new Error(data.error || 'Failed to queue');
  598. toast(`Job ${data.job_id} queued`, 'success');
  599. clearForm();
  600. await loadBrowseData(); // refresh folder lists after potential new folder
  601. applySnapshot(data); // the new job is in data.jobs with a fresh version
  602. startPolling();
  603. } catch(e) {
  604. toast(e.message, 'error');
  605. } finally {
  606. btn.disabled = false;
  607. btn.textContent = originalText;
  608. }
  609. }
  610. function clearForm() {
  611. ['url-input','title','author','upload_date','series','episode_title','year',
  612. 'artist','album','track','music_year','genre'].forEach(id => {
  613. const el = document.getElementById(id);
  614. if (el) el.value = '';
  615. });
  616. document.getElementById('season').value = '1';
  617. document.getElementById('episode').value = '1';
  618. const seriesSelect = document.getElementById('series-folder-select');
  619. if (seriesSelect) seriesSelect.value = '';
  620. const artistSelect = document.getElementById('artist-folder-select');
  621. if (artistSelect) artistSelect.value = '';
  622. const formatSel = document.getElementById('format');
  623. if (formatSel) {
  624. formatSel.value = 'm4a';
  625. onFormatChange(); // reset hint text
  626. }
  627. document.getElementById('preview-card').classList.remove('visible');
  628. document.getElementById('path-preview').classList.remove('visible');
  629. setProbeStatus('');
  630. probeData = {};
  631. }
  632. // ── Job list ──────────────────────────────────────────────────────────────────
  633. //
  634. // Race-free rendering — backed by a server-side action_version counter.
  635. //
  636. // Every response from /api/jobs and from mutating endpoints carries an
  637. // `action_version`. We keep the highest version we've applied locally and
  638. // refuse to render anything older. This makes the order of fetch responses
  639. // irrelevant: a stale poll cannot overwrite the result of a click, and a
  640. // click's response (which always carries the freshest version, since
  641. // mutations bump the counter before responding) always wins.
  642. //
  643. // Mutating endpoints also include the new job list in their response,
  644. // so we never have to issue a follow-up GET that could race with polling.
  645. let lastAppliedVersion = -1;
  646. let lastServerInstance = null;
  647. // pollTimer is declared once in the State block near the top of this script.
  648. /**
  649. * Apply a server snapshot to the DOM, but only if it's strictly newer than
  650. * what's currently rendered. Stale snapshots are silently dropped.
  651. *
  652. * Accepts the shape: { server_instance: string, action_version: number, jobs: [...] }
  653. *
  654. * `server_instance` is a per-process UUID issued by the backend. Whenever it
  655. * changes (server restart, code reload, container redeployment), we reset
  656. * `lastAppliedVersion` so the fresh state is accepted even though the
  657. * counter started over at 0. Without this, the queue would silently freeze
  658. * after any backend restart until enough new actions occurred to push the
  659. * counter past the client's stale watermark.
  660. */
  661. function applySnapshot(snapshot) {
  662. if (!snapshot || typeof snapshot.action_version !== 'number') return;
  663. // Detect server restart and reset the version watermark.
  664. if (snapshot.server_instance && snapshot.server_instance !== lastServerInstance) {
  665. lastServerInstance = snapshot.server_instance;
  666. lastAppliedVersion = -1;
  667. }
  668. if (snapshot.action_version < lastAppliedVersion) return;
  669. lastAppliedVersion = snapshot.action_version;
  670. const list = Array.isArray(snapshot.jobs) ? snapshot.jobs : [];
  671. renderJobsList(list);
  672. }
  673. async function loadJobs() {
  674. try {
  675. const res = await fetch('/api/jobs');
  676. const data = await res.json();
  677. applySnapshot(data);
  678. } catch(e) {
  679. // Network blip — leave the current view alone; next call retries.
  680. }
  681. }
  682. function refreshJobs() {
  683. loadJobs().then(() => toast('refreshed', ''));
  684. }
  685. function renderJobsList(jobs) {
  686. const list = document.getElementById('jobs-list');
  687. // Update count badge
  688. document.getElementById('job-count').textContent =
  689. `${jobs.length} job${jobs.length !== 1 ? 's' : ''}`;
  690. // Build new content in a fragment so the swap is atomic (no flash)
  691. if (!jobs.length) {
  692. const placeholder = document.createElement('div');
  693. placeholder.className = 'empty-state';
  694. placeholder.textContent = 'no jobs yet — paste a URL to get started';
  695. list.replaceChildren(placeholder);
  696. stopPolling();
  697. return;
  698. }
  699. const frag = document.createDocumentFragment();
  700. for (const job of jobs) {
  701. const wrapper = document.createElement('div');
  702. wrapper.innerHTML = renderJob(job).trim();
  703. const card = wrapper.firstChild;
  704. if (card) frag.appendChild(card);
  705. }
  706. list.replaceChildren(frag);
  707. // Poll only when work is in progress
  708. const hasActive = jobs.some(j => j.status === 'running' || j.status === 'queued');
  709. if (hasActive) startPolling(); else stopPolling();
  710. }
  711. function renderJob(j) {
  712. const iconClass = j.media_type === 'movie' ? 'icon-film'
  713. : j.media_type === 'music' ? 'icon-music'
  714. : 'icon-tv';
  715. const iconLabel = j.media_type === 'movie' ? '🎬'
  716. : j.media_type === 'music' ? '♪'
  717. : 'TV';
  718. const pct = Math.round(j.progress || 0);
  719. const fillCls = j.status === 'done' ? 'done' : j.status === 'error' ? 'error' : '';
  720. let sub;
  721. if (j.media_type === 'movie') {
  722. sub = `movie ${j.year || ''}`;
  723. } else if (j.media_type === 'music') {
  724. sub = `${j.artist || j.author || '—'}${j.album ? ' · ' + j.album : ''}`;
  725. } else {
  726. sub = `${j.series || j.folder || '—'} S${String(j.season||1).padStart(2,'0')}E${String(j.episode||1).padStart(2,'0')}`;
  727. }
  728. const speed = j.status === 'running' && j.speed
  729. ? `<div class="job-speed">${escHtml(j.speed)}${j.eta ? ' · ETA ' + escHtml(j.eta) : ''}</div>` : '';
  730. const outPath = j.output_path
  731. ? `<div class="job-speed" style="word-break:break-all;margin-top:3px">${escHtml(j.output_path)}</div>` : '';
  732. const error = j.status === 'error' && j.error
  733. ? `<div class="job-error">${escHtml(String(j.error).slice(0, 400))}</div>` : '';
  734. const retryBtn = j.status === 'error'
  735. ? `<button class="btn-sm" onclick="retryJob('${escAttr(j.id)}')">↺ retry</button>` : '';
  736. return `<div class="job-card" data-job-id="${escAttr(j.id)}">
  737. <div class="job-top">
  738. <div class="job-icon ${iconClass}">${iconLabel}</div>
  739. <div class="job-info">
  740. <div class="job-title">${escHtml(j.title || j.url)}</div>
  741. <div class="job-sub">${escHtml(sub)} · <span style="font-size:10px;font-family:monospace">${escHtml(j.id)}</span></div>
  742. </div>
  743. <div class="job-status status-${j.status}">${j.status}</div>
  744. </div>
  745. <div class="progress-bar"><div class="progress-fill ${fillCls}" style="width:${pct}%"></div></div>
  746. ${speed}${outPath}${error}
  747. <div class="job-actions">${retryBtn}<button class="btn-sm danger" onclick="deleteJob('${escAttr(j.id)}')">✕ remove</button></div>
  748. </div>`;
  749. }
  750. async function retryJob(id) {
  751. try {
  752. const res = await fetch(`/api/jobs/${id}/retry`, { method: 'POST' });
  753. const data = await res.json();
  754. toast(`Job ${id} retrying`, 'success');
  755. applySnapshot(data);
  756. } catch(e) {
  757. toast('Retry failed', 'error');
  758. return;
  759. }
  760. startPolling();
  761. }
  762. async function deleteJob(id) {
  763. // Optimistic: remove the card immediately so the click feels responsive.
  764. // The server's response then becomes the authoritative state via applySnapshot.
  765. const card = document.querySelector(`.job-card[data-job-id="${cssEsc(id)}"]`);
  766. if (card) card.remove();
  767. try {
  768. const res = await fetch(`/api/jobs/${id}`, { method: 'DELETE' });
  769. const data = await res.json();
  770. applySnapshot(data);
  771. } catch(e) {
  772. // Network blip — fall back to fetching fresh state
  773. await loadJobs();
  774. }
  775. }
  776. async function clearFinished() {
  777. const btn = document.getElementById('clear-finished-btn');
  778. btn.disabled = true;
  779. try {
  780. const res = await fetch('/api/jobs', { method: 'DELETE' });
  781. const data = await res.json();
  782. toast(`Cleared ${data.count || 0} finished job${data.count === 1 ? '' : 's'}`, 'success');
  783. applySnapshot(data);
  784. } catch(e) {
  785. toast('Clear failed', 'error');
  786. } finally {
  787. btn.disabled = false;
  788. }
  789. }
  790. // ── Polling ───────────────────────────────────────────────────────────────────
  791. function startPolling() { if (!pollTimer) pollTimer = setInterval(loadJobs, 2500); }
  792. function stopPolling() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } }
  793. // ── Toast ─────────────────────────────────────────────────────────────────────
  794. function toast(msg, type='') {
  795. const el = document.getElementById('toast');
  796. el.textContent = msg;
  797. el.className = 'show ' + type;
  798. setTimeout(() => el.classList.remove('show'), 3500);
  799. }
  800. // ── Utils ─────────────────────────────────────────────────────────────────────
  801. function escHtml(s) {
  802. return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;')
  803. .replace(/>/g,'&gt;').replace(/"/g,'&quot;');
  804. }
  805. function escAttr(s) { return escHtml(s); }
  806. /** Escape a string for safe use in a CSS attribute selector value. */
  807. function cssEsc(s) {
  808. if (window.CSS && CSS.escape) return CSS.escape(String(s ?? ''));
  809. return String(s ?? '').replace(/[^a-zA-Z0-9_-]/g, '\\$&');
  810. }
  811. </script>
  812. </body>
  813. </html>