fetchforge 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fetchforge/__init__.py +3 -0
- fetchforge/__main__.py +4 -0
- fetchforge/cli.py +27 -0
- fetchforge/index.html +2978 -0
- fetchforge/provision.py +116 -0
- fetchforge/server.py +2198 -0
- fetchforge-2.0.0.dist-info/METADATA +131 -0
- fetchforge-2.0.0.dist-info/RECORD +12 -0
- fetchforge-2.0.0.dist-info/WHEEL +5 -0
- fetchforge-2.0.0.dist-info/entry_points.txt +2 -0
- fetchforge-2.0.0.dist-info/licenses/LICENSE +21 -0
- fetchforge-2.0.0.dist-info/top_level.txt +1 -0
fetchforge/index.html
ADDED
|
@@ -0,0 +1,2978 @@
|
|
|
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
|
+
<meta name="dlpr-token" content="__DLPR_TOKEN__">
|
|
7
|
+
<title>FetchForge — YouTube Downloader & H.265 Converter</title>
|
|
8
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
9
|
+
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
10
|
+
<style>
|
|
11
|
+
:root {
|
|
12
|
+
--bg: #111318;
|
|
13
|
+
--surface: #1a1d25;
|
|
14
|
+
--surface2: #22262f;
|
|
15
|
+
--border: #2d3240;
|
|
16
|
+
--border2: #3a3f50;
|
|
17
|
+
--accent: #e8a023;
|
|
18
|
+
--accent2: #ef5350;
|
|
19
|
+
--accent3: #4dd0a0;
|
|
20
|
+
--accent4: #64b5f6;
|
|
21
|
+
--text: #e8e4df;
|
|
22
|
+
--text2: #8a8696;
|
|
23
|
+
--muted: #5e5b6a;
|
|
24
|
+
--mono: 'JetBrains Mono', monospace;
|
|
25
|
+
--sans: 'Outfit', sans-serif;
|
|
26
|
+
--radius: 10px;
|
|
27
|
+
--radius-sm: 7px;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
31
|
+
|
|
32
|
+
body {
|
|
33
|
+
background: var(--bg);
|
|
34
|
+
color: var(--text);
|
|
35
|
+
font-family: var(--sans);
|
|
36
|
+
font-size: 15px;
|
|
37
|
+
line-height: 1.6;
|
|
38
|
+
min-height: 100vh;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.container {
|
|
42
|
+
max-width: 900px;
|
|
43
|
+
margin: 0 auto;
|
|
44
|
+
padding: 36px 28px 70px;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@keyframes fadeUp {
|
|
48
|
+
from { opacity: 0; transform: translateY(8px); }
|
|
49
|
+
to { opacity: 1; transform: translateY(0); }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
header {
|
|
53
|
+
margin-bottom: 32px;
|
|
54
|
+
padding-bottom: 20px;
|
|
55
|
+
border-bottom: 1px solid var(--border);
|
|
56
|
+
animation: fadeUp 0.4s ease both;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.header-brand {
|
|
60
|
+
display: flex;
|
|
61
|
+
align-items: center;
|
|
62
|
+
justify-content: space-between;
|
|
63
|
+
margin-bottom: 12px;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.channel-logo {
|
|
67
|
+
width: 80px;
|
|
68
|
+
height: 80px;
|
|
69
|
+
border-radius: 50%;
|
|
70
|
+
border: 2px solid var(--border2);
|
|
71
|
+
flex-shrink: 0;
|
|
72
|
+
object-fit: cover;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.logo {
|
|
76
|
+
font-family: var(--sans);
|
|
77
|
+
font-weight: 800;
|
|
78
|
+
font-size: 28px;
|
|
79
|
+
letter-spacing: -0.5px;
|
|
80
|
+
color: var(--accent);
|
|
81
|
+
line-height: 1.2;
|
|
82
|
+
white-space: nowrap;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.logo span {
|
|
86
|
+
/* colored words inherit logo size — no override */
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.hero-blurb {
|
|
90
|
+
font-size: 14px;
|
|
91
|
+
color: var(--text2);
|
|
92
|
+
line-height: 1.6;
|
|
93
|
+
margin-top: -12px;
|
|
94
|
+
margin-bottom: 28px;
|
|
95
|
+
max-width: 680px;
|
|
96
|
+
}
|
|
97
|
+
.hero-blurb strong { color: var(--text); font-weight: 600; }
|
|
98
|
+
|
|
99
|
+
.header-status {
|
|
100
|
+
display: flex;
|
|
101
|
+
flex-wrap: wrap;
|
|
102
|
+
gap: 6px;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.status-pill {
|
|
106
|
+
display: inline-flex;
|
|
107
|
+
align-items: center;
|
|
108
|
+
gap: 7px;
|
|
109
|
+
font-family: var(--mono);
|
|
110
|
+
font-size: 11px;
|
|
111
|
+
color: var(--text2);
|
|
112
|
+
background: var(--surface);
|
|
113
|
+
border: 1px solid var(--border);
|
|
114
|
+
padding: 4px 12px;
|
|
115
|
+
border-radius: 16px;
|
|
116
|
+
transition: border-color 0.2s;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.status-pill:hover {
|
|
120
|
+
border-color: var(--border2);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.dot {
|
|
124
|
+
width: 7px; height: 7px;
|
|
125
|
+
border-radius: 50%;
|
|
126
|
+
background: var(--accent3);
|
|
127
|
+
box-shadow: 0 0 6px rgba(77,208,160,0.5);
|
|
128
|
+
animation: pulse 2.5s infinite;
|
|
129
|
+
}
|
|
130
|
+
.dot.warn { background: var(--accent2); box-shadow: 0 0 6px rgba(239,83,80,0.5); animation: none; }
|
|
131
|
+
|
|
132
|
+
@keyframes pulse { 0%,100%{opacity:1;} 50%{opacity:0.3;} }
|
|
133
|
+
|
|
134
|
+
.card {
|
|
135
|
+
background: var(--surface);
|
|
136
|
+
border: 1px solid var(--border);
|
|
137
|
+
border-radius: var(--radius);
|
|
138
|
+
margin-bottom: 14px;
|
|
139
|
+
transition: border-color 0.2s ease;
|
|
140
|
+
animation: fadeUp 0.4s ease both;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.card:nth-child(2) { animation-delay: 0.03s; }
|
|
144
|
+
.card:nth-child(3) { animation-delay: 0.06s; }
|
|
145
|
+
.card:nth-child(4) { animation-delay: 0.09s; }
|
|
146
|
+
.card:nth-child(5) { animation-delay: 0.12s; }
|
|
147
|
+
.card:nth-child(6) { animation-delay: 0.15s; }
|
|
148
|
+
.card:nth-child(7) { animation-delay: 0.18s; }
|
|
149
|
+
|
|
150
|
+
.card:hover {
|
|
151
|
+
border-color: var(--border2);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.card-header {
|
|
155
|
+
display: flex;
|
|
156
|
+
align-items: center;
|
|
157
|
+
justify-content: space-between;
|
|
158
|
+
padding: 12px 20px;
|
|
159
|
+
background: rgba(255,255,255,0.02);
|
|
160
|
+
border-bottom: 1px solid var(--border);
|
|
161
|
+
border-radius: var(--radius) var(--radius) 0 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.card-title {
|
|
165
|
+
font-family: var(--sans);
|
|
166
|
+
font-size: 13px;
|
|
167
|
+
font-weight: 600;
|
|
168
|
+
color: var(--text2);
|
|
169
|
+
letter-spacing: 0.5px;
|
|
170
|
+
display: flex;
|
|
171
|
+
align-items: center;
|
|
172
|
+
gap: 10px;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.card-num {
|
|
176
|
+
font-family: var(--mono);
|
|
177
|
+
font-size: 11px;
|
|
178
|
+
font-weight: 700;
|
|
179
|
+
color: var(--accent);
|
|
180
|
+
background: rgba(232,160,35,0.1);
|
|
181
|
+
border: 1px solid rgba(232,160,35,0.2);
|
|
182
|
+
padding: 2px 8px;
|
|
183
|
+
border-radius: 4px;
|
|
184
|
+
letter-spacing: 0.5px;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.card-body { padding: 20px; }
|
|
188
|
+
|
|
189
|
+
.input-group { display: flex; gap: 8px; }
|
|
190
|
+
|
|
191
|
+
input[type="url"], input[type="text"] {
|
|
192
|
+
flex: 1;
|
|
193
|
+
background: var(--surface2);
|
|
194
|
+
border: 1px solid var(--border);
|
|
195
|
+
color: var(--text);
|
|
196
|
+
font-family: var(--mono);
|
|
197
|
+
font-size: 13px;
|
|
198
|
+
padding: 11px 14px;
|
|
199
|
+
border-radius: var(--radius-sm);
|
|
200
|
+
outline: none;
|
|
201
|
+
transition: border-color 0.15s, box-shadow 0.15s;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
input[type="url"]:focus, input[type="text"]:focus {
|
|
205
|
+
border-color: var(--accent);
|
|
206
|
+
box-shadow: 0 0 0 2px rgba(232,160,35,0.15);
|
|
207
|
+
}
|
|
208
|
+
input[type="file"] { display: none; }
|
|
209
|
+
|
|
210
|
+
.btn {
|
|
211
|
+
font-family: var(--sans);
|
|
212
|
+
font-weight: 600;
|
|
213
|
+
font-size: 13px;
|
|
214
|
+
letter-spacing: 0.5px;
|
|
215
|
+
text-transform: uppercase;
|
|
216
|
+
border: none;
|
|
217
|
+
cursor: pointer;
|
|
218
|
+
padding: 11px 22px;
|
|
219
|
+
border-radius: var(--radius-sm);
|
|
220
|
+
transition: all 0.15s ease;
|
|
221
|
+
white-space: nowrap;
|
|
222
|
+
position: relative;
|
|
223
|
+
overflow: hidden;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
.btn::after { display: none; }
|
|
227
|
+
|
|
228
|
+
.btn-primary { background: var(--accent); color: #111; }
|
|
229
|
+
.btn-primary:hover { background: #f0b429; transform: translateY(-1px); box-shadow: 0 4px 16px rgba(232,160,35,0.25); }
|
|
230
|
+
.btn-success { background: var(--accent3); color: #111; }
|
|
231
|
+
.btn-success:hover { transform: translateY(-1px); box-shadow: 0 4px 16px rgba(77,208,160,0.25); }
|
|
232
|
+
.btn-danger { background: var(--accent2); color: #fff; }
|
|
233
|
+
.btn-danger:hover { transform: translateY(-1px); box-shadow: 0 4px 16px rgba(239,83,80,0.25); }
|
|
234
|
+
.btn-outline { background: transparent; color: var(--text2); border: 1px solid var(--border2); }
|
|
235
|
+
.btn-outline:hover { border-color: var(--accent); color: var(--accent); transform: translateY(-1px); }
|
|
236
|
+
.btn:disabled { opacity: 0.3; cursor: not-allowed; transform: none !important; box-shadow: none !important; }
|
|
237
|
+
.btn-sm { padding: 7px 14px; font-size: 11px; }
|
|
238
|
+
.btn:active:not(:disabled) { transform: translateY(0px) scale(0.98); }
|
|
239
|
+
|
|
240
|
+
.cookies-row { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
|
|
241
|
+
|
|
242
|
+
.cookies-paste-row { margin-top: 14px; padding-top: 14px; border-top: 1px dashed var(--border); }
|
|
243
|
+
.cookies-paste-row textarea {
|
|
244
|
+
width: 100%;
|
|
245
|
+
min-height: 70px;
|
|
246
|
+
box-sizing: border-box;
|
|
247
|
+
background: var(--bg);
|
|
248
|
+
border: 1px solid var(--border);
|
|
249
|
+
border-radius: 6px;
|
|
250
|
+
color: var(--text);
|
|
251
|
+
font-family: var(--mono);
|
|
252
|
+
font-size: 11px;
|
|
253
|
+
padding: 10px 12px;
|
|
254
|
+
resize: vertical;
|
|
255
|
+
}
|
|
256
|
+
.cookies-paste-row textarea:focus { outline: none; border-color: var(--accent); }
|
|
257
|
+
.cookies-paste-actions { display: flex; align-items: center; gap: 12px; margin-top: 8px; flex-wrap: wrap; }
|
|
258
|
+
.cookies-paste-hint { font-size: 12px; color: var(--muted); margin-bottom: 8px; }
|
|
259
|
+
|
|
260
|
+
.badge {
|
|
261
|
+
font-family: var(--mono);
|
|
262
|
+
font-size: 11px;
|
|
263
|
+
padding: 4px 10px;
|
|
264
|
+
border-radius: 14px;
|
|
265
|
+
}
|
|
266
|
+
.badge-ok { background: rgba(77,208,160,0.12); color: var(--accent3); border: 1px solid rgba(77,208,160,0.25); }
|
|
267
|
+
.badge-warn { background: rgba(239,83,80,0.1); color: var(--accent2); border: 1px solid rgba(239,83,80,0.2); }
|
|
268
|
+
|
|
269
|
+
.video-info {
|
|
270
|
+
display: none;
|
|
271
|
+
gap: 18px;
|
|
272
|
+
margin-top: 18px;
|
|
273
|
+
padding-top: 18px;
|
|
274
|
+
border-top: 1px solid var(--border);
|
|
275
|
+
}
|
|
276
|
+
.video-info.visible { display: flex; }
|
|
277
|
+
|
|
278
|
+
.thumb {
|
|
279
|
+
width: 190px;
|
|
280
|
+
flex-shrink: 0;
|
|
281
|
+
aspect-ratio: 16/9;
|
|
282
|
+
object-fit: cover;
|
|
283
|
+
border: 1px solid var(--border);
|
|
284
|
+
border-radius: var(--radius-sm);
|
|
285
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
|
286
|
+
transition: transform 0.2s ease;
|
|
287
|
+
}
|
|
288
|
+
.thumb:hover { transform: scale(1.03); }
|
|
289
|
+
|
|
290
|
+
.video-meta { flex: 1; min-width: 0; }
|
|
291
|
+
|
|
292
|
+
.video-title {
|
|
293
|
+
font-family: var(--sans);
|
|
294
|
+
font-size: 17px;
|
|
295
|
+
font-weight: 700;
|
|
296
|
+
line-height: 1.3;
|
|
297
|
+
color: var(--text);
|
|
298
|
+
margin-bottom: 6px;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
.meta-line { font-size: 13px; color: var(--text2); margin-bottom: 3px; }
|
|
302
|
+
|
|
303
|
+
.format-row {
|
|
304
|
+
display: grid;
|
|
305
|
+
grid-template-columns: 1fr 1fr;
|
|
306
|
+
gap: 14px;
|
|
307
|
+
margin-top: 16px;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
.field { display: flex; flex-direction: column; gap: 5px; }
|
|
311
|
+
|
|
312
|
+
.field-label {
|
|
313
|
+
font-family: var(--mono);
|
|
314
|
+
font-size: 10px;
|
|
315
|
+
letter-spacing: 1.5px;
|
|
316
|
+
color: var(--muted);
|
|
317
|
+
text-transform: uppercase;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
select {
|
|
321
|
+
background: var(--surface2);
|
|
322
|
+
border: 1px solid var(--border);
|
|
323
|
+
color: var(--text);
|
|
324
|
+
font-family: var(--mono);
|
|
325
|
+
font-size: 12px;
|
|
326
|
+
padding: 9px 30px 9px 12px;
|
|
327
|
+
border-radius: var(--radius-sm);
|
|
328
|
+
outline: none;
|
|
329
|
+
cursor: pointer;
|
|
330
|
+
appearance: none;
|
|
331
|
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%235e5b6a'/%3E%3C/svg%3E");
|
|
332
|
+
background-repeat: no-repeat;
|
|
333
|
+
background-position: right 10px center;
|
|
334
|
+
width: 100%;
|
|
335
|
+
transition: border-color 0.2s, box-shadow 0.2s;
|
|
336
|
+
}
|
|
337
|
+
select:focus { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(232,160,35,0.15); }
|
|
338
|
+
select:hover { border-color: var(--border2); }
|
|
339
|
+
|
|
340
|
+
/* Preset cards */
|
|
341
|
+
.preset-grid {
|
|
342
|
+
display: grid;
|
|
343
|
+
grid-template-columns: repeat(2, 1fr);
|
|
344
|
+
gap: 10px;
|
|
345
|
+
margin-bottom: 16px;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
.preset-card {
|
|
349
|
+
background: var(--surface2);
|
|
350
|
+
border: 1px solid var(--border);
|
|
351
|
+
padding: 14px 16px;
|
|
352
|
+
cursor: pointer;
|
|
353
|
+
transition: all 0.2s ease;
|
|
354
|
+
display: block;
|
|
355
|
+
position: relative;
|
|
356
|
+
border-radius: var(--radius-sm);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
.preset-card:hover { border-color: var(--border2); transform: translateY(-1px); }
|
|
360
|
+
.preset-card.selected { border-color: var(--accent); background: rgba(232,160,35,0.06); box-shadow: 0 0 0 1px var(--accent); }
|
|
361
|
+
.preset-card input[type="radio"] { position: absolute; opacity: 0; width: 0; height: 0; }
|
|
362
|
+
|
|
363
|
+
.preset-name {
|
|
364
|
+
font-family: var(--sans);
|
|
365
|
+
font-weight: 700;
|
|
366
|
+
font-size: 15px;
|
|
367
|
+
color: var(--text);
|
|
368
|
+
margin-bottom: 4px;
|
|
369
|
+
}
|
|
370
|
+
.preset-card.selected .preset-name { color: var(--accent); }
|
|
371
|
+
|
|
372
|
+
.preset-desc { font-size: 13px; color: var(--text2); line-height: 1.5; margin-bottom: 8px; }
|
|
373
|
+
|
|
374
|
+
.preset-tags { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
375
|
+
|
|
376
|
+
.preset-tag {
|
|
377
|
+
font-family: var(--mono);
|
|
378
|
+
font-size: 10px;
|
|
379
|
+
color: var(--muted);
|
|
380
|
+
background: var(--surface);
|
|
381
|
+
padding: 3px 8px;
|
|
382
|
+
border-radius: 4px;
|
|
383
|
+
}
|
|
384
|
+
.preset-card.selected .preset-tag { background: rgba(232,160,35,0.1); color: var(--accent); }
|
|
385
|
+
|
|
386
|
+
/* Advanced */
|
|
387
|
+
.advanced-toggle {
|
|
388
|
+
font-family: var(--mono);
|
|
389
|
+
font-size: 11px;
|
|
390
|
+
color: var(--muted);
|
|
391
|
+
cursor: pointer;
|
|
392
|
+
display: inline-flex;
|
|
393
|
+
align-items: center;
|
|
394
|
+
gap: 6px;
|
|
395
|
+
margin-bottom: 12px;
|
|
396
|
+
user-select: none;
|
|
397
|
+
}
|
|
398
|
+
.advanced-toggle:hover { color: var(--text2); }
|
|
399
|
+
|
|
400
|
+
.advanced-section { display: none; }
|
|
401
|
+
.advanced-section.open { display: block; }
|
|
402
|
+
|
|
403
|
+
.adv-grid {
|
|
404
|
+
display: grid;
|
|
405
|
+
grid-template-columns: repeat(3, 1fr);
|
|
406
|
+
gap: 12px;
|
|
407
|
+
padding: 14px;
|
|
408
|
+
background: var(--surface2);
|
|
409
|
+
border: 1px solid var(--border);
|
|
410
|
+
border-radius: var(--radius-sm);
|
|
411
|
+
margin-bottom: 12px;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
.adv-note { font-size: 13px; color: var(--muted); margin-bottom: 14px; }
|
|
415
|
+
|
|
416
|
+
/* Toggle */
|
|
417
|
+
.toggle-row { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
|
|
418
|
+
|
|
419
|
+
.toggle { position: relative; width: 42px; height: 24px; flex-shrink: 0; }
|
|
420
|
+
.toggle input { opacity: 0; width: 0; height: 0; }
|
|
421
|
+
|
|
422
|
+
.slider {
|
|
423
|
+
position: absolute; inset: 0;
|
|
424
|
+
background: var(--border2);
|
|
425
|
+
cursor: pointer; transition: 0.25s ease; border-radius: 12px;
|
|
426
|
+
}
|
|
427
|
+
.slider:before {
|
|
428
|
+
content: '';
|
|
429
|
+
position: absolute;
|
|
430
|
+
height: 18px; width: 18px;
|
|
431
|
+
left: 3px; bottom: 3px;
|
|
432
|
+
background: var(--surface);
|
|
433
|
+
transition: 0.25s ease; border-radius: 50%;
|
|
434
|
+
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
|
435
|
+
}
|
|
436
|
+
input:checked + .slider { background: var(--accent); }
|
|
437
|
+
input:checked + .slider:before { transform: translateX(18px); background: #111; }
|
|
438
|
+
|
|
439
|
+
.toggle-label { font-size: 14px; color: var(--text2); }
|
|
440
|
+
|
|
441
|
+
/* Progress */
|
|
442
|
+
.progress-section { display: none; margin-bottom: 16px; }
|
|
443
|
+
.progress-section.visible { display: block; }
|
|
444
|
+
|
|
445
|
+
/* Charts */
|
|
446
|
+
.chart-panel {
|
|
447
|
+
background: var(--surface2);
|
|
448
|
+
border: 1px solid var(--border);
|
|
449
|
+
border-radius: 8px;
|
|
450
|
+
padding: 8px 10px 6px;
|
|
451
|
+
min-width: 0;
|
|
452
|
+
}
|
|
453
|
+
.chart-lbl {
|
|
454
|
+
font-family: var(--mono);
|
|
455
|
+
font-size: 10px;
|
|
456
|
+
letter-spacing: 1.5px;
|
|
457
|
+
color: var(--muted);
|
|
458
|
+
text-transform: uppercase;
|
|
459
|
+
margin-bottom: 4px;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/* F1: Phase row + indicator dot */
|
|
463
|
+
.phase-row {
|
|
464
|
+
display: flex;
|
|
465
|
+
align-items: center;
|
|
466
|
+
gap: 8px;
|
|
467
|
+
margin-bottom: 4px;
|
|
468
|
+
border-radius: 4px;
|
|
469
|
+
}
|
|
470
|
+
.phase-dot {
|
|
471
|
+
width: 10px; height: 10px;
|
|
472
|
+
border-radius: 50%;
|
|
473
|
+
flex-shrink: 0;
|
|
474
|
+
background: var(--muted);
|
|
475
|
+
transition: background 0.3s ease, box-shadow 0.3s ease;
|
|
476
|
+
}
|
|
477
|
+
.phase-dot.idle { background: var(--muted); box-shadow: none; animation: none; }
|
|
478
|
+
.phase-dot.downloading { background: var(--accent3); box-shadow: 0 0 8px rgba(78,205,196,0.6); animation: phasePulse 1.5s ease infinite; }
|
|
479
|
+
.phase-dot.converting { background: var(--accent); box-shadow: 0 0 8px rgba(124,92,252,0.6); animation: phasePulse 1.5s ease infinite; }
|
|
480
|
+
.phase-dot.done { background: #68d391; box-shadow: 0 0 8px rgba(104,211,145,0.6); animation: none; }
|
|
481
|
+
.phase-dot.error { background: var(--accent2); box-shadow: 0 0 8px rgba(240,98,146,0.5); animation: none; }
|
|
482
|
+
@keyframes phasePulse {
|
|
483
|
+
0%, 100% { opacity: 1; transform: scale(1); }
|
|
484
|
+
50% { opacity: 0.5; transform: scale(0.8); }
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
.phase-label {
|
|
488
|
+
font-family: var(--sans);
|
|
489
|
+
font-weight: 700;
|
|
490
|
+
font-size: 15px;
|
|
491
|
+
letter-spacing: 0;
|
|
492
|
+
color: var(--accent);
|
|
493
|
+
margin-bottom: 0;
|
|
494
|
+
transition: color 0.3s ease;
|
|
495
|
+
}
|
|
496
|
+
.phase-file {
|
|
497
|
+
font-size: 12px;
|
|
498
|
+
color: var(--muted);
|
|
499
|
+
white-space: nowrap;
|
|
500
|
+
overflow: hidden;
|
|
501
|
+
text-overflow: ellipsis;
|
|
502
|
+
margin-bottom: 10px;
|
|
503
|
+
max-width: 100%;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/* F3: Phase transition animation */
|
|
507
|
+
@keyframes phaseFlash {
|
|
508
|
+
0% { opacity: 0.3; transform: translateX(12px); }
|
|
509
|
+
100% { opacity: 1; transform: translateX(0); }
|
|
510
|
+
}
|
|
511
|
+
.phase-label.transitioning { animation: phaseFlash 0.35s ease-out; }
|
|
512
|
+
@keyframes phaseGlow {
|
|
513
|
+
0% { box-shadow: inset 0 0 0 1px rgba(124,92,252,0.3); }
|
|
514
|
+
100% { box-shadow: inset 0 0 0 1px transparent; }
|
|
515
|
+
}
|
|
516
|
+
.phase-row.glow { animation: phaseGlow 0.6s ease-out; }
|
|
517
|
+
|
|
518
|
+
.bar-bg {
|
|
519
|
+
height: 8px;
|
|
520
|
+
background: var(--surface2);
|
|
521
|
+
margin-bottom: 10px;
|
|
522
|
+
overflow: hidden;
|
|
523
|
+
border-radius: 4px;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
.bar-fill {
|
|
527
|
+
height: 100%;
|
|
528
|
+
background: linear-gradient(90deg, #d4891a, var(--accent), #f0c050);
|
|
529
|
+
background-size: 200% 100%;
|
|
530
|
+
animation: shimmer 2.5s ease infinite;
|
|
531
|
+
width: 0%;
|
|
532
|
+
transition: width 0.4s ease, background 0.6s ease, box-shadow 0.6s ease;
|
|
533
|
+
border-radius: 4px;
|
|
534
|
+
box-shadow: 0 0 10px rgba(232,160,35,0.35);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/* F4: Phase-aware bar colors */
|
|
538
|
+
.bar-fill.phase-downloading {
|
|
539
|
+
background: linear-gradient(90deg, #2a9d8f, var(--accent3), #6ee7c0);
|
|
540
|
+
background-size: 200% 100%;
|
|
541
|
+
box-shadow: 0 0 10px rgba(78,205,196,0.35);
|
|
542
|
+
}
|
|
543
|
+
.bar-fill.phase-converting {
|
|
544
|
+
background: linear-gradient(90deg, #6c4ce0, var(--accent), #b794f4);
|
|
545
|
+
background-size: 200% 100%;
|
|
546
|
+
box-shadow: 0 0 10px rgba(124,92,252,0.35);
|
|
547
|
+
}
|
|
548
|
+
.bar-fill.phase-done {
|
|
549
|
+
background: linear-gradient(90deg, #48bb78, #68d391, #9ae6b4);
|
|
550
|
+
background-size: 200% 100%;
|
|
551
|
+
box-shadow: 0 0 12px rgba(104,211,145,0.4);
|
|
552
|
+
}
|
|
553
|
+
.bar-fill.phase-error {
|
|
554
|
+
background: linear-gradient(90deg, #c53030, var(--accent2), #fc8181);
|
|
555
|
+
background-size: 200% 100%;
|
|
556
|
+
box-shadow: 0 0 10px rgba(240,98,146,0.35);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
@keyframes shimmer {
|
|
560
|
+
0% { background-position: 200% 0; }
|
|
561
|
+
100% { background-position: -200% 0; }
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
.bar-indeterminate .bar-fill {
|
|
565
|
+
width: 30% !important;
|
|
566
|
+
animation: slide 1.5s infinite ease-in-out, shimmer 2.5s ease infinite;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
@keyframes slide { 0%{transform:translateX(-200%)} 100%{transform:translateX(500%)} }
|
|
570
|
+
|
|
571
|
+
/* F2: Encode settings pills */
|
|
572
|
+
.encode-pill {
|
|
573
|
+
font-family: var(--mono);
|
|
574
|
+
font-size: 11px;
|
|
575
|
+
padding: 4px 10px;
|
|
576
|
+
border-radius: 14px;
|
|
577
|
+
background: rgba(124,92,252,0.08);
|
|
578
|
+
color: var(--accent);
|
|
579
|
+
border: 1px solid rgba(124,92,252,0.18);
|
|
580
|
+
animation: pillFadeIn 0.4s ease both;
|
|
581
|
+
white-space: nowrap;
|
|
582
|
+
}
|
|
583
|
+
.encode-pill .pill-key { color: var(--muted); margin-right: 3px; }
|
|
584
|
+
.encode-pill:nth-child(2) { animation-delay: 0.05s; }
|
|
585
|
+
.encode-pill:nth-child(3) { animation-delay: 0.1s; }
|
|
586
|
+
.encode-pill:nth-child(4) { animation-delay: 0.15s; }
|
|
587
|
+
.encode-pill:nth-child(5) { animation-delay: 0.2s; }
|
|
588
|
+
@keyframes pillFadeIn {
|
|
589
|
+
from { opacity: 0; transform: translateY(4px); }
|
|
590
|
+
to { opacity: 1; transform: translateY(0); }
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/* F5: Celebration */
|
|
594
|
+
@keyframes celebrationRise {
|
|
595
|
+
0% { opacity: 1; transform: translateY(0) translateX(0) scale(1); }
|
|
596
|
+
100% { opacity: 0; transform: translateY(-60px) translateX(var(--drift, 0px)) scale(0.3); }
|
|
597
|
+
}
|
|
598
|
+
.confetti-particle {
|
|
599
|
+
position: absolute;
|
|
600
|
+
width: 5px; height: 5px;
|
|
601
|
+
border-radius: 50%;
|
|
602
|
+
animation: celebrationRise 1.4s ease-out forwards;
|
|
603
|
+
pointer-events: none;
|
|
604
|
+
}
|
|
605
|
+
@keyframes cardGlow {
|
|
606
|
+
0% { box-shadow: 0 2px 12px rgba(124,92,252,0.06), 0 1px 3px rgba(0,0,0,0.04); }
|
|
607
|
+
30% { box-shadow: 0 0 24px 4px rgba(104,211,145,0.3), 0 2px 12px rgba(124,92,252,0.06); }
|
|
608
|
+
100%{ box-shadow: 0 2px 12px rgba(124,92,252,0.06), 0 1px 3px rgba(0,0,0,0.04); }
|
|
609
|
+
}
|
|
610
|
+
.card.celebrating { animation: cardGlow 1.5s ease-out; }
|
|
611
|
+
@keyframes barRainbow {
|
|
612
|
+
0% { filter: hue-rotate(0deg) brightness(1.1); }
|
|
613
|
+
50% { filter: hue-rotate(90deg) brightness(1.2); }
|
|
614
|
+
100%{ filter: hue-rotate(0deg) brightness(1); }
|
|
615
|
+
}
|
|
616
|
+
.bar-fill.celebrating { animation: barRainbow 1.2s ease, shimmer 2.5s ease infinite; }
|
|
617
|
+
|
|
618
|
+
.progress-stats {
|
|
619
|
+
display: flex; gap: 24px;
|
|
620
|
+
font-size: 13px; color: var(--text2);
|
|
621
|
+
margin-bottom: 16px; flex-wrap: wrap;
|
|
622
|
+
}
|
|
623
|
+
.progress-stats strong { color: var(--text); font-weight: 600; }
|
|
624
|
+
|
|
625
|
+
/* Live size tracker */
|
|
626
|
+
.size-tracker {
|
|
627
|
+
display: none;
|
|
628
|
+
margin-top: 2px;
|
|
629
|
+
margin-bottom: 10px;
|
|
630
|
+
padding: 8px 12px;
|
|
631
|
+
background: var(--surface2);
|
|
632
|
+
border: 1px solid var(--border);
|
|
633
|
+
border-radius: 8px;
|
|
634
|
+
font-size: 12px;
|
|
635
|
+
color: var(--text2);
|
|
636
|
+
}
|
|
637
|
+
.size-tracker.visible { display: block; }
|
|
638
|
+
.size-tracker-header {
|
|
639
|
+
display: flex;
|
|
640
|
+
justify-content: space-between;
|
|
641
|
+
align-items: center;
|
|
642
|
+
margin-bottom: 6px;
|
|
643
|
+
}
|
|
644
|
+
.size-tracker-label {
|
|
645
|
+
font-family: var(--mono);
|
|
646
|
+
font-size: 10px;
|
|
647
|
+
letter-spacing: 1.5px;
|
|
648
|
+
text-transform: uppercase;
|
|
649
|
+
color: var(--muted);
|
|
650
|
+
}
|
|
651
|
+
.size-tracker-pct {
|
|
652
|
+
font-family: var(--mono);
|
|
653
|
+
font-size: 13px;
|
|
654
|
+
font-weight: 700;
|
|
655
|
+
transition: color 0.3s ease;
|
|
656
|
+
}
|
|
657
|
+
.size-tracker-bar {
|
|
658
|
+
height: 6px;
|
|
659
|
+
background: var(--bg);
|
|
660
|
+
border-radius: 3px;
|
|
661
|
+
overflow: hidden;
|
|
662
|
+
position: relative;
|
|
663
|
+
margin-bottom: 5px;
|
|
664
|
+
}
|
|
665
|
+
.size-tracker-fill {
|
|
666
|
+
height: 100%;
|
|
667
|
+
border-radius: 3px;
|
|
668
|
+
transition: width 0.5s ease, background 0.5s ease;
|
|
669
|
+
width: 0%;
|
|
670
|
+
}
|
|
671
|
+
.size-tracker-mark {
|
|
672
|
+
position: absolute;
|
|
673
|
+
top: -2px;
|
|
674
|
+
bottom: -2px;
|
|
675
|
+
width: 2px;
|
|
676
|
+
background: var(--text);
|
|
677
|
+
border-radius: 1px;
|
|
678
|
+
opacity: 0.4;
|
|
679
|
+
}
|
|
680
|
+
.size-tracker-values {
|
|
681
|
+
display: flex;
|
|
682
|
+
justify-content: space-between;
|
|
683
|
+
font-family: var(--mono);
|
|
684
|
+
font-size: 11px;
|
|
685
|
+
color: var(--muted);
|
|
686
|
+
}
|
|
687
|
+
.st-green { color: #48bb78; }
|
|
688
|
+
.st-teal { color: #38b2ac; }
|
|
689
|
+
.st-orange { color: #ed8936; }
|
|
690
|
+
.st-red { color: #e53e3e; }
|
|
691
|
+
.st-fill-green { background: linear-gradient(90deg, #48bb78, #68d391); }
|
|
692
|
+
.st-fill-teal { background: linear-gradient(90deg, #38b2ac, #4ecdc4); }
|
|
693
|
+
.st-fill-orange { background: linear-gradient(90deg, #ed8936, #f6ad55); }
|
|
694
|
+
.st-fill-red { background: linear-gradient(90deg, #e53e3e, #fc8181); }
|
|
695
|
+
|
|
696
|
+
.log-box {
|
|
697
|
+
background: var(--bg);
|
|
698
|
+
border: 1px solid var(--border);
|
|
699
|
+
border-radius: var(--radius-sm);
|
|
700
|
+
font-family: var(--mono);
|
|
701
|
+
font-size: 12px;
|
|
702
|
+
color: var(--text2);
|
|
703
|
+
padding: 12px 14px;
|
|
704
|
+
min-height: 150px;
|
|
705
|
+
max-height: 320px;
|
|
706
|
+
resize: vertical;
|
|
707
|
+
overflow-y: auto;
|
|
708
|
+
line-height: 1.8;
|
|
709
|
+
}
|
|
710
|
+
.log-error { color: var(--accent2); font-weight: 500; }
|
|
711
|
+
.log-done { color: var(--accent3); font-weight: 500; }
|
|
712
|
+
.log-info { color: var(--accent4); }
|
|
713
|
+
|
|
714
|
+
.action-row {
|
|
715
|
+
display: flex; gap: 12px;
|
|
716
|
+
align-items: center; margin-top: 18px; flex-wrap: wrap;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
.shutdown-row {
|
|
720
|
+
display: flex; align-items: center; gap: 10px;
|
|
721
|
+
margin-top: 10px; padding: 10px 14px;
|
|
722
|
+
background: rgba(239,83,80,0.05);
|
|
723
|
+
border: 1px solid rgba(239,83,80,0.12);
|
|
724
|
+
border-radius: var(--radius-sm);
|
|
725
|
+
}
|
|
726
|
+
.shutdown-row input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--accent2); cursor: pointer; }
|
|
727
|
+
.shutdown-row label { font-size: 13px; color: var(--text2); cursor: pointer; }
|
|
728
|
+
|
|
729
|
+
.idle-msg { font-size: 14px; color: var(--muted); margin-bottom: 14px; }
|
|
730
|
+
|
|
731
|
+
.hidden { display: none !important; }
|
|
732
|
+
|
|
733
|
+
.update-log { font-family: var(--mono); font-size: 13px; color: var(--text2); margin-top: 8px; min-height: 20px; }
|
|
734
|
+
|
|
735
|
+
::-webkit-scrollbar { width: 5px; }
|
|
736
|
+
::-webkit-scrollbar-track { background: var(--bg); border-radius: 3px; }
|
|
737
|
+
::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 3px; }
|
|
738
|
+
|
|
739
|
+
.history-panel { margin-top: 10px; }
|
|
740
|
+
|
|
741
|
+
.history-header {
|
|
742
|
+
display: flex;
|
|
743
|
+
align-items: center;
|
|
744
|
+
justify-content: space-between;
|
|
745
|
+
font-family: var(--mono);
|
|
746
|
+
font-size: 10px;
|
|
747
|
+
letter-spacing: 2px;
|
|
748
|
+
color: var(--muted);
|
|
749
|
+
text-transform: uppercase;
|
|
750
|
+
margin-bottom: 8px;
|
|
751
|
+
cursor: pointer;
|
|
752
|
+
user-select: none;
|
|
753
|
+
}
|
|
754
|
+
.history-header:hover { color: var(--text2); }
|
|
755
|
+
|
|
756
|
+
.history-header-right { display: flex; align-items: center; gap: 12px; }
|
|
757
|
+
|
|
758
|
+
.history-clear {
|
|
759
|
+
font-size: 10px;
|
|
760
|
+
color: var(--accent2);
|
|
761
|
+
cursor: pointer;
|
|
762
|
+
letter-spacing: 1px;
|
|
763
|
+
}
|
|
764
|
+
.history-clear:hover { color: #f77; }
|
|
765
|
+
|
|
766
|
+
.history-list {
|
|
767
|
+
background: var(--surface2);
|
|
768
|
+
border: 1px solid var(--border);
|
|
769
|
+
border-radius: var(--radius-sm);
|
|
770
|
+
max-height: 220px;
|
|
771
|
+
overflow-y: auto;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
.history-item {
|
|
775
|
+
display: flex;
|
|
776
|
+
align-items: center;
|
|
777
|
+
gap: 12px;
|
|
778
|
+
padding: 8px 12px;
|
|
779
|
+
border-bottom: 1px solid var(--border);
|
|
780
|
+
cursor: pointer;
|
|
781
|
+
transition: background 0.1s;
|
|
782
|
+
}
|
|
783
|
+
.history-item:last-child { border-bottom: none; }
|
|
784
|
+
.history-item:hover { background: rgba(232,160,35,0.05); }
|
|
785
|
+
|
|
786
|
+
.history-thumb {
|
|
787
|
+
width: 56px;
|
|
788
|
+
height: 32px;
|
|
789
|
+
object-fit: cover;
|
|
790
|
+
flex-shrink: 0;
|
|
791
|
+
border: 1px solid var(--border);
|
|
792
|
+
border-radius: 4px;
|
|
793
|
+
}
|
|
794
|
+
.history-thumb-blank {
|
|
795
|
+
width: 56px;
|
|
796
|
+
height: 32px;
|
|
797
|
+
background: var(--border);
|
|
798
|
+
flex-shrink: 0;
|
|
799
|
+
border: 1px solid var(--border);
|
|
800
|
+
border-radius: 4px;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
.history-meta { flex: 1; min-width: 0; }
|
|
804
|
+
|
|
805
|
+
.history-title {
|
|
806
|
+
font-size: 13px;
|
|
807
|
+
color: var(--text);
|
|
808
|
+
white-space: nowrap;
|
|
809
|
+
overflow: hidden;
|
|
810
|
+
text-overflow: ellipsis;
|
|
811
|
+
margin-bottom: 2px;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
.history-sub {
|
|
815
|
+
font-family: var(--mono);
|
|
816
|
+
font-size: 10px;
|
|
817
|
+
color: var(--muted);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/* Shutdown countdown overlay */
|
|
821
|
+
.shutdown-overlay {
|
|
822
|
+
position: fixed;
|
|
823
|
+
inset: 0;
|
|
824
|
+
z-index: 9999;
|
|
825
|
+
display: flex;
|
|
826
|
+
align-items: center;
|
|
827
|
+
justify-content: center;
|
|
828
|
+
background: rgba(10,11,15,0.82);
|
|
829
|
+
backdrop-filter: blur(3px);
|
|
830
|
+
animation: fadeUp 0.25s ease both;
|
|
831
|
+
}
|
|
832
|
+
.shutdown-overlay-card {
|
|
833
|
+
background: var(--surface);
|
|
834
|
+
border: 1px solid rgba(239,83,80,0.4);
|
|
835
|
+
border-radius: var(--radius);
|
|
836
|
+
box-shadow: 0 12px 48px rgba(0,0,0,0.5), 0 0 0 1px rgba(239,83,80,0.15);
|
|
837
|
+
padding: 32px 40px;
|
|
838
|
+
text-align: center;
|
|
839
|
+
max-width: 440px;
|
|
840
|
+
width: 90%;
|
|
841
|
+
}
|
|
842
|
+
.shutdown-overlay-title {
|
|
843
|
+
font-family: var(--sans);
|
|
844
|
+
font-weight: 800;
|
|
845
|
+
font-size: 20px;
|
|
846
|
+
color: var(--accent2);
|
|
847
|
+
margin-bottom: 6px;
|
|
848
|
+
letter-spacing: -0.3px;
|
|
849
|
+
}
|
|
850
|
+
.shutdown-overlay-count {
|
|
851
|
+
font-family: var(--mono);
|
|
852
|
+
font-weight: 700;
|
|
853
|
+
font-size: 42px;
|
|
854
|
+
color: var(--text);
|
|
855
|
+
margin: 10px 0 6px;
|
|
856
|
+
letter-spacing: 1px;
|
|
857
|
+
}
|
|
858
|
+
.shutdown-overlay-sub {
|
|
859
|
+
font-size: 13px;
|
|
860
|
+
color: var(--text2);
|
|
861
|
+
margin-bottom: 22px;
|
|
862
|
+
}
|
|
863
|
+
.shutdown-overlay-card .btn { width: 100%; padding: 15px 22px; font-size: 15px; }
|
|
864
|
+
|
|
865
|
+
/* Failure summary panel (sticky above progress) */
|
|
866
|
+
.failure-panel {
|
|
867
|
+
position: sticky;
|
|
868
|
+
top: 8px;
|
|
869
|
+
z-index: 50;
|
|
870
|
+
display: none;
|
|
871
|
+
background: var(--surface);
|
|
872
|
+
border: 1px solid var(--accent2);
|
|
873
|
+
border-radius: var(--radius);
|
|
874
|
+
margin-bottom: 14px;
|
|
875
|
+
box-shadow: 0 4px 16px rgba(239,83,80,0.12);
|
|
876
|
+
animation: fadeUp 0.3s ease both;
|
|
877
|
+
}
|
|
878
|
+
.failure-panel.visible { display: block; }
|
|
879
|
+
.failure-panel-header {
|
|
880
|
+
display: flex;
|
|
881
|
+
align-items: center;
|
|
882
|
+
justify-content: space-between;
|
|
883
|
+
padding: 10px 16px;
|
|
884
|
+
border-bottom: 1px solid var(--border);
|
|
885
|
+
}
|
|
886
|
+
.failure-panel-title {
|
|
887
|
+
font-family: var(--sans);
|
|
888
|
+
font-weight: 700;
|
|
889
|
+
font-size: 14px;
|
|
890
|
+
color: var(--accent2);
|
|
891
|
+
display: flex;
|
|
892
|
+
align-items: center;
|
|
893
|
+
gap: 8px;
|
|
894
|
+
}
|
|
895
|
+
.failure-panel-dismiss {
|
|
896
|
+
font-family: var(--mono);
|
|
897
|
+
font-size: 16px;
|
|
898
|
+
line-height: 1;
|
|
899
|
+
color: var(--text2);
|
|
900
|
+
cursor: pointer;
|
|
901
|
+
padding: 2px 6px;
|
|
902
|
+
border-radius: 4px;
|
|
903
|
+
user-select: none;
|
|
904
|
+
}
|
|
905
|
+
.failure-panel-dismiss:hover { color: var(--accent2); background: rgba(239,83,80,0.08); }
|
|
906
|
+
.failure-panel-list {
|
|
907
|
+
list-style: none;
|
|
908
|
+
margin: 0;
|
|
909
|
+
padding: 6px 0;
|
|
910
|
+
max-height: 220px;
|
|
911
|
+
overflow-y: auto;
|
|
912
|
+
}
|
|
913
|
+
.failure-item {
|
|
914
|
+
display: flex;
|
|
915
|
+
align-items: flex-start;
|
|
916
|
+
gap: 10px;
|
|
917
|
+
padding: 7px 16px;
|
|
918
|
+
font-size: 13px;
|
|
919
|
+
border-bottom: 1px solid var(--border);
|
|
920
|
+
}
|
|
921
|
+
.failure-item:last-child { border-bottom: none; }
|
|
922
|
+
.failure-item-icon { flex-shrink: 0; font-size: 13px; line-height: 1.5; }
|
|
923
|
+
.failure-item.transient .failure-item-icon { color: #f6ad55; }
|
|
924
|
+
.failure-item.permanent .failure-item-icon { color: var(--accent2); }
|
|
925
|
+
.failure-item-body { flex: 1; min-width: 0; }
|
|
926
|
+
.failure-item-title {
|
|
927
|
+
color: var(--text);
|
|
928
|
+
white-space: nowrap;
|
|
929
|
+
overflow: hidden;
|
|
930
|
+
text-overflow: ellipsis;
|
|
931
|
+
}
|
|
932
|
+
.failure-item-reason {
|
|
933
|
+
font-family: var(--mono);
|
|
934
|
+
font-size: 11px;
|
|
935
|
+
color: var(--text2);
|
|
936
|
+
margin-top: 2px;
|
|
937
|
+
}
|
|
938
|
+
.failure-item.transient .failure-item-cat { color: #f6ad55; }
|
|
939
|
+
.failure-item.permanent .failure-item-cat { color: var(--accent2); }
|
|
940
|
+
.failure-item-cat {
|
|
941
|
+
font-family: var(--mono);
|
|
942
|
+
font-size: 10px;
|
|
943
|
+
text-transform: uppercase;
|
|
944
|
+
letter-spacing: 1px;
|
|
945
|
+
font-weight: 700;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/* Captured-cookies confirmation state */
|
|
949
|
+
.cookies-paste-row textarea.captured {
|
|
950
|
+
border-color: var(--accent3) !important;
|
|
951
|
+
background: rgba(77,208,160,0.06);
|
|
952
|
+
color: var(--accent3);
|
|
953
|
+
font-weight: 700;
|
|
954
|
+
}
|
|
955
|
+
.cookies-method-header {
|
|
956
|
+
font-size: 12px;
|
|
957
|
+
color: var(--text2);
|
|
958
|
+
margin-bottom: 12px;
|
|
959
|
+
line-height: 1.5;
|
|
960
|
+
}
|
|
961
|
+
</style>
|
|
962
|
+
</head>
|
|
963
|
+
<body>
|
|
964
|
+
<div class="container">
|
|
965
|
+
|
|
966
|
+
<header>
|
|
967
|
+
<div class="header-brand">
|
|
968
|
+
<div class="logo">
|
|
969
|
+
<div><span style="color:#76b900">Fetch</span><span style="color:#f3f3f3">Forge</span></div>
|
|
970
|
+
<div><span style="color:#f3f3f3">You</span><span style="color:#cc0000">Tube</span> Downloader</div>
|
|
971
|
+
</div>
|
|
972
|
+
<img id="channel-logo" class="channel-logo" src="" alt="" style="display:none;">
|
|
973
|
+
</div>
|
|
974
|
+
<div class="header-status">
|
|
975
|
+
<div class="status-pill"><span class="dot"></span><span id="ytdlp-version-label">yt-dlp: checking...</span></div>
|
|
976
|
+
<div class="status-pill"><span class="dot warn" id="cookie-dot"></span><span id="cookie-header">cookies: checking</span></div>
|
|
977
|
+
<div class="status-pill"><span class="dot" id="app-version-dot" style="background:var(--muted)"></span><span id="app-version-label">v —</span></div>
|
|
978
|
+
</div>
|
|
979
|
+
</header>
|
|
980
|
+
|
|
981
|
+
<div class="hero-blurb">
|
|
982
|
+
Download YouTube videos and playlists, then convert to <strong>H.265/HEVC</strong> using NVIDIA NVENC hardware encoding.
|
|
983
|
+
Supports single videos, full playlists with pipeline mode, and local file conversion. Smart auto-analysis picks optimal CQ, maxrate, and preset from the source.
|
|
984
|
+
<br>Requires <strong>ffmpeg</strong> and an <strong>NVIDIA GPU</strong> — the H.265 encode uses hardware NVENC.
|
|
985
|
+
</div>
|
|
986
|
+
|
|
987
|
+
<!-- #01 AUTHENTICATION -->
|
|
988
|
+
<div class="card">
|
|
989
|
+
<div class="card-header">
|
|
990
|
+
<span class="card-title"><span class="card-num">#01</span> Authentication</span>
|
|
991
|
+
</div>
|
|
992
|
+
<div class="card-body">
|
|
993
|
+
<div class="cookies-method-header">Use either method — paste is convenient on shared machines where you don't want files left behind.</div>
|
|
994
|
+
<div class="cookies-row">
|
|
995
|
+
<span class="badge" id="cookie-badge">Checking...</span>
|
|
996
|
+
<button class="btn btn-outline btn-sm" onclick="document.getElementById('cookie-file').click()">Load cookies.txt</button>
|
|
997
|
+
<input type="file" id="cookie-file" accept=".txt" onchange="uploadCookies(this)">
|
|
998
|
+
<span style="font-size:12px;color:var(--muted);">In Cookie-Editor, use Export → download to get the file</span>
|
|
999
|
+
</div>
|
|
1000
|
+
|
|
1001
|
+
<div class="cookies-paste-row">
|
|
1002
|
+
<div class="cookies-paste-hint">Or paste cookies (Cookie-Editor → Export → Netscape). Pasted text is masked and never displayed.</div>
|
|
1003
|
+
<textarea id="cookie-paste" placeholder="Click here, then Ctrl+V to paste cookies (text is captured silently — not displayed)" autocomplete="off" spellcheck="false" onpaste="handleCookiePaste(event)"></textarea>
|
|
1004
|
+
<div class="cookies-paste-actions">
|
|
1005
|
+
<button class="btn btn-primary btn-sm" onclick="savePastedCookies()">Save & Verify</button>
|
|
1006
|
+
<button class="btn btn-outline btn-sm" onclick="clearPastedCookies()">Clear field</button>
|
|
1007
|
+
<span class="badge" id="cookie-paste-status" style="display:none;"></span>
|
|
1008
|
+
</div>
|
|
1009
|
+
</div>
|
|
1010
|
+
</div>
|
|
1011
|
+
</div>
|
|
1012
|
+
|
|
1013
|
+
<!-- #02 VIDEO URL -->
|
|
1014
|
+
<div class="card">
|
|
1015
|
+
<div class="card-header">
|
|
1016
|
+
<span class="card-title"><span class="card-num">#02</span> Video URL</span>
|
|
1017
|
+
</div>
|
|
1018
|
+
<div class="card-body">
|
|
1019
|
+
<div class="input-group">
|
|
1020
|
+
<input type="url" id="url-input" placeholder="https://www.youtube.com/watch?v=..." />
|
|
1021
|
+
<button class="btn btn-primary" id="fetch-btn" onclick="fetchInfo()">Fetch Info</button>
|
|
1022
|
+
</div>
|
|
1023
|
+
|
|
1024
|
+
<div id="history-panel" class="history-panel hidden">
|
|
1025
|
+
<div class="history-header" onclick="toggleHistory()">
|
|
1026
|
+
<span><span id="history-arrow">▸</span> <span id="history-toggle-label">Recent</span></span>
|
|
1027
|
+
<div class="history-header-right">
|
|
1028
|
+
<span class="history-clear" onclick="event.stopPropagation(); clearHistory()">✕ Clear</span>
|
|
1029
|
+
</div>
|
|
1030
|
+
</div>
|
|
1031
|
+
<div id="history-list" class="history-list hidden"></div>
|
|
1032
|
+
</div>
|
|
1033
|
+
|
|
1034
|
+
<div id="video-info" class="video-info">
|
|
1035
|
+
<img id="thumb" class="thumb" src="" alt="">
|
|
1036
|
+
<div class="video-meta">
|
|
1037
|
+
<div class="video-title" id="video-title"></div>
|
|
1038
|
+
<div class="meta-line" id="video-uploader"></div>
|
|
1039
|
+
<div class="meta-line" id="video-duration"></div>
|
|
1040
|
+
<div class="format-row">
|
|
1041
|
+
<div class="field" id="video-format-field">
|
|
1042
|
+
<div class="field-label">Video Stream</div>
|
|
1043
|
+
<select id="video-format"></select>
|
|
1044
|
+
</div>
|
|
1045
|
+
<div class="field">
|
|
1046
|
+
<div class="field-label">Audio Stream</div>
|
|
1047
|
+
<select id="audio-format"></select>
|
|
1048
|
+
</div>
|
|
1049
|
+
</div>
|
|
1050
|
+
<div style="margin-top:10px;">
|
|
1051
|
+
<button class="btn btn-outline" onclick="addToQueue()" id="queue-add-btn" style="display:none;">➕ Add to Queue</button>
|
|
1052
|
+
</div>
|
|
1053
|
+
</div>
|
|
1054
|
+
</div>
|
|
1055
|
+
|
|
1056
|
+
<!-- Download queue -->
|
|
1057
|
+
<div id="dl-queue-section" style="display:none; margin-top:14px;">
|
|
1058
|
+
<div style="font-family:var(--mono);font-size:10px;letter-spacing:2px;color:var(--muted);margin-bottom:8px;">
|
|
1059
|
+
JOB QUEUE — <span id="dl-queue-count">0</span> item(s)
|
|
1060
|
+
<span style="float:right;cursor:pointer;color:var(--accent2);" onclick="clearDlQueue()">✕ Clear</span>
|
|
1061
|
+
</div>
|
|
1062
|
+
<div id="dl-queue-items" style="
|
|
1063
|
+
background:var(--bg);
|
|
1064
|
+
border:1px solid var(--border);
|
|
1065
|
+
max-height:200px;
|
|
1066
|
+
overflow-y:auto;
|
|
1067
|
+
font-family:var(--mono);
|
|
1068
|
+
font-size:11px;
|
|
1069
|
+
color:var(--text2);
|
|
1070
|
+
border-radius:var(--radius-sm);
|
|
1071
|
+
"></div>
|
|
1072
|
+
</div>
|
|
1073
|
+
</div>
|
|
1074
|
+
</div>
|
|
1075
|
+
|
|
1076
|
+
<!-- #03 CONVERSION -->
|
|
1077
|
+
<div class="card">
|
|
1078
|
+
<div class="card-header">
|
|
1079
|
+
<span class="card-title"><span class="card-num">#03</span> Conversion</span>
|
|
1080
|
+
</div>
|
|
1081
|
+
<div class="card-body">
|
|
1082
|
+
|
|
1083
|
+
<div id="convert-toggle-row" class="toggle-row">
|
|
1084
|
+
<label class="toggle">
|
|
1085
|
+
<input type="checkbox" id="convert-toggle" checked onchange="toggleConvert()">
|
|
1086
|
+
<span class="slider"></span>
|
|
1087
|
+
</label>
|
|
1088
|
+
<span class="toggle-label">Convert to H.265 / MP4 after download</span>
|
|
1089
|
+
</div>
|
|
1090
|
+
|
|
1091
|
+
<div class="field" style="margin-bottom:16px;">
|
|
1092
|
+
<div class="field-label">Output Directory</div>
|
|
1093
|
+
<div class="input-group" style="display:flex;gap:6px;">
|
|
1094
|
+
<input type="text" id="output-dir" placeholder="Default: downloads/converted (YouTube) or source/converted (local)" style="flex:1;" />
|
|
1095
|
+
<button type="button" class="btn" style="white-space:nowrap;padding:0 14px;" onclick="browseOutputDir()">📁 Browse</button>
|
|
1096
|
+
</div>
|
|
1097
|
+
</div>
|
|
1098
|
+
|
|
1099
|
+
<!-- Mode: Video → H.265, or Audio extract -->
|
|
1100
|
+
<div class="preset-grid" style="margin-bottom:14px;">
|
|
1101
|
+
<label class="preset-card selected" data-mode="video" onclick="selectMode('video')" style="padding:14px;">
|
|
1102
|
+
<input type="radio" name="convmode" checked>
|
|
1103
|
+
<div class="preset-name" style="margin-bottom:0;">🎬 Video → H.265</div>
|
|
1104
|
+
</label>
|
|
1105
|
+
<label class="preset-card" data-mode="audio" onclick="selectMode('audio')" style="padding:14px;">
|
|
1106
|
+
<input type="radio" name="convmode">
|
|
1107
|
+
<div class="preset-name" style="margin-bottom:0;">🎵 Audio extract</div>
|
|
1108
|
+
</label>
|
|
1109
|
+
</div>
|
|
1110
|
+
|
|
1111
|
+
<div id="conv-settings">
|
|
1112
|
+
<div class="preset-grid" id="video-presets">
|
|
1113
|
+
|
|
1114
|
+
<label class="preset-card selected" data-preset="auto" onclick="selectPreset(this,'0','','uhq')">
|
|
1115
|
+
<input type="radio" name="qpreset" checked>
|
|
1116
|
+
<div class="preset-name">Excellent (Slow)</div>
|
|
1117
|
+
<div class="preset-desc">Analyzes source codec, bitrate, and resolution. Derives CQ and maxrate from source. Uses uhq tune for maximum quality-per-bit.</div>
|
|
1118
|
+
<div class="preset-tags">
|
|
1119
|
+
<span class="preset-tag">CQ auto</span>
|
|
1120
|
+
<span class="preset-tag">uhq tune</span>
|
|
1121
|
+
<span class="preset-tag">~1.5× speed</span>
|
|
1122
|
+
</div>
|
|
1123
|
+
</label>
|
|
1124
|
+
|
|
1125
|
+
<label class="preset-card" data-preset="quality" onclick="selectPreset(this,'0','','hq')">
|
|
1126
|
+
<input type="radio" name="qpreset">
|
|
1127
|
+
<div class="preset-name">Great (Fast)</div>
|
|
1128
|
+
<div class="preset-desc">Fast encode for NLE editing. hq tune with hardware AQ — excellent quality at 4–6× speed. Output capped at source bitrate so files stay at or below source size.</div>
|
|
1129
|
+
<div class="preset-tags">
|
|
1130
|
+
<span class="preset-tag">CQ auto</span>
|
|
1131
|
+
<span class="preset-tag">hq tune</span>
|
|
1132
|
+
<span class="preset-tag">4–6× speed</span>
|
|
1133
|
+
</div>
|
|
1134
|
+
</label>
|
|
1135
|
+
|
|
1136
|
+
</div>
|
|
1137
|
+
|
|
1138
|
+
<div class="preset-grid" id="audio-presets" style="display:none;">
|
|
1139
|
+
|
|
1140
|
+
<label class="preset-card selected" data-audio-preset="wav" onclick="selectAudioPreset(this,'wav')">
|
|
1141
|
+
<input type="radio" name="apreset" checked>
|
|
1142
|
+
<div class="preset-name">WAV</div>
|
|
1143
|
+
<div class="preset-desc">Lossless PCM 16-bit, preserves source sample rate. Ideal for editing or further processing. Auto-selected when source is ≥160 kbps.</div>
|
|
1144
|
+
<div class="preset-tags">
|
|
1145
|
+
<span class="preset-tag">PCM s16le</span>
|
|
1146
|
+
<span class="preset-tag">uncompressed</span>
|
|
1147
|
+
<span class="preset-tag">large files</span>
|
|
1148
|
+
</div>
|
|
1149
|
+
</label>
|
|
1150
|
+
|
|
1151
|
+
<label class="preset-card" data-audio-preset="mp3" onclick="selectAudioPreset(this,'mp3')">
|
|
1152
|
+
<input type="radio" name="apreset">
|
|
1153
|
+
<div class="preset-name">MP3 320</div>
|
|
1154
|
+
<div class="preset-desc">320 kbps CBR — universally compatible, good balance of quality and size. Auto-selected when source is <160 kbps (no benefit to lossless from a low-bitrate source).</div>
|
|
1155
|
+
<div class="preset-tags">
|
|
1156
|
+
<span class="preset-tag">320 kbps</span>
|
|
1157
|
+
<span class="preset-tag">libmp3lame</span>
|
|
1158
|
+
<span class="preset-tag">small files</span>
|
|
1159
|
+
</div>
|
|
1160
|
+
</label>
|
|
1161
|
+
|
|
1162
|
+
</div>
|
|
1163
|
+
|
|
1164
|
+
<div id="video-only-options">
|
|
1165
|
+
<div class="advanced-toggle" onclick="toggleAdvanced()">
|
|
1166
|
+
<span id="adv-arrow">▶</span> Advanced settings
|
|
1167
|
+
</div>
|
|
1168
|
+
|
|
1169
|
+
<div class="advanced-section" id="advanced-section">
|
|
1170
|
+
<div class="adv-grid" style="grid-template-columns: 1fr 1fr;">
|
|
1171
|
+
<div class="field">
|
|
1172
|
+
<div class="field-label">Quality (CQ) — 0 = Auto</div>
|
|
1173
|
+
<select id="cq-select">
|
|
1174
|
+
<option value="0" selected>0 — Auto (recommended)</option>
|
|
1175
|
+
<option value="18">18 — Near-lossless</option>
|
|
1176
|
+
<option value="20">20 — High quality</option>
|
|
1177
|
+
<option value="22">22</option>
|
|
1178
|
+
<option value="24">24</option>
|
|
1179
|
+
<option value="26">26 — Balanced</option>
|
|
1180
|
+
<option value="28">28</option>
|
|
1181
|
+
<option value="30">30</option>
|
|
1182
|
+
<option value="32">32 — Compact</option>
|
|
1183
|
+
<option value="34">34 — Smallest</option>
|
|
1184
|
+
</select>
|
|
1185
|
+
</div>
|
|
1186
|
+
<div class="field">
|
|
1187
|
+
<div class="field-label">Max Bitrate Ceiling (optional)</div>
|
|
1188
|
+
<select id="maxrate">
|
|
1189
|
+
<option value="" selected>Auto (from source)</option>
|
|
1190
|
+
<option value="4M">4 Mbps</option>
|
|
1191
|
+
<option value="6M">6 Mbps</option>
|
|
1192
|
+
<option value="8M">8 Mbps</option>
|
|
1193
|
+
<option value="10M">10 Mbps</option>
|
|
1194
|
+
<option value="12M">12 Mbps</option>
|
|
1195
|
+
<option value="15M">15 Mbps</option>
|
|
1196
|
+
<option value="20M">20 Mbps</option>
|
|
1197
|
+
<option value="25M">25 Mbps</option>
|
|
1198
|
+
</select>
|
|
1199
|
+
</div>
|
|
1200
|
+
</div>
|
|
1201
|
+
<div class="adv-note">
|
|
1202
|
+
CQ controls perceptual quality (lower = better, larger file). NVENC preset (p2/p4) and 10-bit output are chosen automatically from source analysis. Set a max bitrate ceiling to hard-cap output size on high-bitrate sources.
|
|
1203
|
+
</div>
|
|
1204
|
+
</div>
|
|
1205
|
+
|
|
1206
|
+
<div class="shutdown-row" style="background:rgba(232,160,35,0.04);border-color:rgba(232,160,35,0.12);margin-bottom:8px;">
|
|
1207
|
+
<input type="checkbox" id="pipeline-check" style="accent-color:var(--accent);">
|
|
1208
|
+
<label for="pipeline-check" style="color:var(--text2);">Pipeline mode — download next video while encoding current one (playlists only)</label>
|
|
1209
|
+
</div>
|
|
1210
|
+
|
|
1211
|
+
<div class="shutdown-row" style="background:rgba(232,160,35,0.04);border-color:rgba(232,160,35,0.12);margin-bottom:8px;">
|
|
1212
|
+
<input type="checkbox" id="download-only-check" style="accent-color:var(--accent);" onchange="toggleDownloadOnly()">
|
|
1213
|
+
<label for="download-only-check" style="color:var(--text2);">Download file only — skip conversion, keep MKV in cache</label>
|
|
1214
|
+
</div>
|
|
1215
|
+
</div><!-- /video-only-options -->
|
|
1216
|
+
|
|
1217
|
+
<div class="shutdown-row" style="background:rgba(77,208,160,0.04);border-color:rgba(77,208,160,0.12);margin-bottom:8px;">
|
|
1218
|
+
<input type="checkbox" id="delete-cache-check" style="accent-color:var(--accent3);" checked>
|
|
1219
|
+
<label for="delete-cache-check" style="color:var(--text2);">Delete cache after conversion — remove source MKV once MP4 is done</label>
|
|
1220
|
+
</div>
|
|
1221
|
+
|
|
1222
|
+
<div class="shutdown-row">
|
|
1223
|
+
<input type="checkbox" id="shutdown-check">
|
|
1224
|
+
<label for="shutdown-check">Shut down PC when complete — 60 second delay with cancel window</label>
|
|
1225
|
+
</div>
|
|
1226
|
+
</div>
|
|
1227
|
+
|
|
1228
|
+
</div>
|
|
1229
|
+
</div>
|
|
1230
|
+
|
|
1231
|
+
<!-- #04 LOCAL CONVERSION -->
|
|
1232
|
+
<div class="card">
|
|
1233
|
+
<div class="card-header">
|
|
1234
|
+
<span class="card-title"><span class="card-num">#04</span> Local Conversion</span>
|
|
1235
|
+
</div>
|
|
1236
|
+
<div class="card-body">
|
|
1237
|
+
|
|
1238
|
+
<!-- Browse buttons -->
|
|
1239
|
+
<div style="display:flex;gap:8px;margin-bottom:12px;">
|
|
1240
|
+
<button class="btn btn-outline" style="flex:1;padding:14px 12px;" onclick="browseLocalFiles()">📄 Browse Files</button>
|
|
1241
|
+
<button class="btn btn-outline" style="flex:1;padding:14px 12px;" onclick="browseLocalFolder()">📁 Browse Folder</button>
|
|
1242
|
+
</div>
|
|
1243
|
+
|
|
1244
|
+
<!-- Folder path input -->
|
|
1245
|
+
<div class="input-group" style="margin-bottom:12px;">
|
|
1246
|
+
<input type="text" id="local-folder" placeholder="Or paste a folder path, e.g. E:\Videos\MyFolder" />
|
|
1247
|
+
<button class="btn btn-outline" onclick="scanFolder()">Scan</button>
|
|
1248
|
+
</div>
|
|
1249
|
+
|
|
1250
|
+
<!-- File list -->
|
|
1251
|
+
<div id="local-file-list" style="display:none; margin-bottom:12px;">
|
|
1252
|
+
<div style="font-family:var(--mono);font-size:10px;letter-spacing:2px;color:var(--muted);margin-bottom:8px;">
|
|
1253
|
+
FILES QUEUED — <span id="local-file-count">0</span> file(s)
|
|
1254
|
+
<span style="float:right;cursor:pointer;color:var(--accent2);" onclick="clearLocalFiles()">✕ Clear</span>
|
|
1255
|
+
</div>
|
|
1256
|
+
<div id="local-file-items" style="
|
|
1257
|
+
background:var(--bg);
|
|
1258
|
+
border:1px solid var(--border);
|
|
1259
|
+
max-height:160px;
|
|
1260
|
+
overflow-y:auto;
|
|
1261
|
+
font-family:var(--mono);
|
|
1262
|
+
font-size:11px;
|
|
1263
|
+
color:var(--text2);
|
|
1264
|
+
"></div>
|
|
1265
|
+
</div>
|
|
1266
|
+
|
|
1267
|
+
<div class="action-row" style="gap:8px;">
|
|
1268
|
+
<button class="btn btn-outline" onclick="addLocalToQueue()">➕ Add to Queue</button>
|
|
1269
|
+
<button class="btn btn-success" id="local-convert-btn" onclick="startLocalConvert()">▶ Convert Now</button>
|
|
1270
|
+
</div>
|
|
1271
|
+
|
|
1272
|
+
</div>
|
|
1273
|
+
</div>
|
|
1274
|
+
|
|
1275
|
+
<!-- FAILURE SUMMARY (sticky, survives the session) -->
|
|
1276
|
+
<div id="failure-panel" class="failure-panel">
|
|
1277
|
+
<div class="failure-panel-header">
|
|
1278
|
+
<span class="failure-panel-title">⚠ <span id="failure-panel-count">0</span> item(s) failed</span>
|
|
1279
|
+
<span class="failure-panel-dismiss" onclick="dismissFailurePanel()" title="Dismiss">✕</span>
|
|
1280
|
+
</div>
|
|
1281
|
+
<ul id="failure-panel-list" class="failure-panel-list"></ul>
|
|
1282
|
+
</div>
|
|
1283
|
+
|
|
1284
|
+
<!-- #05 PROGRESS -->
|
|
1285
|
+
<div class="card">
|
|
1286
|
+
<div class="card-header">
|
|
1287
|
+
<span class="card-title"><span class="card-num">#05</span> Progress</span>
|
|
1288
|
+
</div>
|
|
1289
|
+
<div class="card-body">
|
|
1290
|
+
|
|
1291
|
+
<div id="progress-section" class="progress-section">
|
|
1292
|
+
|
|
1293
|
+
<div id="overall-section" style="display:none;margin-bottom:12px;">
|
|
1294
|
+
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;">
|
|
1295
|
+
<span style="font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;">Job</span>
|
|
1296
|
+
<strong id="stat-overall" style="font-size:13px;">—</strong>
|
|
1297
|
+
</div>
|
|
1298
|
+
<div class="bar-bg" id="overall-bar-bg">
|
|
1299
|
+
<div class="bar-fill" id="overall-bar-fill"></div>
|
|
1300
|
+
</div>
|
|
1301
|
+
</div>
|
|
1302
|
+
|
|
1303
|
+
<div class="phase-row" id="phase-row">
|
|
1304
|
+
<span class="phase-dot idle" id="phase-dot"></span>
|
|
1305
|
+
<div class="phase-label" id="phase-label">—</div>
|
|
1306
|
+
</div>
|
|
1307
|
+
<div class="phase-file" id="phase-file"></div>
|
|
1308
|
+
<div class="bar-bg" id="bar-bg">
|
|
1309
|
+
<div class="bar-fill" id="bar-fill"></div>
|
|
1310
|
+
</div>
|
|
1311
|
+
<div id="celebration-container" style="position:relative;height:0;overflow:visible;pointer-events:none;"></div>
|
|
1312
|
+
<div class="progress-stats">
|
|
1313
|
+
<div>Progress <strong id="stat-pct">—</strong></div>
|
|
1314
|
+
<div>Size <strong id="stat-size">—</strong></div>
|
|
1315
|
+
<div>Speed <strong id="stat-speed">—</strong></div>
|
|
1316
|
+
<div>ETA <strong id="stat-eta">—</strong></div>
|
|
1317
|
+
<div>Elapsed <strong id="stat-elapsed">—</strong></div>
|
|
1318
|
+
</div>
|
|
1319
|
+
|
|
1320
|
+
<div id="encode-params-row" style="display:none;margin-top:2px;margin-bottom:10px;">
|
|
1321
|
+
<div id="encode-params-pills" style="display:flex;gap:6px;flex-wrap:wrap;"></div>
|
|
1322
|
+
</div>
|
|
1323
|
+
|
|
1324
|
+
<div id="size-tracker" class="size-tracker">
|
|
1325
|
+
<div class="size-tracker-header">
|
|
1326
|
+
<span class="size-tracker-label">Output vs Source</span>
|
|
1327
|
+
<span class="size-tracker-pct" id="st-pct">—</span>
|
|
1328
|
+
</div>
|
|
1329
|
+
<div class="size-tracker-bar">
|
|
1330
|
+
<div class="size-tracker-fill" id="st-fill"></div>
|
|
1331
|
+
<div class="size-tracker-mark" id="st-mark" style="left:50%"></div>
|
|
1332
|
+
</div>
|
|
1333
|
+
<div class="size-tracker-values">
|
|
1334
|
+
<span id="st-output">0 MB</span>
|
|
1335
|
+
<span id="st-source">/ — MB</span>
|
|
1336
|
+
</div>
|
|
1337
|
+
</div>
|
|
1338
|
+
|
|
1339
|
+
<div id="size-info-row" style="display:none;margin-top:10px;padding:8px 10px;background:var(--surface2);border:1px solid var(--border);border-radius:6px;font-size:13px;color:var(--text2);">
|
|
1340
|
+
<span id="size-info-text">—</span>
|
|
1341
|
+
</div>
|
|
1342
|
+
|
|
1343
|
+
<div id="dl-status-row" style="display:none;margin-top:8px;padding:6px 10px;background:var(--surface2);border:1px solid var(--border);border-radius:6px;font-size:12px;color:var(--muted);">
|
|
1344
|
+
<span style="text-transform:uppercase;letter-spacing:.05em;margin-right:6px;">DL</span>
|
|
1345
|
+
<strong id="dl-status-label">—</strong>
|
|
1346
|
+
<span id="dl-status-pct" style="margin-left:8px;"></span>
|
|
1347
|
+
<span id="dl-status-speed" style="margin-left:8px;"></span>
|
|
1348
|
+
<span id="dl-status-eta" style="margin-left:8px;color:var(--muted);">ETA —</span>
|
|
1349
|
+
</div>
|
|
1350
|
+
|
|
1351
|
+
<div id="charts-row" style="display:none;margin-top:12px;grid-template-columns:1fr 1fr;gap:10px;">
|
|
1352
|
+
<div class="chart-panel">
|
|
1353
|
+
<div class="chart-lbl">Download <span style="opacity:.6;font-size:9px;">MiB/s</span></div>
|
|
1354
|
+
<canvas id="chart-dl" style="width:100%;height:90px;display:block;"></canvas>
|
|
1355
|
+
</div>
|
|
1356
|
+
<div class="chart-panel">
|
|
1357
|
+
<div class="chart-lbl">Encode <span style="opacity:.6;font-size:9px;">speed ×</span></div>
|
|
1358
|
+
<canvas id="chart-enc" style="width:100%;height:90px;display:block;"></canvas>
|
|
1359
|
+
</div>
|
|
1360
|
+
</div>
|
|
1361
|
+
|
|
1362
|
+
</div>
|
|
1363
|
+
|
|
1364
|
+
<div class="idle-msg" id="idle-msg">Waiting for job. Fetch a video or load local files and press Start.</div>
|
|
1365
|
+
|
|
1366
|
+
<div class="log-box" id="log-box"></div>
|
|
1367
|
+
|
|
1368
|
+
<div class="action-row">
|
|
1369
|
+
<button class="btn btn-success" id="start-btn" onclick="startDownload()">▶ Start Jobs</button>
|
|
1370
|
+
<button class="btn btn-danger hidden" id="cancel-btn" onclick="cancelJob()">✕ Cancel</button>
|
|
1371
|
+
</div>
|
|
1372
|
+
|
|
1373
|
+
</div>
|
|
1374
|
+
</div>
|
|
1375
|
+
|
|
1376
|
+
<script>
|
|
1377
|
+
const API = 'http://localhost:8765';
|
|
1378
|
+
|
|
1379
|
+
let currentCQ = '0';
|
|
1380
|
+
let currentMaxrate = '';
|
|
1381
|
+
let currentTuneMode = 'uhq';
|
|
1382
|
+
let currentMode = 'video'; // 'video' (H.265) or 'audio' (WAV/MP3)
|
|
1383
|
+
let currentAudioPreset = 'wav'; // 'wav' or 'mp3'
|
|
1384
|
+
let currentVideoDuration = 0; // duration (secs) of the last /video-info result; 0 for playlists/unknown
|
|
1385
|
+
let _lastAudioFormats = []; // last fetched audio format list, for auto-preset
|
|
1386
|
+
let videoEncodeStart = null;
|
|
1387
|
+
// Smart encode-ETA state (EMA of progress rate). Reset per video.
|
|
1388
|
+
const ETA_TAU = 25; // seconds — smoothing time constant ("rock-steady")
|
|
1389
|
+
const ETA_WARMUP_PCT = 5; // show "Estimating…" until this %
|
|
1390
|
+
let etaEmaRate = null; // EMA of pct-per-second
|
|
1391
|
+
let etaLastPct = 0;
|
|
1392
|
+
let etaLastT = 0; // Date.now() ms of last progress tick (0 = none yet)
|
|
1393
|
+
|
|
1394
|
+
// ---- CHARTS ----
|
|
1395
|
+
class SparkChart {
|
|
1396
|
+
constructor(canvasId, color, unit) {
|
|
1397
|
+
this.canvas = document.getElementById(canvasId);
|
|
1398
|
+
this.ctx = this.canvas.getContext('2d');
|
|
1399
|
+
this.color = color;
|
|
1400
|
+
this.unit = unit;
|
|
1401
|
+
this.data = [];
|
|
1402
|
+
this.markers = [];
|
|
1403
|
+
this.N = 120;
|
|
1404
|
+
this.peak = 0;
|
|
1405
|
+
this.current = 0;
|
|
1406
|
+
}
|
|
1407
|
+
_sync() {
|
|
1408
|
+
const w = this.canvas.offsetWidth, h = this.canvas.offsetHeight;
|
|
1409
|
+
if (w > 0 && h > 0 && (this.canvas.width !== w || this.canvas.height !== h)) {
|
|
1410
|
+
this.canvas.width = w; this.canvas.height = h;
|
|
1411
|
+
}
|
|
1412
|
+
return this.canvas.width > 0 && this.canvas.height > 0;
|
|
1413
|
+
}
|
|
1414
|
+
push(val) {
|
|
1415
|
+
if (val == null || !isFinite(val) || val < 0) return;
|
|
1416
|
+
this.data.push(val);
|
|
1417
|
+
if (this.data.length > this.N) this.data.shift();
|
|
1418
|
+
this.current = val;
|
|
1419
|
+
if (val > this.peak) this.peak = val;
|
|
1420
|
+
this.draw();
|
|
1421
|
+
}
|
|
1422
|
+
addMarker(label, color) {
|
|
1423
|
+
this.markers.push({ index: this.data.length, label: label, color: color });
|
|
1424
|
+
}
|
|
1425
|
+
reset() {
|
|
1426
|
+
this.data = []; this.markers = []; this.peak = 0; this.current = 0;
|
|
1427
|
+
if (this._sync()) this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
1428
|
+
}
|
|
1429
|
+
draw() {
|
|
1430
|
+
if (!this._sync()) return;
|
|
1431
|
+
const ctx = this.ctx, W = this.canvas.width, H = this.canvas.height;
|
|
1432
|
+
const PT = 6, PB = 20, PL = 6, PR = 6;
|
|
1433
|
+
const plotW = W - PL - PR, plotH = H - PT - PB;
|
|
1434
|
+
ctx.clearRect(0, 0, W, H);
|
|
1435
|
+
|
|
1436
|
+
// Gridlines
|
|
1437
|
+
ctx.setLineDash([2, 4]);
|
|
1438
|
+
ctx.strokeStyle = 'rgba(154,144,176,0.12)';
|
|
1439
|
+
ctx.lineWidth = 1;
|
|
1440
|
+
for (const frac of [0.25, 0.5, 0.75]) {
|
|
1441
|
+
const gy = PT + plotH * (1 - frac);
|
|
1442
|
+
ctx.beginPath(); ctx.moveTo(PL, gy); ctx.lineTo(PL + plotW, gy); ctx.stroke();
|
|
1443
|
+
}
|
|
1444
|
+
ctx.setLineDash([]);
|
|
1445
|
+
|
|
1446
|
+
if (this.data.length < 2) {
|
|
1447
|
+
ctx.setLineDash([3, 3]);
|
|
1448
|
+
ctx.strokeStyle = 'rgba(154,144,176,0.3)';
|
|
1449
|
+
ctx.lineWidth = 1;
|
|
1450
|
+
ctx.beginPath(); ctx.moveTo(PL, PT + plotH); ctx.lineTo(PL + plotW, PT + plotH); ctx.stroke();
|
|
1451
|
+
ctx.setLineDash([]);
|
|
1452
|
+
ctx.fillStyle = 'rgba(154,144,176,0.5)';
|
|
1453
|
+
ctx.font = '11px monospace';
|
|
1454
|
+
ctx.textAlign = 'center';
|
|
1455
|
+
ctx.fillText('—', W / 2, H - 5);
|
|
1456
|
+
return;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
const maxVal = Math.max(...this.data, 0.001);
|
|
1460
|
+
const step = plotW / (this.N - 1);
|
|
1461
|
+
const xOff = (this.N - this.data.length) * step;
|
|
1462
|
+
const xOf = i => PL + xOff + i * step;
|
|
1463
|
+
const yOf = v => PT + plotH - (v / maxVal) * plotH;
|
|
1464
|
+
|
|
1465
|
+
const r = parseInt(this.color.slice(1,3),16);
|
|
1466
|
+
const g = parseInt(this.color.slice(3,5),16);
|
|
1467
|
+
const b = parseInt(this.color.slice(5,7),16);
|
|
1468
|
+
const grad = ctx.createLinearGradient(0, PT, 0, PT + plotH);
|
|
1469
|
+
grad.addColorStop(0, `rgba(${r},${g},${b},0.35)`);
|
|
1470
|
+
grad.addColorStop(1, `rgba(${r},${g},${b},0.02)`);
|
|
1471
|
+
|
|
1472
|
+
ctx.beginPath();
|
|
1473
|
+
this.data.forEach((v, i) => { const x = xOf(i), y = yOf(v); i === 0 ? ctx.moveTo(x,y) : ctx.lineTo(x,y); });
|
|
1474
|
+
ctx.lineTo(xOf(this.data.length-1), PT+plotH);
|
|
1475
|
+
ctx.lineTo(xOf(0), PT+plotH);
|
|
1476
|
+
ctx.closePath();
|
|
1477
|
+
ctx.fillStyle = grad;
|
|
1478
|
+
ctx.fill();
|
|
1479
|
+
|
|
1480
|
+
ctx.beginPath();
|
|
1481
|
+
this.data.forEach((v, i) => { const x = xOf(i), y = yOf(v); i === 0 ? ctx.moveTo(x,y) : ctx.lineTo(x,y); });
|
|
1482
|
+
ctx.strokeStyle = this.color;
|
|
1483
|
+
ctx.lineWidth = 1.5;
|
|
1484
|
+
ctx.lineJoin = 'round';
|
|
1485
|
+
ctx.stroke();
|
|
1486
|
+
|
|
1487
|
+
// Phase markers
|
|
1488
|
+
this.markers.forEach(m => {
|
|
1489
|
+
if (m.index >= this.data.length) return;
|
|
1490
|
+
const mx = xOf(m.index);
|
|
1491
|
+
ctx.strokeStyle = m.color || 'rgba(124,92,252,0.4)';
|
|
1492
|
+
ctx.lineWidth = 1;
|
|
1493
|
+
ctx.setLineDash([3, 3]);
|
|
1494
|
+
ctx.beginPath(); ctx.moveTo(mx, PT); ctx.lineTo(mx, PT + plotH); ctx.stroke();
|
|
1495
|
+
ctx.setLineDash([]);
|
|
1496
|
+
ctx.fillStyle = m.color || 'rgba(124,92,252,0.4)';
|
|
1497
|
+
ctx.font = '9px monospace';
|
|
1498
|
+
ctx.textAlign = 'center';
|
|
1499
|
+
ctx.fillText(m.label || '', mx, PT + 8);
|
|
1500
|
+
});
|
|
1501
|
+
|
|
1502
|
+
ctx.font = '11px monospace';
|
|
1503
|
+
ctx.fillStyle = '#6b6089';
|
|
1504
|
+
ctx.textAlign = 'left';
|
|
1505
|
+
ctx.fillText(this.current.toFixed(1) + ' ' + this.unit, PL, H - 5);
|
|
1506
|
+
ctx.fillStyle = '#9a90b0';
|
|
1507
|
+
ctx.textAlign = 'right';
|
|
1508
|
+
ctx.fillText('↑ ' + this.peak.toFixed(1), W - PR, H - 5);
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
function parseDownloadSpeed(s) {
|
|
1513
|
+
if (!s) return null;
|
|
1514
|
+
const m = s.match(/([\d.]+)\s*(G|M|K)iB\/s/i);
|
|
1515
|
+
if (!m) return null;
|
|
1516
|
+
const v = parseFloat(m[1]), u = m[2].toUpperCase();
|
|
1517
|
+
return u === 'G' ? v * 1024 : u === 'K' ? v / 1024 : v;
|
|
1518
|
+
}
|
|
1519
|
+
function parseEncodeSpeed(s) {
|
|
1520
|
+
if (!s) return null;
|
|
1521
|
+
const m = s.match(/^([\d.]+)x$/i);
|
|
1522
|
+
return m ? parseFloat(m[1]) : null;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
const chartDl = new SparkChart('chart-dl', '#4ecdc4', 'MiB/s');
|
|
1526
|
+
const chartEnc = new SparkChart('chart-enc', '#7c5cfc', '×');
|
|
1527
|
+
|
|
1528
|
+
// ---- PHASE STATE (F1, F4) ----
|
|
1529
|
+
let _currentPhase = 'idle';
|
|
1530
|
+
let _lastPhaseText = '';
|
|
1531
|
+
|
|
1532
|
+
function setPhaseState(phase) {
|
|
1533
|
+
const changed = _currentPhase !== phase;
|
|
1534
|
+
_currentPhase = phase;
|
|
1535
|
+
|
|
1536
|
+
// F1: indicator dot
|
|
1537
|
+
document.getElementById('phase-dot').className = 'phase-dot ' + phase;
|
|
1538
|
+
|
|
1539
|
+
// F1: label color
|
|
1540
|
+
const colors = { idle: 'var(--muted)', downloading: 'var(--accent3)', converting: 'var(--accent)', done: '#68d391', error: 'var(--accent2)' };
|
|
1541
|
+
document.getElementById('phase-label').style.color = colors[phase] || 'var(--accent)';
|
|
1542
|
+
|
|
1543
|
+
// F4: bar gradient
|
|
1544
|
+
const bar = document.getElementById('bar-fill');
|
|
1545
|
+
bar.classList.remove('phase-downloading', 'phase-converting', 'phase-done', 'phase-error');
|
|
1546
|
+
if (phase !== 'idle') bar.classList.add('phase-' + phase);
|
|
1547
|
+
|
|
1548
|
+
return changed;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
// ---- ENCODE BADGES (F2) ----
|
|
1552
|
+
function showEncodeParams(logMsg) {
|
|
1553
|
+
const match = logMsg.match(/Encode params:\s*(.+)/);
|
|
1554
|
+
if (!match) return;
|
|
1555
|
+
const pairs = match[1].match(/\S+=\S+/g);
|
|
1556
|
+
if (!pairs || pairs.length === 0) return;
|
|
1557
|
+
const container = document.getElementById('encode-params-pills');
|
|
1558
|
+
container.innerHTML = '';
|
|
1559
|
+
pairs.forEach(function(pair) {
|
|
1560
|
+
const eq = pair.indexOf('=');
|
|
1561
|
+
const key = pair.slice(0, eq);
|
|
1562
|
+
const val = pair.slice(eq + 1);
|
|
1563
|
+
const pill = document.createElement('span');
|
|
1564
|
+
pill.className = 'encode-pill';
|
|
1565
|
+
pill.innerHTML = '<span class="pill-key">' + key + '</span>' + val;
|
|
1566
|
+
container.appendChild(pill);
|
|
1567
|
+
});
|
|
1568
|
+
document.getElementById('encode-params-row').style.display = '';
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
// ---- CELEBRATION (F5) ----
|
|
1572
|
+
function celebrate() {
|
|
1573
|
+
const container = document.getElementById('celebration-container');
|
|
1574
|
+
container.innerHTML = '';
|
|
1575
|
+
const colors = ['#68d391', '#4ecdc4', '#f6e05e', '#fc8181', '#b794f4', '#63b3ed'];
|
|
1576
|
+
for (let i = 0; i < 24; i++) {
|
|
1577
|
+
const p = document.createElement('div');
|
|
1578
|
+
p.className = 'confetti-particle';
|
|
1579
|
+
p.style.left = (Math.random() * 100).toFixed(1) + '%';
|
|
1580
|
+
p.style.bottom = '0';
|
|
1581
|
+
p.style.background = colors[Math.floor(Math.random() * colors.length)];
|
|
1582
|
+
p.style.animationDelay = (Math.random() * 0.4).toFixed(2) + 's';
|
|
1583
|
+
p.style.animationDuration = (1.0 + Math.random() * 0.6).toFixed(2) + 's';
|
|
1584
|
+
p.style.setProperty('--drift', (Math.random() * 40 - 20).toFixed(0) + 'px');
|
|
1585
|
+
container.appendChild(p);
|
|
1586
|
+
}
|
|
1587
|
+
const card = container.closest('.card');
|
|
1588
|
+
card.classList.add('celebrating');
|
|
1589
|
+
const barFill = document.getElementById('bar-fill');
|
|
1590
|
+
barFill.classList.add('celebrating');
|
|
1591
|
+
setTimeout(function() {
|
|
1592
|
+
container.innerHTML = '';
|
|
1593
|
+
card.classList.remove('celebrating');
|
|
1594
|
+
barFill.classList.remove('celebrating');
|
|
1595
|
+
}, 2000);
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
// ---- SIZE TRACKER ----
|
|
1599
|
+
let _sourceBytes = 0;
|
|
1600
|
+
|
|
1601
|
+
function resetSizeTracker() {
|
|
1602
|
+
_sourceBytes = 0;
|
|
1603
|
+
var el = document.getElementById('size-tracker');
|
|
1604
|
+
el.classList.remove('visible');
|
|
1605
|
+
document.getElementById('st-fill').style.width = '0%';
|
|
1606
|
+
document.getElementById('st-pct').textContent = '—';
|
|
1607
|
+
document.getElementById('st-output').textContent = '0 MB';
|
|
1608
|
+
document.getElementById('st-source').textContent = '/ — MB';
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
function initSizeTracker(sourceBytes) {
|
|
1612
|
+
_sourceBytes = sourceBytes;
|
|
1613
|
+
if (_sourceBytes <= 0) return;
|
|
1614
|
+
var el = document.getElementById('size-tracker');
|
|
1615
|
+
el.classList.add('visible');
|
|
1616
|
+
document.getElementById('st-source').textContent = '/ ' + (_sourceBytes / 1024 / 1024).toFixed(0) + ' MB';
|
|
1617
|
+
document.getElementById('st-output').textContent = '0 MB';
|
|
1618
|
+
document.getElementById('st-fill').style.width = '0%';
|
|
1619
|
+
document.getElementById('st-fill').className = 'size-tracker-fill st-fill-green';
|
|
1620
|
+
document.getElementById('st-pct').textContent = '0%';
|
|
1621
|
+
document.getElementById('st-pct').className = 'size-tracker-pct st-green';
|
|
1622
|
+
// Position the 100% marker
|
|
1623
|
+
document.getElementById('st-mark').style.left = '50%';
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
function updateSizeTracker(outputBytes) {
|
|
1627
|
+
if (_sourceBytes <= 0 || outputBytes <= 0) return;
|
|
1628
|
+
var ratio = outputBytes / _sourceBytes;
|
|
1629
|
+
var pct = (ratio * 100).toFixed(1);
|
|
1630
|
+
var outMB = (outputBytes / 1024 / 1024).toFixed(0);
|
|
1631
|
+
|
|
1632
|
+
// Color tiers
|
|
1633
|
+
var tier;
|
|
1634
|
+
if (ratio < 0.85) tier = 'green';
|
|
1635
|
+
else if (ratio < 1.05) tier = 'teal';
|
|
1636
|
+
else if (ratio < 1.30) tier = 'orange';
|
|
1637
|
+
else tier = 'red';
|
|
1638
|
+
|
|
1639
|
+
document.getElementById('st-output').textContent = outMB + ' MB';
|
|
1640
|
+
document.getElementById('st-pct').textContent = pct + '%';
|
|
1641
|
+
document.getElementById('st-pct').className = 'size-tracker-pct st-' + tier;
|
|
1642
|
+
|
|
1643
|
+
// Bar: 100% of source = 50% of bar width, so output can visually exceed source
|
|
1644
|
+
var barPct = Math.min(ratio * 50, 100);
|
|
1645
|
+
var fill = document.getElementById('st-fill');
|
|
1646
|
+
fill.style.width = barPct.toFixed(1) + '%';
|
|
1647
|
+
fill.className = 'size-tracker-fill st-fill-' + tier;
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
// ---- ELAPSED TIMER (F7) ----
|
|
1651
|
+
let _elapsedInterval = null;
|
|
1652
|
+
function startElapsedTimer() {
|
|
1653
|
+
stopElapsedTimer();
|
|
1654
|
+
const el = document.getElementById('stat-elapsed');
|
|
1655
|
+
_elapsedInterval = setInterval(function() {
|
|
1656
|
+
if (!videoEncodeStart) return;
|
|
1657
|
+
const secs = Math.floor((Date.now() - videoEncodeStart) / 1000);
|
|
1658
|
+
const h = Math.floor(secs / 3600);
|
|
1659
|
+
const m = Math.floor((secs % 3600) / 60);
|
|
1660
|
+
const s = secs % 60;
|
|
1661
|
+
el.textContent = [h, m, s].map(function(x) { return String(x).padStart(2, '0'); }).join(':');
|
|
1662
|
+
}, 1000);
|
|
1663
|
+
}
|
|
1664
|
+
function stopElapsedTimer() {
|
|
1665
|
+
if (_elapsedInterval) { clearInterval(_elapsedInterval); _elapsedInterval = null; }
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
// ---- WAKE LOCK ----
|
|
1669
|
+
// Prevents Chrome from freezing this tab during long overnight jobs.
|
|
1670
|
+
let _wakeLock = null;
|
|
1671
|
+
|
|
1672
|
+
async function acquireWakeLock() {
|
|
1673
|
+
if (!('wakeLock' in navigator)) return;
|
|
1674
|
+
try {
|
|
1675
|
+
_wakeLock = await navigator.wakeLock.request('screen');
|
|
1676
|
+
_wakeLock.addEventListener('release', () => { _wakeLock = null; });
|
|
1677
|
+
} catch {}
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
function releaseWakeLock() {
|
|
1681
|
+
if (_wakeLock) { _wakeLock.release(); _wakeLock = null; }
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
// Re-acquire on tab re-focus (wake locks are released when tab loses visibility)
|
|
1685
|
+
document.addEventListener('visibilitychange', () => {
|
|
1686
|
+
if (document.visibilityState === 'visible' && _wakeLock === null && _jobRunning) {
|
|
1687
|
+
acquireWakeLock();
|
|
1688
|
+
}
|
|
1689
|
+
});
|
|
1690
|
+
|
|
1691
|
+
// Warn before closing tab during a job, or with unsaved pasted cookies (Issue #14).
|
|
1692
|
+
window.addEventListener('beforeunload', (e) => {
|
|
1693
|
+
if (_jobRunning || (typeof _pendingCookies === 'string' && _pendingCookies.trim())) {
|
|
1694
|
+
e.preventDefault();
|
|
1695
|
+
// Modern browsers show a generic prompt; the returnValue must be set.
|
|
1696
|
+
e.returnValue = 'You have unsaved pasted cookies. Save before reloading?';
|
|
1697
|
+
return e.returnValue;
|
|
1698
|
+
}
|
|
1699
|
+
});
|
|
1700
|
+
|
|
1701
|
+
let _jobRunning = false;
|
|
1702
|
+
|
|
1703
|
+
// ---- CANCEL STATE (Issue #5) ----
|
|
1704
|
+
let cancelled = false; // set by cancelJob(); halts the whole run, no retries
|
|
1705
|
+
let _currentController = null; // AbortController for the in-flight SSE fetch
|
|
1706
|
+
let _lastErrorMsg = ''; // last 'error' SSE msg for the current item
|
|
1707
|
+
|
|
1708
|
+
function fmtEta(secs) {
|
|
1709
|
+
if (!isFinite(secs) || secs <= 0) return '—';
|
|
1710
|
+
secs = Math.round(secs);
|
|
1711
|
+
if (secs < 60) return secs + 's';
|
|
1712
|
+
return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's';
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
function autoSelectPreset() {
|
|
1716
|
+
// Video: Smart Auto is always the right default — server analyses the source.
|
|
1717
|
+
// Audio: pick WAV vs MP3 based on best available source bitrate.
|
|
1718
|
+
const card = document.querySelector('#video-presets .preset-card[data-preset="auto"]');
|
|
1719
|
+
if (card) {
|
|
1720
|
+
document.querySelectorAll('#video-presets .preset-card').forEach(c => c.classList.remove('selected'));
|
|
1721
|
+
card.classList.add('selected');
|
|
1722
|
+
currentCQ = '0';
|
|
1723
|
+
currentMaxrate = '';
|
|
1724
|
+
currentTuneMode = 'uhq';
|
|
1725
|
+
setSelectVal('cq-select', '0');
|
|
1726
|
+
setSelectVal('maxrate', '');
|
|
1727
|
+
}
|
|
1728
|
+
autoSelectAudioPreset();
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
function autoSelectAudioPreset() {
|
|
1732
|
+
// ≥160 kbps source warrants lossless WAV; below that, MP3 320 is the better default.
|
|
1733
|
+
const bestAbr = (_lastAudioFormats || []).reduce((m, f) => Math.max(m, f.abr || 0), 0);
|
|
1734
|
+
const target = bestAbr >= 160 ? 'wav' : 'mp3';
|
|
1735
|
+
const card = document.querySelector(`#audio-presets [data-audio-preset="${target}"]`);
|
|
1736
|
+
if (!card) return;
|
|
1737
|
+
document.querySelectorAll('#audio-presets .preset-card').forEach(c => c.classList.remove('selected'));
|
|
1738
|
+
card.classList.add('selected');
|
|
1739
|
+
currentAudioPreset = target;
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
function selectPreset(el, cq, maxrate, tuneMode) {
|
|
1743
|
+
document.querySelectorAll('#video-presets .preset-card').forEach(c => c.classList.remove('selected'));
|
|
1744
|
+
el.classList.add('selected');
|
|
1745
|
+
currentCQ = cq;
|
|
1746
|
+
currentMaxrate = maxrate;
|
|
1747
|
+
currentTuneMode = tuneMode || 'uhq';
|
|
1748
|
+
setSelectVal('cq-select', cq);
|
|
1749
|
+
setSelectVal('maxrate', maxrate);
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
function selectAudioPreset(el, preset) {
|
|
1753
|
+
document.querySelectorAll('#audio-presets .preset-card').forEach(c => c.classList.remove('selected'));
|
|
1754
|
+
el.classList.add('selected');
|
|
1755
|
+
currentAudioPreset = preset;
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
function selectMode(mode) {
|
|
1759
|
+
currentMode = mode;
|
|
1760
|
+
document.querySelectorAll('.preset-card[data-mode]').forEach(c => {
|
|
1761
|
+
c.classList.toggle('selected', c.dataset.mode === mode);
|
|
1762
|
+
});
|
|
1763
|
+
const isAudio = mode === 'audio';
|
|
1764
|
+
document.getElementById('video-presets').style.display = isAudio ? 'none' : '';
|
|
1765
|
+
document.getElementById('audio-presets').style.display = isAudio ? '' : 'none';
|
|
1766
|
+
document.getElementById('video-only-options').style.display = isAudio ? 'none' : '';
|
|
1767
|
+
document.getElementById('convert-toggle-row').style.display = isAudio ? 'none' : '';
|
|
1768
|
+
const vfField = document.getElementById('video-format-field');
|
|
1769
|
+
if (vfField) vfField.style.display = isAudio ? 'none' : '';
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
async function browseOutputDir() {
|
|
1773
|
+
try {
|
|
1774
|
+
const r = await fetch(`${API}/browse-folder`);
|
|
1775
|
+
const data = await r.json();
|
|
1776
|
+
if (data.path) document.getElementById('output-dir').value = data.path;
|
|
1777
|
+
} catch (e) {}
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
function setSelectVal(id, val) {
|
|
1781
|
+
const sel = document.getElementById(id);
|
|
1782
|
+
for (const opt of sel.options) if (opt.value === val) { sel.value = val; break; }
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
function toggleAdvanced() {
|
|
1786
|
+
const sec = document.getElementById('advanced-section');
|
|
1787
|
+
const arr = document.getElementById('adv-arrow');
|
|
1788
|
+
sec.classList.toggle('open');
|
|
1789
|
+
arr.textContent = sec.classList.contains('open') ? '▼' : '▶';
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
function toggleConvert() {
|
|
1793
|
+
const on = document.getElementById('convert-toggle').checked;
|
|
1794
|
+
document.getElementById('conv-settings').style.display = on ? '' : 'none';
|
|
1795
|
+
document.getElementById('download-only-check').checked = !on;
|
|
1796
|
+
document.getElementById('delete-cache-check').disabled = !on;
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
function toggleDownloadOnly() {
|
|
1800
|
+
const downloadOnly = document.getElementById('download-only-check').checked;
|
|
1801
|
+
document.getElementById('convert-toggle').checked = !downloadOnly;
|
|
1802
|
+
document.getElementById('conv-settings').style.display = downloadOnly ? 'none' : '';
|
|
1803
|
+
document.getElementById('delete-cache-check').disabled = downloadOnly;
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
checkCookies();
|
|
1807
|
+
fetchVersion();
|
|
1808
|
+
fetchAppVersion();
|
|
1809
|
+
loadHistory();
|
|
1810
|
+
updateYtdlp();
|
|
1811
|
+
loadChannelLogo();
|
|
1812
|
+
|
|
1813
|
+
// ---- HEARTBEAT ----
|
|
1814
|
+
// Ping the server while this tab is open. When the tab closes the pings stop
|
|
1815
|
+
// and the server self-exits after its grace window — so the hidden server
|
|
1816
|
+
// process doesn't linger after you're done. Ping every 5s vs the server's
|
|
1817
|
+
// ~30s timeout, so a brief stall or refresh never trips it.
|
|
1818
|
+
function _heartbeat() { fetch(`${API}/heartbeat`).catch(() => {}); }
|
|
1819
|
+
_heartbeat();
|
|
1820
|
+
setInterval(_heartbeat, 5000);
|
|
1821
|
+
|
|
1822
|
+
// ---- HISTORY ----
|
|
1823
|
+
let _historyItems = [];
|
|
1824
|
+
let _historyOpen = false;
|
|
1825
|
+
|
|
1826
|
+
async function loadHistory() {
|
|
1827
|
+
try {
|
|
1828
|
+
const r = await fetch(`${API}/history`);
|
|
1829
|
+
_historyItems = await r.json();
|
|
1830
|
+
renderHistory();
|
|
1831
|
+
} catch {}
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
function renderHistory() {
|
|
1835
|
+
const panel = document.getElementById('history-panel');
|
|
1836
|
+
const list = document.getElementById('history-list');
|
|
1837
|
+
if (!_historyItems || _historyItems.length === 0) {
|
|
1838
|
+
panel.classList.add('hidden');
|
|
1839
|
+
_historyOpen = false;
|
|
1840
|
+
return;
|
|
1841
|
+
}
|
|
1842
|
+
panel.classList.remove('hidden');
|
|
1843
|
+
document.getElementById('history-toggle-label').textContent = 'Recent (' + _historyItems.length + ')';
|
|
1844
|
+
list.innerHTML = _historyItems.map((item, i) => {
|
|
1845
|
+
const thumb = item.thumbnail
|
|
1846
|
+
? `<img class="history-thumb" src="${item.thumbnail}" alt="">`
|
|
1847
|
+
: `<div class="history-thumb-blank"></div>`;
|
|
1848
|
+
const sub = (item.is_playlist ? `Playlist \u00b7 ${item.count} videos \u00b7 ` : '') + (item.added ? item.added.replace('T', ' ') : '');
|
|
1849
|
+
return `<div class="history-item" onclick="loadFromHistory(${i})">${thumb}<div class="history-meta"><div class="history-title">${item.title || item.url}</div><div class="history-sub">${sub}</div></div></div>`;
|
|
1850
|
+
}).join('');
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
function toggleHistory() {
|
|
1854
|
+
_historyOpen = !_historyOpen;
|
|
1855
|
+
document.getElementById('history-list').classList.toggle('hidden', !_historyOpen);
|
|
1856
|
+
document.getElementById('history-arrow').textContent = _historyOpen ? '\u25be' : '\u25b6';
|
|
1857
|
+
document.getElementById('history-toggle-label').textContent = 'Recent (' + _historyItems.length + ')';
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
function loadFromHistory(idx) {
|
|
1861
|
+
const item = _historyItems[idx];
|
|
1862
|
+
if (!item) return;
|
|
1863
|
+
document.getElementById('url-input').value = item.url;
|
|
1864
|
+
fetchInfo();
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
async function saveToHistory(info, url) {
|
|
1868
|
+
const form = new FormData();
|
|
1869
|
+
form.append('url', url);
|
|
1870
|
+
form.append('title', info.title || '');
|
|
1871
|
+
form.append('thumbnail', info.thumbnail || '');
|
|
1872
|
+
form.append('is_playlist', info.is_playlist ? 'true' : 'false');
|
|
1873
|
+
form.append('count', String(info.count || 1));
|
|
1874
|
+
try {
|
|
1875
|
+
await fetch(`${API}/history`, { method: 'POST', body: form });
|
|
1876
|
+
await loadHistory();
|
|
1877
|
+
// Keep panel open if it was open
|
|
1878
|
+
if (_historyOpen) {
|
|
1879
|
+
const list = document.getElementById('history-list');
|
|
1880
|
+
list.classList.remove('hidden');
|
|
1881
|
+
}
|
|
1882
|
+
} catch {}
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
async function clearHistory() {
|
|
1886
|
+
try {
|
|
1887
|
+
await fetch(`${API}/history`, { method: 'DELETE' });
|
|
1888
|
+
_historyItems = [];
|
|
1889
|
+
_historyOpen = false;
|
|
1890
|
+
renderHistory();
|
|
1891
|
+
} catch {}
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
function loadChannelLogo() {
|
|
1895
|
+
const img = document.getElementById('channel-logo');
|
|
1896
|
+
img.src = 'https://yt3.googleusercontent.com/wgn0tWnbhuvuDVMJZ-LBzOZpL0YwLVhwAVKIeWSwv9IpB91IvStBUQg_HSWg8sLWpqsYp7sa=s176-c-k-c0x00ffffff-no-rj';
|
|
1897
|
+
img.onload = () => { img.style.display = ''; };
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
async function fetchVersion() {
|
|
1901
|
+
try {
|
|
1902
|
+
const r = await fetch(`${API}/ytdlp-version`);
|
|
1903
|
+
const d = await r.json();
|
|
1904
|
+
document.getElementById('ytdlp-version-label').textContent = 'yt-dlp: ' + d.version;
|
|
1905
|
+
} catch {
|
|
1906
|
+
document.getElementById('ytdlp-version-label').textContent = 'yt-dlp: unknown';
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
async function fetchAppVersion() {
|
|
1911
|
+
try {
|
|
1912
|
+
const r = await fetch(`${API}/version`);
|
|
1913
|
+
const d = await r.json();
|
|
1914
|
+
document.getElementById('app-version-label').textContent = 'v ' + d.version;
|
|
1915
|
+
document.getElementById('app-version-dot').style.background = '';
|
|
1916
|
+
document.getElementById('app-version-dot').className = 'dot';
|
|
1917
|
+
} catch {
|
|
1918
|
+
document.getElementById('app-version-label').textContent = 'v —';
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
async function checkCookies() {
|
|
1923
|
+
try {
|
|
1924
|
+
const r = await fetch(`${API}/check-cookies`);
|
|
1925
|
+
const d = await r.json();
|
|
1926
|
+
const badge = document.getElementById('cookie-badge');
|
|
1927
|
+
const hdr = document.getElementById('cookie-header');
|
|
1928
|
+
const dot = document.getElementById('cookie-dot');
|
|
1929
|
+
if (d.exists && d.valid) {
|
|
1930
|
+
const yt = d.youtube_cookies || 0;
|
|
1931
|
+
badge.textContent = `✓ cookies loaded (${d.cookies}, ${yt} YouTube)`;
|
|
1932
|
+
badge.className = 'badge badge-ok';
|
|
1933
|
+
hdr.textContent = 'cookies: ✓';
|
|
1934
|
+
dot.className = 'dot';
|
|
1935
|
+
} else if (d.exists) {
|
|
1936
|
+
badge.textContent = `⚠ cookies.txt invalid: ${d.reason || 'parse error'}`;
|
|
1937
|
+
badge.className = 'badge badge-warn';
|
|
1938
|
+
hdr.textContent = 'cookies: invalid';
|
|
1939
|
+
dot.className = 'dot warn';
|
|
1940
|
+
} else {
|
|
1941
|
+
badge.textContent = '✗ No cookies — throttling likely';
|
|
1942
|
+
badge.className = 'badge badge-warn';
|
|
1943
|
+
hdr.textContent = 'cookies: missing';
|
|
1944
|
+
dot.className = 'dot warn';
|
|
1945
|
+
}
|
|
1946
|
+
} catch(e) {
|
|
1947
|
+
document.getElementById('cookie-badge').textContent = 'Server not reachable';
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
async function uploadCookies(input) {
|
|
1952
|
+
const file = input.files[0];
|
|
1953
|
+
if (!file) return;
|
|
1954
|
+
const form = new FormData();
|
|
1955
|
+
form.append('file', file);
|
|
1956
|
+
await fetch(`${API}/upload-cookies`, { method: 'POST', body: form });
|
|
1957
|
+
checkCookies();
|
|
1958
|
+
log('Cookies uploaded.', 'info');
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
let _pendingCookies = '';
|
|
1962
|
+
|
|
1963
|
+
function handleCookiePaste(e) {
|
|
1964
|
+
e.preventDefault();
|
|
1965
|
+
const text = (e.clipboardData || window.clipboardData).getData('text') || '';
|
|
1966
|
+
_pendingCookies = text;
|
|
1967
|
+
const lines = text.split('\n').filter(l => l.trim() && !l.trim().startsWith('#')).length;
|
|
1968
|
+
const ta = document.getElementById('cookie-paste');
|
|
1969
|
+
// Unambiguous captured state: green border + checkmark + bold text.
|
|
1970
|
+
ta.value = `✓ Captured ${lines} cookies — click Save and Verify to commit`;
|
|
1971
|
+
ta.classList.add('captured');
|
|
1972
|
+
const status = document.getElementById('cookie-paste-status');
|
|
1973
|
+
status.textContent = `✓ ${lines} captured — ready to save`;
|
|
1974
|
+
status.className = 'badge badge-ok';
|
|
1975
|
+
status.style.display = '';
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
function clearPastedCookies() {
|
|
1979
|
+
_pendingCookies = '';
|
|
1980
|
+
const ta = document.getElementById('cookie-paste');
|
|
1981
|
+
ta.value = '';
|
|
1982
|
+
ta.classList.remove('captured');
|
|
1983
|
+
const status = document.getElementById('cookie-paste-status');
|
|
1984
|
+
status.style.display = 'none';
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
async function savePastedCookies() {
|
|
1988
|
+
const ta = document.getElementById('cookie-paste');
|
|
1989
|
+
const status = document.getElementById('cookie-paste-status');
|
|
1990
|
+
const text = _pendingCookies;
|
|
1991
|
+
if (!text.trim()) {
|
|
1992
|
+
status.textContent = '⚠ paste cookies first';
|
|
1993
|
+
status.className = 'badge badge-warn';
|
|
1994
|
+
status.style.display = '';
|
|
1995
|
+
return;
|
|
1996
|
+
}
|
|
1997
|
+
status.textContent = 'Saving…';
|
|
1998
|
+
status.className = 'badge';
|
|
1999
|
+
status.style.display = '';
|
|
2000
|
+
try {
|
|
2001
|
+
const form = new FormData();
|
|
2002
|
+
form.append('text', text);
|
|
2003
|
+
const r = await fetch(`${API}/paste-cookies`, { method: 'POST', body: form });
|
|
2004
|
+
const d = await r.json();
|
|
2005
|
+
if (d.valid) {
|
|
2006
|
+
const yt = d.youtube_cookies || 0;
|
|
2007
|
+
status.textContent = `✓ valid — ${d.cookies} cookies (${yt} YouTube)`;
|
|
2008
|
+
status.className = 'badge badge-ok';
|
|
2009
|
+
_pendingCookies = '';
|
|
2010
|
+
ta.value = '';
|
|
2011
|
+
ta.classList.remove('captured');
|
|
2012
|
+
log(`Cookies pasted: ${d.cookies} entries, ${yt} YouTube.`, 'info');
|
|
2013
|
+
checkCookies();
|
|
2014
|
+
} else {
|
|
2015
|
+
status.textContent = `✗ ${d.reason || 'invalid'}`;
|
|
2016
|
+
status.className = 'badge badge-warn';
|
|
2017
|
+
}
|
|
2018
|
+
} catch (e) {
|
|
2019
|
+
status.textContent = '✗ save failed';
|
|
2020
|
+
status.className = 'badge badge-warn';
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
async function updateYtdlp() {
|
|
2025
|
+
try {
|
|
2026
|
+
const r = await fetch(`${API}/update-ytdlp`, { method: 'POST' });
|
|
2027
|
+
const d = await r.json();
|
|
2028
|
+
if (d.output) log('yt-dlp: ' + d.output);
|
|
2029
|
+
fetchVersion();
|
|
2030
|
+
} catch {}
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
function formatDuration(secs) {
|
|
2034
|
+
const h = Math.floor(secs/3600), m = Math.floor((secs%3600)/60), s = Math.floor(secs%60);
|
|
2035
|
+
return [h,m,s].map(x=>String(x).padStart(2,'0')).join(':');
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
async function fetchInfo() {
|
|
2039
|
+
const url = document.getElementById('url-input').value.trim();
|
|
2040
|
+
if (!url) return;
|
|
2041
|
+
const btn = document.getElementById('fetch-btn');
|
|
2042
|
+
btn.disabled = true; btn.textContent = 'Fetching...';
|
|
2043
|
+
log('Fetching video info...', 'info');
|
|
2044
|
+
try {
|
|
2045
|
+
const r = await fetch(`${API}/video-info?url=${encodeURIComponent(url)}`);
|
|
2046
|
+
if (!r.ok) { const d = await r.json(); log('Error: ' + d.error, 'error'); return; }
|
|
2047
|
+
const info = await r.json();
|
|
2048
|
+
document.getElementById('thumb').src = info.thumbnail || '';
|
|
2049
|
+
document.getElementById('video-title').textContent = info.is_playlist
|
|
2050
|
+
? 'Playlist: ' + info.title + ' (' + info.count + ' videos)'
|
|
2051
|
+
: info.title;
|
|
2052
|
+
document.getElementById('video-uploader').textContent = info.uploader ? 'by ' + info.uploader : '';
|
|
2053
|
+
document.getElementById('video-duration').textContent = info.is_playlist
|
|
2054
|
+
? 'Formats shown from first video - all videos will use the same selection'
|
|
2055
|
+
: (info.duration ? 'Duration: ' + formatDuration(info.duration) : '');
|
|
2056
|
+
// Playlists resolve durations per-video server-side; a single /video-info
|
|
2057
|
+
// duration isn't representative, so only stamp it for single videos.
|
|
2058
|
+
currentVideoDuration = info.is_playlist ? 0 : (info.duration || 0);
|
|
2059
|
+
const vSel = document.getElementById('video-format');
|
|
2060
|
+
vSel.innerHTML = '';
|
|
2061
|
+
info.video_formats.forEach(f => {
|
|
2062
|
+
const o = document.createElement('option');
|
|
2063
|
+
o.value = f.id; o.textContent = `[${f.id}] ${f.label}`;
|
|
2064
|
+
o.dataset.filesize = f.filesize || 0;
|
|
2065
|
+
vSel.appendChild(o);
|
|
2066
|
+
});
|
|
2067
|
+
const aSel = document.getElementById('audio-format');
|
|
2068
|
+
aSel.innerHTML = '';
|
|
2069
|
+
info.audio_formats.forEach(f => {
|
|
2070
|
+
const o = document.createElement('option');
|
|
2071
|
+
o.value = f.id; o.textContent = `[${f.id}] ${f.label}`;
|
|
2072
|
+
o.dataset.filesize = f.filesize || 0;
|
|
2073
|
+
o.dataset.abr = f.abr || 0;
|
|
2074
|
+
aSel.appendChild(o);
|
|
2075
|
+
});
|
|
2076
|
+
_lastAudioFormats = info.audio_formats || [];
|
|
2077
|
+
document.getElementById('video-info').classList.add('visible');
|
|
2078
|
+
document.getElementById('queue-add-btn').style.display = '';
|
|
2079
|
+
autoSelectPreset();
|
|
2080
|
+
log('Loaded: ' + info.title, 'info');
|
|
2081
|
+
saveToHistory(info, url);
|
|
2082
|
+
} finally {
|
|
2083
|
+
btn.disabled = false; btn.textContent = 'Fetch Info';
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
function getSelectedExpectedSize() {
|
|
2088
|
+
const vSel = document.getElementById('video-format');
|
|
2089
|
+
const aSel = document.getElementById('audio-format');
|
|
2090
|
+
const aSize = parseInt(aSel.selectedOptions[0]?.dataset.filesize || '0', 10);
|
|
2091
|
+
// In audio mode the video stream isn't downloaded — don't count it toward expected size.
|
|
2092
|
+
if (currentMode === 'audio') return aSize;
|
|
2093
|
+
const vSize = parseInt(vSel.selectedOptions[0]?.dataset.filesize || '0', 10);
|
|
2094
|
+
return vSize + aSize;
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
// Returns true on success, false on error.
|
|
2098
|
+
// Dispatches to /download (youtube) or /convert-local (local) based on item.type.
|
|
2099
|
+
async function runOneItem(item, opts) {
|
|
2100
|
+
let endpoint, form;
|
|
2101
|
+
|
|
2102
|
+
if (item.type === 'local') {
|
|
2103
|
+
endpoint = `${API}/convert-local`;
|
|
2104
|
+
form = new FormData();
|
|
2105
|
+
form.append('files', JSON.stringify([item.path]));
|
|
2106
|
+
form.append('cq', opts.cq);
|
|
2107
|
+
form.append('maxrate', opts.maxrate);
|
|
2108
|
+
form.append('shutdown', 'false');
|
|
2109
|
+
form.append('output_dir', opts.outputDir);
|
|
2110
|
+
form.append('tune_mode', opts.tuneMode);
|
|
2111
|
+
form.append('mode', opts.mode);
|
|
2112
|
+
form.append('audio_preset', opts.audioPreset);
|
|
2113
|
+
} else {
|
|
2114
|
+
endpoint = `${API}/download`;
|
|
2115
|
+
form = new FormData();
|
|
2116
|
+
form.append('url', item.url);
|
|
2117
|
+
form.append('video_format', item.video_format || '');
|
|
2118
|
+
form.append('audio_format', item.audio_format);
|
|
2119
|
+
form.append('convert', opts.convert);
|
|
2120
|
+
form.append('cq', opts.cq);
|
|
2121
|
+
form.append('maxrate', opts.maxrate);
|
|
2122
|
+
form.append('pipeline', opts.pipeline);
|
|
2123
|
+
form.append('delete_cache', opts.deleteCacheVal);
|
|
2124
|
+
form.append('shutdown', 'false');
|
|
2125
|
+
form.append('output_dir', opts.outputDir);
|
|
2126
|
+
form.append('tune_mode', opts.tuneMode);
|
|
2127
|
+
form.append('expected_size', String(item.expected_size || 0));
|
|
2128
|
+
form.append('mode', opts.mode);
|
|
2129
|
+
form.append('audio_preset', opts.audioPreset);
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
let gotError = false;
|
|
2133
|
+
let gotCancelled = false;
|
|
2134
|
+
// Fresh AbortController per item so cancelJob() can abort the in-flight stream.
|
|
2135
|
+
const controller = new AbortController();
|
|
2136
|
+
_currentController = controller;
|
|
2137
|
+
|
|
2138
|
+
try {
|
|
2139
|
+
const r = await fetch(endpoint, { method: 'POST', body: form, signal: controller.signal });
|
|
2140
|
+
const reader = r.body.getReader();
|
|
2141
|
+
const decoder = new TextDecoder();
|
|
2142
|
+
let buf = '';
|
|
2143
|
+
|
|
2144
|
+
while (true) {
|
|
2145
|
+
const { done, value } = await reader.read();
|
|
2146
|
+
if (done) break;
|
|
2147
|
+
buf += decoder.decode(value, { stream: true });
|
|
2148
|
+
const lines = buf.split('\n');
|
|
2149
|
+
buf = lines.pop();
|
|
2150
|
+
for (const line of lines) {
|
|
2151
|
+
if (!line.startsWith('data: ')) continue;
|
|
2152
|
+
const raw = line.slice(6).trim();
|
|
2153
|
+
if (!raw) continue;
|
|
2154
|
+
try {
|
|
2155
|
+
const msg = JSON.parse(raw);
|
|
2156
|
+
if (msg.type === 'error') gotError = true;
|
|
2157
|
+
else if (msg.type === 'cancelled') gotCancelled = true;
|
|
2158
|
+
handleMsg(msg);
|
|
2159
|
+
} catch {}
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
} catch (e) {
|
|
2163
|
+
// An aborted fetch (user hit Cancel) is terminal, not a retryable error.
|
|
2164
|
+
if (e && e.name === 'AbortError') gotCancelled = true;
|
|
2165
|
+
else throw e;
|
|
2166
|
+
} finally {
|
|
2167
|
+
if (_currentController === controller) _currentController = null;
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
// Treat a server-side 'cancelled' event or a client abort as terminal-no-retry.
|
|
2171
|
+
if (gotCancelled || cancelled) return { ok: false, cancelled: true };
|
|
2172
|
+
return { ok: !gotError, cancelled: false };
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
// Map a queued youtube item to the server's per-item batch payload.
|
|
2176
|
+
function toItemPayload(it) {
|
|
2177
|
+
return {
|
|
2178
|
+
url: it.url,
|
|
2179
|
+
video_format: it.video_format || '',
|
|
2180
|
+
audio_format: it.audio_format,
|
|
2181
|
+
expected_size: it.expected_size || 0,
|
|
2182
|
+
output_dir: it.output_dir || document.getElementById('output-dir').value.trim(),
|
|
2183
|
+
tune_mode: it.tune_mode || currentTuneMode,
|
|
2184
|
+
title: it.title || it.url,
|
|
2185
|
+
duration: it.duration || 0,
|
|
2186
|
+
};
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
// Batch: POST a contiguous run of youtube items as one pipelined /download.
|
|
2190
|
+
// The SSE read loop mirrors runOneItem, but also maps item_done/item_failed
|
|
2191
|
+
// (by 1-based idx, submission order) back to the original items so the
|
|
2192
|
+
// caller can retry only the ones that actually failed (#28).
|
|
2193
|
+
// Returns { results: Array<{item, ok, cancelled, error}> } in submission order.
|
|
2194
|
+
async function runBatch(items, opts) {
|
|
2195
|
+
const form = new FormData();
|
|
2196
|
+
// url/audio_format are required form fields on /download; the server ignores
|
|
2197
|
+
// them in batch mode but they must be present. Seed from the first item.
|
|
2198
|
+
form.append('url', items[0].url);
|
|
2199
|
+
form.append('audio_format', items[0].audio_format || '');
|
|
2200
|
+
form.append('items', JSON.stringify(items.map(toItemPayload)));
|
|
2201
|
+
form.append('convert', opts.convert);
|
|
2202
|
+
form.append('cq', opts.cq);
|
|
2203
|
+
form.append('maxrate', opts.maxrate);
|
|
2204
|
+
form.append('pipeline', 'true');
|
|
2205
|
+
form.append('delete_cache', opts.deleteCacheVal);
|
|
2206
|
+
form.append('shutdown', 'false');
|
|
2207
|
+
form.append('output_dir', opts.outputDir);
|
|
2208
|
+
form.append('tune_mode', opts.tuneMode);
|
|
2209
|
+
form.append('mode', opts.mode);
|
|
2210
|
+
form.append('audio_preset', opts.audioPreset);
|
|
2211
|
+
|
|
2212
|
+
let gotCancelled = false;
|
|
2213
|
+
const outcome = new Map(); // idx (1-based) -> { ok, error? }
|
|
2214
|
+
const controller = new AbortController();
|
|
2215
|
+
_currentController = controller;
|
|
2216
|
+
|
|
2217
|
+
try {
|
|
2218
|
+
const r = await fetch(`${API}/download`, { method: 'POST', body: form, signal: controller.signal });
|
|
2219
|
+
const reader = r.body.getReader();
|
|
2220
|
+
const decoder = new TextDecoder();
|
|
2221
|
+
let buf = '';
|
|
2222
|
+
|
|
2223
|
+
while (true) {
|
|
2224
|
+
const { done, value } = await reader.read();
|
|
2225
|
+
if (done) break;
|
|
2226
|
+
buf += decoder.decode(value, { stream: true });
|
|
2227
|
+
const lines = buf.split('\n');
|
|
2228
|
+
buf = lines.pop();
|
|
2229
|
+
for (const line of lines) {
|
|
2230
|
+
if (!line.startsWith('data: ')) continue;
|
|
2231
|
+
const raw = line.slice(6).trim();
|
|
2232
|
+
if (!raw) continue;
|
|
2233
|
+
try {
|
|
2234
|
+
const msg = JSON.parse(raw);
|
|
2235
|
+
if (msg.type === 'item_done') outcome.set(msg.idx, { ok: true });
|
|
2236
|
+
else if (msg.type === 'item_failed') outcome.set(msg.idx, { ok: false, error: msg.msg || 'failed' });
|
|
2237
|
+
else if (msg.type === 'cancelled') gotCancelled = true;
|
|
2238
|
+
handleMsg(msg);
|
|
2239
|
+
} catch {}
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
} catch (e) {
|
|
2243
|
+
if (e && e.name === 'AbortError') gotCancelled = true;
|
|
2244
|
+
else throw e;
|
|
2245
|
+
} finally {
|
|
2246
|
+
if (_currentController === controller) _currentController = null;
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
// Build results in submission order. An item with no outcome (e.g. the
|
|
2250
|
+
// stream died mid-run, or the encoder consecutive-failure abort cap left an
|
|
2251
|
+
// already-downloaded item with neither event) counts as failed so it retries
|
|
2252
|
+
// rather than being silently dropped.
|
|
2253
|
+
const results = items.map((item, i) => {
|
|
2254
|
+
const o = outcome.get(i + 1);
|
|
2255
|
+
if (gotCancelled || cancelled) return { item, ok: false, cancelled: true };
|
|
2256
|
+
return { item, ok: !!(o && o.ok), cancelled: false, error: o ? o.error : 'no result' };
|
|
2257
|
+
});
|
|
2258
|
+
return { results };
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
const MAX_RETRY_ROUNDS = 3;
|
|
2262
|
+
|
|
2263
|
+
async function startDownload() {
|
|
2264
|
+
// If no queue, seed it from the current URL
|
|
2265
|
+
if (dlQueue.length === 0) {
|
|
2266
|
+
const url = document.getElementById('url-input').value.trim();
|
|
2267
|
+
const vf = document.getElementById('video-format').value;
|
|
2268
|
+
const af = document.getElementById('audio-format').value;
|
|
2269
|
+
if (!url) { log('Enter a URL first, or add items to the queue.', 'error'); return; }
|
|
2270
|
+
const needVideo = currentMode !== 'audio';
|
|
2271
|
+
if ((needVideo && !vf) || !af) { log('Fetch video info and select formats first.', 'error'); return; }
|
|
2272
|
+
dlQueue.push({
|
|
2273
|
+
type: 'youtube', url, title: url,
|
|
2274
|
+
video_format: needVideo ? vf : '', audio_format: af,
|
|
2275
|
+
expected_size: getSelectedExpectedSize(),
|
|
2276
|
+
output_dir: document.getElementById('output-dir').value.trim(),
|
|
2277
|
+
tune_mode: currentTuneMode,
|
|
2278
|
+
duration: currentVideoDuration || 0,
|
|
2279
|
+
});
|
|
2280
|
+
renderDlQueue();
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
const opts = {
|
|
2284
|
+
cq: document.getElementById('cq-select').value,
|
|
2285
|
+
maxrate: document.getElementById('maxrate').value,
|
|
2286
|
+
convert: document.getElementById('convert-toggle').checked ? 'true' : 'false',
|
|
2287
|
+
pipeline: document.getElementById('pipeline-check').checked ? 'true' : 'false',
|
|
2288
|
+
deleteCacheVal: document.getElementById('delete-cache-check').checked ? 'true' : 'false',
|
|
2289
|
+
shutdownVal: document.getElementById('shutdown-check').checked ? 'true' : 'false',
|
|
2290
|
+
outputDir: document.getElementById('output-dir').value.trim(),
|
|
2291
|
+
tuneMode: currentTuneMode,
|
|
2292
|
+
mode: currentMode,
|
|
2293
|
+
audioPreset: currentAudioPreset,
|
|
2294
|
+
};
|
|
2295
|
+
|
|
2296
|
+
_jobRunning = true;
|
|
2297
|
+
cancelled = false; // fresh run — clear any prior cancel state
|
|
2298
|
+
_currentController = null;
|
|
2299
|
+
acquireWakeLock();
|
|
2300
|
+
|
|
2301
|
+
document.getElementById('start-btn').classList.add('hidden');
|
|
2302
|
+
document.getElementById('cancel-btn').classList.remove('hidden');
|
|
2303
|
+
document.getElementById('idle-msg').classList.add('hidden');
|
|
2304
|
+
document.getElementById('progress-section').classList.add('visible');
|
|
2305
|
+
document.getElementById('dl-status-row').style.display = 'none';
|
|
2306
|
+
document.getElementById('size-info-row').style.display = 'none';
|
|
2307
|
+
document.getElementById('charts-row').style.display = 'grid';
|
|
2308
|
+
document.getElementById('encode-params-row').style.display = 'none';
|
|
2309
|
+
document.getElementById('stat-elapsed').textContent = '—';
|
|
2310
|
+
etaEmaRate = null; etaLastPct = 0; etaLastT = 0;
|
|
2311
|
+
chartDl.reset();
|
|
2312
|
+
chartEnc.reset();
|
|
2313
|
+
resetSizeTracker();
|
|
2314
|
+
setPhaseState('idle');
|
|
2315
|
+
_lastPhaseText = '';
|
|
2316
|
+
|
|
2317
|
+
let completed = 0;
|
|
2318
|
+
let retryQueue = []; // items that failed and need retrying
|
|
2319
|
+
|
|
2320
|
+
// Main loop: drain dlQueue live — new items added mid-job are picked up
|
|
2321
|
+
while (dlQueue.length > 0 || retryQueue.length > 0) {
|
|
2322
|
+
// User cancelled — stop without starting the next item or any retries.
|
|
2323
|
+
if (cancelled) break;
|
|
2324
|
+
|
|
2325
|
+
// If main queue is drained but retries remain, swap them in after a pause
|
|
2326
|
+
if (dlQueue.length === 0 && retryQueue.length > 0) {
|
|
2327
|
+
if (retryQueue[0]._retries >= MAX_RETRY_ROUNDS - 1) {
|
|
2328
|
+
// Max retries exhausted
|
|
2329
|
+
log(`✕ ${retryQueue.length} item(s) failed after ${MAX_RETRY_ROUNDS - 1} retries:`, 'error');
|
|
2330
|
+
retryQueue.forEach(p => {
|
|
2331
|
+
log(` • ${p.title || p.url}`, 'error');
|
|
2332
|
+
recordFailure(p, p._lastError || '', true);
|
|
2333
|
+
});
|
|
2334
|
+
break;
|
|
2335
|
+
}
|
|
2336
|
+
const round = retryQueue[0]._retries + 1;
|
|
2337
|
+
const waitSecs = 15 * round;
|
|
2338
|
+
log(`── Retry round ${round}/${MAX_RETRY_ROUNDS - 1}: ${retryQueue.length} item(s) — waiting ${waitSecs}s ──`, 'info');
|
|
2339
|
+
// Interruptible wait so Cancel takes effect during the backoff.
|
|
2340
|
+
for (let waited = 0; waited < waitSecs * 1000 && !cancelled; waited += 250) {
|
|
2341
|
+
await new Promise(r => setTimeout(r, 250));
|
|
2342
|
+
}
|
|
2343
|
+
if (cancelled) break;
|
|
2344
|
+
// Move retries back into main queue (new items added during wait go first)
|
|
2345
|
+
dlQueue.push(...retryQueue);
|
|
2346
|
+
retryQueue = [];
|
|
2347
|
+
renderDlQueue();
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
// Batch mode: when pipelining video conversions, gather the maximal run of
|
|
2351
|
+
// consecutive youtube items at the front of the queue and send them as one
|
|
2352
|
+
// pipelined /download so download(N+1) overlaps encode(N) across the run.
|
|
2353
|
+
// Per-item retry (#28): only the items that actually failed are requeued.
|
|
2354
|
+
const canBatch = opts.pipeline === 'true' && opts.convert === 'true' && opts.mode !== 'audio';
|
|
2355
|
+
if (canBatch && dlQueue.length >= 2 && dlQueue[0].type === 'youtube' && dlQueue[1].type === 'youtube') {
|
|
2356
|
+
const batchItems = [];
|
|
2357
|
+
while (dlQueue.length > 0 && dlQueue[0].type === 'youtube') {
|
|
2358
|
+
batchItems.push(dlQueue.shift());
|
|
2359
|
+
}
|
|
2360
|
+
renderDlQueue();
|
|
2361
|
+
completed += batchItems.length;
|
|
2362
|
+
const totalVisible = completed + dlQueue.length + retryQueue.length;
|
|
2363
|
+
document.getElementById('overall-section').style.display = '';
|
|
2364
|
+
document.getElementById('stat-overall').textContent = completed + ' / ' + totalVisible;
|
|
2365
|
+
log(`── Batch of ${batchItems.length} video(s) — pipelined ──`, 'info');
|
|
2366
|
+
opts.shutdown = 'false';
|
|
2367
|
+
const { results } = await runBatch(batchItems, opts);
|
|
2368
|
+
if (results.some(r => r.cancelled) || cancelled) { cancelled = true; break; }
|
|
2369
|
+
let anyFailed = false;
|
|
2370
|
+
for (const r of results) {
|
|
2371
|
+
if (r.ok) continue;
|
|
2372
|
+
anyFailed = true;
|
|
2373
|
+
r.item._lastError = r.error;
|
|
2374
|
+
r.item._retries = (r.item._retries || 0) + 1;
|
|
2375
|
+
if (r.item._retries < MAX_RETRY_ROUNDS) {
|
|
2376
|
+
log(`⚠ Failed: ${r.item.title || r.item.url} — will retry after remaining items.`, 'error');
|
|
2377
|
+
recordFailure(r.item, r.error, false);
|
|
2378
|
+
retryQueue.push(r.item);
|
|
2379
|
+
} else {
|
|
2380
|
+
log(`✕ Failed: ${r.item.title || r.item.url} — max retries exhausted.`, 'error');
|
|
2381
|
+
recordFailure(r.item, r.error, true);
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
if (!anyFailed) {
|
|
2385
|
+
document.getElementById('overall-bar-fill').style.width = (completed / totalVisible * 100).toFixed(1) + '%';
|
|
2386
|
+
}
|
|
2387
|
+
continue;
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
// Pop next item from front of queue
|
|
2391
|
+
const item = dlQueue.shift();
|
|
2392
|
+
renderDlQueue();
|
|
2393
|
+
completed++;
|
|
2394
|
+
|
|
2395
|
+
const totalVisible = completed + dlQueue.length + retryQueue.length;
|
|
2396
|
+
const showOverall = totalVisible > 1 || completed > 1;
|
|
2397
|
+
if (showOverall) {
|
|
2398
|
+
document.getElementById('overall-section').style.display = '';
|
|
2399
|
+
document.getElementById('stat-overall').textContent = completed + ' / ' + totalVisible;
|
|
2400
|
+
document.getElementById('overall-bar-fill').style.width = ((completed - 1) / totalVisible * 100).toFixed(1) + '%';
|
|
2401
|
+
} else {
|
|
2402
|
+
document.getElementById('overall-section').style.display = 'none';
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
const label = item.title || item.url;
|
|
2406
|
+
const retryTag = item._retries ? ` (retry ${item._retries})` : '';
|
|
2407
|
+
if (showOverall || item._retries) {
|
|
2408
|
+
log(`── ${completed}/${totalVisible}: ${label}${retryTag}`, 'info');
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
// Never send shutdown to server mid-queue — handled client-side after all items complete
|
|
2412
|
+
opts.shutdown = 'false';
|
|
2413
|
+
|
|
2414
|
+
_lastErrorMsg = '';
|
|
2415
|
+
const result = await runOneItem(item, opts);
|
|
2416
|
+
|
|
2417
|
+
// Cancelled (client abort or server 'cancelled' event): terminal, no retry, stop the run.
|
|
2418
|
+
if (result.cancelled || cancelled) {
|
|
2419
|
+
cancelled = true;
|
|
2420
|
+
break;
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
if (!result.ok) {
|
|
2424
|
+
item._lastError = _lastErrorMsg;
|
|
2425
|
+
item._retries = (item._retries || 0) + 1;
|
|
2426
|
+
if (item._retries < MAX_RETRY_ROUNDS) {
|
|
2427
|
+
log(`⚠ Failed: ${label} — will retry after remaining items.`, 'error');
|
|
2428
|
+
recordFailure(item, _lastErrorMsg, false); // transient (mid-retry)
|
|
2429
|
+
retryQueue.push(item);
|
|
2430
|
+
} else {
|
|
2431
|
+
log(`✕ Failed: ${label} — max retries exhausted.`, 'error');
|
|
2432
|
+
recordFailure(item, _lastErrorMsg, true); // permanent
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
if (showOverall && result.ok) {
|
|
2437
|
+
document.getElementById('overall-bar-fill').style.width = (completed / totalVisible * 100).toFixed(1) + '%';
|
|
2438
|
+
}
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
const allDone = !cancelled && dlQueue.length === 0 && retryQueue.length === 0;
|
|
2442
|
+
|
|
2443
|
+
if (allDone) {
|
|
2444
|
+
document.getElementById('overall-bar-fill').style.width = '100%';
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
_jobRunning = false;
|
|
2448
|
+
releaseWakeLock();
|
|
2449
|
+
stopElapsedTimer();
|
|
2450
|
+
document.getElementById('start-btn').classList.remove('hidden');
|
|
2451
|
+
document.getElementById('cancel-btn').classList.add('hidden');
|
|
2452
|
+
|
|
2453
|
+
// Shutdown: queue is fully drained, nothing was added during final item
|
|
2454
|
+
if (allDone && opts.shutdownVal === 'true') {
|
|
2455
|
+
const proceed = await runShutdownCountdown(60);
|
|
2456
|
+
if (proceed) {
|
|
2457
|
+
log('Countdown elapsed — shutting down.', 'info');
|
|
2458
|
+
const token = document.querySelector('meta[name="dlpr-token"]')?.content;
|
|
2459
|
+
try {
|
|
2460
|
+
await fetch(`${API}/shutdown-now`, { method: 'POST', headers: { 'X-DLPR-Token': token } });
|
|
2461
|
+
} catch {}
|
|
2462
|
+
} else {
|
|
2463
|
+
log('Shutdown cancelled by user.', 'info');
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
// Issue #6 — visible, cancellable shutdown countdown overlay.
|
|
2469
|
+
// Resolves true if the countdown runs out, false if the user cancels.
|
|
2470
|
+
function runShutdownCountdown(seconds) {
|
|
2471
|
+
return new Promise((resolve) => {
|
|
2472
|
+
log(`All jobs complete — shutting down in ${seconds} seconds.`, 'info');
|
|
2473
|
+
|
|
2474
|
+
const overlay = document.createElement('div');
|
|
2475
|
+
overlay.className = 'shutdown-overlay';
|
|
2476
|
+
overlay.id = 'shutdown-overlay';
|
|
2477
|
+
overlay.innerHTML =
|
|
2478
|
+
'<div class="shutdown-overlay-card">' +
|
|
2479
|
+
'<div class="shutdown-overlay-title">Shutting down PC</div>' +
|
|
2480
|
+
'<div class="shutdown-overlay-count" id="shutdown-overlay-count">0:00</div>' +
|
|
2481
|
+
'<div class="shutdown-overlay-sub">All jobs are complete. The PC will power off when the timer reaches zero.</div>' +
|
|
2482
|
+
'<button class="btn btn-danger" id="shutdown-cancel-btn">✕ Cancel Shutdown</button>' +
|
|
2483
|
+
'</div>';
|
|
2484
|
+
document.body.appendChild(overlay);
|
|
2485
|
+
|
|
2486
|
+
let remaining = seconds;
|
|
2487
|
+
let timer = null;
|
|
2488
|
+
const countEl = document.getElementById('shutdown-overlay-count');
|
|
2489
|
+
|
|
2490
|
+
function fmt(s) {
|
|
2491
|
+
const m = Math.floor(s / 60);
|
|
2492
|
+
return m + ':' + String(s % 60).padStart(2, '0');
|
|
2493
|
+
}
|
|
2494
|
+
function teardown() {
|
|
2495
|
+
if (timer) { clearInterval(timer); timer = null; }
|
|
2496
|
+
const el = document.getElementById('shutdown-overlay');
|
|
2497
|
+
if (el && el.parentNode) el.parentNode.removeChild(el);
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
countEl.textContent = fmt(remaining);
|
|
2501
|
+
document.getElementById('shutdown-cancel-btn').onclick = function () {
|
|
2502
|
+
teardown();
|
|
2503
|
+
resolve(false);
|
|
2504
|
+
};
|
|
2505
|
+
|
|
2506
|
+
timer = setInterval(function () {
|
|
2507
|
+
remaining -= 1;
|
|
2508
|
+
if (remaining <= 0) {
|
|
2509
|
+
teardown();
|
|
2510
|
+
resolve(true);
|
|
2511
|
+
return;
|
|
2512
|
+
}
|
|
2513
|
+
countEl.textContent = fmt(remaining);
|
|
2514
|
+
}, 1000);
|
|
2515
|
+
});
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
function handleMsg(msg) {
|
|
2519
|
+
const barBg = document.getElementById('bar-bg');
|
|
2520
|
+
if (msg.type === 'video_start') {
|
|
2521
|
+
const overallSec = document.getElementById('overall-section');
|
|
2522
|
+
videoEncodeStart = Date.now();
|
|
2523
|
+
etaEmaRate = null; etaLastPct = 0; etaLastT = 0;
|
|
2524
|
+
setPhaseState('idle');
|
|
2525
|
+
document.getElementById('encode-params-row').style.display = 'none';
|
|
2526
|
+
document.getElementById('stat-elapsed').textContent = '00:00:00';
|
|
2527
|
+
resetSizeTracker();
|
|
2528
|
+
startElapsedTimer();
|
|
2529
|
+
if (msg.total > 1) {
|
|
2530
|
+
overallSec.style.display = '';
|
|
2531
|
+
document.getElementById('stat-overall').textContent = msg.current + ' / ' + msg.total;
|
|
2532
|
+
const pct = ((msg.current - 1) / msg.total * 100).toFixed(1);
|
|
2533
|
+
document.getElementById('overall-bar-fill').style.width = pct + '%';
|
|
2534
|
+
} else {
|
|
2535
|
+
overallSec.style.display = 'none';
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2538
|
+
} else if (msg.type === 'phase') {
|
|
2539
|
+
if (!videoEncodeStart) { videoEncodeStart = Date.now(); etaEmaRate = null; etaLastPct = 0; etaLastT = 0; }
|
|
2540
|
+
const colonIdx = msg.msg.indexOf(': ');
|
|
2541
|
+
var phLabel;
|
|
2542
|
+
if (colonIdx !== -1) {
|
|
2543
|
+
phLabel = msg.msg.slice(0, colonIdx);
|
|
2544
|
+
document.getElementById('phase-label').textContent = phLabel;
|
|
2545
|
+
document.getElementById('phase-file').textContent = msg.msg.slice(colonIdx + 2);
|
|
2546
|
+
} else {
|
|
2547
|
+
phLabel = msg.msg;
|
|
2548
|
+
document.getElementById('phase-label').textContent = phLabel;
|
|
2549
|
+
document.getElementById('phase-file').textContent = '';
|
|
2550
|
+
}
|
|
2551
|
+
document.getElementById('bar-fill').style.width = '0%';
|
|
2552
|
+
barBg.classList.add('bar-indeterminate');
|
|
2553
|
+
document.getElementById('stat-pct').textContent = '—';
|
|
2554
|
+
document.getElementById('stat-eta').textContent = '—';
|
|
2555
|
+
|
|
2556
|
+
// F1: detect phase from label text
|
|
2557
|
+
var newPhase = /download/i.test(phLabel) ? 'downloading' : 'converting';
|
|
2558
|
+
var phaseChanged = setPhaseState(newPhase);
|
|
2559
|
+
|
|
2560
|
+
// F3: phase transition animation
|
|
2561
|
+
if (phLabel !== _lastPhaseText) {
|
|
2562
|
+
var labelEl = document.getElementById('phase-label');
|
|
2563
|
+
var rowEl = document.getElementById('phase-row');
|
|
2564
|
+
labelEl.classList.remove('transitioning');
|
|
2565
|
+
rowEl.classList.remove('glow');
|
|
2566
|
+
void labelEl.offsetWidth; // force reflow
|
|
2567
|
+
labelEl.classList.add('transitioning');
|
|
2568
|
+
rowEl.classList.add('glow');
|
|
2569
|
+
_lastPhaseText = phLabel;
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2572
|
+
// F6: chart phase markers
|
|
2573
|
+
if (phaseChanged) {
|
|
2574
|
+
var markerLabel = newPhase === 'downloading' ? 'DL' : 'ENC';
|
|
2575
|
+
var markerColor = newPhase === 'downloading' ? 'rgba(78,205,196,0.5)' : 'rgba(124,92,252,0.5)';
|
|
2576
|
+
chartDl.addMarker(markerLabel, markerColor);
|
|
2577
|
+
chartEnc.addMarker(markerLabel, markerColor);
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2580
|
+
} else if (msg.type === 'progress') {
|
|
2581
|
+
barBg.classList.remove('bar-indeterminate');
|
|
2582
|
+
document.getElementById('bar-fill').style.width = msg.pct.toFixed(1) + '%';
|
|
2583
|
+
document.getElementById('stat-pct').textContent = msg.pct.toFixed(1) + '%';
|
|
2584
|
+
if (msg.overall != null) {
|
|
2585
|
+
document.getElementById('overall-bar-fill').style.width = msg.overall.toFixed(1) + '%';
|
|
2586
|
+
}
|
|
2587
|
+
if (msg.size) document.getElementById('stat-size').textContent = msg.size;
|
|
2588
|
+
if (msg.speed) document.getElementById('stat-speed').textContent = msg.speed;
|
|
2589
|
+
if (videoEncodeStart) {
|
|
2590
|
+
var now = Date.now();
|
|
2591
|
+
if (etaLastT) {
|
|
2592
|
+
var dt = (now - etaLastT) / 1000;
|
|
2593
|
+
if (dt > 0) {
|
|
2594
|
+
var instRate = (msg.pct - etaLastPct) / dt; // pct per second
|
|
2595
|
+
if (instRate > 0 && isFinite(instRate)) {
|
|
2596
|
+
var alpha = 1 - Math.exp(-dt / ETA_TAU);
|
|
2597
|
+
etaEmaRate = (etaEmaRate == null) ? instRate
|
|
2598
|
+
: alpha * instRate + (1 - alpha) * etaEmaRate;
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2601
|
+
}
|
|
2602
|
+
etaLastPct = msg.pct;
|
|
2603
|
+
etaLastT = now;
|
|
2604
|
+
|
|
2605
|
+
if (msg.pct >= ETA_WARMUP_PCT && etaEmaRate && etaEmaRate > 0) {
|
|
2606
|
+
document.getElementById('stat-eta').textContent = fmtEta((100 - msg.pct) / etaEmaRate);
|
|
2607
|
+
} else {
|
|
2608
|
+
document.getElementById('stat-eta').textContent = 'Estimating…';
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
2611
|
+
if (msg.speed) {
|
|
2612
|
+
var encSpd = parseEncodeSpeed(msg.speed);
|
|
2613
|
+
var dlSpd = parseDownloadSpeed(msg.speed);
|
|
2614
|
+
if (encSpd !== null) chartEnc.push(encSpd);
|
|
2615
|
+
else if (dlSpd !== null) chartDl.push(dlSpd);
|
|
2616
|
+
}
|
|
2617
|
+
if (msg.output_bytes) updateSizeTracker(msg.output_bytes);
|
|
2618
|
+
|
|
2619
|
+
} else if (msg.type === 'size_info') {
|
|
2620
|
+
var sign = msg.bloat_pct >= 0 ? '+' : '';
|
|
2621
|
+
var color = msg.bloat_pct > 20 ? '#fc8181' : msg.bloat_pct > 0 ? '#f6ad55' : '#68d391';
|
|
2622
|
+
var row = document.getElementById('size-info-row');
|
|
2623
|
+
document.getElementById('size-info-text').innerHTML =
|
|
2624
|
+
'Source: <strong>' + msg.source_mb.toFixed(0) + ' MB</strong>' +
|
|
2625
|
+
' → Output: <strong>' + msg.output_mb.toFixed(0) + ' MB</strong>' +
|
|
2626
|
+
' <span style="color:' + color + '">(' + sign + msg.bloat_pct.toFixed(1) + '%)</span>';
|
|
2627
|
+
row.style.display = '';
|
|
2628
|
+
|
|
2629
|
+
} else if (msg.type === 'source_size') {
|
|
2630
|
+
initSizeTracker(msg.bytes);
|
|
2631
|
+
|
|
2632
|
+
} else if (msg.type === 'dl_progress') {
|
|
2633
|
+
var dlRow = document.getElementById('dl-status-row');
|
|
2634
|
+
dlRow.style.display = '';
|
|
2635
|
+
document.getElementById('dl-status-label').textContent = 'DL ' + msg.idx + '/' + msg.total + ' — ' + msg.size;
|
|
2636
|
+
document.getElementById('dl-status-pct').textContent = msg.pct.toFixed(1) + '%';
|
|
2637
|
+
document.getElementById('dl-status-speed').textContent = msg.speed;
|
|
2638
|
+
document.getElementById('dl-status-eta').textContent = 'ETA ' + msg.eta;
|
|
2639
|
+
chartDl.push(parseDownloadSpeed(msg.speed));
|
|
2640
|
+
|
|
2641
|
+
} else if (msg.type === 'log') {
|
|
2642
|
+
log(msg.msg);
|
|
2643
|
+
if (msg.msg.indexOf('Encode params:') !== -1) showEncodeParams(msg.msg);
|
|
2644
|
+
|
|
2645
|
+
} else if (msg.type === 'error') {
|
|
2646
|
+
_lastErrorMsg = msg.msg || '';
|
|
2647
|
+
log('ERROR: ' + msg.msg, 'error');
|
|
2648
|
+
document.getElementById('phase-label').textContent = '✕ Failed';
|
|
2649
|
+
document.getElementById('phase-file').textContent = '';
|
|
2650
|
+
barBg.classList.remove('bar-indeterminate');
|
|
2651
|
+
setPhaseState('error');
|
|
2652
|
+
stopElapsedTimer();
|
|
2653
|
+
|
|
2654
|
+
} else if (msg.type === 'cancelled') {
|
|
2655
|
+
// Server confirmed the job was cancelled by the user — distinct from an error.
|
|
2656
|
+
cancelled = true;
|
|
2657
|
+
log(msg.msg || 'Cancelled by user.', 'info');
|
|
2658
|
+
document.getElementById('phase-label').textContent = '✕ Cancelled';
|
|
2659
|
+
document.getElementById('phase-file').textContent = '';
|
|
2660
|
+
barBg.classList.remove('bar-indeterminate');
|
|
2661
|
+
setPhaseState('error');
|
|
2662
|
+
stopElapsedTimer();
|
|
2663
|
+
|
|
2664
|
+
} else if (msg.type === 'done') {
|
|
2665
|
+
document.getElementById('phase-label').textContent = '✓ Complete';
|
|
2666
|
+
document.getElementById('phase-file').textContent = '';
|
|
2667
|
+
document.getElementById('bar-fill').style.width = '100%';
|
|
2668
|
+
document.getElementById('stat-pct').textContent = '100%';
|
|
2669
|
+
document.getElementById('overall-bar-fill').style.width = '100%';
|
|
2670
|
+
document.getElementById('dl-status-row').style.display = 'none';
|
|
2671
|
+
barBg.classList.remove('bar-indeterminate');
|
|
2672
|
+
setPhaseState('done');
|
|
2673
|
+
stopElapsedTimer();
|
|
2674
|
+
celebrate();
|
|
2675
|
+
log(msg.msg, 'done');
|
|
2676
|
+
|
|
2677
|
+
} else if (msg.type === 'shutdown') {
|
|
2678
|
+
log('⚠ ' + msg.msg, 'info');
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
const LOG_MAX_NODES = 500;
|
|
2683
|
+
let _logBuffer = []; // pending lines, flushed in a rAF
|
|
2684
|
+
let _logFlushScheduled = false;
|
|
2685
|
+
|
|
2686
|
+
function log(msg, type='') {
|
|
2687
|
+
_logBuffer.push({ msg, type });
|
|
2688
|
+
if (_logFlushScheduled) return;
|
|
2689
|
+
_logFlushScheduled = true;
|
|
2690
|
+
requestAnimationFrame(_flushLog);
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
function _flushLog() {
|
|
2694
|
+
_logFlushScheduled = false;
|
|
2695
|
+
const box = document.getElementById('log-box');
|
|
2696
|
+
if (!box) { _logBuffer = []; return; }
|
|
2697
|
+
|
|
2698
|
+
// (1) Only auto-scroll if the user was already pinned to the bottom.
|
|
2699
|
+
const atBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 4;
|
|
2700
|
+
|
|
2701
|
+
const batch = _logBuffer;
|
|
2702
|
+
_logBuffer = [];
|
|
2703
|
+
const frag = document.createDocumentFragment();
|
|
2704
|
+
for (const { msg, type } of batch) {
|
|
2705
|
+
const line = document.createElement('div');
|
|
2706
|
+
if (type === 'error') line.className = 'log-error';
|
|
2707
|
+
else if (type === 'done') line.className = 'log-done';
|
|
2708
|
+
else if (type === 'info') line.className = 'log-info';
|
|
2709
|
+
line.textContent = msg;
|
|
2710
|
+
frag.appendChild(line);
|
|
2711
|
+
}
|
|
2712
|
+
box.appendChild(frag);
|
|
2713
|
+
|
|
2714
|
+
// (2) Cap node count — trim oldest lines beyond the limit.
|
|
2715
|
+
while (box.childNodes.length > LOG_MAX_NODES) {
|
|
2716
|
+
box.removeChild(box.firstChild);
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
// (1) Auto-scroll only if the user hadn't scrolled up.
|
|
2720
|
+
if (atBottom) box.scrollTop = box.scrollHeight;
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
// ---- FAILURE SUMMARY PANEL (Issue #10) ----
|
|
2724
|
+
// Accumulates failed items for the whole session; survives later completions.
|
|
2725
|
+
// Each entry: { title, reason, category, permanent }.
|
|
2726
|
+
let _failures = [];
|
|
2727
|
+
|
|
2728
|
+
function categorizeFailure(msg) {
|
|
2729
|
+
const m = (msg || '').toLowerCase();
|
|
2730
|
+
if (/cookie|sign in|login required|consent|age.?restrict|not a bot|confirm you/.test(m))
|
|
2731
|
+
return 'cookies expired';
|
|
2732
|
+
if (/yt-dlp|extractor|unable to extract|nsig|player|json|parse|unavailable|private video|removed/.test(m))
|
|
2733
|
+
return 'yt-dlp parse error';
|
|
2734
|
+
if (/ffmpeg|nvenc|encode|initializeencoder|invalid param|hevc/.test(m))
|
|
2735
|
+
return 'ffmpeg crash';
|
|
2736
|
+
if (/network|timeout|timed out|connection|resolve host|temporary failure|http error|10054|reset/.test(m))
|
|
2737
|
+
return 'network';
|
|
2738
|
+
return 'other';
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2741
|
+
function recordFailure(item, reason, permanent) {
|
|
2742
|
+
const title = (item && (item.title || item.url || item.path)) || 'Unknown item';
|
|
2743
|
+
// Promote an existing transient entry to permanent rather than duplicating.
|
|
2744
|
+
const existing = _failures.find(f => (f.title === title));
|
|
2745
|
+
if (existing) {
|
|
2746
|
+
existing.reason = reason || existing.reason;
|
|
2747
|
+
existing.category = categorizeFailure(reason || existing.reason);
|
|
2748
|
+
if (permanent) existing.permanent = true;
|
|
2749
|
+
} else {
|
|
2750
|
+
_failures.push({
|
|
2751
|
+
title,
|
|
2752
|
+
reason: reason || '(no detail)',
|
|
2753
|
+
category: categorizeFailure(reason),
|
|
2754
|
+
permanent: !!permanent,
|
|
2755
|
+
});
|
|
2756
|
+
}
|
|
2757
|
+
renderFailurePanel();
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
function renderFailurePanel() {
|
|
2761
|
+
const panel = document.getElementById('failure-panel');
|
|
2762
|
+
const list = document.getElementById('failure-panel-list');
|
|
2763
|
+
const count = document.getElementById('failure-panel-count');
|
|
2764
|
+
if (!panel) return;
|
|
2765
|
+
if (_failures.length === 0) { panel.classList.remove('visible'); return; }
|
|
2766
|
+
panel.classList.add('visible');
|
|
2767
|
+
count.textContent = _failures.length;
|
|
2768
|
+
list.innerHTML = _failures.map(f => {
|
|
2769
|
+
const cls = f.permanent ? 'permanent' : 'transient';
|
|
2770
|
+
const icon = f.permanent ? '✕' : '⚠';
|
|
2771
|
+
const reason = (f.reason || '').replace(/\s+/g, ' ').slice(0, 160);
|
|
2772
|
+
return '<li class="failure-item ' + cls + '">' +
|
|
2773
|
+
'<span class="failure-item-icon">' + icon + '</span>' +
|
|
2774
|
+
'<span class="failure-item-body">' +
|
|
2775
|
+
'<span class="failure-item-title" title="' + escapeAttr(f.title) + '">' + escapeHtml(f.title) + '</span>' +
|
|
2776
|
+
'<span class="failure-item-reason"><span class="failure-item-cat">' + escapeHtml(f.category) + '</span> · ' + escapeHtml(reason) + '</span>' +
|
|
2777
|
+
'</span>' +
|
|
2778
|
+
'</li>';
|
|
2779
|
+
}).join('');
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2782
|
+
function dismissFailurePanel() {
|
|
2783
|
+
_failures = [];
|
|
2784
|
+
renderFailurePanel();
|
|
2785
|
+
}
|
|
2786
|
+
|
|
2787
|
+
function escapeHtml(s) {
|
|
2788
|
+
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
2789
|
+
}
|
|
2790
|
+
function escapeAttr(s) {
|
|
2791
|
+
return escapeHtml(s).replace(/"/g, '"');
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
// ---- JOB QUEUE ----
|
|
2795
|
+
let dlQueue = []; // array of {url, title, video_format, audio_format}
|
|
2796
|
+
|
|
2797
|
+
function addToQueue() {
|
|
2798
|
+
const url = document.getElementById('url-input').value.trim();
|
|
2799
|
+
const vf = document.getElementById('video-format').value;
|
|
2800
|
+
const af = document.getElementById('audio-format').value;
|
|
2801
|
+
const title = document.getElementById('video-title').textContent || url;
|
|
2802
|
+
const needVideo = currentMode !== 'audio';
|
|
2803
|
+
if (!url || (needVideo && !vf) || !af) { log('Fetch video info and select formats first.', 'error'); return; }
|
|
2804
|
+
if (dlQueue.find(x => x.type === 'youtube' && x.url === url)) { log('Already in queue.', 'info'); return; }
|
|
2805
|
+
dlQueue.push({
|
|
2806
|
+
type: 'youtube', url, title,
|
|
2807
|
+
video_format: needVideo ? vf : '', audio_format: af,
|
|
2808
|
+
expected_size: getSelectedExpectedSize(),
|
|
2809
|
+
output_dir: document.getElementById('output-dir').value.trim(),
|
|
2810
|
+
tune_mode: currentTuneMode,
|
|
2811
|
+
duration: currentVideoDuration || 0, // captured from /video-info
|
|
2812
|
+
});
|
|
2813
|
+
log('Queued: ' + title, 'info');
|
|
2814
|
+
renderDlQueue();
|
|
2815
|
+
// Clear URL input for next entry
|
|
2816
|
+
document.getElementById('url-input').value = '';
|
|
2817
|
+
document.getElementById('video-info').classList.remove('visible');
|
|
2818
|
+
document.getElementById('queue-add-btn').style.display = 'none';
|
|
2819
|
+
currentVideoDuration = 0; // stale until the next /video-info fetch
|
|
2820
|
+
}
|
|
2821
|
+
|
|
2822
|
+
function renderDlQueue() {
|
|
2823
|
+
const sec = document.getElementById('dl-queue-section');
|
|
2824
|
+
const items = document.getElementById('dl-queue-items');
|
|
2825
|
+
const count = document.getElementById('dl-queue-count');
|
|
2826
|
+
if (dlQueue.length === 0) { sec.style.display = 'none'; return; }
|
|
2827
|
+
sec.style.display = 'block';
|
|
2828
|
+
count.textContent = dlQueue.length;
|
|
2829
|
+
items.innerHTML = dlQueue.map((q, i) => {
|
|
2830
|
+
const tag = q.type === 'local'
|
|
2831
|
+
? '<span style="background:var(--accent3);color:#111;font-size:9px;padding:1px 5px;border-radius:3px;margin-right:6px;">ENC</span>'
|
|
2832
|
+
: '<span style="background:var(--accent);color:#111;font-size:9px;padding:1px 5px;border-radius:3px;margin-right:6px;">DL</span>';
|
|
2833
|
+
const fmtLabel = q.video_format ? `${q.video_format}+${q.audio_format}` : `audio:${q.audio_format}`;
|
|
2834
|
+
const detail = q.type === 'local'
|
|
2835
|
+
? `<span style="color:var(--muted);margin:0 12px;flex-shrink:0;font-size:10px;">${q.size_gb} GB</span>`
|
|
2836
|
+
: `<span style="color:var(--muted);margin:0 12px;flex-shrink:0;font-size:10px;">${fmtLabel}${q.expected_size > 0 ? ' \u00b7 ' + (q.expected_size / 1024**3).toFixed(1) + 'GB' : ''}</span>`;
|
|
2837
|
+
const tip = q.type === 'local' ? q.path : q.url;
|
|
2838
|
+
const tuneCtl = q.type === 'youtube'
|
|
2839
|
+
? `<select onchange="dlQueue[${i}].tune_mode=this.value" style="font-size:10px;margin:0 6px;">
|
|
2840
|
+
<option value="uhq"${q.tune_mode !== 'hq' ? ' selected' : ''}>Excellent (Slow)</option>
|
|
2841
|
+
<option value="hq"${q.tune_mode === 'hq' ? ' selected' : ''}>Great (Fast)</option>
|
|
2842
|
+
</select>`
|
|
2843
|
+
: '';
|
|
2844
|
+
const dirCtl = q.type === 'youtube'
|
|
2845
|
+
? `<input value="${escapeAttr(q.output_dir || '')}" placeholder="(default out)"
|
|
2846
|
+
onchange="dlQueue[${i}].output_dir=this.value.trim()"
|
|
2847
|
+
style="font-size:10px;width:120px;margin-right:6px;">`
|
|
2848
|
+
: '';
|
|
2849
|
+
return `<div style="display:flex;justify-content:space-between;align-items:center;padding:5px 10px;border-bottom:1px solid var(--border);">
|
|
2850
|
+
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${tip}">${tag}${q.title}</span>
|
|
2851
|
+
${detail}
|
|
2852
|
+
${tuneCtl}
|
|
2853
|
+
${dirCtl}
|
|
2854
|
+
<span style="color:var(--accent2);cursor:pointer;flex-shrink:0;" onclick="removeDlQueue(${i})">✕</span>
|
|
2855
|
+
</div>`;
|
|
2856
|
+
}).join('');
|
|
2857
|
+
}
|
|
2858
|
+
|
|
2859
|
+
function removeDlQueue(idx) {
|
|
2860
|
+
dlQueue.splice(idx, 1);
|
|
2861
|
+
renderDlQueue();
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2864
|
+
function clearDlQueue() {
|
|
2865
|
+
dlQueue = [];
|
|
2866
|
+
renderDlQueue();
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
// ---- LOCAL CONVERSION ----
|
|
2870
|
+
let localFiles = []; // array of {name, path, size_gb}
|
|
2871
|
+
|
|
2872
|
+
function probeAndSelectPreset() {
|
|
2873
|
+
// Smart Auto analyses the source on the server — just ensure it's selected
|
|
2874
|
+
autoSelectPreset();
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2877
|
+
async function browseLocalFiles() {
|
|
2878
|
+
const r = await fetch(`${API}/browse-files`);
|
|
2879
|
+
const d = await r.json();
|
|
2880
|
+
if (!d.files || d.files.length === 0) return;
|
|
2881
|
+
d.files.forEach(f => {
|
|
2882
|
+
if (!localFiles.find(x => x.path === f.path)) localFiles.push(f);
|
|
2883
|
+
});
|
|
2884
|
+
log(`Added ${d.files.length} file(s)`, 'info');
|
|
2885
|
+
renderLocalFiles();
|
|
2886
|
+
probeAndSelectPreset();
|
|
2887
|
+
}
|
|
2888
|
+
|
|
2889
|
+
async function browseLocalFolder() {
|
|
2890
|
+
const r = await fetch(`${API}/browse-source-folder`);
|
|
2891
|
+
const d = await r.json();
|
|
2892
|
+
if (!d.path) return;
|
|
2893
|
+
document.getElementById('local-folder').value = d.path;
|
|
2894
|
+
await scanFolder();
|
|
2895
|
+
}
|
|
2896
|
+
|
|
2897
|
+
async function scanFolder() {
|
|
2898
|
+
const folder = document.getElementById('local-folder').value.trim();
|
|
2899
|
+
if (!folder) return;
|
|
2900
|
+
const form = new FormData();
|
|
2901
|
+
form.append('folder', folder);
|
|
2902
|
+
const r = await fetch(`${API}/scan-folder`, { method: 'POST', body: form });
|
|
2903
|
+
const d = await r.json();
|
|
2904
|
+
if (d.error) { log('Error: ' + d.error, 'error'); return; }
|
|
2905
|
+
d.files.forEach(f => {
|
|
2906
|
+
if (!localFiles.find(x => x.path === f.path)) localFiles.push(f);
|
|
2907
|
+
});
|
|
2908
|
+
log(`Scanned: ${d.files.length} file(s) found in ${d.folder}`, 'info');
|
|
2909
|
+
renderLocalFiles();
|
|
2910
|
+
probeAndSelectPreset();
|
|
2911
|
+
}
|
|
2912
|
+
|
|
2913
|
+
function renderLocalFiles() {
|
|
2914
|
+
const list = document.getElementById('local-file-list');
|
|
2915
|
+
const items = document.getElementById('local-file-items');
|
|
2916
|
+
const count = document.getElementById('local-file-count');
|
|
2917
|
+
if (localFiles.length === 0) { list.style.display = 'none'; return; }
|
|
2918
|
+
list.style.display = 'block';
|
|
2919
|
+
count.textContent = localFiles.length;
|
|
2920
|
+
items.innerHTML = localFiles.map((f, i) => `
|
|
2921
|
+
<div style="display:flex;justify-content:space-between;align-items:center;padding:5px 10px;border-bottom:1px solid var(--border);">
|
|
2922
|
+
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${f.path}">${f.name}</span>
|
|
2923
|
+
<span style="color:var(--muted);margin:0 12px;flex-shrink:0;">${f.size_gb} GB</span>
|
|
2924
|
+
<span style="color:var(--accent2);cursor:pointer;flex-shrink:0;" onclick="removeLocalFile(${i})">✕</span>
|
|
2925
|
+
</div>
|
|
2926
|
+
`).join('');
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2929
|
+
function removeLocalFile(idx) {
|
|
2930
|
+
localFiles.splice(idx, 1);
|
|
2931
|
+
renderLocalFiles();
|
|
2932
|
+
}
|
|
2933
|
+
|
|
2934
|
+
function clearLocalFiles() {
|
|
2935
|
+
localFiles = [];
|
|
2936
|
+
renderLocalFiles();
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2939
|
+
function addLocalToQueue() {
|
|
2940
|
+
if (localFiles.length === 0) { log('No files loaded. Browse files or scan a folder first.', 'error'); return; }
|
|
2941
|
+
let added = 0;
|
|
2942
|
+
localFiles.forEach(f => {
|
|
2943
|
+
if (!dlQueue.find(x => x.type === 'local' && x.path === f.path)) {
|
|
2944
|
+
dlQueue.push({ type: 'local', title: f.name, path: f.path, size_gb: f.size_gb });
|
|
2945
|
+
added++;
|
|
2946
|
+
}
|
|
2947
|
+
});
|
|
2948
|
+
log(`Added ${added} local file(s) to queue.`, 'info');
|
|
2949
|
+
renderDlQueue();
|
|
2950
|
+
// Clear local file list — they're in the queue now
|
|
2951
|
+
localFiles = [];
|
|
2952
|
+
renderLocalFiles();
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2955
|
+
async function startLocalConvert() {
|
|
2956
|
+
if (localFiles.length === 0) { log('No files loaded. Browse files or scan a folder first.', 'error'); return; }
|
|
2957
|
+
// Add to queue and start immediately
|
|
2958
|
+
addLocalToQueue();
|
|
2959
|
+
startDownload();
|
|
2960
|
+
}
|
|
2961
|
+
|
|
2962
|
+
async function cancelJob() {
|
|
2963
|
+
// (1) Mark the whole run cancelled so the queue/retry loops stop.
|
|
2964
|
+
cancelled = true;
|
|
2965
|
+
// (2) Abort the in-flight SSE fetch/reader so runOneItem unwinds immediately.
|
|
2966
|
+
if (_currentController) { try { _currentController.abort(); } catch {} }
|
|
2967
|
+
// (3) Terminate server-side ffmpeg/yt-dlp.
|
|
2968
|
+
try { await fetch(`${API}/cancel`, { method: 'POST' }); } catch {}
|
|
2969
|
+
log('Cancelled.', 'error');
|
|
2970
|
+
document.getElementById('start-btn').classList.remove('hidden');
|
|
2971
|
+
document.getElementById('cancel-btn').classList.add('hidden');
|
|
2972
|
+
document.getElementById('phase-label').textContent = '✕ Cancelled';
|
|
2973
|
+
setPhaseState('error');
|
|
2974
|
+
stopElapsedTimer();
|
|
2975
|
+
}
|
|
2976
|
+
</script>
|
|
2977
|
+
</body>
|
|
2978
|
+
</html>
|