movizone 1.0.1
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 +105 -0
- package/download.mjs +70 -0
- package/index.ts +935 -0
- package/package.json +42 -0
package/README.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Movizon
|
|
2
|
+
|
|
3
|
+
A movie torrent explorer CLI. Search, browse, and download movies directly in the terminal.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Fuzzy search** - handles typos like "zootobia" -> Zootopia, "incpetion" -> Inception
|
|
8
|
+
- **In-terminal downloads** - download via WebTorrent with a live progress bar, no torrent client needed
|
|
9
|
+
- **Browse** - sort by trending, rating, seeds, year, or date added with genre filters
|
|
10
|
+
- **Movie details** - rating, runtime, genres, synopsis, trailer link, torrent info
|
|
11
|
+
- **Copy magnet links** - to clipboard for use in external clients
|
|
12
|
+
- **Similar movies** - discover related films
|
|
13
|
+
|
|
14
|
+
## Requirements
|
|
15
|
+
|
|
16
|
+
- [Bun](https://bun.sh) v1.0+
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# Clone and install
|
|
22
|
+
git clone https://github.com/alilibx/movizon.git
|
|
23
|
+
cd movizon
|
|
24
|
+
bun install
|
|
25
|
+
|
|
26
|
+
# Link globally
|
|
27
|
+
bun link
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Or run directly:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
bun run index.ts
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
$ movizon
|
|
40
|
+
|
|
41
|
+
Movizon — Movie Torrent Explorer
|
|
42
|
+
────────────────────────────────────────
|
|
43
|
+
Downloads: ~/Downloads/Movizon
|
|
44
|
+
|
|
45
|
+
? What do you want to do?
|
|
46
|
+
> Search movies
|
|
47
|
+
Browse movies
|
|
48
|
+
Trending now
|
|
49
|
+
Top rated
|
|
50
|
+
Exit
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Search
|
|
54
|
+
|
|
55
|
+
Search handles typos and misspellings automatically:
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
? Search movies: zootobia 2 2025
|
|
59
|
+
|
|
60
|
+
Results for "zootobia 2 2025" · 2 found
|
|
61
|
+
|
|
62
|
+
1. Zootopia 2 (2025) ★ 6.6 720p ↑42 Animation, Adventure, Comedy
|
|
63
|
+
2. Zootopia (2016) ★ 8 1080p ↑95 Animation, Adventure, Comedy
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Download
|
|
67
|
+
|
|
68
|
+
Select a movie and choose a quality to download directly in the terminal:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
? Action: Download 1080p (1.85 GB, ↑100)
|
|
72
|
+
|
|
73
|
+
Saving to: ~/Downloads/Movizon
|
|
74
|
+
Downloading: Inception.2010.1080p.BluRay.x264.mp4
|
|
75
|
+
Size: 1.85 GB
|
|
76
|
+
|
|
77
|
+
████████████░░░░░░░░░░░░░░░░░░ 40.2% 756MB/1.85GB 2.3MB/s ETA 8m 12s 15 peers
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Browse
|
|
81
|
+
|
|
82
|
+
Filter by genre and sort order:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
? Sort by: Rating
|
|
86
|
+
? Genre: Sci-Fi
|
|
87
|
+
|
|
88
|
+
Browse · Page 1 · 4,521 total
|
|
89
|
+
|
|
90
|
+
1. Inception (2010) ★ 8.8 1080p ↑100 Action, Sci-Fi, Thriller
|
|
91
|
+
2. Interstellar (2014) ★ 8.7 2160p ↑100 Adventure, Drama, Sci-Fi
|
|
92
|
+
3. The Matrix (1999) ★ 8.7 1080p ↑100 Action, Sci-Fi
|
|
93
|
+
...
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## How It Works
|
|
97
|
+
|
|
98
|
+
- **Movie data** comes from the YTS API (73,000+ movies with torrent links)
|
|
99
|
+
- **Fuzzy search** uses edit-distance-1 corrections (transposes, similar-char substitutions) tried in parallel, with Levenshtein scoring to rank results
|
|
100
|
+
- **Downloads** use [WebTorrent](https://github.com/webtorrent/webtorrent) for peer-to-peer downloading directly in Node/Bun
|
|
101
|
+
- Movies are saved to `~/Downloads/Movizon/`
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
|
|
105
|
+
MIT
|
package/download.mjs
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Torrent download helper - runs under Node.js to avoid Bun's libuv limitations
|
|
3
|
+
import WebTorrent from "webtorrent";
|
|
4
|
+
import { mkdirSync, existsSync } from "fs";
|
|
5
|
+
|
|
6
|
+
const [,, magnet, downloadDir] = process.argv;
|
|
7
|
+
|
|
8
|
+
if (!magnet || !downloadDir) {
|
|
9
|
+
console.error(JSON.stringify({ type: "error", message: "Usage: download.mjs <magnet> <dir>" }));
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (!existsSync(downloadDir)) {
|
|
14
|
+
mkdirSync(downloadDir, { recursive: true });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const client = new WebTorrent();
|
|
18
|
+
|
|
19
|
+
function send(obj) {
|
|
20
|
+
process.stdout.write(JSON.stringify(obj) + "\n");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
client.add(magnet, { path: downloadDir }, (torrent) => {
|
|
24
|
+
send({
|
|
25
|
+
type: "meta",
|
|
26
|
+
name: torrent.name,
|
|
27
|
+
size: torrent.length,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const interval = setInterval(() => {
|
|
31
|
+
send({
|
|
32
|
+
type: "progress",
|
|
33
|
+
progress: torrent.progress,
|
|
34
|
+
downloaded: torrent.downloaded,
|
|
35
|
+
total: torrent.length,
|
|
36
|
+
speed: torrent.downloadSpeed,
|
|
37
|
+
eta: torrent.timeRemaining,
|
|
38
|
+
peers: torrent.numPeers,
|
|
39
|
+
});
|
|
40
|
+
}, 500);
|
|
41
|
+
|
|
42
|
+
torrent.on("done", () => {
|
|
43
|
+
clearInterval(interval);
|
|
44
|
+
send({ type: "done", path: `${downloadDir}/${torrent.name}` });
|
|
45
|
+
client.destroy();
|
|
46
|
+
process.exit(0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
torrent.on("error", (err) => {
|
|
50
|
+
clearInterval(interval);
|
|
51
|
+
send({ type: "error", message: err.message });
|
|
52
|
+
client.destroy();
|
|
53
|
+
process.exit(1);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
client.on("error", (err) => {
|
|
58
|
+
send({ type: "error", message: err.message });
|
|
59
|
+
client.destroy();
|
|
60
|
+
process.exit(1);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Timeout if no metadata after 30s
|
|
64
|
+
setTimeout(() => {
|
|
65
|
+
if (client.torrents.length === 0 || !client.torrents[0].ready) {
|
|
66
|
+
send({ type: "timeout" });
|
|
67
|
+
client.destroy();
|
|
68
|
+
process.exit(0);
|
|
69
|
+
}
|
|
70
|
+
}, 30000);
|
package/index.ts
ADDED
|
@@ -0,0 +1,935 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import inquirer from "inquirer";
|
|
4
|
+
import ora from "ora";
|
|
5
|
+
import boxen from "boxen";
|
|
6
|
+
import Table from "cli-table3";
|
|
7
|
+
import figlet from "figlet";
|
|
8
|
+
import gradient from "gradient-string";
|
|
9
|
+
import { homedir } from "os";
|
|
10
|
+
import { join, dirname } from "path";
|
|
11
|
+
import { createInterface } from "readline";
|
|
12
|
+
import { fileURLToPath } from "url";
|
|
13
|
+
|
|
14
|
+
// --- TUI Theme ---
|
|
15
|
+
|
|
16
|
+
const movizonGradient = gradient(["#ff00ff", "#00ffff"]);
|
|
17
|
+
|
|
18
|
+
function renderHeader(): string {
|
|
19
|
+
const ascii = figlet.textSync("MOVIZON", { font: "ANSI Shadow", horizontalLayout: "fitted" });
|
|
20
|
+
return boxen(movizonGradient.multiline(ascii) + "\n" + chalk.dim(" Movie Torrent Explorer"), {
|
|
21
|
+
padding: { top: 0, bottom: 0, left: 2, right: 2 },
|
|
22
|
+
borderStyle: "double",
|
|
23
|
+
borderColor: "magenta",
|
|
24
|
+
dimBorder: true,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function stripAnsi(str: string): string {
|
|
29
|
+
return str.replace(/\x1b\[[0-9;]*m/g, "");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function contextBar(left: string, right: string): string {
|
|
33
|
+
const width = Math.max(process.stdout.columns || 80, 60);
|
|
34
|
+
const innerWidth = width - 4; // boxen borders + padding
|
|
35
|
+
const visibleLeft = stripAnsi(left).length;
|
|
36
|
+
const visibleRight = stripAnsi(right).length;
|
|
37
|
+
const gap = innerWidth - visibleLeft - visibleRight;
|
|
38
|
+
const content = left + " ".repeat(Math.max(gap, 2)) + right;
|
|
39
|
+
return boxen(content, {
|
|
40
|
+
borderStyle: "single",
|
|
41
|
+
borderColor: "gray",
|
|
42
|
+
dimBorder: true,
|
|
43
|
+
padding: 0,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function navFooter(): string {
|
|
48
|
+
return boxen(
|
|
49
|
+
chalk.dim(" ↑↓ Navigate") + " " +
|
|
50
|
+
chalk.dim("⏎ Select") + " " +
|
|
51
|
+
chalk.dim("n Next") + " " +
|
|
52
|
+
chalk.dim("p Prev") + " " +
|
|
53
|
+
chalk.dim("b Back"),
|
|
54
|
+
{
|
|
55
|
+
borderStyle: "single",
|
|
56
|
+
borderColor: "gray",
|
|
57
|
+
dimBorder: true,
|
|
58
|
+
padding: 0,
|
|
59
|
+
}
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// --- API Layer ---
|
|
64
|
+
|
|
65
|
+
const API_BASE = "https://yts.torrentbay.st/api/v2";
|
|
66
|
+
const DOWNLOAD_DIR = join(homedir(), "Downloads", "Movizon");
|
|
67
|
+
|
|
68
|
+
const TRACKERS = [
|
|
69
|
+
"udp://open.demonii.com:1337/announce",
|
|
70
|
+
"udp://tracker.openbittorrent.com:80",
|
|
71
|
+
"udp://tracker.coppersurfer.tk:6969",
|
|
72
|
+
"udp://glotorrents.pw:6969/announce",
|
|
73
|
+
"udp://tracker.opentrackr.org:1337/announce",
|
|
74
|
+
"udp://torrent.gresille.org:80/announce",
|
|
75
|
+
"udp://p4p.arenabg.com:1337",
|
|
76
|
+
"udp://tracker.leechers-paradise.org:6969",
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
interface Torrent {
|
|
80
|
+
url: string;
|
|
81
|
+
hash: string;
|
|
82
|
+
quality: string;
|
|
83
|
+
type: string;
|
|
84
|
+
seeds: number;
|
|
85
|
+
peers: number;
|
|
86
|
+
size: string;
|
|
87
|
+
size_bytes: number;
|
|
88
|
+
video_codec: string;
|
|
89
|
+
bit_depth: string;
|
|
90
|
+
audio_channels: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface Movie {
|
|
94
|
+
id: number;
|
|
95
|
+
title: string;
|
|
96
|
+
title_long: string;
|
|
97
|
+
year: number;
|
|
98
|
+
rating: number;
|
|
99
|
+
runtime: number;
|
|
100
|
+
genres: string[];
|
|
101
|
+
summary: string;
|
|
102
|
+
language: string;
|
|
103
|
+
imdb_code: string;
|
|
104
|
+
yt_trailer_code: string;
|
|
105
|
+
small_cover_image: string;
|
|
106
|
+
medium_cover_image: string;
|
|
107
|
+
large_cover_image: string;
|
|
108
|
+
torrents: Torrent[];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface ListResponse {
|
|
112
|
+
status: string;
|
|
113
|
+
data: {
|
|
114
|
+
movie_count: number;
|
|
115
|
+
limit: number;
|
|
116
|
+
page_number: number;
|
|
117
|
+
movies: Movie[];
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function apiGet(endpoint: string, params: Record<string, any> = {}): Promise<any> {
|
|
122
|
+
const url = new URL(`${API_BASE}/${endpoint}`);
|
|
123
|
+
for (const [key, value] of Object.entries(params)) {
|
|
124
|
+
if (value !== undefined && value !== null && value !== "") {
|
|
125
|
+
url.searchParams.set(key, String(value));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const res = await fetch(url.toString());
|
|
130
|
+
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
|
131
|
+
return res.json();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function listMovies(page = 1, opts: Record<string, any> = {}): Promise<ListResponse> {
|
|
135
|
+
return apiGet("list_movies.json", { limit: 20, page, sort_by: "date_added", order_by: "desc", ...opts });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function searchMovies(query: string, page = 1): Promise<ListResponse> {
|
|
139
|
+
return apiGet("list_movies.json", { query_term: query, limit: 20, page });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function getMovieSuggestions(movieId: number): Promise<ListResponse> {
|
|
143
|
+
return apiGet("movie_suggestions.json", { movie_id: movieId });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function buildMagnet(hash: string, title: string): string {
|
|
147
|
+
const dn = encodeURIComponent(title);
|
|
148
|
+
const trackers = TRACKERS.map((t) => `&tr=${encodeURIComponent(t)}`).join("");
|
|
149
|
+
return `magnet:?xt=urn:btih:${hash}&dn=${dn}${trackers}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// --- Fuzzy Search ---
|
|
153
|
+
|
|
154
|
+
function levenshtein(a: string, b: string): number {
|
|
155
|
+
const m = a.length, n = b.length;
|
|
156
|
+
const dp: number[][] = Array.from({ length: m + 1 }, () => Array<number>(n + 1).fill(0));
|
|
157
|
+
for (let i = 0; i <= m; i++) dp[i]![0] = i;
|
|
158
|
+
for (let j = 0; j <= n; j++) dp[0]![j] = j;
|
|
159
|
+
for (let i = 1; i <= m; i++) {
|
|
160
|
+
for (let j = 1; j <= n; j++) {
|
|
161
|
+
dp[i]![j] = a[i - 1] === b[j - 1]
|
|
162
|
+
? dp[i - 1]![j - 1]!
|
|
163
|
+
: 1 + Math.min(dp[i - 1]![j]!, dp[i]![j - 1]!, dp[i - 1]![j - 1]!);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return dp[m]![n]!;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function fuzzyScore(query: string, title: string): number {
|
|
170
|
+
const q = query.toLowerCase().trim();
|
|
171
|
+
const t = title.toLowerCase().trim();
|
|
172
|
+
|
|
173
|
+
// Exact substring match is best
|
|
174
|
+
if (t.includes(q)) return 100;
|
|
175
|
+
|
|
176
|
+
// Check each query word against the title
|
|
177
|
+
const qWords = q.split(/\s+/);
|
|
178
|
+
const tWords = t.split(/\s+/);
|
|
179
|
+
|
|
180
|
+
let wordMatches = 0;
|
|
181
|
+
for (const qw of qWords) {
|
|
182
|
+
// Check if it's a year (4 digits)
|
|
183
|
+
if (/^\d{4}$/.test(qw)) continue; // year is handled separately
|
|
184
|
+
|
|
185
|
+
let bestDist = Infinity;
|
|
186
|
+
for (const tw of tWords) {
|
|
187
|
+
const dist = levenshtein(qw, tw);
|
|
188
|
+
bestDist = Math.min(bestDist, dist);
|
|
189
|
+
}
|
|
190
|
+
// Allow up to 2 edits per word for a match
|
|
191
|
+
if (bestDist <= 2) wordMatches++;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const nonYearWords = qWords.filter((w) => !/^\d{4}$/.test(w));
|
|
195
|
+
if (nonYearWords.length === 0) return 0;
|
|
196
|
+
|
|
197
|
+
return (wordMatches / nonYearWords.length) * 80;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function extractYear(query: string): number | null {
|
|
201
|
+
const match = query.match(/\b(19|20)\d{2}\b/);
|
|
202
|
+
return match ? parseInt(match[0]) : null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function generateTypoCorrections(word: string): string[] {
|
|
206
|
+
const corrections: string[] = [];
|
|
207
|
+
const w = word.toLowerCase();
|
|
208
|
+
|
|
209
|
+
// Priority 1: Transpose adjacent characters (most common typo)
|
|
210
|
+
for (let i = 0; i < w.length - 1; i++) {
|
|
211
|
+
corrections.push(w.slice(0, i) + w[i + 1] + w[i] + w.slice(i + 2));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Priority 2: Replace each character with nearby vowel/consonant
|
|
215
|
+
// Common substitutions: a<->e, i<->e, o<->a, b<->p, etc.
|
|
216
|
+
const similar: Record<string, string> = {
|
|
217
|
+
a: "eou", b: "pvd", c: "ks", d: "tbg", e: "iao", f: "vph",
|
|
218
|
+
g: "jkd", h: "g", i: "eya", j: "g", k: "cg", l: "r",
|
|
219
|
+
m: "n", n: "m", o: "aue", p: "b", q: "k", r: "l",
|
|
220
|
+
s: "czx", t: "d", u: "oi", v: "bf", w: "v", x: "sz",
|
|
221
|
+
y: "ie", z: "sx",
|
|
222
|
+
};
|
|
223
|
+
for (let i = 0; i < w.length; i++) {
|
|
224
|
+
const subs = similar[w[i]!] || "";
|
|
225
|
+
for (const c of subs) {
|
|
226
|
+
corrections.push(w.slice(0, i) + c + w.slice(i + 1));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Priority 3: Delete one character
|
|
231
|
+
for (let i = 0; i < w.length; i++) {
|
|
232
|
+
corrections.push(w.slice(0, i) + w.slice(i + 1));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Dedupe while preserving order
|
|
236
|
+
return [...new Set(corrections)];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function tryCorrections(words: string[], wordIndex: number, maxAttempts = 30): Promise<Movie[]> {
|
|
240
|
+
const corrections = generateTypoCorrections(words[wordIndex]!).slice(0, maxAttempts);
|
|
241
|
+
|
|
242
|
+
// Try in parallel batches of 10
|
|
243
|
+
for (let i = 0; i < corrections.length; i += 10) {
|
|
244
|
+
const batch = corrections.slice(i, i + 10);
|
|
245
|
+
const results = await Promise.all(
|
|
246
|
+
batch.map(async (correction) => {
|
|
247
|
+
const correctedWords = [...words];
|
|
248
|
+
correctedWords[wordIndex] = correction;
|
|
249
|
+
try {
|
|
250
|
+
const res = await searchMovies(correctedWords.join(" "));
|
|
251
|
+
return res.data.movies?.length ? res.data.movies : null;
|
|
252
|
+
} catch {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
})
|
|
256
|
+
);
|
|
257
|
+
const found = results.find((r) => r !== null);
|
|
258
|
+
if (found) return found;
|
|
259
|
+
}
|
|
260
|
+
return [];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function smartSearch(query: string): Promise<Movie[]> {
|
|
264
|
+
// First try exact API search
|
|
265
|
+
const exact = await searchMovies(query);
|
|
266
|
+
if (exact.data.movies?.length) return exact.data.movies;
|
|
267
|
+
|
|
268
|
+
// Extract year if present for filtering
|
|
269
|
+
const year = extractYear(query);
|
|
270
|
+
const queryWithoutYear = query.replace(/\b(19|20)\d{2}\b/, "").trim();
|
|
271
|
+
|
|
272
|
+
// Try searching without the year
|
|
273
|
+
if (queryWithoutYear !== query) {
|
|
274
|
+
const noYear = await searchMovies(queryWithoutYear);
|
|
275
|
+
if (noYear.data.movies?.length) {
|
|
276
|
+
if (year) {
|
|
277
|
+
const yearFiltered = noYear.data.movies.filter((m) => m.year === year);
|
|
278
|
+
if (yearFiltered.length) return yearFiltered;
|
|
279
|
+
}
|
|
280
|
+
return noYear.data.movies;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Try typo corrections - all words in parallel
|
|
285
|
+
const words = queryWithoutYear.split(/\s+/).filter((w) => w.length >= 3);
|
|
286
|
+
const allResults: Movie[] = [];
|
|
287
|
+
const seenIds = new Set<number>();
|
|
288
|
+
|
|
289
|
+
const correctionResults = await Promise.all(
|
|
290
|
+
words.slice(0, 3).map((_, wi) => tryCorrections(words, wi))
|
|
291
|
+
);
|
|
292
|
+
for (const results of correctionResults) {
|
|
293
|
+
for (const m of results) {
|
|
294
|
+
if (!seenIds.has(m.id)) {
|
|
295
|
+
seenIds.add(m.id);
|
|
296
|
+
allResults.push(m);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Fallback: try each word individually, sorted by rating for better results
|
|
302
|
+
if (!allResults.length) {
|
|
303
|
+
for (const word of words.slice(0, 3)) {
|
|
304
|
+
try {
|
|
305
|
+
const res = await listMovies(1, { query_term: word, sort_by: "rating", order_by: "desc" });
|
|
306
|
+
if (res.data.movies) {
|
|
307
|
+
for (const m of res.data.movies) {
|
|
308
|
+
if (!seenIds.has(m.id)) {
|
|
309
|
+
seenIds.add(m.id);
|
|
310
|
+
allResults.push(m);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
} catch {}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (!allResults.length) return [];
|
|
319
|
+
|
|
320
|
+
// Score and sort by fuzzy match + year bonus + rating tiebreaker
|
|
321
|
+
const scored = allResults
|
|
322
|
+
.map((m) => ({
|
|
323
|
+
movie: m,
|
|
324
|
+
score: fuzzyScore(query, m.title) + (year && m.year === year ? 20 : 0) + (m.rating || 0) * 0.5,
|
|
325
|
+
}))
|
|
326
|
+
.sort((a, b) => b.score - a.score);
|
|
327
|
+
|
|
328
|
+
return scored.slice(0, 20).map((s) => s.movie);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// --- Download with WebTorrent (via Node.js subprocess) ---
|
|
332
|
+
|
|
333
|
+
function formatBytes(bytes: number): string {
|
|
334
|
+
if (bytes === 0) return "0 B";
|
|
335
|
+
const k = 1024;
|
|
336
|
+
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
|
337
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
338
|
+
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function formatSpeed(bytesPerSec: number): string {
|
|
342
|
+
return `${formatBytes(bytesPerSec)}/s`;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function formatEta(ms: number): string {
|
|
346
|
+
const seconds = ms / 1000;
|
|
347
|
+
if (!seconds || !isFinite(seconds)) return "--:--";
|
|
348
|
+
const h = Math.floor(seconds / 3600);
|
|
349
|
+
const m = Math.floor((seconds % 3600) / 60);
|
|
350
|
+
const s = Math.floor(seconds % 60);
|
|
351
|
+
if (h > 0) return `${h}h ${m}m`;
|
|
352
|
+
return `${m}m ${s}s`;
|
|
353
|
+
}
|
|
354
|
+
|
|
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");
|
|
358
|
+
|
|
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
|
+
}));
|
|
374
|
+
|
|
375
|
+
console.log(chalk.dim(" Connecting to peers...\n"));
|
|
376
|
+
|
|
377
|
+
return new Promise<void>((resolve) => {
|
|
378
|
+
const child = Bun.spawn(["node", helperPath, magnet, DOWNLOAD_DIR], {
|
|
379
|
+
stdout: "pipe",
|
|
380
|
+
stderr: "inherit",
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
const reader = child.stdout.getReader();
|
|
384
|
+
const decoder = new TextDecoder();
|
|
385
|
+
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
|
+
}
|
|
422
|
+
|
|
423
|
+
async function readOutput() {
|
|
424
|
+
while (true) {
|
|
425
|
+
const { done, value } = await reader.read();
|
|
426
|
+
if (done) break;
|
|
427
|
+
|
|
428
|
+
buffer += decoder.decode(value, { stream: true });
|
|
429
|
+
const lines = buffer.split("\n");
|
|
430
|
+
buffer = lines.pop() || "";
|
|
431
|
+
|
|
432
|
+
for (const line of lines) {
|
|
433
|
+
if (!line.trim()) continue;
|
|
434
|
+
try {
|
|
435
|
+
const msg = JSON.parse(line);
|
|
436
|
+
|
|
437
|
+
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`));
|
|
441
|
+
} 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
|
+
} 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
|
+
));
|
|
459
|
+
} else if (msg.type === "error") {
|
|
460
|
+
console.log(chalk.red(`\n Download error: ${msg.message}\n`));
|
|
461
|
+
} else if (msg.type === "timeout") {
|
|
462
|
+
console.log(chalk.yellow("\n Could not connect to peers. Try a torrent with more seeds.\n"));
|
|
463
|
+
}
|
|
464
|
+
} catch {}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
readOutput().then(() => {
|
|
470
|
+
child.exited.then(() => resolve());
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// --- Display Helpers ---
|
|
476
|
+
|
|
477
|
+
function formatRuntime(minutes: number): string {
|
|
478
|
+
if (!minutes) return "N/A";
|
|
479
|
+
const h = Math.floor(minutes / 60);
|
|
480
|
+
const m = minutes % 60;
|
|
481
|
+
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function healthColor(seeds: number): (text: string) => string {
|
|
485
|
+
if (seeds >= 50) return chalk.green;
|
|
486
|
+
if (seeds >= 10) return chalk.yellow;
|
|
487
|
+
return chalk.red;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function displayMovieTable(movies: Movie[]): void {
|
|
491
|
+
const table = new Table({
|
|
492
|
+
head: [
|
|
493
|
+
chalk.dim("#"),
|
|
494
|
+
chalk.bold("Title"),
|
|
495
|
+
chalk.dim("Year"),
|
|
496
|
+
chalk.yellow("Rating"),
|
|
497
|
+
chalk.cyan("Quality"),
|
|
498
|
+
chalk.green("Seeds"),
|
|
499
|
+
chalk.dim("Genre"),
|
|
500
|
+
],
|
|
501
|
+
colWidths: [5, 32, 7, 9, 9, 8, 22],
|
|
502
|
+
style: { head: [], border: ["gray"], compact: false },
|
|
503
|
+
wordWrap: true,
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
for (let i = 0; i < movies.length; i++) {
|
|
507
|
+
const m = movies[i]!;
|
|
508
|
+
const rating = m.rating ? chalk.yellow(`★ ${m.rating}`) : chalk.dim("--");
|
|
509
|
+
const genres = m.genres?.slice(0, 2).join(", ") || "N/A";
|
|
510
|
+
const bestTorrent = m.torrents?.reduce((best, t) => (t.seeds > (best?.seeds || 0) ? t : best), m.torrents[0]);
|
|
511
|
+
const seeds = bestTorrent ? healthColor(bestTorrent.seeds)(`↑${bestTorrent.seeds}`) : chalk.dim("--");
|
|
512
|
+
const quality = bestTorrent ? chalk.cyan(bestTorrent.quality) : chalk.dim("--");
|
|
513
|
+
|
|
514
|
+
table.push([
|
|
515
|
+
chalk.dim(`${i + 1}`),
|
|
516
|
+
chalk.bold.white(m.title),
|
|
517
|
+
chalk.dim(`${m.year}`),
|
|
518
|
+
rating,
|
|
519
|
+
quality,
|
|
520
|
+
seeds,
|
|
521
|
+
chalk.dim(genres),
|
|
522
|
+
]);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
console.log(table.toString());
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function displayMovieDetail(movie: Movie): void {
|
|
529
|
+
console.log();
|
|
530
|
+
|
|
531
|
+
// Title bar
|
|
532
|
+
const titleLine = chalk.bold.white(movie.title) + chalk.dim(` (${movie.year})`) + " " + chalk.dim(movie.imdb_code);
|
|
533
|
+
console.log(boxen(titleLine, {
|
|
534
|
+
borderStyle: "double",
|
|
535
|
+
borderColor: "magenta",
|
|
536
|
+
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
|
537
|
+
}));
|
|
538
|
+
|
|
539
|
+
// Info row
|
|
540
|
+
const ratingVal = movie.rating || 0;
|
|
541
|
+
const ratingBarFull = Math.round(ratingVal);
|
|
542
|
+
const ratingBar = chalk.yellow("█".repeat(ratingBarFull)) + chalk.dim("░".repeat(10 - ratingBarFull));
|
|
543
|
+
const infoTable = new Table({
|
|
544
|
+
style: { head: [], border: ["gray"], compact: true },
|
|
545
|
+
colWidths: [22, 12, 10, 30],
|
|
546
|
+
});
|
|
547
|
+
infoTable.push([
|
|
548
|
+
`${chalk.yellow(`★ ${ratingVal}`)} ${ratingBar}`,
|
|
549
|
+
chalk.white(formatRuntime(movie.runtime)),
|
|
550
|
+
chalk.white(movie.language?.toUpperCase() || "EN"),
|
|
551
|
+
chalk.dim(movie.genres?.join(", ") || "N/A"),
|
|
552
|
+
]);
|
|
553
|
+
console.log(infoTable.toString());
|
|
554
|
+
|
|
555
|
+
if (movie.yt_trailer_code) {
|
|
556
|
+
console.log(chalk.dim(` Trailer: https://youtube.com/watch?v=${movie.yt_trailer_code}`));
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Synopsis
|
|
560
|
+
if (movie.summary) {
|
|
561
|
+
const wrapWidth = 68;
|
|
562
|
+
const words = movie.summary.split(" ");
|
|
563
|
+
const lines: string[] = [];
|
|
564
|
+
let line = "";
|
|
565
|
+
for (const word of words) {
|
|
566
|
+
if ((line + " " + word).length > wrapWidth) {
|
|
567
|
+
lines.push(line);
|
|
568
|
+
line = word;
|
|
569
|
+
} else {
|
|
570
|
+
line = line ? line + " " + word : word;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
if (line) lines.push(line);
|
|
574
|
+
const synopsisText = lines.slice(0, 6).join("\n") + (lines.length > 6 ? chalk.dim("\n...") : "");
|
|
575
|
+
console.log(boxen(synopsisText, {
|
|
576
|
+
title: chalk.bold(" Synopsis "),
|
|
577
|
+
titleAlignment: "left",
|
|
578
|
+
borderStyle: "round",
|
|
579
|
+
borderColor: "gray",
|
|
580
|
+
dimBorder: true,
|
|
581
|
+
padding: { top: 0, bottom: 0, left: 1, right: 1 },
|
|
582
|
+
}));
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Torrents
|
|
586
|
+
if (movie.torrents?.length) {
|
|
587
|
+
const torrentTable = new Table({
|
|
588
|
+
head: [
|
|
589
|
+
chalk.cyan("Quality"),
|
|
590
|
+
chalk.dim("Type"),
|
|
591
|
+
chalk.white("Size"),
|
|
592
|
+
chalk.green("Seeds"),
|
|
593
|
+
chalk.red("Peers"),
|
|
594
|
+
chalk.dim("Codec"),
|
|
595
|
+
chalk.dim("Audio"),
|
|
596
|
+
],
|
|
597
|
+
style: { head: [], border: ["gray"], compact: false },
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
for (const t of movie.torrents) {
|
|
601
|
+
torrentTable.push([
|
|
602
|
+
chalk.cyan.bold(t.quality),
|
|
603
|
+
chalk.dim(t.type),
|
|
604
|
+
t.size,
|
|
605
|
+
healthColor(t.seeds)(`↑${t.seeds}`),
|
|
606
|
+
chalk.dim(`↓${t.peers}`),
|
|
607
|
+
chalk.dim(t.video_codec || "--"),
|
|
608
|
+
chalk.dim(t.audio_channels ? `${t.audio_channels}ch` : "--"),
|
|
609
|
+
]);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
console.log(boxen(torrentTable.toString(), {
|
|
613
|
+
title: chalk.bold(" Torrents "),
|
|
614
|
+
titleAlignment: "left",
|
|
615
|
+
borderStyle: "round",
|
|
616
|
+
borderColor: "cyan",
|
|
617
|
+
dimBorder: true,
|
|
618
|
+
padding: { top: 0, bottom: 0, left: 0, right: 0 },
|
|
619
|
+
}));
|
|
620
|
+
} else {
|
|
621
|
+
console.log(chalk.dim(" No torrents available"));
|
|
622
|
+
}
|
|
623
|
+
console.log();
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// --- Menu Actions ---
|
|
627
|
+
|
|
628
|
+
const SORT_OPTIONS = [
|
|
629
|
+
{ name: "Date Added", value: "date_added" },
|
|
630
|
+
{ name: "Trending", value: "like_count" },
|
|
631
|
+
{ name: "Rating", value: "rating" },
|
|
632
|
+
{ name: "Seeds", value: "seeds" },
|
|
633
|
+
{ name: "Year", value: "year" },
|
|
634
|
+
{ name: "Title", value: "title" },
|
|
635
|
+
];
|
|
636
|
+
|
|
637
|
+
const GENRE_OPTIONS = [
|
|
638
|
+
"all", "action", "adventure", "animation", "biography", "comedy", "crime",
|
|
639
|
+
"documentary", "drama", "family", "fantasy", "history", "horror", "music",
|
|
640
|
+
"mystery", "romance", "sci-fi", "sport", "thriller", "war", "western",
|
|
641
|
+
];
|
|
642
|
+
|
|
643
|
+
async function browseMovies(): Promise<void> {
|
|
644
|
+
const { sortBy } = await inquirer.prompt([
|
|
645
|
+
{ type: "list", name: "sortBy", message: "Sort by:", choices: SORT_OPTIONS },
|
|
646
|
+
]);
|
|
647
|
+
|
|
648
|
+
const { genre } = await inquirer.prompt([
|
|
649
|
+
{
|
|
650
|
+
type: "list",
|
|
651
|
+
name: "genre",
|
|
652
|
+
message: "Genre:",
|
|
653
|
+
choices: GENRE_OPTIONS.map((g) => ({
|
|
654
|
+
name: g === "all" ? "All Genres" : g.charAt(0).toUpperCase() + g.slice(1),
|
|
655
|
+
value: g,
|
|
656
|
+
})),
|
|
657
|
+
},
|
|
658
|
+
]);
|
|
659
|
+
|
|
660
|
+
await paginatedList(
|
|
661
|
+
(page) => {
|
|
662
|
+
const params: Record<string, any> = { sort_by: sortBy, order_by: "desc" };
|
|
663
|
+
if (genre !== "all") params.genre = genre;
|
|
664
|
+
return listMovies(page, params);
|
|
665
|
+
},
|
|
666
|
+
"Browse",
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async function searchAction(): Promise<void> {
|
|
671
|
+
const { query } = await inquirer.prompt([
|
|
672
|
+
{ type: "input", name: "query", message: "Search movies:" },
|
|
673
|
+
]);
|
|
674
|
+
|
|
675
|
+
if (!query.trim()) return;
|
|
676
|
+
|
|
677
|
+
const spinner = ora("Searching...").start();
|
|
678
|
+
try {
|
|
679
|
+
const movies = await smartSearch(query.trim());
|
|
680
|
+
spinner.stop();
|
|
681
|
+
|
|
682
|
+
if (!movies.length) {
|
|
683
|
+
console.log(chalk.yellow(`\n No results for "${query}".\n`));
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
console.log();
|
|
688
|
+
console.log(contextBar(chalk.bold.magenta("MOVIZON"), chalk.dim(`Search: "${query}" · ${movies.length} found`)));
|
|
689
|
+
displayMovieTable(movies);
|
|
690
|
+
console.log(navFooter());
|
|
691
|
+
|
|
692
|
+
const choices: any[] = movies.map((m, i) => ({
|
|
693
|
+
name: `${i + 1}. ${m.title} (${m.year})`,
|
|
694
|
+
value: `movie_${i}`,
|
|
695
|
+
}));
|
|
696
|
+
choices.push({ name: "↩ Back to menu", value: "back" });
|
|
697
|
+
|
|
698
|
+
const { action } = await inquirer.prompt([
|
|
699
|
+
{ type: "list", name: "action", message: "Select:", choices, pageSize: 25 },
|
|
700
|
+
]);
|
|
701
|
+
|
|
702
|
+
if (action.startsWith("movie_")) {
|
|
703
|
+
const idx = parseInt(action.split("_")[1]);
|
|
704
|
+
await viewMovie(movies[idx]!);
|
|
705
|
+
}
|
|
706
|
+
} catch (err: any) {
|
|
707
|
+
spinner.stop();
|
|
708
|
+
if (isExitPromptError(err)) throw err;
|
|
709
|
+
console.log(chalk.red(`\n Error: ${err.message}\n`));
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
async function viewMovie(movie: Movie): Promise<void> {
|
|
714
|
+
displayMovieDetail(movie);
|
|
715
|
+
|
|
716
|
+
let viewing = true;
|
|
717
|
+
while (viewing) {
|
|
718
|
+
const choices: any[] = [];
|
|
719
|
+
|
|
720
|
+
if (movie.torrents?.length) {
|
|
721
|
+
for (const t of movie.torrents) {
|
|
722
|
+
choices.push({
|
|
723
|
+
name: `Download ${t.quality} (${t.size}, ↑${t.seeds})`,
|
|
724
|
+
value: `dl_${t.hash}`,
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
choices.push({ name: "Copy magnet link", value: "magnet" });
|
|
730
|
+
choices.push({ name: "Similar movies", value: "similar" });
|
|
731
|
+
choices.push({ name: "Back", value: "back" });
|
|
732
|
+
|
|
733
|
+
const { action } = await inquirer.prompt([
|
|
734
|
+
{ type: "list", name: "action", message: "Action:", choices },
|
|
735
|
+
]);
|
|
736
|
+
|
|
737
|
+
if (action === "back") {
|
|
738
|
+
viewing = false;
|
|
739
|
+
} else if (action === "similar") {
|
|
740
|
+
await showSimilar(movie);
|
|
741
|
+
} else if (action === "magnet") {
|
|
742
|
+
await selectTorrentAndCopyMagnet(movie);
|
|
743
|
+
} else if (action.startsWith("dl_")) {
|
|
744
|
+
const hash = action.slice(3);
|
|
745
|
+
const torrent = movie.torrents?.find((t) => t.hash === hash);
|
|
746
|
+
const magnet = buildMagnet(hash, movie.title);
|
|
747
|
+
await downloadTorrent(magnet, movie.title, torrent);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
async function selectTorrentAndCopyMagnet(movie: Movie): Promise<void> {
|
|
753
|
+
if (!movie.torrents?.length) {
|
|
754
|
+
console.log(chalk.yellow("\n No torrents available.\n"));
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const { torrent } = await inquirer.prompt([
|
|
759
|
+
{
|
|
760
|
+
type: "list",
|
|
761
|
+
name: "torrent",
|
|
762
|
+
message: "Select quality:",
|
|
763
|
+
choices: movie.torrents.map((t) => ({
|
|
764
|
+
name: `${t.quality} · ${t.size} · ↑${t.seeds} ↓${t.peers}`,
|
|
765
|
+
value: t,
|
|
766
|
+
})),
|
|
767
|
+
},
|
|
768
|
+
]);
|
|
769
|
+
|
|
770
|
+
const magnet = buildMagnet(torrent.hash, movie.title);
|
|
771
|
+
|
|
772
|
+
const proc = Bun.spawn(["pbcopy"], { stdin: "pipe" });
|
|
773
|
+
proc.stdin.write(magnet);
|
|
774
|
+
proc.stdin.end();
|
|
775
|
+
await proc.exited;
|
|
776
|
+
|
|
777
|
+
console.log(chalk.green("\n Magnet link copied to clipboard!\n"));
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
async function showSimilar(movie: Movie): Promise<void> {
|
|
781
|
+
const spinner = ora("Finding similar movies...").start();
|
|
782
|
+
try {
|
|
783
|
+
const res = await getMovieSuggestions(movie.id);
|
|
784
|
+
spinner.stop();
|
|
785
|
+
|
|
786
|
+
if (!res.data.movies?.length) {
|
|
787
|
+
console.log(chalk.yellow("\n No suggestions found.\n"));
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
console.log();
|
|
792
|
+
console.log(contextBar(chalk.bold.magenta("MOVIZON"), chalk.dim(`Similar to "${movie.title}"`)));
|
|
793
|
+
displayMovieTable(res.data.movies);
|
|
794
|
+
console.log(navFooter());
|
|
795
|
+
|
|
796
|
+
const choices: any[] = res.data.movies.map((m, i) => ({
|
|
797
|
+
name: `${i + 1}. ${m.title} (${m.year})`,
|
|
798
|
+
value: `movie_${i}`,
|
|
799
|
+
}));
|
|
800
|
+
choices.push({ name: "Back", value: "back" });
|
|
801
|
+
|
|
802
|
+
const { action } = await inquirer.prompt([
|
|
803
|
+
{ type: "list", name: "action", message: "Select:", choices },
|
|
804
|
+
]);
|
|
805
|
+
|
|
806
|
+
if (action.startsWith("movie_")) {
|
|
807
|
+
const idx = parseInt(action.split("_")[1]);
|
|
808
|
+
await viewMovie(res.data.movies[idx]!);
|
|
809
|
+
}
|
|
810
|
+
} catch (err: any) {
|
|
811
|
+
spinner.stop();
|
|
812
|
+
if (isExitPromptError(err)) throw err;
|
|
813
|
+
console.log(chalk.red(`\n Error: ${err.message}\n`));
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// --- Shared Paginated List ---
|
|
818
|
+
|
|
819
|
+
async function paginatedList(
|
|
820
|
+
fetcher: (page: number) => Promise<ListResponse>,
|
|
821
|
+
label: string,
|
|
822
|
+
): Promise<void> {
|
|
823
|
+
let page = 1;
|
|
824
|
+
let browsing = true;
|
|
825
|
+
|
|
826
|
+
while (browsing) {
|
|
827
|
+
const spinner = ora("Loading...").start();
|
|
828
|
+
try {
|
|
829
|
+
const res = await fetcher(page);
|
|
830
|
+
spinner.stop();
|
|
831
|
+
|
|
832
|
+
if (!res.data.movies?.length) {
|
|
833
|
+
console.log(chalk.yellow("\n No movies found.\n"));
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
console.log();
|
|
838
|
+
console.log(contextBar(chalk.bold.magenta("MOVIZON"), chalk.dim(`${label} · Page ${page} · ${res.data.movie_count.toLocaleString()} total`)));
|
|
839
|
+
displayMovieTable(res.data.movies);
|
|
840
|
+
console.log(navFooter());
|
|
841
|
+
|
|
842
|
+
const choices: any[] = res.data.movies.map((m, i) => ({
|
|
843
|
+
name: `${i + 1}. ${m.title} (${m.year})`,
|
|
844
|
+
value: `movie_${i}`,
|
|
845
|
+
}));
|
|
846
|
+
if (page > 1) choices.push({ name: "Previous page", value: "prev" });
|
|
847
|
+
if (res.data.movies.length === 20) choices.push({ name: "Next page", value: "next" });
|
|
848
|
+
choices.push({ name: "Back to menu", value: "back" });
|
|
849
|
+
|
|
850
|
+
const { action } = await inquirer.prompt([
|
|
851
|
+
{ type: "list", name: "action", message: "Select:", choices, pageSize: 25 },
|
|
852
|
+
]);
|
|
853
|
+
|
|
854
|
+
if (action === "back") browsing = false;
|
|
855
|
+
else if (action === "next") page++;
|
|
856
|
+
else if (action === "prev") page--;
|
|
857
|
+
else if (action.startsWith("movie_")) {
|
|
858
|
+
const idx = parseInt(action.split("_")[1]);
|
|
859
|
+
await viewMovie(res.data.movies[idx]!);
|
|
860
|
+
}
|
|
861
|
+
} catch (err: any) {
|
|
862
|
+
spinner.stop();
|
|
863
|
+
if (isExitPromptError(err)) throw err;
|
|
864
|
+
console.log(chalk.red(`\n Error: ${err.message}\n`));
|
|
865
|
+
browsing = false;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// --- SIGINT / Exit Handling ---
|
|
871
|
+
|
|
872
|
+
function isExitPromptError(err: unknown): boolean {
|
|
873
|
+
return err instanceof Error && err.name === "ExitPromptError";
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function exitGracefully(): never {
|
|
877
|
+
console.log(chalk.dim("\n Bye!\n"));
|
|
878
|
+
process.exit(0);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// --- Main ---
|
|
882
|
+
|
|
883
|
+
async function main(): Promise<void> {
|
|
884
|
+
console.log();
|
|
885
|
+
console.log(renderHeader());
|
|
886
|
+
console.log(chalk.dim(` Downloads: ${DOWNLOAD_DIR}`));
|
|
887
|
+
console.log();
|
|
888
|
+
|
|
889
|
+
let running = true;
|
|
890
|
+
|
|
891
|
+
while (running) {
|
|
892
|
+
try {
|
|
893
|
+
const { action } = await inquirer.prompt([
|
|
894
|
+
{
|
|
895
|
+
type: "list",
|
|
896
|
+
name: "action",
|
|
897
|
+
message: "What do you want to do?",
|
|
898
|
+
choices: [
|
|
899
|
+
{ name: "Search movies", value: "search" },
|
|
900
|
+
{ name: "Browse movies", value: "browse" },
|
|
901
|
+
{ name: "Trending now", value: "trending" },
|
|
902
|
+
{ name: "Top rated", value: "top" },
|
|
903
|
+
{ name: "Exit", value: "exit" },
|
|
904
|
+
],
|
|
905
|
+
},
|
|
906
|
+
]);
|
|
907
|
+
|
|
908
|
+
switch (action) {
|
|
909
|
+
case "search":
|
|
910
|
+
await searchAction();
|
|
911
|
+
break;
|
|
912
|
+
case "browse":
|
|
913
|
+
await browseMovies();
|
|
914
|
+
break;
|
|
915
|
+
case "trending":
|
|
916
|
+
await paginatedList((p) => listMovies(p, { sort_by: "like_count", order_by: "desc" }), "Trending");
|
|
917
|
+
break;
|
|
918
|
+
case "top":
|
|
919
|
+
await paginatedList((p) => listMovies(p, { sort_by: "rating", order_by: "desc", minimum_rating: 7 }), "Top Rated");
|
|
920
|
+
break;
|
|
921
|
+
case "exit":
|
|
922
|
+
exitGracefully();
|
|
923
|
+
}
|
|
924
|
+
} catch (err) {
|
|
925
|
+
if (isExitPromptError(err)) exitGracefully();
|
|
926
|
+
throw err;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
main().catch((err) => {
|
|
932
|
+
if (isExitPromptError(err)) exitGracefully();
|
|
933
|
+
console.error(err);
|
|
934
|
+
process.exit(1);
|
|
935
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "movizone",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Movie torrent explorer CLI with fuzzy search and in-terminal downloads",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"movizon": "./index.ts"
|
|
8
|
+
},
|
|
9
|
+
"files": ["index.ts", "download.mjs", "README.md"],
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "bun run index.ts",
|
|
12
|
+
"typecheck": "bun x tsc --noEmit"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"movie",
|
|
16
|
+
"torrent",
|
|
17
|
+
"cli",
|
|
18
|
+
"download",
|
|
19
|
+
"search",
|
|
20
|
+
"webtorrent"
|
|
21
|
+
],
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"boxen": "^8.0.1",
|
|
25
|
+
"chalk": "5",
|
|
26
|
+
"cli-table3": "^0.6.5",
|
|
27
|
+
"figlet": "^1.10.0",
|
|
28
|
+
"gradient-string": "^3.0.0",
|
|
29
|
+
"inquirer": "12",
|
|
30
|
+
"ora": "8",
|
|
31
|
+
"webtorrent": "^2.8.5"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/bun": "latest",
|
|
35
|
+
"@types/figlet": "^1.7.0",
|
|
36
|
+
"typescript": "^5"
|
|
37
|
+
},
|
|
38
|
+
"trustedDependencies": [
|
|
39
|
+
"node-datachannel",
|
|
40
|
+
"utp-native"
|
|
41
|
+
]
|
|
42
|
+
}
|