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.
Files changed (4) hide show
  1. package/README.md +10 -0
  2. package/download.mjs +61 -3
  3. package/index.ts +630 -147
  4. 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, ReturnType<typeof Bun.spawn>>();
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 = String(++this.idCounter);
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 = Bun.spawn(["node", helperPath, magnet, DOWNLOAD_DIR], {
413
- stdout: "pipe",
414
- stderr: "inherit",
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 async readOutput(id: string, child: ReturnType<typeof Bun.spawn>): Promise<void> {
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
- try {
432
- while (true) {
433
- const { done, value } = await reader.read();
434
- if (done) break;
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
- for (const line of lines) {
441
- if (!line.trim()) continue;
442
- try {
443
- const msg = JSON.parse(line);
444
-
445
- if (msg.type === "meta") {
446
- state.total = msg.size;
447
- state.status = "downloading";
448
- } else if (msg.type === "progress") {
449
- state.status = "downloading";
450
- state.progress = msg.progress;
451
- state.downloaded = msg.downloaded;
452
- state.total = msg.total;
453
- state.speed = msg.speed;
454
- state.eta = msg.eta;
455
- state.peers = msg.peers;
456
- } else if (msg.type === "done") {
457
- state.status = "done";
458
- state.progress = 1;
459
- state.filePath = msg.path;
460
- } else if (msg.type === "error") {
461
- state.status = "error";
462
- state.error = msg.message;
463
- } else if (msg.type === "timeout") {
464
- state.status = "timeout";
465
- state.error = "Could not connect to peers";
466
- }
467
- } catch {}
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
- } catch {}
597
+ });
471
598
 
472
- // Ensure terminal state if process ended without explicit done/error
473
- await child.exited;
474
- if (state.status === "connecting" || state.status === "downloading") {
475
- state.status = "error";
476
- state.error = "Process ended unexpectedly";
477
- }
478
- this.processes.delete(id);
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
- const state = this.downloads.get(id);
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
- async function viewDownloads(): Promise<void> {
956
- let viewing = true;
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
- while (viewing) {
959
- const downloads = downloadManager.getDownloads();
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
- console.log();
1371
+ await downloadManager.refreshOrphaned();
967
1372
 
968
- const table = new Table({
969
- head: [
970
- chalk.dim("#"),
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
- for (let i = 0; i < downloads.length; i++) {
984
- const d = downloads[i]!;
985
- const pct = (d.progress * 100).toFixed(1) + "%";
986
- const progressCell = d.status === "downloading"
987
- ? `${downloadProgressBar(d.progress)} ${chalk.bold(pct)}`
988
- : d.status === "done"
989
- ? `${downloadProgressBar(1)} ${chalk.bold("100%")}`
990
- : chalk.dim("--");
991
- const speedCell = d.status === "downloading" ? chalk.cyan(formatSpeed(d.speed)) : chalk.dim("--");
992
- const etaCell = d.status === "downloading" ? formatEta(d.eta) : chalk.dim("--");
993
- const statusCell = `${downloadStatusIcon(d.status)} ${d.status === "error" || d.status === "timeout" ? chalk.red(d.error || d.status) : d.status}`;
994
-
995
- table.push([
996
- chalk.dim(`${i + 1}`),
997
- chalk.white(d.movieTitle),
998
- chalk.cyan(d.quality),
999
- progressCell,
1000
- speedCell,
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
- console.log(boxen(table.toString(), {
1007
- title: chalk.bold(" Downloads "),
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
- // Show file paths for completed downloads
1016
- const completed = downloads.filter((d) => d.status === "done" && d.filePath);
1017
- if (completed.length) {
1018
- for (const d of completed) {
1019
- console.log(chalk.dim(` ✓ ${d.movieTitle}: ${d.filePath}`));
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
- const choices: any[] = [];
1425
+ // Clear completed/failed from list
1426
+ if (k === "x" && inactive.length) {
1427
+ downloadManager.clearCompleted();
1428
+ }
1025
1429
 
1026
- const active = downloadManager.getActive();
1027
- if (active.length) {
1028
- for (const d of active) {
1029
- choices.push({
1030
- name: `Cancel: ${d.movieTitle} (${d.quality})`,
1031
- value: `cancel_${d.id}`,
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
- const inactive = downloads.filter((d) => d.status === "done" || d.status === "error" || d.status === "timeout");
1037
- if (inactive.length) {
1038
- choices.push({ name: "Clear completed/failed", value: "clear" });
1039
- }
1470
+ // Any other key just re-render
1471
+ }
1472
+ }
1040
1473
 
1041
- choices.push({ name: "Refresh", value: "refresh" });
1042
- choices.push({ name: "Back to menu", value: "back" });
1474
+ // --- Self-Update ---
1043
1475
 
1044
- const { action } = await inquirer.prompt([
1045
- { type: "list", name: "action", message: "Action:", choices },
1046
- ]);
1476
+ interface UpdateInfo {
1477
+ latest: string;
1478
+ current: string;
1479
+ hasUpdate: boolean;
1480
+ }
1047
1481
 
1048
- if (action === "back") {
1049
- viewing = false;
1050
- } else if (action === "clear") {
1051
- downloadManager.clearCompleted();
1052
- console.log(chalk.dim(" Cleared.\n"));
1053
- } else if (action === "refresh") {
1054
- // loop continues, will re-render
1055
- } else if (action.startsWith("cancel_")) {
1056
- const id = action.slice(7);
1057
- downloadManager.cancelDownload(id);
1058
- console.log(chalk.yellow(" Download cancelled.\n"));
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "movizone",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "Movie torrent explorer CLI with fuzzy search and in-terminal downloads",
5
5
  "type": "module",
6
6
  "bin": {