movizone 1.0.4 → 1.1.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 (2) hide show
  1. package/index.ts +301 -99
  2. package/package.json +6 -5
package/index.ts CHANGED
@@ -6,6 +6,7 @@ import boxen from "boxen";
6
6
  import Table from "cli-table3";
7
7
  import figlet from "figlet";
8
8
  import gradient from "gradient-string";
9
+ import terminalImage from "terminal-image";
9
10
  import { homedir } from "os";
10
11
  import { join, dirname } from "path";
11
12
  import { createInterface } from "readline";
@@ -143,15 +144,28 @@ async function getMovieSuggestions(movieId: number): Promise<ListResponse> {
143
144
  return apiGet("movie_suggestions.json", { movie_id: movieId });
144
145
  }
145
146
 
146
- function buildMagnet(hash: string, title: string): string {
147
+ export function buildMagnet(hash: string, title: string): string {
147
148
  const dn = encodeURIComponent(title);
148
149
  const trackers = TRACKERS.map((t) => `&tr=${encodeURIComponent(t)}`).join("");
149
150
  return `magnet:?xt=urn:btih:${hash}&dn=${dn}${trackers}`;
150
151
  }
151
152
 
153
+ // --- Poster Image ---
154
+
155
+ async function fetchPosterImage(url: string): Promise<string | null> {
156
+ try {
157
+ const res = await fetch(url);
158
+ if (!res.ok) return null;
159
+ const buffer = new Uint8Array(await res.arrayBuffer());
160
+ return await terminalImage.buffer(buffer, { width: 30 });
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+
152
166
  // --- Fuzzy Search ---
153
167
 
154
- function levenshtein(a: string, b: string): number {
168
+ export function levenshtein(a: string, b: string): number {
155
169
  const m = a.length, n = b.length;
156
170
  const dp: number[][] = Array.from({ length: m + 1 }, () => Array<number>(n + 1).fill(0));
157
171
  for (let i = 0; i <= m; i++) dp[i]![0] = i;
@@ -166,7 +180,7 @@ function levenshtein(a: string, b: string): number {
166
180
  return dp[m]![n]!;
167
181
  }
168
182
 
169
- function fuzzyScore(query: string, title: string): number {
183
+ export function fuzzyScore(query: string, title: string): number {
170
184
  const q = query.toLowerCase().trim();
171
185
  const t = title.toLowerCase().trim();
172
186
 
@@ -197,12 +211,12 @@ function fuzzyScore(query: string, title: string): number {
197
211
  return (wordMatches / nonYearWords.length) * 80;
198
212
  }
199
213
 
200
- function extractYear(query: string): number | null {
214
+ export function extractYear(query: string): number | null {
201
215
  const match = query.match(/\b(19|20)\d{2}\b/);
202
216
  return match ? parseInt(match[0]) : null;
203
217
  }
204
218
 
205
- function generateTypoCorrections(word: string): string[] {
219
+ export function generateTypoCorrections(word: string): string[] {
206
220
  const corrections: string[] = [];
207
221
  const w = word.toLowerCase();
208
222
 
@@ -330,7 +344,7 @@ async function smartSearch(query: string): Promise<Movie[]> {
330
344
 
331
345
  // --- Download with WebTorrent (via Node.js subprocess) ---
332
346
 
333
- function formatBytes(bytes: number): string {
347
+ export function formatBytes(bytes: number): string {
334
348
  if (bytes === 0) return "0 B";
335
349
  const k = 1024;
336
350
  const sizes = ["B", "KB", "MB", "GB", "TB"];
@@ -338,11 +352,11 @@ function formatBytes(bytes: number): string {
338
352
  return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
339
353
  }
340
354
 
341
- function formatSpeed(bytesPerSec: number): string {
355
+ export function formatSpeed(bytesPerSec: number): string {
342
356
  return `${formatBytes(bytesPerSec)}/s`;
343
357
  }
344
358
 
345
- function formatEta(ms: number): string {
359
+ export function formatEta(ms: number): string {
346
360
  const seconds = ms / 1000;
347
361
  if (!seconds || !isFinite(seconds)) return "--:--";
348
362
  const h = Math.floor(seconds / 3600);
@@ -352,75 +366,67 @@ function formatEta(ms: number): string {
352
366
  return `${m}m ${s}s`;
353
367
  }
354
368
 
355
- async function downloadTorrent(magnet: string, movieTitle: string, torrentInfo?: Torrent): Promise<void> {
356
- const scriptDir = dirname(fileURLToPath(import.meta.url));
357
- const helperPath = join(scriptDir, "download.mjs");
369
+ // --- Download Manager ---
358
370
 
359
- // Movie info box
360
- const infoLines = [
361
- `${chalk.bold("Title:")} ${chalk.white(movieTitle)}`,
362
- torrentInfo ? `${chalk.bold("Quality:")} ${chalk.cyan(torrentInfo.quality)} ${chalk.dim(torrentInfo.type)}` : "",
363
- torrentInfo ? `${chalk.bold("Size:")} ${torrentInfo.size}` : "",
364
- torrentInfo ? `${chalk.bold("Codec:")} ${chalk.dim(`${torrentInfo.video_codec} ${torrentInfo.audio_channels}ch`)}` : "",
365
- `${chalk.bold("Save to:")} ${chalk.dim(DOWNLOAD_DIR)}`,
366
- ].filter(Boolean).join("\n");
367
- console.log(boxen(infoLines, {
368
- title: chalk.bold(" Download "),
369
- titleAlignment: "left",
370
- borderStyle: "round",
371
- borderColor: "green",
372
- padding: { top: 0, bottom: 0, left: 1, right: 1 },
373
- }));
371
+ interface DownloadState {
372
+ id: string;
373
+ movieTitle: string;
374
+ quality: string;
375
+ status: "connecting" | "downloading" | "done" | "error" | "timeout";
376
+ progress: number;
377
+ downloaded: number;
378
+ total: number;
379
+ speed: number;
380
+ eta: number;
381
+ peers: number;
382
+ filePath?: string;
383
+ error?: string;
384
+ }
374
385
 
375
- console.log(chalk.dim(" Connecting to peers...\n"));
386
+ export class DownloadManager {
387
+ private downloads = new Map<string, DownloadState>();
388
+ private processes = new Map<string, ReturnType<typeof Bun.spawn>>();
389
+ private idCounter = 0;
390
+
391
+ startDownload(magnet: string, movieTitle: string, torrentInfo?: Torrent): string {
392
+ const id = String(++this.idCounter);
393
+ const scriptDir = dirname(fileURLToPath(import.meta.url));
394
+ const helperPath = join(scriptDir, "download.mjs");
395
+
396
+ const state: DownloadState = {
397
+ id,
398
+ movieTitle,
399
+ quality: torrentInfo?.quality || "unknown",
400
+ status: "connecting",
401
+ progress: 0,
402
+ downloaded: 0,
403
+ total: torrentInfo?.size_bytes || 0,
404
+ speed: 0,
405
+ eta: 0,
406
+ peers: 0,
407
+ };
408
+ this.downloads.set(id, state);
376
409
 
377
- return new Promise<void>((resolve) => {
378
410
  const child = Bun.spawn(["node", helperPath, magnet, DOWNLOAD_DIR], {
379
411
  stdout: "pipe",
380
412
  stderr: "inherit",
381
413
  });
414
+ this.processes.set(id, child);
415
+
416
+ // Start reading output in background (no await)
417
+ this.readOutput(id, child);
382
418
 
383
- const reader = child.stdout.getReader();
419
+ return id;
420
+ }
421
+
422
+ private async readOutput(id: string, child: ReturnType<typeof Bun.spawn>): Promise<void> {
423
+ const stdout = child.stdout as ReadableStream<Uint8Array>;
424
+ const reader = stdout.getReader();
384
425
  const decoder = new TextDecoder();
385
426
  let buffer = "";
386
- let totalSize = "";
387
- let progressLines = 0;
388
-
389
- function renderProgress(percent: string, downloaded: string, speed: string, eta: string, peers: number, progress: number) {
390
- const barWidth = 50;
391
- const filled = Math.round(progress * barWidth);
392
- const bar = chalk.green("█".repeat(filled)) + chalk.dim("░".repeat(barWidth - filled));
393
-
394
- const statsTable = new Table({
395
- style: { head: [], border: ["gray"], compact: true },
396
- colWidths: [16, 16, 14, 12],
397
- });
398
- statsTable.push([
399
- `${chalk.dim("Downloaded")} ${chalk.white(downloaded + "/" + totalSize)}`,
400
- `${chalk.dim("Speed")} ${chalk.cyan(speed)}`,
401
- `${chalk.dim("ETA")} ${chalk.white(eta)}`,
402
- `${chalk.dim("Peers")} ${chalk.white(String(peers))}`,
403
- ]);
404
-
405
- const content = ` ${bar} ${chalk.bold(percent + "%")}\n${statsTable.toString()}`;
406
- const frame = boxen(content, {
407
- title: chalk.bold(" Progress "),
408
- titleAlignment: "left",
409
- borderStyle: "round",
410
- borderColor: "cyan",
411
- dimBorder: true,
412
- padding: { top: 0, bottom: 0, left: 0, right: 0 },
413
- });
414
-
415
- // Clear previous progress frame
416
- if (progressLines > 0) {
417
- process.stdout.write(`\x1b[${progressLines}A\x1b[0J`);
418
- }
419
- process.stdout.write(frame + "\n");
420
- progressLines = frame.split("\n").length;
421
- }
427
+ const state = this.downloads.get(id)!;
422
428
 
423
- async function readOutput() {
429
+ try {
424
430
  while (true) {
425
431
  const { done, value } = await reader.read();
426
432
  if (done) break;
@@ -435,46 +441,98 @@ async function downloadTorrent(magnet: string, movieTitle: string, torrentInfo?:
435
441
  const msg = JSON.parse(line);
436
442
 
437
443
  if (msg.type === "meta") {
438
- totalSize = formatBytes(msg.size);
439
- console.log(chalk.green(` Downloading: ${msg.name}`));
440
- console.log(chalk.dim(` Size: ${totalSize}\n`));
444
+ state.total = msg.size;
445
+ state.status = "downloading";
441
446
  } else if (msg.type === "progress") {
442
- const percent = (msg.progress * 100).toFixed(1);
443
- const downloaded = formatBytes(msg.downloaded);
444
- const speed = formatSpeed(msg.speed);
445
- const eta = formatEta(msg.eta);
446
- renderProgress(percent, downloaded, speed, eta, msg.peers, msg.progress);
447
+ state.status = "downloading";
448
+ state.progress = msg.progress;
449
+ state.downloaded = msg.downloaded;
450
+ state.total = msg.total;
451
+ state.speed = msg.speed;
452
+ state.eta = msg.eta;
453
+ state.peers = msg.peers;
447
454
  } else if (msg.type === "done") {
448
- if (progressLines > 0) {
449
- process.stdout.write(`\x1b[${progressLines}A\x1b[0J`);
450
- }
451
- console.log(boxen(
452
- chalk.green.bold(" Download complete!") + "\n" + chalk.dim(` Saved to: ${msg.path}`),
453
- {
454
- borderStyle: "round",
455
- borderColor: "green",
456
- padding: { top: 0, bottom: 0, left: 0, right: 0 },
457
- }
458
- ));
455
+ state.status = "done";
456
+ state.progress = 1;
457
+ state.filePath = msg.path;
459
458
  } else if (msg.type === "error") {
460
- console.log(chalk.red(`\n Download error: ${msg.message}\n`));
459
+ state.status = "error";
460
+ state.error = msg.message;
461
461
  } else if (msg.type === "timeout") {
462
- console.log(chalk.yellow("\n Could not connect to peers. Try a torrent with more seeds.\n"));
462
+ state.status = "timeout";
463
+ state.error = "Could not connect to peers";
463
464
  }
464
465
  } catch {}
465
466
  }
466
467
  }
468
+ } catch {}
469
+
470
+ // Ensure terminal state if process ended without explicit done/error
471
+ await child.exited;
472
+ if (state.status === "connecting" || state.status === "downloading") {
473
+ state.status = "error";
474
+ state.error = "Process ended unexpectedly";
467
475
  }
476
+ this.processes.delete(id);
477
+ }
468
478
 
469
- readOutput().then(() => {
470
- child.exited.then(() => resolve());
471
- });
472
- });
479
+ getDownloads(): DownloadState[] {
480
+ return [...this.downloads.values()];
481
+ }
482
+
483
+ getActive(): DownloadState[] {
484
+ return this.getDownloads().filter((d) => d.status === "connecting" || d.status === "downloading");
485
+ }
486
+
487
+ cancelDownload(id: string): void {
488
+ const child = this.processes.get(id);
489
+ if (child) {
490
+ child.kill();
491
+ this.processes.delete(id);
492
+ }
493
+ const state = this.downloads.get(id);
494
+ if (state && (state.status === "connecting" || state.status === "downloading")) {
495
+ state.status = "error";
496
+ state.error = "Cancelled";
497
+ }
498
+ }
499
+
500
+ clearCompleted(): void {
501
+ for (const [id, state] of this.downloads) {
502
+ if (state.status === "done" || state.status === "error" || state.status === "timeout") {
503
+ this.downloads.delete(id);
504
+ }
505
+ }
506
+ }
507
+ }
508
+
509
+ const downloadManager = new DownloadManager();
510
+
511
+ async function downloadTorrent(magnet: string, movieTitle: string, torrentInfo?: Torrent): Promise<void> {
512
+ // Movie info box
513
+ const infoLines = [
514
+ `${chalk.bold("Title:")} ${chalk.white(movieTitle)}`,
515
+ torrentInfo ? `${chalk.bold("Quality:")} ${chalk.cyan(torrentInfo.quality)} ${chalk.dim(torrentInfo.type)}` : "",
516
+ torrentInfo ? `${chalk.bold("Size:")} ${torrentInfo.size}` : "",
517
+ torrentInfo ? `${chalk.bold("Codec:")} ${chalk.dim(`${torrentInfo.video_codec} ${torrentInfo.audio_channels}ch`)}` : "",
518
+ `${chalk.bold("Save to:")} ${chalk.dim(DOWNLOAD_DIR)}`,
519
+ ].filter(Boolean).join("\n");
520
+ console.log(boxen(infoLines, {
521
+ title: chalk.bold(" Download "),
522
+ titleAlignment: "left",
523
+ borderStyle: "round",
524
+ borderColor: "green",
525
+ padding: { top: 0, bottom: 0, left: 1, right: 1 },
526
+ }));
527
+
528
+ downloadManager.startDownload(magnet, movieTitle, torrentInfo);
529
+ console.log(chalk.green("\n Download started in background!"));
530
+ console.log(chalk.dim(" Check progress from the Downloads menu.\n"));
473
531
  }
474
532
 
475
533
  // --- Display Helpers ---
476
534
 
477
- function formatRuntime(minutes: number): string {
535
+ export function formatRuntime(minutes: number): string {
478
536
  if (!minutes) return "N/A";
479
537
  const h = Math.floor(minutes / 60);
480
538
  const m = minutes % 60;
@@ -525,9 +583,17 @@ function displayMovieTable(movies: Movie[]): void {
525
583
  console.log(table.toString());
526
584
  }
527
585
 
528
- function displayMovieDetail(movie: Movie): void {
586
+ async function displayMovieDetail(movie: Movie): Promise<void> {
529
587
  console.log();
530
588
 
589
+ // Poster image
590
+ if (movie.medium_cover_image) {
591
+ const poster = await fetchPosterImage(movie.medium_cover_image);
592
+ if (poster) {
593
+ console.log(poster);
594
+ }
595
+ }
596
+
531
597
  // Title bar
532
598
  const titleLine = chalk.bold.white(movie.title) + chalk.dim(` (${movie.year})`) + " " + chalk.dim(movie.imdb_code);
533
599
  console.log(boxen(titleLine, {
@@ -711,7 +777,7 @@ async function searchAction(): Promise<void> {
711
777
  }
712
778
 
713
779
  async function viewMovie(movie: Movie): Promise<void> {
714
- displayMovieDetail(movie);
780
+ await displayMovieDetail(movie);
715
781
 
716
782
  let viewing = true;
717
783
  while (viewing) {
@@ -867,6 +933,131 @@ async function paginatedList(
867
933
  }
868
934
  }
869
935
 
936
+ // --- Downloads View ---
937
+
938
+ function downloadStatusIcon(status: DownloadState["status"]): string {
939
+ switch (status) {
940
+ case "connecting": return chalk.yellow("◌");
941
+ case "downloading": return chalk.cyan("▼");
942
+ case "done": return chalk.green("✓");
943
+ case "error": return chalk.red("✗");
944
+ case "timeout": return chalk.yellow("⏱");
945
+ }
946
+ }
947
+
948
+ function downloadProgressBar(progress: number, width = 20): string {
949
+ const filled = Math.round(progress * width);
950
+ return chalk.green("█".repeat(filled)) + chalk.dim("░".repeat(width - filled));
951
+ }
952
+
953
+ async function viewDownloads(): Promise<void> {
954
+ let viewing = true;
955
+
956
+ while (viewing) {
957
+ const downloads = downloadManager.getDownloads();
958
+
959
+ if (!downloads.length) {
960
+ console.log(chalk.dim("\n No downloads yet.\n"));
961
+ return;
962
+ }
963
+
964
+ console.log();
965
+
966
+ const table = new Table({
967
+ head: [
968
+ chalk.dim("#"),
969
+ chalk.bold("Title"),
970
+ chalk.cyan("Quality"),
971
+ chalk.white("Progress"),
972
+ chalk.cyan("Speed"),
973
+ chalk.white("ETA"),
974
+ chalk.dim("Status"),
975
+ ],
976
+ colWidths: [5, 28, 10, 28, 12, 10, 12],
977
+ style: { head: [], border: ["gray"], compact: false },
978
+ wordWrap: true,
979
+ });
980
+
981
+ for (let i = 0; i < downloads.length; i++) {
982
+ const d = downloads[i]!;
983
+ const pct = (d.progress * 100).toFixed(1) + "%";
984
+ const progressCell = d.status === "downloading"
985
+ ? `${downloadProgressBar(d.progress)} ${chalk.bold(pct)}`
986
+ : d.status === "done"
987
+ ? `${downloadProgressBar(1)} ${chalk.bold("100%")}`
988
+ : chalk.dim("--");
989
+ const speedCell = d.status === "downloading" ? chalk.cyan(formatSpeed(d.speed)) : chalk.dim("--");
990
+ const etaCell = d.status === "downloading" ? formatEta(d.eta) : chalk.dim("--");
991
+ const statusCell = `${downloadStatusIcon(d.status)} ${d.status === "error" || d.status === "timeout" ? chalk.red(d.error || d.status) : d.status}`;
992
+
993
+ table.push([
994
+ chalk.dim(`${i + 1}`),
995
+ chalk.white(d.movieTitle),
996
+ chalk.cyan(d.quality),
997
+ progressCell,
998
+ speedCell,
999
+ etaCell,
1000
+ statusCell,
1001
+ ]);
1002
+ }
1003
+
1004
+ console.log(boxen(table.toString(), {
1005
+ title: chalk.bold(" Downloads "),
1006
+ titleAlignment: "left",
1007
+ borderStyle: "round",
1008
+ borderColor: "cyan",
1009
+ dimBorder: true,
1010
+ padding: { top: 0, bottom: 0, left: 0, right: 0 },
1011
+ }));
1012
+
1013
+ // Show file paths for completed downloads
1014
+ const completed = downloads.filter((d) => d.status === "done" && d.filePath);
1015
+ if (completed.length) {
1016
+ for (const d of completed) {
1017
+ console.log(chalk.dim(` ✓ ${d.movieTitle}: ${d.filePath}`));
1018
+ }
1019
+ console.log();
1020
+ }
1021
+
1022
+ const choices: any[] = [];
1023
+
1024
+ const active = downloadManager.getActive();
1025
+ if (active.length) {
1026
+ for (const d of active) {
1027
+ choices.push({
1028
+ name: `Cancel: ${d.movieTitle} (${d.quality})`,
1029
+ value: `cancel_${d.id}`,
1030
+ });
1031
+ }
1032
+ }
1033
+
1034
+ const inactive = downloads.filter((d) => d.status === "done" || d.status === "error" || d.status === "timeout");
1035
+ if (inactive.length) {
1036
+ choices.push({ name: "Clear completed/failed", value: "clear" });
1037
+ }
1038
+
1039
+ choices.push({ name: "Refresh", value: "refresh" });
1040
+ choices.push({ name: "Back to menu", value: "back" });
1041
+
1042
+ const { action } = await inquirer.prompt([
1043
+ { type: "list", name: "action", message: "Action:", choices },
1044
+ ]);
1045
+
1046
+ if (action === "back") {
1047
+ viewing = false;
1048
+ } else if (action === "clear") {
1049
+ downloadManager.clearCompleted();
1050
+ console.log(chalk.dim(" Cleared.\n"));
1051
+ } else if (action === "refresh") {
1052
+ // loop continues, will re-render
1053
+ } else if (action.startsWith("cancel_")) {
1054
+ const id = action.slice(7);
1055
+ downloadManager.cancelDownload(id);
1056
+ console.log(chalk.yellow(" Download cancelled.\n"));
1057
+ }
1058
+ }
1059
+ }
1060
+
870
1061
  // --- SIGINT / Exit Handling ---
871
1062
 
872
1063
  function isExitPromptError(err: unknown): boolean {
@@ -890,6 +1081,11 @@ async function main(): Promise<void> {
890
1081
 
891
1082
  while (running) {
892
1083
  try {
1084
+ const activeCount = downloadManager.getActive().length;
1085
+ const downloadsLabel = activeCount > 0
1086
+ ? `Downloads (${activeCount} active)`
1087
+ : "Downloads";
1088
+
893
1089
  const { action } = await inquirer.prompt([
894
1090
  {
895
1091
  type: "list",
@@ -900,6 +1096,7 @@ async function main(): Promise<void> {
900
1096
  { name: "Browse movies", value: "browse" },
901
1097
  { name: "Trending now", value: "trending" },
902
1098
  { name: "Top rated", value: "top" },
1099
+ { name: downloadsLabel, value: "downloads" },
903
1100
  { name: "Exit", value: "exit" },
904
1101
  ],
905
1102
  },
@@ -918,6 +1115,9 @@ async function main(): Promise<void> {
918
1115
  case "top":
919
1116
  await paginatedList((p) => listMovies(p, { sort_by: "rating", order_by: "desc", minimum_rating: 7 }), "Top Rated");
920
1117
  break;
1118
+ case "downloads":
1119
+ await viewDownloads();
1120
+ break;
921
1121
  case "exit":
922
1122
  exitGracefully();
923
1123
  }
@@ -928,8 +1128,10 @@ async function main(): Promise<void> {
928
1128
  }
929
1129
  }
930
1130
 
931
- main().catch((err) => {
932
- if (isExitPromptError(err)) exitGracefully();
933
- console.error(err);
934
- process.exit(1);
935
- });
1131
+ if (import.meta.main) {
1132
+ main().catch((err) => {
1133
+ if (isExitPromptError(err)) exitGracefully();
1134
+ console.error(err);
1135
+ process.exit(1);
1136
+ });
1137
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "movizone",
3
- "version": "1.0.4",
3
+ "version": "1.1.0",
4
4
  "description": "Movie torrent explorer CLI with fuzzy search and in-terminal downloads",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,6 +13,7 @@
13
13
  ],
14
14
  "scripts": {
15
15
  "start": "bun run index.ts",
16
+ "test": "bun test",
16
17
  "typecheck": "bun x tsc --noEmit"
17
18
  },
18
19
  "keywords": [
@@ -36,15 +37,15 @@
36
37
  "cli-table3": "^0.6.5",
37
38
  "figlet": "^1.10.0",
38
39
  "gradient-string": "^3.0.0",
39
- "inquirer": "12",
40
+ "inquirer": "9",
40
41
  "ora": "8",
41
- "string-width": "^8.2.0",
42
- "webtorrent": "^2.8.5",
43
- "wrap-ansi": "^10.0.0"
42
+ "terminal-image": "^4.2.0",
43
+ "webtorrent": "^2.8.5"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/bun": "latest",
47
47
  "@types/figlet": "^1.7.0",
48
+ "@types/inquirer": "^9.0.9",
48
49
  "typescript": "^5"
49
50
  },
50
51
  "trustedDependencies": [