movizone 1.1.1 → 1.2.0
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.
- package/README.md +10 -0
- package/download.mjs +61 -3
- package/index.ts +630 -147
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -176,6 +176,16 @@ Filter by genre and sort order, with pagination:
|
|
|
176
176
|
|
|
177
177
|
- [Bun](https://bun.sh) v1.0+ (or Node.js 18+ via npx)
|
|
178
178
|
|
|
179
|
+
## Disclaimer
|
|
180
|
+
|
|
181
|
+
> **This is a hobby project built for educational and informational purposes only.**
|
|
182
|
+
>
|
|
183
|
+
> The developers of Movizone do not host, store, distribute, or endorse any copyrighted content. This tool interfaces with publicly available third-party APIs; the developers have no control over the content indexed by those services.
|
|
184
|
+
>
|
|
185
|
+
> Torrent magnet links may point to copyrighted material. Downloading copyrighted content without proper authorization may be illegal in your jurisdiction. **You are solely responsible** for ensuring that your use of this tool complies with all applicable laws and regulations.
|
|
186
|
+
>
|
|
187
|
+
> This project is not intended to facilitate or encourage copyright infringement. The developers assume no liability for how this tool is used. Use at your own risk.
|
|
188
|
+
|
|
179
189
|
## License
|
|
180
190
|
|
|
181
191
|
MIT
|
package/download.mjs
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Torrent download helper - runs under Node.js to avoid Bun's libuv limitations
|
|
3
3
|
import WebTorrent from "webtorrent";
|
|
4
|
-
import { mkdirSync, existsSync } from "fs";
|
|
4
|
+
import { mkdirSync, existsSync, writeFileSync, readFileSync } from "fs";
|
|
5
5
|
|
|
6
|
-
const [,, magnet, downloadDir] = process.argv;
|
|
6
|
+
const [,, magnet, downloadDir, stateFilePath] = process.argv;
|
|
7
7
|
|
|
8
8
|
if (!magnet || !downloadDir) {
|
|
9
|
-
console.error(JSON.stringify({ type: "error", message: "Usage: download.mjs <magnet> <dir>" }));
|
|
9
|
+
console.error(JSON.stringify({ type: "error", message: "Usage: download.mjs <magnet> <dir> [stateFile]" }));
|
|
10
10
|
process.exit(1);
|
|
11
11
|
}
|
|
12
12
|
|
|
@@ -14,10 +14,68 @@ if (!existsSync(downloadDir)) {
|
|
|
14
14
|
mkdirSync(downloadDir, { recursive: true });
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
// Read existing state file to preserve CLI-written metadata (id, movieTitle, etc.)
|
|
18
|
+
let existingState = {};
|
|
19
|
+
if (stateFilePath) {
|
|
20
|
+
try {
|
|
21
|
+
existingState = JSON.parse(readFileSync(stateFilePath, "utf-8"));
|
|
22
|
+
} catch {}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Current state tracked in memory, flushed to disk on every send()
|
|
26
|
+
const state = {
|
|
27
|
+
...existingState,
|
|
28
|
+
pid: process.pid,
|
|
29
|
+
magnet,
|
|
30
|
+
status: "connecting",
|
|
31
|
+
progress: 0,
|
|
32
|
+
downloaded: 0,
|
|
33
|
+
total: 0,
|
|
34
|
+
speed: 0,
|
|
35
|
+
eta: 0,
|
|
36
|
+
peers: 0,
|
|
37
|
+
filePath: null,
|
|
38
|
+
error: null,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function writeState() {
|
|
42
|
+
if (!stateFilePath) return;
|
|
43
|
+
try {
|
|
44
|
+
writeFileSync(stateFilePath, JSON.stringify(state) + "\n");
|
|
45
|
+
} catch {}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Write initial state with PID
|
|
49
|
+
writeState();
|
|
50
|
+
|
|
17
51
|
const client = new WebTorrent();
|
|
18
52
|
|
|
19
53
|
function send(obj) {
|
|
20
54
|
process.stdout.write(JSON.stringify(obj) + "\n");
|
|
55
|
+
// Mirror to state file
|
|
56
|
+
if (obj.type === "meta") {
|
|
57
|
+
state.total = obj.size;
|
|
58
|
+
state.status = "downloading";
|
|
59
|
+
} else if (obj.type === "progress") {
|
|
60
|
+
state.status = "downloading";
|
|
61
|
+
state.progress = obj.progress;
|
|
62
|
+
state.downloaded = obj.downloaded;
|
|
63
|
+
state.total = obj.total;
|
|
64
|
+
state.speed = obj.speed;
|
|
65
|
+
state.eta = obj.eta;
|
|
66
|
+
state.peers = obj.peers;
|
|
67
|
+
} else if (obj.type === "done") {
|
|
68
|
+
state.status = "done";
|
|
69
|
+
state.progress = 1;
|
|
70
|
+
state.filePath = obj.path;
|
|
71
|
+
} else if (obj.type === "error") {
|
|
72
|
+
state.status = "error";
|
|
73
|
+
state.error = obj.message;
|
|
74
|
+
} else if (obj.type === "timeout") {
|
|
75
|
+
state.status = "timeout";
|
|
76
|
+
state.error = "Could not connect to peers";
|
|
77
|
+
}
|
|
78
|
+
writeState();
|
|
21
79
|
}
|
|
22
80
|
|
|
23
81
|
client.add(magnet, { path: downloadDir }, (torrent) => {
|
package/index.ts
CHANGED
|
@@ -9,6 +9,9 @@ import gradient from "gradient-string";
|
|
|
9
9
|
import terminalImage from "terminal-image";
|
|
10
10
|
import { homedir } from "os";
|
|
11
11
|
import { join, dirname } from "path";
|
|
12
|
+
import { readdir, rename, unlink, rm, mkdir } from "node:fs/promises";
|
|
13
|
+
import { mkdirSync } from "node:fs";
|
|
14
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
12
15
|
import { createInterface } from "readline";
|
|
13
16
|
import { fileURLToPath } from "url";
|
|
14
17
|
|
|
@@ -28,6 +31,13 @@ function renderHeader(): string {
|
|
|
28
31
|
});
|
|
29
32
|
}
|
|
30
33
|
|
|
34
|
+
const DISCLAIMER = chalk.yellow("Disclaimer: ") + chalk.dim(
|
|
35
|
+
"This is a hobby project for educational purposes only. " +
|
|
36
|
+
"The developers do not host, distribute, or endorse any copyrighted content. " +
|
|
37
|
+
"Downloading copyrighted material without authorization may be illegal in your jurisdiction. " +
|
|
38
|
+
"You are solely responsible for ensuring your use complies with applicable laws."
|
|
39
|
+
);
|
|
40
|
+
|
|
31
41
|
function stripAnsi(str: string): string {
|
|
32
42
|
return str.replace(/\x1b\[[0-9;]*m/g, "");
|
|
33
43
|
}
|
|
@@ -67,6 +77,7 @@ function navFooter(): string {
|
|
|
67
77
|
|
|
68
78
|
const API_BASE = "https://yts.torrentbay.st/api/v2";
|
|
69
79
|
const DOWNLOAD_DIR = join(homedir(), "Downloads", "Movizone");
|
|
80
|
+
const STATE_DIR = join(DOWNLOAD_DIR, ".downloads");
|
|
70
81
|
|
|
71
82
|
const TRACKERS = [
|
|
72
83
|
"udp://open.demonii.com:1337/announce",
|
|
@@ -79,6 +90,16 @@ const TRACKERS = [
|
|
|
79
90
|
"udp://tracker.leechers-paradise.org:6969",
|
|
80
91
|
];
|
|
81
92
|
|
|
93
|
+
const SUBTITLE_DOMAINS = ["yts-subs.com", "yifysubtitles.ch"];
|
|
94
|
+
|
|
95
|
+
const SUBTITLE_LANGUAGES = [
|
|
96
|
+
"English", "Arabic", "Spanish", "French", "German", "Portuguese",
|
|
97
|
+
"Brazilian Portuguese", "Turkish", "Italian", "Dutch", "Polish",
|
|
98
|
+
"Russian", "Chinese", "Korean", "Japanese", "Indonesian", "Romanian",
|
|
99
|
+
"Greek", "Swedish", "Norwegian", "Finnish", "Danish", "Farsi/Persian",
|
|
100
|
+
"Urdu", "Vietnamese",
|
|
101
|
+
];
|
|
102
|
+
|
|
82
103
|
interface Torrent {
|
|
83
104
|
url: string;
|
|
84
105
|
hash: string;
|
|
@@ -93,6 +114,13 @@ interface Torrent {
|
|
|
93
114
|
audio_channels: string;
|
|
94
115
|
}
|
|
95
116
|
|
|
117
|
+
export interface SubtitleEntry {
|
|
118
|
+
language: string;
|
|
119
|
+
release: string;
|
|
120
|
+
rating: number;
|
|
121
|
+
downloadPath: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
96
124
|
interface Movie {
|
|
97
125
|
id: number;
|
|
98
126
|
title: string;
|
|
@@ -372,6 +400,8 @@ export function formatEta(ms: number): string {
|
|
|
372
400
|
|
|
373
401
|
interface DownloadState {
|
|
374
402
|
id: string;
|
|
403
|
+
pid?: number;
|
|
404
|
+
magnet?: string;
|
|
375
405
|
movieTitle: string;
|
|
376
406
|
quality: string;
|
|
377
407
|
status: "connecting" | "downloading" | "done" | "error" | "timeout";
|
|
@@ -383,20 +413,118 @@ interface DownloadState {
|
|
|
383
413
|
peers: number;
|
|
384
414
|
filePath?: string;
|
|
385
415
|
error?: string;
|
|
416
|
+
startedAt?: number;
|
|
386
417
|
}
|
|
387
418
|
|
|
388
419
|
export class DownloadManager {
|
|
389
420
|
private downloads = new Map<string, DownloadState>();
|
|
390
|
-
private processes = new Map<string,
|
|
421
|
+
private processes = new Map<string, import("node:child_process").ChildProcess>();
|
|
391
422
|
private idCounter = 0;
|
|
392
423
|
|
|
424
|
+
private stateFilePath(id: string): string {
|
|
425
|
+
return join(STATE_DIR, `${id}.json`);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
private writeState(state: DownloadState): void {
|
|
429
|
+
Bun.write(this.stateFilePath(state.id), JSON.stringify(state) + "\n");
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
private deleteStateFile(id: string): void {
|
|
433
|
+
unlink(this.stateFilePath(id)).catch(() => {});
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async loadDownloads(): Promise<void> {
|
|
437
|
+
let files: string[];
|
|
438
|
+
try {
|
|
439
|
+
files = await readdir(STATE_DIR);
|
|
440
|
+
} catch {
|
|
441
|
+
return; // Directory doesn't exist yet — no prior downloads
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const now = Date.now();
|
|
445
|
+
const ONE_DAY = 24 * 60 * 60 * 1000;
|
|
446
|
+
|
|
447
|
+
for (const file of files) {
|
|
448
|
+
if (!file.endsWith(".json")) continue;
|
|
449
|
+
|
|
450
|
+
try {
|
|
451
|
+
const data = await Bun.file(join(STATE_DIR, file)).json();
|
|
452
|
+
const state = data as DownloadState;
|
|
453
|
+
if (!state.id || this.downloads.has(state.id)) continue;
|
|
454
|
+
|
|
455
|
+
const isTerminal = state.status === "done" || state.status === "error" || state.status === "timeout";
|
|
456
|
+
|
|
457
|
+
// Auto-clean terminal states older than 24h
|
|
458
|
+
if (isTerminal && state.startedAt && (now - state.startedAt) > ONE_DAY) {
|
|
459
|
+
this.deleteStateFile(state.id);
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// For active downloads, check if the process is still alive
|
|
464
|
+
if (!isTerminal && state.pid) {
|
|
465
|
+
try {
|
|
466
|
+
process.kill(state.pid, 0);
|
|
467
|
+
// Process is alive — re-read state file for latest progress
|
|
468
|
+
} catch {
|
|
469
|
+
// Process is dead
|
|
470
|
+
state.status = "error";
|
|
471
|
+
state.error = "Process ended unexpectedly";
|
|
472
|
+
this.writeState(state);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
this.downloads.set(state.id, state);
|
|
477
|
+
} catch {}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Re-read state files for downloads without a live stdout connection (previous sessions) */
|
|
482
|
+
async refreshOrphaned(): Promise<void> {
|
|
483
|
+
for (const [id, state] of this.downloads) {
|
|
484
|
+
if (this.processes.has(id)) continue; // current session, has live stdout
|
|
485
|
+
|
|
486
|
+
const filePath = this.stateFilePath(id);
|
|
487
|
+
try {
|
|
488
|
+
const data = await Bun.file(filePath).json();
|
|
489
|
+
const fresh = data as DownloadState;
|
|
490
|
+
// Update in-memory state with latest from disk
|
|
491
|
+
state.status = fresh.status;
|
|
492
|
+
state.progress = fresh.progress;
|
|
493
|
+
state.downloaded = fresh.downloaded;
|
|
494
|
+
state.total = fresh.total;
|
|
495
|
+
state.speed = fresh.speed;
|
|
496
|
+
state.eta = fresh.eta;
|
|
497
|
+
state.peers = fresh.peers;
|
|
498
|
+
state.filePath = fresh.filePath;
|
|
499
|
+
state.error = fresh.error;
|
|
500
|
+
|
|
501
|
+
// Check if process died since last refresh
|
|
502
|
+
const isTerminal = state.status === "done" || state.status === "error" || state.status === "timeout";
|
|
503
|
+
if (!isTerminal && state.pid) {
|
|
504
|
+
try {
|
|
505
|
+
process.kill(state.pid, 0);
|
|
506
|
+
} catch {
|
|
507
|
+
state.status = "error";
|
|
508
|
+
state.error = "Process ended unexpectedly";
|
|
509
|
+
this.writeState(state);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
} catch {}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
393
516
|
startDownload(magnet: string, movieTitle: string, torrentInfo?: Torrent): string {
|
|
394
|
-
const id =
|
|
517
|
+
const id = `${Date.now()}-${++this.idCounter}`;
|
|
395
518
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
|
396
519
|
const helperPath = join(scriptDir, "download.mjs");
|
|
397
520
|
|
|
521
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
522
|
+
|
|
523
|
+
const stateFile = this.stateFilePath(id);
|
|
524
|
+
|
|
398
525
|
const state: DownloadState = {
|
|
399
526
|
id,
|
|
527
|
+
magnet,
|
|
400
528
|
movieTitle,
|
|
401
529
|
quality: torrentInfo?.quality || "unknown",
|
|
402
530
|
status: "connecting",
|
|
@@ -406,13 +534,20 @@ export class DownloadManager {
|
|
|
406
534
|
speed: 0,
|
|
407
535
|
eta: 0,
|
|
408
536
|
peers: 0,
|
|
537
|
+
startedAt: Date.now(),
|
|
409
538
|
};
|
|
410
539
|
this.downloads.set(id, state);
|
|
540
|
+
this.writeState(state);
|
|
411
541
|
|
|
412
|
-
const child =
|
|
413
|
-
|
|
414
|
-
|
|
542
|
+
const child = nodeSpawn("node", [helperPath, magnet, DOWNLOAD_DIR, stateFile], {
|
|
543
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
544
|
+
detached: true,
|
|
415
545
|
});
|
|
546
|
+
child.unref();
|
|
547
|
+
|
|
548
|
+
state.pid = child.pid;
|
|
549
|
+
this.writeState(state);
|
|
550
|
+
|
|
416
551
|
this.processes.set(id, child);
|
|
417
552
|
|
|
418
553
|
// Start reading output in background (no await)
|
|
@@ -421,61 +556,54 @@ export class DownloadManager {
|
|
|
421
556
|
return id;
|
|
422
557
|
}
|
|
423
558
|
|
|
424
|
-
private
|
|
425
|
-
const stdout = child.stdout as ReadableStream<Uint8Array>;
|
|
426
|
-
const reader = stdout.getReader();
|
|
427
|
-
const decoder = new TextDecoder();
|
|
428
|
-
let buffer = "";
|
|
559
|
+
private readOutput(id: string, child: import("node:child_process").ChildProcess): void {
|
|
429
560
|
const state = this.downloads.get(id)!;
|
|
561
|
+
let buffer = "";
|
|
430
562
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
buffer += decoder.decode(value, { stream: true });
|
|
437
|
-
const lines = buffer.split("\n");
|
|
438
|
-
buffer = lines.pop() || "";
|
|
563
|
+
child.stdout!.on("data", (chunk: Buffer) => {
|
|
564
|
+
buffer += chunk.toString();
|
|
565
|
+
const lines = buffer.split("\n");
|
|
566
|
+
buffer = lines.pop() || "";
|
|
439
567
|
|
|
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
|
-
}
|
|
568
|
+
for (const line of lines) {
|
|
569
|
+
if (!line.trim()) continue;
|
|
570
|
+
try {
|
|
571
|
+
const msg = JSON.parse(line);
|
|
572
|
+
|
|
573
|
+
if (msg.type === "meta") {
|
|
574
|
+
state.total = msg.size;
|
|
575
|
+
state.status = "downloading";
|
|
576
|
+
} else if (msg.type === "progress") {
|
|
577
|
+
state.status = "downloading";
|
|
578
|
+
state.progress = msg.progress;
|
|
579
|
+
state.downloaded = msg.downloaded;
|
|
580
|
+
state.total = msg.total;
|
|
581
|
+
state.speed = msg.speed;
|
|
582
|
+
state.eta = msg.eta;
|
|
583
|
+
state.peers = msg.peers;
|
|
584
|
+
} else if (msg.type === "done") {
|
|
585
|
+
state.status = "done";
|
|
586
|
+
state.progress = 1;
|
|
587
|
+
state.filePath = msg.path;
|
|
588
|
+
} else if (msg.type === "error") {
|
|
589
|
+
state.status = "error";
|
|
590
|
+
state.error = msg.message;
|
|
591
|
+
} else if (msg.type === "timeout") {
|
|
592
|
+
state.status = "timeout";
|
|
593
|
+
state.error = "Could not connect to peers";
|
|
594
|
+
}
|
|
595
|
+
} catch {}
|
|
469
596
|
}
|
|
470
|
-
}
|
|
597
|
+
});
|
|
471
598
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
599
|
+
child.on("close", () => {
|
|
600
|
+
if (state.status === "connecting" || state.status === "downloading") {
|
|
601
|
+
state.status = "error";
|
|
602
|
+
state.error = "Process ended unexpectedly";
|
|
603
|
+
this.writeState(state);
|
|
604
|
+
}
|
|
605
|
+
this.processes.delete(id);
|
|
606
|
+
});
|
|
479
607
|
}
|
|
480
608
|
|
|
481
609
|
getDownloads(): DownloadState[] {
|
|
@@ -487,15 +615,22 @@ export class DownloadManager {
|
|
|
487
615
|
}
|
|
488
616
|
|
|
489
617
|
cancelDownload(id: string): void {
|
|
618
|
+
const state = this.downloads.get(id);
|
|
619
|
+
|
|
620
|
+
// Kill via live child process handle (current session)
|
|
490
621
|
const child = this.processes.get(id);
|
|
491
622
|
if (child) {
|
|
492
623
|
child.kill();
|
|
493
624
|
this.processes.delete(id);
|
|
625
|
+
} else if (state?.pid) {
|
|
626
|
+
// Kill via PID (previous session's detached process)
|
|
627
|
+
try { process.kill(state.pid); } catch {}
|
|
494
628
|
}
|
|
495
|
-
|
|
629
|
+
|
|
496
630
|
if (state && (state.status === "connecting" || state.status === "downloading")) {
|
|
497
631
|
state.status = "error";
|
|
498
632
|
state.error = "Cancelled";
|
|
633
|
+
this.writeState(state);
|
|
499
634
|
}
|
|
500
635
|
}
|
|
501
636
|
|
|
@@ -503,9 +638,26 @@ export class DownloadManager {
|
|
|
503
638
|
for (const [id, state] of this.downloads) {
|
|
504
639
|
if (state.status === "done" || state.status === "error" || state.status === "timeout") {
|
|
505
640
|
this.downloads.delete(id);
|
|
641
|
+
this.deleteStateFile(id);
|
|
506
642
|
}
|
|
507
643
|
}
|
|
508
644
|
}
|
|
645
|
+
|
|
646
|
+
deleteDownload(id: string): void {
|
|
647
|
+
const state = this.downloads.get(id);
|
|
648
|
+
if (!state) return;
|
|
649
|
+
|
|
650
|
+
const child = this.processes.get(id);
|
|
651
|
+
if (child) {
|
|
652
|
+
child.kill();
|
|
653
|
+
this.processes.delete(id);
|
|
654
|
+
} else if (state.pid) {
|
|
655
|
+
try { process.kill(state.pid); } catch {}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
this.downloads.delete(id);
|
|
659
|
+
this.deleteStateFile(id);
|
|
660
|
+
}
|
|
509
661
|
}
|
|
510
662
|
|
|
511
663
|
const downloadManager = new DownloadManager();
|
|
@@ -527,11 +679,177 @@ async function downloadTorrent(magnet: string, movieTitle: string, torrentInfo?:
|
|
|
527
679
|
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
|
528
680
|
}));
|
|
529
681
|
|
|
682
|
+
console.log(chalk.yellow(" Note: ") + chalk.dim("Ensure you have the right to download this content in your jurisdiction."));
|
|
530
683
|
downloadManager.startDownload(magnet, movieTitle, torrentInfo);
|
|
531
684
|
console.log(chalk.green("\n Download started in background!"));
|
|
532
685
|
console.log(chalk.dim(" Check progress from the Downloads menu.\n"));
|
|
533
686
|
}
|
|
534
687
|
|
|
688
|
+
// --- Subtitle Downloads ---
|
|
689
|
+
|
|
690
|
+
export function parseSubtitleRows(html: string): SubtitleEntry[] {
|
|
691
|
+
const entries: SubtitleEntry[] = [];
|
|
692
|
+
const rowRegex = /<tr\s+data-id="[^"]*"[^>]*>([\s\S]*?)<\/tr>/gi;
|
|
693
|
+
let rowMatch: RegExpExecArray | null;
|
|
694
|
+
|
|
695
|
+
while ((rowMatch = rowRegex.exec(html)) !== null) {
|
|
696
|
+
const row = rowMatch[1]!;
|
|
697
|
+
|
|
698
|
+
const langMatch = row.match(/<span\s+class="sub-lang">([^<]+)<\/span>/);
|
|
699
|
+
const ratingMatch = row.match(/<td\s+class="rating-cell"[^>]*>\s*<span[^>]*>(\d+)<\/span>/);
|
|
700
|
+
const releaseMatch = row.match(/<td>\s*<a\s+href="[^"]*"[^>]*>\s*(?:<span[^>]*>[^<]*<\/span>\s*)?([^<]+)<\/a>/);
|
|
701
|
+
const downloadMatch = row.match(/<td\s+class="download-cell"[^>]*>\s*<a\s+href="([^"]+)"/);
|
|
702
|
+
|
|
703
|
+
if (langMatch && downloadMatch) {
|
|
704
|
+
entries.push({
|
|
705
|
+
language: langMatch[1]!.trim(),
|
|
706
|
+
release: releaseMatch?.[1]?.trim() || "",
|
|
707
|
+
rating: parseInt(ratingMatch?.[1] || "0", 10),
|
|
708
|
+
downloadPath: downloadMatch[1]!,
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
return entries;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
async function fetchSubtitles(imdbCode: string): Promise<SubtitleEntry[]> {
|
|
717
|
+
for (const domain of SUBTITLE_DOMAINS) {
|
|
718
|
+
try {
|
|
719
|
+
const url = `https://${domain}/movie-imdb/${imdbCode}`;
|
|
720
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(10000) });
|
|
721
|
+
if (!res.ok) continue;
|
|
722
|
+
const html = await res.text();
|
|
723
|
+
const entries = parseSubtitleRows(html);
|
|
724
|
+
if (entries.length) return entries;
|
|
725
|
+
} catch {
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
return [];
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
export function scoreSubtitle(entry: SubtitleEntry, torrent: Torrent): number {
|
|
733
|
+
let score = entry.rating;
|
|
734
|
+
const rel = entry.release.toLowerCase();
|
|
735
|
+
if (torrent.quality && rel.includes(torrent.quality.toLowerCase())) score += 30;
|
|
736
|
+
if (torrent.type && rel.includes(torrent.type.toLowerCase())) score += 20;
|
|
737
|
+
if (rel.includes("yify") || rel.includes("yts")) score += 15;
|
|
738
|
+
return score;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
async function downloadSubtitle(
|
|
742
|
+
entry: SubtitleEntry,
|
|
743
|
+
movieTitle: string,
|
|
744
|
+
quality: string,
|
|
745
|
+
language: string,
|
|
746
|
+
): Promise<string | null> {
|
|
747
|
+
const zipUrl = `https://subtitles.yts-subs.com${entry.downloadPath}.zip`;
|
|
748
|
+
const tmpDir = join(DOWNLOAD_DIR, ".subtitle-tmp-" + Date.now());
|
|
749
|
+
|
|
750
|
+
try {
|
|
751
|
+
await mkdir(tmpDir, { recursive: true });
|
|
752
|
+
await mkdir(DOWNLOAD_DIR, { recursive: true });
|
|
753
|
+
|
|
754
|
+
const res = await fetch(zipUrl, { signal: AbortSignal.timeout(15000) });
|
|
755
|
+
if (!res.ok) return null;
|
|
756
|
+
|
|
757
|
+
const zipPath = join(tmpDir, "sub.zip");
|
|
758
|
+
await Bun.write(zipPath, await res.arrayBuffer());
|
|
759
|
+
|
|
760
|
+
// Extract ZIP
|
|
761
|
+
const unzipProc = Bun.spawn(["unzip", "-o", zipPath, "-d", tmpDir], {
|
|
762
|
+
stdout: "ignore",
|
|
763
|
+
stderr: "ignore",
|
|
764
|
+
});
|
|
765
|
+
await unzipProc.exited;
|
|
766
|
+
|
|
767
|
+
// Find .srt file
|
|
768
|
+
const files = await readdir(tmpDir);
|
|
769
|
+
const srtFile = files.find((f) => f.endsWith(".srt"));
|
|
770
|
+
if (!srtFile) return null;
|
|
771
|
+
|
|
772
|
+
const safeName = movieTitle.replace(/[^a-zA-Z0-9 ._-]/g, "");
|
|
773
|
+
const finalName = `${safeName}.${quality}.${language}.srt`;
|
|
774
|
+
const finalPath = join(DOWNLOAD_DIR, finalName);
|
|
775
|
+
|
|
776
|
+
await rename(join(tmpDir, srtFile), finalPath);
|
|
777
|
+
return finalPath;
|
|
778
|
+
} catch {
|
|
779
|
+
return null;
|
|
780
|
+
} finally {
|
|
781
|
+
try { await rm(tmpDir, { recursive: true, force: true }); } catch {}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
async function promptSubtitleDownload(movie: Movie, torrent?: Torrent, skipConfirm = false): Promise<void> {
|
|
786
|
+
try {
|
|
787
|
+
if (!skipConfirm) {
|
|
788
|
+
const { wantSubs } = await inquirer.prompt([
|
|
789
|
+
{ type: "confirm", name: "wantSubs", message: "Download subtitles?", default: false },
|
|
790
|
+
]);
|
|
791
|
+
if (!wantSubs) return;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const spinner = ora("Fetching available subtitles...").start();
|
|
795
|
+
const allSubs = await fetchSubtitles(movie.imdb_code);
|
|
796
|
+
|
|
797
|
+
if (!allSubs.length) {
|
|
798
|
+
spinner.fail(chalk.yellow("No subtitles found for this movie."));
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Group by language, count entries per language, sort by SUBTITLE_LANGUAGES order
|
|
803
|
+
const langMap = new Map<string, SubtitleEntry[]>();
|
|
804
|
+
for (const s of allSubs) {
|
|
805
|
+
const key = s.language;
|
|
806
|
+
if (!langMap.has(key)) langMap.set(key, []);
|
|
807
|
+
langMap.get(key)!.push(s);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const availableLangs = [...langMap.keys()].sort((a, b) => {
|
|
811
|
+
const ai = SUBTITLE_LANGUAGES.indexOf(a);
|
|
812
|
+
const bi = SUBTITLE_LANGUAGES.indexOf(b);
|
|
813
|
+
return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
spinner.stop();
|
|
817
|
+
|
|
818
|
+
const { language } = await inquirer.prompt([
|
|
819
|
+
{
|
|
820
|
+
type: "list",
|
|
821
|
+
name: "language",
|
|
822
|
+
message: "Subtitle language:",
|
|
823
|
+
choices: availableLangs.map((l) => ({
|
|
824
|
+
name: `${l} (${langMap.get(l)!.length})`,
|
|
825
|
+
value: l,
|
|
826
|
+
})),
|
|
827
|
+
pageSize: 15,
|
|
828
|
+
},
|
|
829
|
+
]);
|
|
830
|
+
|
|
831
|
+
const langSubs = langMap.get(language)!;
|
|
832
|
+
|
|
833
|
+
// Score and pick best
|
|
834
|
+
const best = langSubs
|
|
835
|
+
.map((s) => ({ entry: s, score: torrent ? scoreSubtitle(s, torrent) : s.rating }))
|
|
836
|
+
.sort((a, b) => b.score - a.score)[0]!;
|
|
837
|
+
|
|
838
|
+
const dlSpinner = ora("Downloading subtitle...").start();
|
|
839
|
+
const quality = torrent?.quality || "unknown";
|
|
840
|
+
const path = await downloadSubtitle(best.entry, movie.title, quality, language);
|
|
841
|
+
|
|
842
|
+
if (path) {
|
|
843
|
+
dlSpinner.succeed(chalk.green(`Subtitle saved: ${chalk.dim(path)}`));
|
|
844
|
+
} else {
|
|
845
|
+
dlSpinner.fail(chalk.yellow("Failed to download subtitle file."));
|
|
846
|
+
}
|
|
847
|
+
} catch (err) {
|
|
848
|
+
if (isExitPromptError(err)) return;
|
|
849
|
+
console.log(chalk.yellow(" Subtitle download skipped."));
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
535
853
|
// --- Display Helpers ---
|
|
536
854
|
|
|
537
855
|
export function formatRuntime(minutes: number): string {
|
|
@@ -795,6 +1113,7 @@ async function viewMovie(movie: Movie): Promise<void> {
|
|
|
795
1113
|
}
|
|
796
1114
|
|
|
797
1115
|
choices.push({ name: "Copy magnet link", value: "magnet" });
|
|
1116
|
+
choices.push({ name: "Download subtitles", value: "subtitles" });
|
|
798
1117
|
choices.push({ name: "Similar movies", value: "similar" });
|
|
799
1118
|
choices.push({ name: "Back", value: "back" });
|
|
800
1119
|
|
|
@@ -808,11 +1127,15 @@ async function viewMovie(movie: Movie): Promise<void> {
|
|
|
808
1127
|
await showSimilar(movie);
|
|
809
1128
|
} else if (action === "magnet") {
|
|
810
1129
|
await selectTorrentAndCopyMagnet(movie);
|
|
1130
|
+
} else if (action === "subtitles") {
|
|
1131
|
+
await promptSubtitleDownload(movie, undefined, true);
|
|
811
1132
|
} else if (action.startsWith("dl_")) {
|
|
812
1133
|
const hash = action.slice(3);
|
|
813
1134
|
const torrent = movie.torrents?.find((t) => t.hash === hash);
|
|
814
1135
|
const magnet = buildMagnet(hash, movie.title);
|
|
815
1136
|
await downloadTorrent(magnet, movie.title, torrent);
|
|
1137
|
+
await promptSubtitleDownload(movie, torrent);
|
|
1138
|
+
viewing = false;
|
|
816
1139
|
}
|
|
817
1140
|
}
|
|
818
1141
|
}
|
|
@@ -952,111 +1275,238 @@ function downloadProgressBar(progress: number, width = 20): string {
|
|
|
952
1275
|
return chalk.green("█".repeat(filled)) + chalk.dim("░".repeat(width - filled));
|
|
953
1276
|
}
|
|
954
1277
|
|
|
955
|
-
|
|
956
|
-
|
|
1278
|
+
function renderDownloadsScreen(downloads: DownloadState[]): void {
|
|
1279
|
+
const table = new Table({
|
|
1280
|
+
head: [
|
|
1281
|
+
chalk.dim("#"),
|
|
1282
|
+
chalk.bold("Title"),
|
|
1283
|
+
chalk.cyan("Quality"),
|
|
1284
|
+
chalk.white("Progress"),
|
|
1285
|
+
chalk.cyan("Speed"),
|
|
1286
|
+
chalk.white("ETA"),
|
|
1287
|
+
chalk.dim("Status"),
|
|
1288
|
+
],
|
|
1289
|
+
colWidths: [5, 28, 10, 28, 12, 10, 12],
|
|
1290
|
+
style: { head: [], border: ["gray"], compact: false },
|
|
1291
|
+
wordWrap: true,
|
|
1292
|
+
});
|
|
957
1293
|
|
|
958
|
-
|
|
959
|
-
const
|
|
1294
|
+
for (let i = 0; i < downloads.length; i++) {
|
|
1295
|
+
const d = downloads[i]!;
|
|
1296
|
+
const pct = (d.progress * 100).toFixed(1) + "%";
|
|
1297
|
+
const progressCell = d.status === "downloading"
|
|
1298
|
+
? `${downloadProgressBar(d.progress)} ${chalk.bold(pct)}`
|
|
1299
|
+
: d.status === "done"
|
|
1300
|
+
? `${downloadProgressBar(1)} ${chalk.bold("100%")}`
|
|
1301
|
+
: chalk.dim("--");
|
|
1302
|
+
const speedCell = d.status === "downloading" ? chalk.cyan(formatSpeed(d.speed)) : chalk.dim("--");
|
|
1303
|
+
const etaCell = d.status === "downloading" ? formatEta(d.eta) : chalk.dim("--");
|
|
1304
|
+
const statusCell = `${downloadStatusIcon(d.status)} ${d.status === "error" || d.status === "timeout" ? chalk.red(d.error || d.status) : d.status}`;
|
|
1305
|
+
|
|
1306
|
+
table.push([
|
|
1307
|
+
chalk.dim(`${i + 1}`),
|
|
1308
|
+
chalk.white(d.movieTitle),
|
|
1309
|
+
chalk.cyan(d.quality),
|
|
1310
|
+
progressCell,
|
|
1311
|
+
speedCell,
|
|
1312
|
+
etaCell,
|
|
1313
|
+
statusCell,
|
|
1314
|
+
]);
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
console.log(boxen(table.toString(), {
|
|
1318
|
+
title: chalk.bold(" Downloads "),
|
|
1319
|
+
titleAlignment: "left",
|
|
1320
|
+
borderStyle: "round",
|
|
1321
|
+
borderColor: "cyan",
|
|
1322
|
+
dimBorder: true,
|
|
1323
|
+
padding: { top: 0, bottom: 0, left: 0, right: 0 },
|
|
1324
|
+
}));
|
|
960
1325
|
|
|
1326
|
+
// Show file paths for completed downloads
|
|
1327
|
+
const doneWithFiles = downloads.filter((d) => d.status === "done" && d.filePath);
|
|
1328
|
+
if (doneWithFiles.length) {
|
|
1329
|
+
for (const d of doneWithFiles) {
|
|
1330
|
+
console.log(chalk.dim(` ✓ ${d.movieTitle}: ${d.filePath}`));
|
|
1331
|
+
}
|
|
1332
|
+
console.log();
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
function waitForKey(timeoutMs?: number): Promise<string | null> {
|
|
1337
|
+
return new Promise((resolve) => {
|
|
1338
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
1339
|
+
|
|
1340
|
+
function cleanup() {
|
|
1341
|
+
if (timer) clearTimeout(timer);
|
|
1342
|
+
process.stdin.removeAllListeners("data");
|
|
1343
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
1344
|
+
process.stdin.pause();
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
if (timeoutMs !== undefined) {
|
|
1348
|
+
timer = setTimeout(() => {
|
|
1349
|
+
cleanup();
|
|
1350
|
+
resolve(null);
|
|
1351
|
+
}, timeoutMs);
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
|
1355
|
+
process.stdin.resume();
|
|
1356
|
+
process.stdin.once("data", (data: Buffer) => {
|
|
1357
|
+
cleanup();
|
|
1358
|
+
resolve(data.toString());
|
|
1359
|
+
});
|
|
1360
|
+
});
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
async function viewDownloads(): Promise<void> {
|
|
1364
|
+
while (true) {
|
|
1365
|
+
const downloads = downloadManager.getDownloads();
|
|
961
1366
|
if (!downloads.length) {
|
|
962
1367
|
console.log(chalk.dim("\n No downloads yet.\n"));
|
|
963
1368
|
return;
|
|
964
1369
|
}
|
|
965
1370
|
|
|
966
|
-
|
|
1371
|
+
await downloadManager.refreshOrphaned();
|
|
967
1372
|
|
|
968
|
-
const
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
chalk.bold("Title"),
|
|
972
|
-
chalk.cyan("Quality"),
|
|
973
|
-
chalk.white("Progress"),
|
|
974
|
-
chalk.cyan("Speed"),
|
|
975
|
-
chalk.white("ETA"),
|
|
976
|
-
chalk.dim("Status"),
|
|
977
|
-
],
|
|
978
|
-
colWidths: [5, 28, 10, 28, 12, 10, 12],
|
|
979
|
-
style: { head: [], border: ["gray"], compact: false },
|
|
980
|
-
wordWrap: true,
|
|
981
|
-
});
|
|
1373
|
+
const active = downloadManager.getActive();
|
|
1374
|
+
const inactive = downloads.filter((d) => d.status === "done" || d.status === "error" || d.status === "timeout");
|
|
1375
|
+
const doneWithFiles = downloads.filter((d) => d.status === "done" && d.filePath);
|
|
982
1376
|
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
etaCell,
|
|
1002
|
-
statusCell,
|
|
1003
|
-
]);
|
|
1377
|
+
// Clear screen and render
|
|
1378
|
+
process.stdout.write("\x1b[2J\x1b[H");
|
|
1379
|
+
renderDownloadsScreen(downloads);
|
|
1380
|
+
|
|
1381
|
+
// Key hints footer
|
|
1382
|
+
const hints: string[] = [];
|
|
1383
|
+
if (active.length) hints.push(chalk.bold("c") + chalk.dim(" Cancel"));
|
|
1384
|
+
if (inactive.length) hints.push(chalk.bold("x") + chalk.dim(" Clear"));
|
|
1385
|
+
if (doneWithFiles.length) hints.push(chalk.bold("d") + chalk.dim(" Delete file"));
|
|
1386
|
+
hints.push(chalk.bold("b") + chalk.dim(" Back"));
|
|
1387
|
+
|
|
1388
|
+
console.log(boxen(
|
|
1389
|
+
" " + hints.join(" ") + " ",
|
|
1390
|
+
{ borderStyle: "single", borderColor: "gray", dimBorder: true, padding: 0 },
|
|
1391
|
+
));
|
|
1392
|
+
|
|
1393
|
+
if (active.length) {
|
|
1394
|
+
console.log(chalk.dim(" Auto-refreshing..."));
|
|
1004
1395
|
}
|
|
1005
1396
|
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
titleAlignment: "left",
|
|
1009
|
-
borderStyle: "round",
|
|
1010
|
-
borderColor: "cyan",
|
|
1011
|
-
dimBorder: true,
|
|
1012
|
-
padding: { top: 0, bottom: 0, left: 0, right: 0 },
|
|
1013
|
-
}));
|
|
1397
|
+
// Wait for keypress (auto-refresh every 1s if active downloads)
|
|
1398
|
+
const key = await waitForKey(active.length > 0 ? 1000 : undefined);
|
|
1014
1399
|
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1400
|
+
if (key === null) continue; // Timeout → auto-refresh
|
|
1401
|
+
|
|
1402
|
+
const k = key.charAt(0);
|
|
1403
|
+
|
|
1404
|
+
if (k === "b" || k === "q" || k === "\x1b") return;
|
|
1405
|
+
if (k === "\x03") exitGracefully();
|
|
1406
|
+
|
|
1407
|
+
// Cancel active download
|
|
1408
|
+
if (k === "c" && active.length) {
|
|
1409
|
+
if (active.length === 1) {
|
|
1410
|
+
downloadManager.cancelDownload(active[0]!.id);
|
|
1411
|
+
} else {
|
|
1412
|
+
const { id } = await inquirer.prompt([{
|
|
1413
|
+
type: "list",
|
|
1414
|
+
name: "id",
|
|
1415
|
+
message: "Cancel which download?",
|
|
1416
|
+
choices: [
|
|
1417
|
+
...active.map((d) => ({ name: `${d.movieTitle} (${d.quality})`, value: d.id })),
|
|
1418
|
+
{ name: "Never mind", value: "" },
|
|
1419
|
+
],
|
|
1420
|
+
}]);
|
|
1421
|
+
if (id) downloadManager.cancelDownload(id);
|
|
1020
1422
|
}
|
|
1021
|
-
console.log();
|
|
1022
1423
|
}
|
|
1023
1424
|
|
|
1024
|
-
|
|
1425
|
+
// Clear completed/failed from list
|
|
1426
|
+
if (k === "x" && inactive.length) {
|
|
1427
|
+
downloadManager.clearCompleted();
|
|
1428
|
+
}
|
|
1025
1429
|
|
|
1026
|
-
|
|
1027
|
-
if (
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1430
|
+
// Delete completed download file from disk
|
|
1431
|
+
if (k === "d" && doneWithFiles.length) {
|
|
1432
|
+
if (doneWithFiles.length === 1) {
|
|
1433
|
+
const d = doneWithFiles[0]!;
|
|
1434
|
+
const { confirm } = await inquirer.prompt([{
|
|
1435
|
+
type: "confirm",
|
|
1436
|
+
name: "confirm",
|
|
1437
|
+
message: `Delete "${d.movieTitle}" from disk?`,
|
|
1438
|
+
default: false,
|
|
1439
|
+
}]);
|
|
1440
|
+
if (confirm) {
|
|
1441
|
+
try {
|
|
1442
|
+
await rm(d.filePath!, { recursive: true, force: true });
|
|
1443
|
+
downloadManager.deleteDownload(d.id);
|
|
1444
|
+
} catch (err: any) {
|
|
1445
|
+
console.log(chalk.red(` Error: ${err.message}`));
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
} else {
|
|
1449
|
+
const { ids } = await inquirer.prompt([{
|
|
1450
|
+
type: "checkbox",
|
|
1451
|
+
name: "ids",
|
|
1452
|
+
message: "Delete which files?",
|
|
1453
|
+
choices: doneWithFiles.map((d) => ({
|
|
1454
|
+
name: `${d.movieTitle} (${d.quality})`,
|
|
1455
|
+
value: d.id,
|
|
1456
|
+
})),
|
|
1457
|
+
}]);
|
|
1458
|
+
for (const id of ids) {
|
|
1459
|
+
const d = downloads.find((dl) => dl.id === id);
|
|
1460
|
+
if (d?.filePath) {
|
|
1461
|
+
try {
|
|
1462
|
+
await rm(d.filePath, { recursive: true, force: true });
|
|
1463
|
+
downloadManager.deleteDownload(id);
|
|
1464
|
+
} catch {}
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1033
1467
|
}
|
|
1034
1468
|
}
|
|
1035
1469
|
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
}
|
|
1470
|
+
// Any other key → just re-render
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1040
1473
|
|
|
1041
|
-
|
|
1042
|
-
choices.push({ name: "Back to menu", value: "back" });
|
|
1474
|
+
// --- Self-Update ---
|
|
1043
1475
|
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1476
|
+
interface UpdateInfo {
|
|
1477
|
+
latest: string;
|
|
1478
|
+
current: string;
|
|
1479
|
+
hasUpdate: boolean;
|
|
1480
|
+
}
|
|
1047
1481
|
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1482
|
+
async function checkForUpdate(): Promise<UpdateInfo | null> {
|
|
1483
|
+
try {
|
|
1484
|
+
const res = await fetch("https://registry.npmjs.org/movizone/latest", {
|
|
1485
|
+
signal: AbortSignal.timeout(3000),
|
|
1486
|
+
});
|
|
1487
|
+
if (!res.ok) return null;
|
|
1488
|
+
const data = await res.json() as { version: string };
|
|
1489
|
+
const latest = data.version;
|
|
1490
|
+
const current = version;
|
|
1491
|
+
const hasUpdate = latest !== current;
|
|
1492
|
+
return { latest, current, hasUpdate };
|
|
1493
|
+
} catch {
|
|
1494
|
+
return null;
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
async function runUpdate(): Promise<void> {
|
|
1499
|
+
console.log(chalk.cyan("\n Updating movizone...\n"));
|
|
1500
|
+
const proc = Bun.spawn(["bun", "install", "-g", "movizone@latest"], {
|
|
1501
|
+
stdout: "inherit",
|
|
1502
|
+
stderr: "inherit",
|
|
1503
|
+
stdin: "inherit",
|
|
1504
|
+
});
|
|
1505
|
+
const code = await proc.exited;
|
|
1506
|
+
if (code === 0) {
|
|
1507
|
+
console.log(chalk.green("\n Updated successfully! Restart movizone to use the new version.\n"));
|
|
1508
|
+
} else {
|
|
1509
|
+
console.log(chalk.red("\n Update failed. Try manually: bun install -g movizone@latest\n"));
|
|
1060
1510
|
}
|
|
1061
1511
|
}
|
|
1062
1512
|
|
|
@@ -1074,11 +1524,36 @@ function exitGracefully(): never {
|
|
|
1074
1524
|
// --- Main ---
|
|
1075
1525
|
|
|
1076
1526
|
async function main(): Promise<void> {
|
|
1527
|
+
const updateCheck = checkForUpdate(); // start fetch immediately
|
|
1528
|
+
|
|
1077
1529
|
console.log();
|
|
1078
1530
|
console.log(renderHeader());
|
|
1079
1531
|
console.log(chalk.dim(` Downloads: ${DOWNLOAD_DIR}`));
|
|
1532
|
+
console.log(boxen(DISCLAIMER, {
|
|
1533
|
+
borderStyle: "round",
|
|
1534
|
+
borderColor: "yellow",
|
|
1535
|
+
dimBorder: true,
|
|
1536
|
+
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
|
1537
|
+
}));
|
|
1538
|
+
|
|
1539
|
+
const update = await updateCheck; // already in-flight
|
|
1540
|
+
if (update?.hasUpdate) {
|
|
1541
|
+
console.log(boxen(
|
|
1542
|
+
chalk.dim(`Current: v${update.current}`) + " → " + chalk.green.bold(`v${update.latest} available`),
|
|
1543
|
+
{
|
|
1544
|
+
title: chalk.yellow.bold(" Update Available "),
|
|
1545
|
+
titleAlignment: "center",
|
|
1546
|
+
borderStyle: "round",
|
|
1547
|
+
borderColor: "yellow",
|
|
1548
|
+
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
|
1549
|
+
},
|
|
1550
|
+
));
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1080
1553
|
console.log();
|
|
1081
1554
|
|
|
1555
|
+
await downloadManager.loadDownloads();
|
|
1556
|
+
|
|
1082
1557
|
let running = true;
|
|
1083
1558
|
|
|
1084
1559
|
while (running) {
|
|
@@ -1088,19 +1563,24 @@ async function main(): Promise<void> {
|
|
|
1088
1563
|
? `Downloads (${activeCount} active)`
|
|
1089
1564
|
: "Downloads";
|
|
1090
1565
|
|
|
1566
|
+
const choices: { name: string; value: string }[] = [
|
|
1567
|
+
{ name: "Search movies", value: "search" },
|
|
1568
|
+
{ name: "Browse movies", value: "browse" },
|
|
1569
|
+
{ name: "Trending now", value: "trending" },
|
|
1570
|
+
{ name: "Top rated", value: "top" },
|
|
1571
|
+
{ name: downloadsLabel, value: "downloads" },
|
|
1572
|
+
];
|
|
1573
|
+
if (update?.hasUpdate) {
|
|
1574
|
+
choices.push({ name: chalk.yellow(`Update available (v${update.latest})`), value: "update" });
|
|
1575
|
+
}
|
|
1576
|
+
choices.push({ name: "Exit", value: "exit" });
|
|
1577
|
+
|
|
1091
1578
|
const { action } = await inquirer.prompt([
|
|
1092
1579
|
{
|
|
1093
1580
|
type: "list",
|
|
1094
1581
|
name: "action",
|
|
1095
1582
|
message: "What do you want to do?",
|
|
1096
|
-
choices
|
|
1097
|
-
{ name: "Search movies", value: "search" },
|
|
1098
|
-
{ name: "Browse movies", value: "browse" },
|
|
1099
|
-
{ name: "Trending now", value: "trending" },
|
|
1100
|
-
{ name: "Top rated", value: "top" },
|
|
1101
|
-
{ name: downloadsLabel, value: "downloads" },
|
|
1102
|
-
{ name: "Exit", value: "exit" },
|
|
1103
|
-
],
|
|
1583
|
+
choices,
|
|
1104
1584
|
},
|
|
1105
1585
|
]);
|
|
1106
1586
|
|
|
@@ -1120,6 +1600,9 @@ async function main(): Promise<void> {
|
|
|
1120
1600
|
case "downloads":
|
|
1121
1601
|
await viewDownloads();
|
|
1122
1602
|
break;
|
|
1603
|
+
case "update":
|
|
1604
|
+
await runUpdate();
|
|
1605
|
+
break;
|
|
1123
1606
|
case "exit":
|
|
1124
1607
|
exitGracefully();
|
|
1125
1608
|
}
|