-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathm3u8 downloader.js
More file actions
547 lines (469 loc) · 18.6 KB
/
m3u8 downloader.js
File metadata and controls
547 lines (469 loc) · 18.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
#!/usr/bin/env node
// Required parameters:
// @raycast.schemaVersion 1
// @raycast.title Download .m3u8
// @raycast.mode fullOutput
// Optional parameters:
// @raycast.packageName Media
// @raycast.icon ⬇️
// Arguments
// @raycast.argument1 { "type": "text", "placeholder": ".m3u8 Link" }
// @raycast.argument2 { "type": "text", "placeholder": "Speed 1+ (default 10)" }
// @raycast.argument3 { "type": "text", "placeholder": "Name year" }
// @raycast.dependencies yt-dlp, ffmpeg, ffprobe
// Optional: aria2c (faster downloads), subliminal (OpenSubtitles)
// @raycast.packageRequirements brew install yt-dlp ffmpeg
"use strict";
const { spawnSync, spawn } = require("child_process");
const { existsSync, readdirSync, mkdirSync, renameSync, rmSync, mkdtempSync, statSync } = require("fs");
const { homedir, tmpdir } = require("os");
const { join, basename, extname } = require("path");
// ============================================================
// PORTABLE USER CONFIG (edit these to adapt to your computer)
// ============================================================
// Where the final "<movie name> movie" folders will be created.
// Default: your Mac's Movies folder.
const USER_OUT_DIR = join(homedir(), "Movies");
// Optional: if you prefer OneDrive, uncomment and adjust:
// const USER_OUT_DIR = join(homedir(), "Library/CloudStorage/OneDrive-<YOUR-ONEDRIVE-NAME>/Movies");
// aria2c tuning (only used if aria2c is installed)
const ARIA2C_CONNECTIONS = 16; // aria2c -x value
const ARIA2C_MIN_SPLIT_SIZE = "1M"; // aria2c -k value
// If true, aria2c attempt prints a clean progress summary in Raycast
const REFORMAT_ARIA2_PROGRESS = true;
// ============================================================
console.log("====================================================");
console.log("== M3U8 DOWNLOADER (PORTABLE) ==");
console.log("== ==");
console.log("== ┌────────────────────────────────────┐ ==");
console.log("== │ ▷ Download .m3u8 → MP4 + subs │ ==");
console.log("== │ ▷ Best video+audio, merge MP4 │ ==");
console.log("== │ ▷ Optional aria2c for speed │ ==");
console.log("== │ ▷ Auto fallback if aria2c fails │ ==");
console.log("== └────────────────────────────────────┘ ==");
console.log("====================================================");
// ---- parse args (Raycast passes them directly in order) ----
const LINK = process.argv[2] || "";
const SPEED_RAW = process.argv[3] || "10";
const NAME_ARG = process.argv[4] || "";
// Parse "Title (Year)", "Title,Year", "Title - Year", or just "Title"
let TITLE = "";
let YEAR = "";
if (NAME_ARG) {
const m = NAME_ARG.match(/^(.*?)[\s]*[\(,\-\[]?\s*(\d{4})?\s*[\)\]]?\s*$/);
if (m) {
TITLE = (m[1] || "").trim();
YEAR = (m[2] || "").trim();
} else {
TITLE = NAME_ARG.trim();
}
}
// ---- validate ----
if (!LINK) {
console.log("Missing .m3u8 Link");
process.exit(1);
}
let SPEED = parseInt(SPEED_RAW, 10);
if (Number.isNaN(SPEED)) {
console.log("Speed must be an integer >= 1");
process.exit(2);
}
if (SPEED < 1) SPEED = 1;
// no upper limit on concurrent fragments
// ---- paths ----
const OUT_DIR = USER_OUT_DIR;
try { mkdirSync(OUT_DIR, { recursive: true }); } catch (_){}
// Use a temporary staging directory; only create final movie folder on success
const STAGE_DIR = mkdtempSync(join(tmpdir(), "m3u8dl-"));
console.log(`Staging to: ${STAGE_DIR}`);
function safeName(s) { return String(s).replace(/[\\/]/g, "-").trim(); }
let MOVIE_SUBDIR = ""; // absolute path to "<movie name> movie" when TITLE is provided
// ---- output template ----
let OUT_TPL = "";
if (TITLE) {
const SAFE_TITLE = safeName(TITLE);
MOVIE_SUBDIR = join(OUT_DIR, `${SAFE_TITLE} movie`); // final destination (created later on success)
OUT_TPL = YEAR
? join(STAGE_DIR, `${SAFE_TITLE} (${YEAR}).%(ext)s`)
: join(STAGE_DIR, `${SAFE_TITLE}.%(ext)s`);
} else {
// No explicit title provided: stage into temp; we'll derive final folder from actual title
OUT_TPL = join(STAGE_DIR, `%(title)s.%(ext)s`);
}
// ---- locate yt-dlp ----
function which(cmd) {
const res = spawnSync(process.env.SHELL || "/bin/bash", ["-lc", `command -v ${cmd}`], { encoding: "utf8" });
return res.status === 0 ? res.stdout.trim() : "";
}
let YTDLP_BIN = which("yt-dlp") || "/opt/homebrew/bin/yt-dlp";
if (!existsSync(YTDLP_BIN)) {
console.log("yt-dlp not found. Install: brew install yt-dlp");
process.exit(3);
}
// ---- locate aria2c (optional, improves speed) ----
const ARIA2C_BIN = which("aria2c") || "/opt/homebrew/bin/aria2c";
const HAS_ARIA2C = existsSync(ARIA2C_BIN);
if (!HAS_ARIA2C) {
console.log("================================================");
console.log("⚠️ OPTIONAL DEPENDENCY NOT FOUND: aria2c");
console.log(" Downloads will still work, but may be slower.");
console.log(" Recommended install: brew install aria2");
console.log("================================================");
}
// ---- dependency check for ffmpeg and ffprobe ----
const missingDeps = [];
function printInstallHint(name, brewPkg) {
console.log(`Missing dependency: ${name}`);
console.log(`→ Install with: brew install ${brewPkg}`);
}
const FFMPEG_BIN_PATH = which("ffmpeg") || "/opt/homebrew/bin/ffmpeg";
const FFPROBE_BIN_PATH = which("ffprobe") || "/opt/homebrew/bin/ffprobe";
if (!existsSync(FFMPEG_BIN_PATH)) missingDeps.push({name: "ffmpeg", brew: "ffmpeg"});
if (!existsSync(FFPROBE_BIN_PATH)) missingDeps.push({name: "ffprobe", brew: "ffmpeg"}); // same package
if (missingDeps.length > 0) {
console.log("================================================");
console.log("❌ REQUIRED DEPENDENCIES MISSING");
console.log(" The download may fail or features will be disabled.");
for (const dep of missingDeps) printInstallHint(dep.name, dep.brew);
console.log("================================================");
}
// ---- yt-dlp args (always soft English subs if available) ----
function buildYtdlpArgs({ useAria2c }) {
const args = [
"-N", String(SPEED),
"-f", "bv*+ba/b",
"--no-warnings",
"--newline",
"-o", OUT_TPL,
"--merge-output-format", "mp4",
"--write-subs", "--write-auto-subs", "--sub-langs", "en.*", "--convert-subs", "srt",
];
if (useAria2c) {
args.push("--external-downloader", "aria2c");
args.push(
"--external-downloader-args",
`-x ${ARIA2C_CONNECTIONS} -k ${ARIA2C_MIN_SPLIT_SIZE} --summary-interval=1 --console-log-level=notice`
);
}
args.push(LINK);
return args;
}
const ytdlpArgsPrimary = buildYtdlpArgs({ useAria2c: HAS_ARIA2C });
const ytdlpArgsFallback = buildYtdlpArgs({ useAria2c: false });
// ---- run yt-dlp and stream logs to Raycast ----
function fmtCmd(args) {
return `${YTDLP_BIN} ${args.map(a => (String(a).includes(" ") ? `"${a}"` : a)).join(" ")}`;
}
function fmtSeconds(totalSeconds) {
const s = Math.max(0, Math.floor(totalSeconds));
const hh = String(Math.floor(s / 3600)).padStart(2, "0");
const mm = String(Math.floor((s % 3600) / 60)).padStart(2, "0");
const ss = String(s % 60).padStart(2, "0");
return `${hh}:${mm}:${ss}`;
}
function runOnce(args, label, opts = {}) {
const { reformatProgress = false } = opts;
console.log(`Running${label ? " (" + label + ")" : ""}: ${fmtCmd(args)}`);
if (!reformatProgress) {
return spawn(YTDLP_BIN, args, { stdio: "inherit" });
}
// Capture output and print a clean, Raycast-friendly progress view.
const child = spawn(YTDLP_BIN, args, { stdio: ["ignore", "pipe", "pipe"] });
let totalFrags = null;
let doneFrags = 0;
let lastSpeed = "";
const t0 = Date.now();
let lastPrintMs = 0;
function maybePrintProgress(force) {
const now = Date.now();
if (!force && (now - lastPrintMs) < 1000) return;
lastPrintMs = now;
const elapsed = (now - t0) / 1000;
const rate = elapsed > 1 ? (doneFrags / elapsed) : 0;
let pct = "";
let eta = "";
if (totalFrags && totalFrags > 0) {
const p = Math.min(100, Math.max(0, (doneFrags / totalFrags) * 100));
pct = `${p.toFixed(1)}%`;
if (rate > 0) {
const remaining = Math.max(0, totalFrags - doneFrags);
eta = fmtSeconds(remaining / rate);
}
}
const parts = [];
if (totalFrags) {
parts.push(`Progress: ${doneFrags}/${totalFrags}${pct ? ` (${pct})` : ""}`);
if (eta) parts.push(`ETA: ${eta}`);
} else {
parts.push(`Progress: ${doneFrags} fragments`);
}
if (lastSpeed) parts.push(`Speed: ${lastSpeed}`);
parts.push(`Elapsed: ${fmtSeconds(elapsed)}`);
console.log(parts.join(" | "));
}
function onLine(line) {
const s = String(line || "").trim();
if (!s) return;
// yt-dlp sometimes prints this even when using external downloader
let m = s.match(/Total\s+fragments:\s*(\d+)/i);
if (m) {
totalFrags = parseInt(m[1], 10);
if (Number.isFinite(totalFrags)) {
console.log(`Detected total fragments: ${totalFrags}`);
maybePrintProgress(true);
}
return;
}
// aria2c live readout can contain DL speed like: [DL:2.3MiB]
m = s.match(/\[DL:([0-9.]+)\s*(KiB|MiB|GiB)(?:\/s)?\]/i);
if (m) {
lastSpeed = `${m[1]} ${m[2]}/s`;
maybePrintProgress(false);
return;
}
// aria2c completion lines: [NOTICE] Download complete: ...part-Frag65
if (/Download complete:/i.test(s)) {
doneFrags += 1;
maybePrintProgress(true);
return;
}
// Pass through obvious errors/warnings
if (/(^ERROR:|^\[error\]|\bERROR\b|\bFATAL\b)/i.test(s)) {
console.log(s);
return;
}
}
function wire(stream) {
let buf = "";
stream.setEncoding("utf8");
stream.on("data", (chunk) => {
buf += chunk;
let idx;
while ((idx = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, idx);
buf = buf.slice(idx + 1);
onLine(line);
}
});
stream.on("end", () => {
if (buf.trim()) onLine(buf);
});
}
wire(child.stdout);
wire(child.stderr);
// Print an initial line so the user sees activity immediately
maybePrintProgress(true);
return child;
}
let y = null;
// Only create final folder on success, clean up stage otherwise
async function handleClose(code) {
try {
const successFile = resolveFinalVideoPathFromStage();
const succeeded = (code === 0) && successFile && fileExists(successFile);
if (succeeded) {
// Create the final folder now (and only now)
let targetDir = "";
if (TITLE) {
try { mkdirSync(MOVIE_SUBDIR, { recursive: true }); } catch (_) {}
targetDir = MOVIE_SUBDIR;
} else {
targetDir = deriveAutoMovieDir(successFile);
try { mkdirSync(targetDir, { recursive: true }); } catch (_) {}
}
moveStageTo(targetDir);
// Determine final video path, print specs and tag metadata
const finalVideo = latestMp4Recursive(targetDir);
const probe = ffprobeJSON(finalVideo);
const specs = buildSpecsFromProbe(probe);
printSpecsBlock(finalVideo, specs);
tagMetadata(finalVideo, TITLE, YEAR, specs);
await tryFetchOpenSubtitlesInDir(targetDir);
console.log(`Saved to: ${targetDir}`);
// Open folder for the user
spawnSync("open", [targetDir], { stdio: "ignore" });
process.exit(0);
} else {
// Cleanup; do not leave any final folder artifacts
try { rmSync(STAGE_DIR, { recursive: true, force: true }); } catch (_) {}
console.log("Download failed. No folder was created.");
process.exit(code || 1);
}
} catch (e) {
try { rmSync(STAGE_DIR, { recursive: true, force: true }); } catch (_) {}
console.log("Unexpected error. No folder was created.");
console.log(String((e && e.message) || e));
process.exit(code || 1);
}
}
if (HAS_ARIA2C) {
y = runOnce(ytdlpArgsPrimary, "aria2c", { reformatProgress: REFORMAT_ARIA2_PROGRESS });
y.on("close", (code) => {
if (code === 0) {
handleClose(code);
} else {
console.log("aria2c attempt failed; retrying without aria2c...");
y = runOnce(ytdlpArgsFallback, "fallback");
y.on("close", (code2) => handleClose(code2));
}
});
} else {
y = runOnce(ytdlpArgsFallback, "no aria2c");
y.on("close", (code) => handleClose(code));
}
async function tryFetchOpenSubtitlesInDir(dir) {
const mp4s = readdirSync(dir).filter(n => n.toLowerCase().endsWith(".mp4"));
if (mp4s.length === 0) return;
const videoPath = join(dir, mp4s[0]);
const baseNoExt = videoPath.slice(0, -extname(videoPath).length);
const subPath = `${baseNoExt}.en.srt`;
if (existsSync(subPath)) return;
const subliminal = which("subliminal") || "/opt/homebrew/bin/subliminal";
if (!existsSync(subliminal)) {
console.log("------------------------------------------------");
console.log("ℹ️ OPTIONAL TOOL NOT FOUND: subliminal");
console.log(" OpenSubtitles auto-fetching is disabled.");
console.log(" Install with: brew install subliminal");
console.log("------------------------------------------------");
return;
}
console.log(`OpenSubtitles search target: ${basename(videoPath)}`);
await new Promise((resolve) => {
const s = spawn(subliminal, ["download", "-l", "en", "-p", "opensubtitles", "--force", videoPath], { stdio: "inherit" });
s.on("close", () => resolve());
});
}
function latestMp4Recursive(dir) {
const fs = require("fs");
let latest = { path: "", time: -1 };
function walk(d) {
let entries;
try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
for (const e of entries) {
const p = join(d, e.name);
if (e.isDirectory()) {
walk(p);
} else if (e.isFile() && e.name.toLowerCase().endsWith(".mp4")) {
let t = -1;
try { t = fs.statSync(p).mtimeMs; } catch { t = -1; }
if (t > latest.time) latest = { path: p, time: t };
}
}
}
walk(dir);
return latest.path;
}
// ---- specs & metadata helpers (ffprobe/ffmpeg) ----
function hasBin(cmd, fallback) {
const p = which(cmd) || fallback || "";
return existsSync(p) ? p : "";
}
const FFPROBE_BIN = hasBin("ffprobe", "/opt/homebrew/bin/ffprobe");
const FFMPEG_BIN = hasBin("ffmpeg", "/opt/homebrew/bin/ffmpeg");
function ffprobeJSON(file) {
if (!FFPROBE_BIN) return null;
const res = spawnSync(FFPROBE_BIN, [
"-v","error",
"-show_entries","stream=index,codec_name,codec_type,width,height,avg_frame_rate,bit_rate,channels,sample_rate",
"-of","json",
file
], { encoding: "utf8" });
if (res.status !== 0) return null;
try { return JSON.parse(res.stdout); } catch { return null; }
}
function parseFps(fr) {
if (!fr) return null;
const s = String(fr);
if (s.includes("/")) {
const [a,b] = s.split("/").map(Number);
if (b) return +(a/b).toFixed(3);
}
const n = Number(s);
return Number.isFinite(n) ? n : null;
}
function buildSpecsFromProbe(p) {
if (!p || !p.streams) return null;
const v = p.streams.find(s => s.codec_type === "video") || {};
const a = p.streams.find(s => s.codec_type === "audio") || {};
return {
vcodec: v.codec_name || "unknown",
width: v.width || null,
height: v.height || null,
fps: parseFps(v.avg_frame_rate),
vbitrate: v.bit_rate ? Math.round(Number(v.bit_rate)/1000) : null, // kbps
acodec: a.codec_name || "unknown",
channels: a.channels || null,
sample_rate: a.sample_rate || null,
abitrate: a.bit_rate ? Math.round(Number(a.bit_rate)/1000) : null // kbps
};
}
function printSpecsBlock(from, specs) {
console.log("------------------SPECS-------------------");
console.log(`File: ${from}`);
if (!specs) {
console.log("Specs unavailable (ffprobe not installed).");
console.log("-------------------------------------------");
return;
}
const res = specs;
const size = (res.width && res.height) ? `${res.width}x${res.height}` : "unknown";
const fps = res.fps ? `${res.fps} fps` : "fps unknown";
const vbr = res.vbitrate ? `${res.vbitrate} kbps` : "vbr unknown";
const abr = res.abitrate ? `${res.abitrate} kbps` : "abr unknown";
console.log(`Video: ${size} ${fps} ${res.vcodec} ~ ${vbr}`);
console.log(`Audio: ${res.acodec} ch=${res.channels ?? "?"} sr=${res.sample_rate ?? "?"} ~ ${abr}`);
const trueHD = (res.height && res.height >= 1080 && res.width && res.width >= 1920);
console.log(`True HD (1080p+): ${trueHD ? "YES" : "NO"}`);
console.log("-------------------------------------------");
}
function tagMetadata(file, title, year, specs) {
if (!FFMPEG_BIN || !existsSync(file)) return;
const baseTitle = title ? (year ? `${title} (${year})` : title) : basename(file, extname(file));
const commentLines = [];
if (specs) {
const size = (specs.width && specs.height) ? `${specs.width}x${specs.height}` : "unknown";
const fps = specs.fps ? `${specs.fps}fps` : "fps?";
const vbr = specs.vbitrate ? `${specs.vbitrate}kbps` : "vbr?";
const abr = specs.abitrate ? `${specs.abitrate}kbps` : "abr?";
commentLines.push(`Video ${size} ${fps} ${specs.vcodec} ~ ${vbr}`);
commentLines.push(`Audio ${specs.acodec} ch=${specs.channels ?? "?"} ~ ${abr}`);
}
const comment = commentLines.join(" | ");
const tmpOut = `${file}.tagged.tmp.mp4`;
const args = ["-y","-i",file,"-c","copy","-metadata",`title=${baseTitle}`];
if (comment) { args.push("-metadata", `comment=${comment}`); }
const res = spawnSync(FFMPEG_BIN, [...args, tmpOut], { stdio: "ignore" });
if (res.status === 0 && existsSync(tmpOut)) {
try {
renameSync(tmpOut, file);
} catch (_) { try { rmSync(tmpOut, { force: true }); } catch {} }
} else {
try { rmSync(tmpOut, { force: true }); } catch {}
}
}
// ---- helpers for staging/final move ----
function fileExists(p) { try { return statSync(p).isFile(); } catch { return false; } }
function resolveFinalVideoPathFromStage() {
const files = readdirSync(STAGE_DIR).filter(n => n.toLowerCase().endsWith(".mp4"));
if (files.length === 0) return "";
let newest = ""; let newestT = -1;
for (const f of files) {
const p = join(STAGE_DIR, f);
let t = -1; try { t = statSync(p).mtimeMs; } catch {}
if (t > newestT) { newestT = t; newest = p; }
}
return newest;
}
function moveStageTo(targetDir) {
const entries = readdirSync(STAGE_DIR);
for (const name of entries) {
const from = join(STAGE_DIR, name);
const to = join(targetDir, name);
try { renameSync(from, to); } catch (_) {}
}
try { rmSync(STAGE_DIR, { recursive: true, force: true }); } catch (_) {}
}
function deriveAutoMovieDir(finalFilePath) {
const base = basename(finalFilePath, extname(finalFilePath));
return join(OUT_DIR, `${safeName(base)} movie`);
}