index.html 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775
  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>WeBrake · HandBrake, poured over the web</title>
  7. <link rel="preconnect" href="https://fonts.googleapis.com">
  8. <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  9. <link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,700&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
  10. <style>
  11. :root{
  12. --bg:#F6F0E2; /* cream */
  13. --bg-raise:#FDFAF2;
  14. --bg-sunken:#EFE7D3;
  15. --ink:#33271B;
  16. --ink-soft:#6E5E49;
  17. --line:#E2D7BE;
  18. --gold:#B98A1E; /* gold accent */
  19. --gold-bright:#D9A82E;
  20. --gold-soft:#F0E3C0;
  21. --ok:#5E7C3A; --err:#A8442F; --warn:#B0761D;
  22. --shadow:0 1px 2px rgba(51,39,27,.06),0 8px 24px rgba(51,39,27,.07);
  23. --radius:14px;
  24. }
  25. [data-theme="dark"]{
  26. --bg:#221610; /* dark roast */
  27. --bg-raise:#2C1E15;
  28. --bg-sunken:#1A100B;
  29. --ink:#F0E4D0;
  30. --ink-soft:#B39B7E;
  31. --line:#3E2C1E;
  32. --gold:#D4A937;
  33. --gold-bright:#E8C158;
  34. --gold-soft:#4A3617;
  35. --ok:#8FB061; --err:#D3745E; --warn:#D9A54B;
  36. --shadow:0 1px 2px rgba(0,0,0,.35),0 10px 28px rgba(0,0,0,.35);
  37. }
  38. *{box-sizing:border-box;margin:0;padding:0}
  39. html{color-scheme:light}
  40. [data-theme="dark"] html,[data-theme="dark"]{color-scheme:dark}
  41. body{
  42. font-family:'Inter',system-ui,sans-serif;font-size:14.5px;line-height:1.5;
  43. background:var(--bg);color:var(--ink);
  44. transition:background .3s,color .3s;min-height:100vh;
  45. }
  46. h1,h2,h3{font-family:'Fraunces',serif;font-weight:700;letter-spacing:-.01em}
  47. code,.mono,textarea.mono,input.mono{font-family:'JetBrains Mono',monospace;font-size:12.5px}
  48. a{color:var(--gold)}
  49. button{font:inherit;cursor:pointer;border:none;background:none;color:inherit}
  50. .wrap{max-width:1100px;margin:0 auto;padding:0 20px 80px}
  51. /* ---------- header ---------- */
  52. header{display:flex;align-items:center;gap:14px;padding:26px 0 20px;flex-wrap:wrap}
  53. .logo{display:flex;align-items:center;gap:12px}
  54. .logo svg{display:block}
  55. .logo h1{font-size:26px}
  56. .logo .tag{font-size:12px;color:var(--ink-soft);margin-top:-2px}
  57. .chips{display:flex;gap:8px;flex-wrap:wrap;margin-left:auto;align-items:center}
  58. .chip{font-size:11.5px;font-weight:600;padding:4px 10px;border-radius:999px;
  59. border:1px solid var(--line);background:var(--bg-raise);color:var(--ink-soft)}
  60. .chip.hw{border-color:var(--gold);color:var(--gold);background:transparent}
  61. .theme-btn{width:38px;height:38px;border-radius:50%;border:1px solid var(--line);
  62. background:var(--bg-raise);display:grid;place-items:center;transition:transform .2s}
  63. .theme-btn:hover{transform:rotate(20deg)}
  64. /* ---------- cards ---------- */
  65. .card{background:var(--bg-raise);border:1px solid var(--line);border-radius:var(--radius);
  66. box-shadow:var(--shadow);padding:22px;margin-bottom:22px}
  67. .card h2{font-size:19px;margin-bottom:4px}
  68. .card .sub{color:var(--ink-soft);font-size:13px;margin-bottom:16px}
  69. /* ---------- dropzone ---------- */
  70. .drop{border:2px dashed var(--line);border-radius:var(--radius);padding:34px;
  71. text-align:center;color:var(--ink-soft);transition:.2s;cursor:pointer;background:var(--bg-sunken)}
  72. .drop.hover{border-color:var(--gold);color:var(--gold);background:var(--gold-soft)}
  73. .drop strong{color:var(--ink)}
  74. .upbar{margin-top:14px;display:none}
  75. .upbar.show{display:block}
  76. /* ---------- progress (signature: gold pour) ---------- */
  77. .bar{height:10px;border-radius:999px;background:var(--bg-sunken);overflow:hidden;
  78. border:1px solid var(--line);position:relative}
  79. .bar>i{display:block;height:100%;width:0;border-radius:999px;
  80. background:linear-gradient(90deg,var(--gold) 0%,var(--gold-bright) 50%,var(--gold) 100%);
  81. background-size:200% 100%;animation:pour 1.6s linear infinite;transition:width .4s ease}
  82. .bar.static>i{animation:none}
  83. @keyframes pour{from{background-position:0 0}to{background-position:-200% 0}}
  84. @media (prefers-reduced-motion:reduce){.bar>i{animation:none}}
  85. /* ---------- sources ---------- */
  86. .src-list{display:flex;flex-direction:column;gap:8px;margin-top:14px}
  87. .src{display:flex;align-items:center;gap:12px;padding:10px 14px;border:1px solid var(--line);
  88. border-radius:10px;background:var(--bg-sunken);cursor:pointer;transition:.15s}
  89. .src:hover{border-color:var(--gold)}
  90. .src.sel{border-color:var(--gold);background:var(--gold-soft);box-shadow:inset 0 0 0 1px var(--gold)}
  91. .src .name{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  92. .src .meta{margin-left:auto;color:var(--ink-soft);font-size:12px;white-space:nowrap}
  93. .src .del{color:var(--ink-soft);padding:2px 6px;border-radius:6px}
  94. .src .del:hover{color:var(--err)}
  95. /* ---------- tabs & fields ---------- */
  96. .tabs{display:flex;gap:2px;flex-wrap:wrap;border-bottom:1px solid var(--line);margin:6px 0 18px}
  97. .tab{padding:9px 14px;font-weight:600;font-size:13px;color:var(--ink-soft);
  98. border-radius:8px 8px 0 0;border-bottom:2px solid transparent;margin-bottom:-1px}
  99. .tab.on{color:var(--gold);border-bottom-color:var(--gold)}
  100. .pane{display:none}.pane.on{display:block}
  101. .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(215px,1fr));gap:14px 16px}
  102. .field label{display:block;font-size:12px;font-weight:600;color:var(--ink-soft);
  103. margin-bottom:5px;text-transform:uppercase;letter-spacing:.04em}
  104. .field input[type=text],.field input[type=number],.field select,.field textarea{
  105. width:100%;padding:8px 10px;border:1px solid var(--line);border-radius:8px;
  106. background:var(--bg-sunken);color:var(--ink);font:inherit;font-size:13.5px}
  107. .field input:focus,.field select:focus,.field textarea:focus{
  108. outline:2px solid var(--gold);outline-offset:1px;border-color:var(--gold)}
  109. .field.wide{grid-column:1/-1}
  110. .check{display:flex;align-items:center;gap:8px;font-size:13.5px;padding:6px 0}
  111. .check input{accent-color:var(--gold);width:16px;height:16px}
  112. .hint{font-size:11.5px;color:var(--ink-soft);margin-top:4px}
  113. textarea{resize:vertical;min-height:70px}
  114. /* ---------- buttons ---------- */
  115. .btn{display:inline-flex;align-items:center;gap:8px;padding:10px 18px;border-radius:10px;
  116. font-weight:600;font-size:14px;border:1px solid var(--line);background:var(--bg-sunken);
  117. color:var(--ink);transition:.15s}
  118. .btn:hover{border-color:var(--gold)}
  119. .btn.gold{background:var(--gold);border-color:var(--gold);color:#fff}
  120. [data-theme="dark"] .btn.gold{color:#221610}
  121. .btn.gold:hover{background:var(--gold-bright);border-color:var(--gold-bright)}
  122. .btn.sm{padding:6px 12px;font-size:12.5px;border-radius:8px}
  123. .btn.danger:hover{border-color:var(--err);color:var(--err)}
  124. .btn:disabled{opacity:.45;cursor:not-allowed}
  125. .actions{display:flex;gap:10px;align-items:center;margin-top:18px;flex-wrap:wrap}
  126. /* ---------- jobs ---------- */
  127. .job{border:1px solid var(--line);border-radius:12px;padding:16px;margin-top:12px;background:var(--bg-sunken)}
  128. .job .row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
  129. .job .fname{font-weight:600}
  130. .status{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;
  131. padding:3px 9px;border-radius:999px;border:1px solid var(--line);color:var(--ink-soft)}
  132. .status.running,.status.scanning{color:var(--gold);border-color:var(--gold)}
  133. .status.done{color:var(--ok);border-color:var(--ok)}
  134. .status.failed{color:var(--err);border-color:var(--err)}
  135. .status.cancelled{color:var(--warn);border-color:var(--warn)}
  136. .job .stats{margin-left:auto;color:var(--ink-soft);font-size:12.5px;display:flex;gap:14px}
  137. .job .bar{margin-top:10px}
  138. .job .btns{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap}
  139. .job .errline{color:var(--err);font-size:12.5px;margin-top:8px}
  140. .logbox{display:none;margin-top:10px;background:var(--bg);border:1px solid var(--line);
  141. border-radius:8px;padding:10px;max-height:260px;overflow:auto;white-space:pre-wrap;font-size:11.5px}
  142. .logbox.show{display:block}
  143. .empty{color:var(--ink-soft);text-align:center;padding:26px;font-size:13.5px}
  144. .toast{position:fixed;bottom:22px;left:50%;transform:translateX(-50%) translateY(20px);
  145. background:var(--ink);color:var(--bg);padding:10px 20px;border-radius:10px;font-size:13.5px;
  146. opacity:0;pointer-events:none;transition:.25s;z-index:50;box-shadow:var(--shadow)}
  147. .toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
  148. footer{color:var(--ink-soft);font-size:12px;text-align:center;padding-top:8px}
  149. .token-panel{border-color:var(--gold)}
  150. .theme-btn.attn{border-color:var(--gold);color:var(--gold);
  151. box-shadow:0 0 0 3px var(--gold-soft);animation:knock 1.4s ease-in-out infinite}
  152. @keyframes knock{0%,100%{transform:none}50%{transform:translateY(-2px)}}
  153. @media (prefers-reduced-motion:reduce){.theme-btn.attn{animation:none}}
  154. .srcerr{color:var(--err);text-align:center;padding:18px;font-size:13px}
  155. .active-src{font-size:13px;color:var(--ink-soft)}
  156. .active-src b{color:var(--gold)}
  157. @media(max-width:640px){.job .stats{margin-left:0;width:100%}}
  158. </style>
  159. </head>
  160. <body>
  161. <div class="wrap">
  162. <header>
  163. <div class="logo">
  164. <!-- coffee-ring / reel mark -->
  165. <svg width="42" height="42" viewBox="0 0 42 42" fill="none" aria-hidden="true">
  166. <circle cx="21" cy="21" r="17" stroke="var(--gold)" stroke-width="3.5"/>
  167. <circle cx="21" cy="21" r="9" stroke="var(--gold)" stroke-width="2" stroke-dasharray="4 5"/>
  168. <circle cx="21" cy="21" r="3" fill="var(--gold)"/>
  169. </svg>
  170. <div>
  171. <h1>WeBrake</h1>
  172. <div class="tag">HandBrake, poured over the web</div>
  173. </div>
  174. </div>
  175. <div class="chips" id="chips">
  176. <span class="chip" id="chip-hb">HandBrake …</span>
  177. <button class="theme-btn" id="tokenBtn" title="API token" aria-label="API token settings">
  178. <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor"
  179. stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  180. <circle cx="7.5" cy="15.5" r="4.5"/><path d="m10.7 12.3 9.3-9.3M16 5l3 3M13 8l3 3"/>
  181. </svg>
  182. </button>
  183. <button class="theme-btn" id="themeBtn" title="Toggle dark roast" aria-label="Toggle theme">
  184. <svg id="themeIcon" width="18" height="18" viewBox="0 0 24 24" fill="none"
  185. stroke="currentColor" stroke-width="2" stroke-linecap="round"></svg>
  186. </button>
  187. </div>
  188. </header>
  189. <!-- ============ token panel ============ -->
  190. <section class="card token-panel" id="tokenPanel" hidden>
  191. <h2>API token</h2>
  192. <div class="sub">Paste the <code>api_token</code> from <code>/opt/webrake/token.json</code>, or upload the file itself — it's read locally in your browser and never sent anywhere except as a request header.</div>
  193. <div class="grid">
  194. <div class="field wide"><label for="tokenInput">Token</label>
  195. <input type="password" id="tokenInput" class="mono" placeholder="paste api_token or a bot token" autocomplete="off">
  196. </div>
  197. </div>
  198. <div class="actions">
  199. <button class="btn gold" id="tokenSave">Save token</button>
  200. <button class="btn" id="tokenFileBtn">Upload token.json…</button>
  201. <input type="file" id="tokenFile" accept=".json,application/json" hidden>
  202. <button class="btn danger" id="tokenClear">Clear</button>
  203. <span class="hint" id="tokenStatus"></span>
  204. </div>
  205. </section>
  206. <!-- ============ upload ============ -->
  207. <section class="card">
  208. <h2>1 · Pour in your media</h2>
  209. <div class="sub">Drop a video file to upload it to the container for remuxing or re-encoding.</div>
  210. <div class="drop" id="drop" tabindex="0" role="button" aria-label="Upload a video file">
  211. <strong>Drop a file here</strong> or click to browse
  212. <div class="hint">MKV, MP4, AVI, TS, MOV, WebM — anything HandBrake can scan.</div>
  213. </div>
  214. <input type="file" id="fileInput" hidden>
  215. <div class="upbar" id="upbar">
  216. <div class="bar"><i id="upfill"></i></div>
  217. <div class="hint" id="uptext">Uploading…</div>
  218. </div>
  219. <div class="src-list" id="srcList"></div>
  220. </section>
  221. <!-- ============ options ============ -->
  222. <section class="card">
  223. <h2>2 · Dial in the brew</h2>
  224. <div class="sub">Every HandBrake feature is here — pick a preset, fine-tune the groups, or drop raw flags in Advanced.</div>
  225. <div class="grid" style="margin-bottom:4px">
  226. <div class="field">
  227. <label for="preset">Preset</label>
  228. <select id="preset"><option value="">(none — manual settings)</option></select>
  229. </div>
  230. <div class="field">
  231. <label for="o_format">Container</label>
  232. <select id="o_format">
  233. <option value="av_mkv">MKV (av_mkv)</option>
  234. <option value="av_mp4">MP4 (av_mp4)</option>
  235. <option value="av_webm">WebM (av_webm)</option>
  236. </select>
  237. </div>
  238. <div class="field">
  239. <label for="o_output_name">Output name (no extension)</label>
  240. <input type="text" id="o_output_name" placeholder="defaults to source name">
  241. </div>
  242. </div>
  243. <div class="tabs" id="tabs">
  244. <button class="tab on" data-pane="p-src">Source</button>
  245. <button class="tab" data-pane="p-vid">Video</button>
  246. <button class="tab" data-pane="p-aud">Audio</button>
  247. <button class="tab" data-pane="p-sub">Subtitles</button>
  248. <button class="tab" data-pane="p-pic">Picture</button>
  249. <button class="tab" data-pane="p-fil">Filters</button>
  250. <button class="tab" data-pane="p-adv">Advanced</button>
  251. </div>
  252. <!-- Source -->
  253. <div class="pane on" id="p-src"><div class="grid">
  254. <div class="field"><label>Title #</label><input type="number" id="o_title" min="0" placeholder="auto"></div>
  255. <div class="field"><label>Chapters</label><input type="text" id="o_chapters" placeholder="e.g. 1-3"></div>
  256. <div class="field"><label>Angle</label><input type="number" id="o_angle" min="1" placeholder=""></div>
  257. <div class="field"><label>Min duration (s)</label><input type="number" id="o_min_duration" placeholder=""></div>
  258. <div class="field"><label>Start at</label><input type="text" id="o_start_at" placeholder="seconds:30 / frames:100 / duration:00:01:00"></div>
  259. <div class="field"><label>Stop at</label><input type="text" id="o_stop_at" placeholder="same units as start"></div>
  260. <div class="field"><label>Previews</label><input type="text" id="o_previews" placeholder="e.g. 30:1"></div>
  261. <div class="field"><label>Start at preview #</label><input type="number" id="o_start_at_preview" placeholder=""></div>
  262. <div class="field wide">
  263. <label class="check"><input type="checkbox" id="o_main_feature"> Main feature title only (--main-feature)</label>
  264. <label class="check"><input type="checkbox" id="o_markers" checked> Chapter markers (--markers)</label>
  265. <label class="check"><input type="checkbox" id="o_optimize"> Web optimized / fast start (--optimize)</label>
  266. <label class="check"><input type="checkbox" id="o_align_av"> Align A/V start (--align-av)</label>
  267. <label class="check"><input type="checkbox" id="o_inline_parameter_sets"> Inline parameter sets (--inline-parameter-sets)</label>
  268. </div>
  269. <div class="field wide" id="scanBox">
  270. <button class="btn sm" id="scanBtn" type="button">Scan source for titles &amp; tracks</button>
  271. <div class="hint mono" id="scanOut"></div>
  272. </div>
  273. </div></div>
  274. <!-- Video -->
  275. <div class="pane" id="p-vid"><div class="grid">
  276. <div class="field"><label>Encoder</label><select id="o_encoder"><option value="">(preset default)</option></select>
  277. <div class="hint" id="hwHint"></div></div>
  278. <div class="field"><label>Encoder preset</label><input type="text" id="o_encoder_preset" placeholder="e.g. medium / p5 / quality"></div>
  279. <div class="field"><label>Encoder tune</label><input type="text" id="o_encoder_tune" placeholder="e.g. film, animation"></div>
  280. <div class="field"><label>Profile</label><input type="text" id="o_encoder_profile" placeholder="e.g. main, high, main10"></div>
  281. <div class="field"><label>Level</label><input type="text" id="o_encoder_level" placeholder="e.g. 4.1"></div>
  282. <div class="field"><label>Quality (CQ/RF)</label><input type="number" id="o_quality" step="0.25" placeholder="e.g. 22"></div>
  283. <div class="field"><label>Bitrate kb/s (--vb)</label><input type="number" id="o_vb" placeholder="overrides quality"></div>
  284. <div class="field"><label>Framerate (--rate)</label><input type="text" id="o_rate" placeholder="e.g. 23.976, 30"></div>
  285. <div class="field"><label>Framerate mode</label>
  286. <select id="o_fr_mode"><option value="">source default</option>
  287. <option value="cfr">Constant (--cfr)</option>
  288. <option value="vfr">Variable (--vfr)</option>
  289. <option value="pfr">Peak (--pfr)</option></select></div>
  290. <div class="field wide"><label>Extra encoder options (--encopts)</label>
  291. <input type="text" class="mono" id="o_encopts" placeholder="key=value:key=value"></div>
  292. <div class="field wide">
  293. <label class="check"><input type="checkbox" id="o_two_pass"> Two-pass encode (--two-pass)</label>
  294. <label class="check"><input type="checkbox" id="o_turbo"> Turbo first pass (--turbo)</label>
  295. <label class="check"><input type="checkbox" id="o_hwdec"> Hardware decoding (--enable-hw-decoding)</label>
  296. </div>
  297. </div></div>
  298. <!-- Audio -->
  299. <div class="pane" id="p-aud"><div class="grid">
  300. <div class="field"><label>Tracks (--audio)</label><input type="text" id="o_audio" placeholder="e.g. 1,2 or leave blank"></div>
  301. <div class="field"><label>Language list</label><input type="text" id="o_audio_lang_list" placeholder="e.g. eng,jpn"></div>
  302. <div class="field"><label>Encoder(s) (--aencoder)</label><input type="text" id="o_aencoder" placeholder="e.g. copy, av_aac, opus, flac"></div>
  303. <div class="field"><label>Copy mask</label><input type="text" id="o_audio_copy_mask" placeholder="e.g. aac,ac3,dtshd,truehd"></div>
  304. <div class="field"><label>Fallback encoder</label><input type="text" id="o_audio_fallback" placeholder="e.g. av_aac"></div>
  305. <div class="field"><label>Bitrate(s) kb/s (--ab)</label><input type="text" id="o_ab" placeholder="e.g. 160,256"></div>
  306. <div class="field"><label>Quality(ies) (--aq)</label><input type="text" id="o_aq" placeholder=""></div>
  307. <div class="field"><label>Compression (--ac)</label><input type="text" id="o_ac" placeholder=""></div>
  308. <div class="field"><label>Mixdown</label><input type="text" id="o_mixdown" placeholder="e.g. stereo, 5point1"></div>
  309. <div class="field"><label>Normalize mix</label><input type="text" id="o_normalize_mix" placeholder="0 or 1"></div>
  310. <div class="field"><label>Samplerate kHz (--arate)</label><input type="text" id="o_arate" placeholder="e.g. 48"></div>
  311. <div class="field"><label>DRC (--drc)</label><input type="text" id="o_drc" placeholder="1.0–4.0"></div>
  312. <div class="field"><label>Gain dB</label><input type="text" id="o_gain" placeholder="e.g. 2"></div>
  313. <div class="field"><label>Dither (--adither)</label><input type="text" id="o_adither" placeholder="auto / none / …"></div>
  314. <div class="field wide"><label>Track name(s) (--aname)</label><input type="text" id="o_aname" placeholder="comma-separated"></div>
  315. <div class="field wide">
  316. <label class="check"><input type="checkbox" id="o_all_audio"> Include all audio tracks (--all-audio)</label>
  317. <label class="check"><input type="checkbox" id="o_first_audio"> First audio track only (--first-audio)</label>
  318. </div>
  319. </div></div>
  320. <!-- Subtitles -->
  321. <div class="pane" id="p-sub"><div class="grid">
  322. <div class="field"><label>Tracks (--subtitle)</label><input type="text" id="o_subtitle" placeholder="e.g. 1,2 / scan"></div>
  323. <div class="field"><label>Language list</label><input type="text" id="o_subtitle_lang_list" placeholder="e.g. eng"></div>
  324. <div class="field"><label>Forced only (--subtitle-forced)</label><input type="text" id="o_subtitle_forced" placeholder="track #s or blank"></div>
  325. <div class="field"><label>Burn in (--subtitle-burned)</label><input type="text" id="o_subtitle_burned" placeholder="track # / native / none"></div>
  326. <div class="field"><label>Default track</label><input type="text" id="o_subtitle_default" placeholder="track # / none"></div>
  327. <div class="field"><label>Track name(s) (--subname)</label><input type="text" id="o_subname" placeholder=""></div>
  328. <div class="field"><label>Native language</label><input type="text" id="o_native_language" placeholder="e.g. eng"></div>
  329. <div class="field wide"><label>External SRT file path (--srt-file)</label>
  330. <input type="text" class="mono" id="o_srt_file" placeholder="/var/lib/webrake/uploads/…"></div>
  331. <div class="field"><label>SRT codeset</label><input type="text" id="o_srt_codeset" placeholder="UTF-8"></div>
  332. <div class="field"><label>SRT offset ms</label><input type="text" id="o_srt_offset" placeholder=""></div>
  333. <div class="field"><label>SRT language</label><input type="text" id="o_srt_lang" placeholder="e.g. eng"></div>
  334. <div class="field"><label>SRT burn (track #)</label><input type="text" id="o_srt_burn" placeholder=""></div>
  335. <div class="field wide">
  336. <label class="check"><input type="checkbox" id="o_all_subtitles"> Include all subtitles (--all-subtitles)</label>
  337. <label class="check"><input type="checkbox" id="o_first_subtitle"> First subtitle only (--first-subtitle)</label>
  338. <label class="check"><input type="checkbox" id="o_native_dub"> Prefer native-language dub (--native-dub)</label>
  339. </div>
  340. </div></div>
  341. <!-- Picture -->
  342. <div class="pane" id="p-pic"><div class="grid">
  343. <div class="field"><label>Width</label><input type="number" id="o_width" placeholder=""></div>
  344. <div class="field"><label>Height</label><input type="number" id="o_height" placeholder=""></div>
  345. <div class="field"><label>Max width</label><input type="number" id="o_maxWidth" placeholder=""></div>
  346. <div class="field"><label>Max height</label><input type="number" id="o_maxHeight" placeholder=""></div>
  347. <div class="field"><label>Crop T:B:L:R</label><input type="text" id="o_crop" placeholder="e.g. 0:0:0:0"></div>
  348. <div class="field"><label>Crop mode</label>
  349. <select id="o_crop_mode"><option value="">default</option>
  350. <option>auto</option><option>conservative</option><option>none</option></select></div>
  351. <div class="field"><label>Anamorphic</label>
  352. <select id="o_anamorphic"><option value="">default</option>
  353. <option value="auto_anamorphic">auto</option>
  354. <option value="loose_anamorphic">loose</option>
  355. <option value="custom_anamorphic">custom</option>
  356. <option value="non_anamorphic">none</option></select></div>
  357. <div class="field"><label>Display width</label><input type="number" id="o_display_width" placeholder=""></div>
  358. <div class="field"><label>Pixel aspect X:Y</label><input type="text" id="o_pixel_aspect" placeholder="e.g. 1:1"></div>
  359. <div class="field"><label>Modulus</label><input type="number" id="o_modulus" placeholder="2"></div>
  360. <div class="field"><label>Color matrix</label><input type="text" id="o_color_matrix" placeholder="709 / 601 / 2020 / …"></div>
  361. <div class="field wide">
  362. <label class="check"><input type="checkbox" id="o_keep_display_aspect"> Keep display aspect (--keep-display-aspect)</label>
  363. </div>
  364. </div></div>
  365. <!-- Filters -->
  366. <div class="pane" id="p-fil"><div class="grid">
  367. <div class="field"><label>Comb detect</label><input type="text" id="o_comb_detect" placeholder="blank=off · 'default' or settings"></div>
  368. <div class="field"><label>Deinterlace</label><input type="text" id="o_deinterlace" placeholder="'default' or preset/settings"></div>
  369. <div class="field"><label>Decomb</label><input type="text" id="o_decomb" placeholder="'default' or settings"></div>
  370. <div class="field"><label>Detelecine</label><input type="text" id="o_detelecine" placeholder="'default' or settings"></div>
  371. <div class="field"><label>Denoise hqdn3d</label><input type="text" id="o_hqdn3d" placeholder="light / medium / strong / custom"></div>
  372. <div class="field"><label>Denoise NLMeans</label><input type="text" id="o_nlmeans" placeholder="ultralight … strong"></div>
  373. <div class="field"><label>NLMeans tune</label><input type="text" id="o_nlmeans_tune" placeholder="film / grain / …"></div>
  374. <div class="field"><label>Chroma smooth</label><input type="text" id="o_chroma_smooth" placeholder=""></div>
  375. <div class="field"><label>Unsharp</label><input type="text" id="o_unsharp" placeholder="light / medium / strong"></div>
  376. <div class="field"><label>Lapsharp</label><input type="text" id="o_lapsharp" placeholder=""></div>
  377. <div class="field"><label>Deblock</label><input type="text" id="o_deblock" placeholder=""></div>
  378. <div class="field"><label>Rotate</label><input type="text" id="o_rotate" placeholder="angle=90:hflip=0"></div>
  379. <div class="field"><label>Pad</label><input type="text" id="o_pad" placeholder="W:H:color:X:Y"></div>
  380. <div class="field"><label>Colorspace</label><input type="text" id="o_colorspace" placeholder="bt709 / bt2020 / …"></div>
  381. <div class="field wide">
  382. <label class="check"><input type="checkbox" id="o_grayscale"> Grayscale (--grayscale)</label>
  383. </div>
  384. <div class="hint wide field">Filter fields map to HandBrake's optional-value flags: enter <code>default</code> to enable with defaults, or paste full settings strings.</div>
  385. </div></div>
  386. <!-- Advanced -->
  387. <div class="pane" id="p-adv"><div class="grid">
  388. <div class="field wide"><label>Raw HandBrakeCLI flags — everything else lives here</label>
  389. <textarea id="o_extra_args" class="mono" placeholder="--preset-import-file /path/custom.json --subtitle-lang-list eng --queue-import-file …"></textarea>
  390. <div class="hint">Appended verbatim to the command line, so the entire HandBrake flag surface is available. See <code>HandBrakeCLI --help</code>.</div>
  391. </div>
  392. <div class="field wide"><label>Command preview</label>
  393. <textarea id="cmdPreview" class="mono" readonly rows="3"></textarea></div>
  394. </div></div>
  395. <div class="actions">
  396. <button class="btn gold" id="startBtn">Start encode</button>
  397. <span class="hint" id="startHint">Select a source above first.</span>
  398. </div>
  399. </section>
  400. <!-- ============ queue ============ -->
  401. <section class="card">
  402. <h2>3 · The counter</h2>
  403. <div class="sub">Live queue — watch progress, then download the finished cup back to this PC.</div>
  404. <div id="jobs"><div class="empty">Nothing brewing yet.</div></div>
  405. </section>
  406. <footer>WeBrake · <span id="verline"></span> · <a href="https://gogs.av2x.dev/av2x/WeBrake">source</a></footer>
  407. </div>
  408. <div class="toast" id="toast"></div>
  409. <script>
  410. "use strict";
  411. /* ---------------- helpers ---------------- */
  412. const $ = s => document.querySelector(s);
  413. const $$ = s => [...document.querySelectorAll(s)];
  414. let API_TOKEN = localStorage.getItem("webrake_token") || "";
  415. let AUTH_NEEDED = false;
  416. function headers(extra){ const h = extra||{}; if(API_TOKEN) h["X-API-Token"]=API_TOKEN; return h; }
  417. function markAuthNeeded(){
  418. AUTH_NEEDED = true;
  419. $("#tokenBtn").classList.add("attn");
  420. $("#tokenStatus").textContent = "A token is required — paste it or upload token.json.";
  421. if($("#tokenPanel").hidden){ $("#tokenPanel").hidden = false; }
  422. }
  423. async function api(path, opts={}){
  424. opts.headers = headers(opts.headers);
  425. const r = await fetch(path, opts);
  426. if(r.status===401){ markAuthNeeded(); throw new Error("API token required"); }
  427. if(!r.ok){ let e={}; try{e=await r.json()}catch{} throw new Error(e.error||r.statusText); }
  428. return r;
  429. }
  430. function toast(msg){ const t=$("#toast"); t.textContent=msg; t.classList.add("show");
  431. clearTimeout(t._h); t._h=setTimeout(()=>t.classList.remove("show"),2600); }
  432. function fmtSize(b){ if(b>1e9)return (b/1e9).toFixed(2)+" GB"; if(b>1e6)return (b/1e6).toFixed(1)+" MB";
  433. return (b/1e3).toFixed(0)+" KB"; }
  434. function fmtEta(s){ if(!s)return ""; const h=~~(s/3600),m=~~(s%3600/60),ss=~~(s%60);
  435. return (h?h+"h ":"")+(m?m+"m ":"")+ss+"s"; }
  436. function esc(x){ const d=document.createElement("div"); d.textContent=x??""; return d.innerHTML; }
  437. /* ---------------- theme ---------------- */
  438. const sunPath = '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>';
  439. const moonPath = '<path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8z"/>';
  440. function setTheme(t){
  441. document.documentElement.dataset.theme=t;
  442. localStorage.setItem("webrake_theme",t);
  443. $("#themeIcon").innerHTML = t==="dark" ? sunPath : moonPath;
  444. }
  445. setTheme(localStorage.getItem("webrake_theme") ||
  446. (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"));
  447. $("#themeBtn").onclick = () =>
  448. setTheme(document.documentElement.dataset.theme==="dark" ? "light" : "dark");
  449. /* ---------------- token panel ---------------- */
  450. function tokenStatusLine(){
  451. $("#tokenStatus").textContent = API_TOKEN
  452. ? "Token saved in this browser." : (AUTH_NEEDED
  453. ? "A token is required — paste it or upload token.json." : "No token set (not required unless auth is enabled).");
  454. $("#tokenInput").value = API_TOKEN;
  455. }
  456. $("#tokenBtn").onclick = () => {
  457. const p = $("#tokenPanel"); p.hidden = !p.hidden;
  458. if(!p.hidden){ tokenStatusLine(); $("#tokenInput").focus(); }
  459. };
  460. function saveToken(tok){
  461. API_TOKEN = (tok||"").trim();
  462. localStorage.setItem("webrake_token", API_TOKEN);
  463. AUTH_NEEDED = false;
  464. $("#tokenBtn").classList.remove("attn");
  465. tokenStatusLine();
  466. toast(API_TOKEN ? "Token saved" : "Token cleared");
  467. bootData(); // re-pull everything with the new credentials
  468. }
  469. $("#tokenSave").onclick = () => saveToken($("#tokenInput").value);
  470. $("#tokenInput").addEventListener("keydown", e => { if(e.key==="Enter") saveToken(e.target.value); });
  471. $("#tokenClear").onclick = () => saveToken("");
  472. $("#tokenFileBtn").onclick = () => $("#tokenFile").click();
  473. $("#tokenFile").onchange = () => {
  474. const f = $("#tokenFile").files[0]; if(!f) return;
  475. const rd = new FileReader();
  476. rd.onload = () => {
  477. try{
  478. const j = JSON.parse(rd.result);
  479. const tok = j.api_token || Object.values(j.bots||{})[0];
  480. if(!tok) throw new Error("no api_token or bot token found");
  481. saveToken(tok);
  482. $("#tokenPanel").hidden = true;
  483. }catch(e){ $("#tokenStatus").textContent = "Couldn't read that file: "+e.message; }
  484. };
  485. rd.readAsText(f);
  486. $("#tokenFile").value = "";
  487. };
  488. /* ---------------- tabs ---------------- */
  489. $$("#tabs .tab").forEach(t => t.onclick = () => {
  490. $$("#tabs .tab").forEach(x=>x.classList.remove("on"));
  491. $$(".pane").forEach(x=>x.classList.remove("on"));
  492. t.classList.add("on"); $("#"+t.dataset.pane).classList.add("on");
  493. });
  494. /* ---------------- capabilities / presets ---------------- */
  495. let CAPS = {}, metaLoaded = false;
  496. async function loadMeta(){
  497. try{
  498. const v = await (await fetch("/api/version")).json();
  499. $("#verline").textContent = "v"+v.webrake+" · HandBrake "+(v.handbrake||"not found");
  500. if(v.auth_required && !API_TOKEN){ markAuthNeeded(); return; }
  501. if(metaLoaded) return;
  502. CAPS = await (await api("/api/capabilities")).json();
  503. metaLoaded = true;
  504. $("#chip-hb").textContent = CAPS.handbrake_found
  505. ? "HandBrake "+(CAPS.handbrake_version||"") : "HandBrake missing!";
  506. const chips = $("#chips"), btn = $("#themeBtn");
  507. for(const [k,label] of [["qsv","Intel QSV"],["nvenc","NVENC"],["vce","AMD VCE"],["vaapi","VAAPI"]]){
  508. if(CAPS[k]){ const c=document.createElement("span"); c.className="chip hw";
  509. c.textContent="⚡ "+label; chips.insertBefore(c,btn); }
  510. }
  511. const sel = $("#o_encoder");
  512. (CAPS.encoders||[]).forEach(e=>{
  513. const o=document.createElement("option"); o.value=e;
  514. o.textContent=(CAPS.hw_encoders||[]).includes(e) ? "⚡ "+e+" (GPU)" : e;
  515. sel.appendChild(o);
  516. });
  517. const hw=(CAPS.hw_encoders||[]);
  518. $("#hwHint").textContent = hw.length
  519. ? "GPU passthru detected — ⚡ encoders run on hardware."
  520. : "No hardware encoders detected; software encoders only.";
  521. const p = await (await api("/api/presets")).json();
  522. let lastCat=null, group=null, ps=$("#preset");
  523. (p.presets||[]).forEach(pr=>{
  524. if(pr.category!==lastCat){ group=document.createElement("optgroup");
  525. group.label=pr.category; ps.appendChild(group); lastCat=pr.category; }
  526. const o=document.createElement("option"); o.value=pr.name; o.textContent=pr.name;
  527. group.appendChild(o);
  528. });
  529. }catch(e){ toast("Backend unreachable: "+e.message); }
  530. }
  531. /* ---------------- uploads ---------------- */
  532. let SELECTED = null;
  533. const drop=$("#drop"), fi=$("#fileInput");
  534. drop.onclick=()=>fi.click();
  535. drop.onkeydown=e=>{ if(e.key==="Enter"||e.key===" ") fi.click(); };
  536. ["dragover","dragenter"].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.add("hover");}));
  537. ["dragleave","drop"].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.remove("hover");}));
  538. drop.addEventListener("drop",e=>{ if(e.dataTransfer.files.length) upload(e.dataTransfer.files[0]); });
  539. fi.onchange=()=>{ if(fi.files.length) upload(fi.files[0]); fi.value=""; };
  540. function upload(file){
  541. const xhr=new XMLHttpRequest(), fd=new FormData(); fd.append("file",file);
  542. $("#upbar").classList.add("show");
  543. const fill=$("#upfill"), txt=$("#uptext");
  544. xhr.upload.onprogress=e=>{
  545. if(e.lengthComputable){
  546. const pct=(e.loaded/e.total*100);
  547. fill.style.width=pct.toFixed(1)+"%";
  548. txt.textContent=`Uploading ${file.name} — ${pct.toFixed(1)}% of ${fmtSize(e.total)}`;
  549. }};
  550. xhr.onload=()=>{
  551. $("#upbar").classList.remove("show"); fill.style.width="0";
  552. if(xhr.status===200){
  553. let res={}; try{ res=JSON.parse(xhr.responseText) }catch{}
  554. SELECTED = res.upload_id || SELECTED; // freshly poured = active source
  555. toast("Uploaded "+file.name+" — selected as source");
  556. refreshUploads(); updateStartState(); previewCmd();
  557. }
  558. else if(xhr.status===401){ markAuthNeeded(); toast("Upload needs an API token"); }
  559. else{
  560. let msg=""; try{ msg=JSON.parse(xhr.responseText).error }catch{}
  561. toast("Upload failed ("+xhr.status+")"+(msg?": "+msg:""));
  562. }
  563. };
  564. xhr.onerror=()=>{ $("#upbar").classList.remove("show"); toast("Upload failed"); };
  565. xhr.open("POST","/api/upload");
  566. if(API_TOKEN) xhr.setRequestHeader("X-API-Token",API_TOKEN);
  567. xhr.send(fd);
  568. }
  569. async function refreshUploads(){
  570. try{
  571. const d = await (await api("/api/uploads")).json();
  572. const list=$("#srcList"); list.innerHTML="";
  573. d.uploads.forEach(u=>{
  574. const row=document.createElement("div");
  575. row.className="src"+(SELECTED===u.upload_id?" sel":"");
  576. row.innerHTML=`<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--gold)" stroke-width="2"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m10 9 5 3-5 3z" fill="var(--gold)"/></svg>
  577. <span class="name">${esc(u.filename)}</span>
  578. <span class="meta">${fmtSize(u.size)}</span>
  579. <button class="del" title="Delete upload" aria-label="Delete upload">✕</button>`;
  580. row.onclick=e=>{
  581. if(e.target.classList.contains("del")) return;
  582. SELECTED=u.upload_id; refreshUploads(); updateStartState(); previewCmd();
  583. };
  584. row.querySelector(".del").onclick=async ()=>{
  585. await api("/api/uploads/"+encodeURIComponent(u.upload_id),{method:"DELETE"});
  586. if(SELECTED===u.upload_id){SELECTED=null;updateStartState();}
  587. refreshUploads(); toast("Upload deleted");
  588. };
  589. list.appendChild(row);
  590. });
  591. if(!d.uploads.length) list.innerHTML='<div class="empty">No sources uploaded yet.</div>';
  592. if(SELECTED && !d.uploads.some(u=>u.upload_id===SELECTED)){
  593. SELECTED=null; updateStartState(); // file vanished server-side — don't pretend it's active
  594. }
  595. }catch(e){
  596. $("#srcList").innerHTML =
  597. `<div class="srcerr">Can't list sources: ${esc(e.message)}${AUTH_NEEDED?" — set your token via the key button above.":""}</div>`;
  598. }
  599. }
  600. function updateStartState(){
  601. $("#startBtn").disabled=!SELECTED;
  602. $("#startHint").innerHTML = SELECTED
  603. ? `<span class="active-src">Active source: <b>${esc(SELECTED.split("_").slice(1).join("_")||SELECTED)}</b></span>`
  604. : "Select a source above first.";
  605. }
  606. /* ---------------- scan ---------------- */
  607. $("#scanBtn").onclick=async ()=>{
  608. if(!SELECTED){ toast("Select a source first"); return; }
  609. $("#scanOut").textContent="Scanning…";
  610. try{
  611. const d = await (await api("/api/scan/"+encodeURIComponent(SELECTED))).json();
  612. const lines=(d.TitleList||[]).map(t=>
  613. `Title ${t.Index}: ${t.Duration?`${t.Duration.Hours}:${String(t.Duration.Minutes).padStart(2,"0")}:${String(t.Duration.Seconds).padStart(2,"0")}`:""} · `+
  614. `${t.Geometry?t.Geometry.Width+"×"+t.Geometry.Height:""} · `+
  615. `${(t.AudioList||[]).length} audio · ${(t.SubtitleList||[]).length} subs`);
  616. $("#scanOut").textContent=lines.join("\n")||"No titles found.";
  617. }catch(e){ $("#scanOut").textContent="Scan failed: "+e.message; }
  618. };
  619. /* ---------------- options collection ---------------- */
  620. const TEXT_KEYS=["title","chapters","angle","min_duration","start_at","stop_at","previews",
  621. "start_at_preview","encoder","encoder_preset","encoder_tune","encoder_profile","encoder_level",
  622. "quality","vb","rate","encopts","audio","audio_lang_list","aencoder","audio_copy_mask",
  623. "audio_fallback","ab","aq","ac","mixdown","normalize_mix","arate","drc","gain","adither","aname",
  624. "subtitle","subtitle_lang_list","subtitle_forced","subtitle_burned","subtitle_default","subname",
  625. "native_language","srt_file","srt_codeset","srt_offset","srt_lang","srt_burn",
  626. "width","height","maxWidth","maxHeight","crop","crop_mode","display_width","pixel_aspect",
  627. "modulus","color_matrix","comb_detect","deinterlace","decomb","detelecine","hqdn3d","nlmeans",
  628. "nlmeans_tune","chroma_smooth","unsharp","lapsharp","deblock","rotate","pad","colorspace",
  629. "output_name","extra_args","preset_import_file"];
  630. const BOOL_KEYS=["main_feature","markers","optimize","align_av","inline_parameter_sets",
  631. "two_pass","turbo","all_audio","first_audio","all_subtitles","first_subtitle","native_dub",
  632. "keep_display_aspect","grayscale"];
  633. const FILTER_KEYS=new Set(["comb_detect","deinterlace","decomb","detelecine","hqdn3d","nlmeans",
  634. "chroma_smooth","unsharp","lapsharp","deblock","rotate","subtitle_forced","subtitle_burned",
  635. "subtitle_default","srt_burn"]);
  636. function collectOptions(){
  637. const o={};
  638. const preset=$("#preset").value; if(preset) o.preset=preset;
  639. o.format=$("#o_format").value;
  640. for(const k of TEXT_KEYS){
  641. const el=document.getElementById("o_"+k); if(!el) continue;
  642. let v=el.value.trim(); if(v==="") continue;
  643. if(FILTER_KEYS.has(k) && v.toLowerCase()==="default") v=true;
  644. o[k]=v;
  645. }
  646. for(const k of BOOL_KEYS){
  647. const el=document.getElementById("o_"+k);
  648. if(el && el.checked) o[k]=true;
  649. }
  650. const fr=$("#o_fr_mode").value; if(fr) o[fr]=true;
  651. const an=$("#o_anamorphic").value; if(an) o[an]=true;
  652. if($("#o_hwdec").checked) o.enable_hw_decoding =
  653. CAPS.nvenc ? "nvdec" : (CAPS.qsv ? "qsv" : "videotoolbox");
  654. return o;
  655. }
  656. function previewCmd(){
  657. const o=collectOptions(), parts=["HandBrakeCLI --json -i <source> -o <output>"];
  658. for(const [k,v] of Object.entries(o)){
  659. if(k==="output_name") continue;
  660. const flag="--"+k.replace(/_/g,"-");
  661. if(v===true) parts.push(flag);
  662. else if(k==="extra_args") parts.push(v);
  663. else parts.push(flag+" "+v);
  664. }
  665. $("#cmdPreview").value=parts.join(" ");
  666. }
  667. document.addEventListener("input",previewCmd);
  668. document.addEventListener("change",previewCmd);
  669. /* ---------------- jobs ---------------- */
  670. $("#startBtn").onclick=async ()=>{
  671. if(!SELECTED) return;
  672. try{
  673. await api("/api/jobs",{method:"POST",
  674. headers:{"Content-Type":"application/json"},
  675. body:JSON.stringify({upload_id:SELECTED,options:collectOptions()})});
  676. toast("Job queued"); refreshJobs();
  677. }catch(e){ toast("Could not queue job: "+e.message); }
  678. };
  679. const openLogs=new Set();
  680. async function refreshJobs(){
  681. try{
  682. const d=await (await api("/api/jobs")).json();
  683. const box=$("#jobs");
  684. if(!d.jobs.length){ box.innerHTML='<div class="empty">Nothing brewing yet.</div>'; return; }
  685. box.innerHTML="";
  686. d.jobs.forEach(j=>{
  687. const el=document.createElement("div"); el.className="job"; el.dataset.id=j.id;
  688. const running=["running","scanning","queued"].includes(j.status);
  689. const stats=[];
  690. if(j.fps) stats.push(j.fps+" fps");
  691. if(j.eta) stats.push("ETA "+fmtEta(j.eta));
  692. if(j.pass&&j.pass>0) stats.push("pass "+j.pass);
  693. el.innerHTML=`
  694. <div class="row">
  695. <span class="fname">${esc(j.filename)}</span>
  696. <span class="status ${j.status}">${j.status}</span>
  697. <span class="stats">${stats.map(esc).join(" · ")}</span>
  698. </div>
  699. <div class="bar ${running?"":"static"}"><i style="width:${j.progress||0}%"></i></div>
  700. ${j.error?`<div class="errline">${esc(j.error)}</div>`:""}
  701. <div class="btns">
  702. ${j.status==="done"?`<a class="btn sm gold" href="/api/download/${j.id}${API_TOKEN?"?token="+encodeURIComponent(API_TOKEN):""}" download>Download</a>`:""}
  703. ${running?`<button class="btn sm danger" data-act="cancel">Cancel</button>`:""}
  704. <button class="btn sm" data-act="log">${openLogs.has(j.id)?"Hide log":"Log"}</button>
  705. ${!running?`<button class="btn sm danger" data-act="del">Delete</button>`:""}
  706. </div>
  707. <pre class="logbox ${openLogs.has(j.id)?"show":""}"></pre>`;
  708. el.querySelectorAll("[data-act]").forEach(b=>b.onclick=async ()=>{
  709. const act=b.dataset.act;
  710. if(act==="cancel"){ await api(`/api/jobs/${j.id}/cancel`,{method:"POST"}); toast("Cancelling…"); }
  711. if(act==="del"){ await api(`/api/jobs/${j.id}`,{method:"DELETE"}); toast("Job deleted"); }
  712. if(act==="log"){ openLogs.has(j.id)?openLogs.delete(j.id):openLogs.add(j.id); }
  713. refreshJobs();
  714. });
  715. if(openLogs.has(j.id)){
  716. api(`/api/jobs/${j.id}/log`).then(r=>r.text()).then(t=>{
  717. const lb=el.querySelector(".logbox"); lb.textContent=t; lb.scrollTop=lb.scrollHeight;
  718. }).catch(()=>{});
  719. }
  720. box.appendChild(el);
  721. });
  722. }catch(e){
  723. if(AUTH_NEEDED) $("#jobs").innerHTML =
  724. '<div class="srcerr">Token required to view the queue — set it via the key button above.</div>';
  725. }
  726. }
  727. /* ---------------- boot ---------------- */
  728. function bootData(){ loadMeta(); refreshUploads(); refreshJobs(); }
  729. bootData(); updateStartState(); previewCmd();
  730. setInterval(()=>{ if(!AUTH_NEEDED) refreshJobs(); },2000);
  731. setInterval(()=>{ if(!AUTH_NEEDED) refreshUploads(); },15000);
  732. </script>
  733. </body>
  734. </html>