@musabhussainoffical/jelly 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +68 -0
- package/bin/jelly.js +311 -0
- package/lib/anidb.js +126 -0
- package/lib/cinemeta.js +88 -0
- package/lib/crypto-videasy.js +103 -0
- package/lib/helpers.js +21 -0
- package/lib/http.js +24 -0
- package/lib/providers.js +47 -0
- package/lib/rivestream.js +133 -0
- package/lib/safe-filter.js +40 -0
- package/lib/videasy.js +84 -0
- package/lib/vidlink.js +86 -0
- package/package.json +35 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jelly
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Jelly
|
|
2
|
+
|
|
3
|
+
A dependency-free CLI streamer (very much in the spirit of **ani-cli**, but the **Jelly** way) that lets you search for any **movie, series, or anime**, pick an episode, and play the chosen source in **VLC**.
|
|
4
|
+
|
|
5
|
+
It searches the free **Cinemeta** catalog (movies + series, no API key), and resolves "extracted versions" from every provider — `videasy`, `rivestream`, `vidlink`, and `anidb` — so you get all available quality/audio options regardless of whether the title is anime or live-action.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g @musabhussainoffical/jelly
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Then run VLC, or let Jelly find `vlc.exe` for you (checks common install paths, then your PATH). The command is `jelly`.
|
|
14
|
+
|
|
15
|
+
> You need [VLC](https://www.videolan.org/vlc/) installed. Jelly is tested on Windows; it should also work on Linux/macOS if `vlc` is on your PATH.
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
jelly # interactive — prompts you for a search term
|
|
21
|
+
jelly "attack on titan" # search with a query right away
|
|
22
|
+
jelly "stranger things" --episode 1
|
|
23
|
+
jelly "your name" --dub
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Flow (just like the Jelly desktop app):
|
|
27
|
+
|
|
28
|
+
1. Search for a title — multiple words are matched, so `jelly "attack titan"` works.
|
|
29
|
+
2. Pick a title from the numbered list (movies and series are shown together; anime that isn't in Cinemeta is appended).
|
|
30
|
+
3. If it's a series, pick an episode.
|
|
31
|
+
4. Sources are resolved from every provider and shown with quality + Sub/Dub labels.
|
|
32
|
+
5. Pick a source — it plays in VLC.
|
|
33
|
+
|
|
34
|
+
After a series episode finishes you get a small menu: `n` next, `r` replay, `s` change source, `q` quit.
|
|
35
|
+
|
|
36
|
+
## Options
|
|
37
|
+
|
|
38
|
+
| Flag | Meaning |
|
|
39
|
+
| --- | --- |
|
|
40
|
+
| `-q, --query <q>` | Search term (same as passing it as the first argument). |
|
|
41
|
+
| `-e, --episode <n>` | Skip the episode picker and play episode `n` directly. |
|
|
42
|
+
| `--dub` | Prefer the dubbed track for anime (anidb source). |
|
|
43
|
+
| `-V, --version` | Print the version. |
|
|
44
|
+
| `-h, --help` | Show help. |
|
|
45
|
+
|
|
46
|
+
## How it works
|
|
47
|
+
|
|
48
|
+
- **Catalog**: [Stremio Cinemeta](https://v3-cinemeta.strem.io) — free, official, no API key. Movies and series come from here.
|
|
49
|
+
- **Providers**: `videasy` (Wings), `rivestream`, `vidlink`, and `anidb.app`. The resolver tries all of them and shows every stream it finds.
|
|
50
|
+
- **Content safety**: Cinemeta results are passed through a light family-safe filter so hardcore/explicit-adult material isn't shown.
|
|
51
|
+
- **Player**: VLC is launched with `--play-and-exit`, plus the Chrome user-agent and referer headers the providers need.
|
|
52
|
+
|
|
53
|
+
## Publish
|
|
54
|
+
|
|
55
|
+
This package is ready to publish to npm:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
npm login
|
|
59
|
+
npm publish
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Published as the scoped package **`@musabhussainoffical/jelly`** (the install command is `npm i -g @musabhussainoffical/jelly`; the CLI command is `jelly`).
|
|
63
|
+
|
|
64
|
+
The publish whitelist is in `package.json` (`files`: `bin`, `lib`, `README.md`, `LICENSE`) so only the needed files go out.
|
|
65
|
+
|
|
66
|
+
## License
|
|
67
|
+
|
|
68
|
+
MIT — see [LICENSE](LICENSE).
|
package/bin/jelly.js
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// Jelly CLI — a dependency-free anime/movie/series streamer (like ani-cli, but
|
|
4
|
+
// the Jelly way). It searches the free Cinemeta catalog (movies + series),
|
|
5
|
+
// then resolves "extracted versions" from every provider (videasy, rivestream,
|
|
6
|
+
// vidlink, anidb) and plays the chosen stream in VLC.
|
|
7
|
+
|
|
8
|
+
const readline = require('readline');
|
|
9
|
+
const { spawn } = require('child_process');
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
|
|
14
|
+
const { search: cinemetaSearch, getMeta } = require('../lib/cinemeta');
|
|
15
|
+
const { search: anidbSearch } = require('../lib/anidb');
|
|
16
|
+
const { resolveSources } = require('../lib/providers');
|
|
17
|
+
|
|
18
|
+
const VERSION = require('../package.json').version;
|
|
19
|
+
|
|
20
|
+
// ------------------------- console helpers -------------------------
|
|
21
|
+
function out(s) { process.stdout.write(s + '\n'); }
|
|
22
|
+
function err(s) { process.stderr.write('\x1b[1;31m' + s + '\x1b[0m\n'); }
|
|
23
|
+
function info(s) { process.stdout.write('\x1b[1;34m' + s + '\x1b[0m\n'); }
|
|
24
|
+
function ok(s) { process.stdout.write('\x1b[1;32m' + s + '\x1b[0m\n'); }
|
|
25
|
+
|
|
26
|
+
// ------------------------- interactive input -------------------------
|
|
27
|
+
function ask(question) {
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
30
|
+
rl.question(question, (answer) => { rl.close(); resolve(answer.trim()); });
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function pickNumber(items, render, prompt) {
|
|
35
|
+
items.forEach((it, i) => out(` ${String(i + 1).padStart(2)} ${render(it, i)}`));
|
|
36
|
+
let choice = null;
|
|
37
|
+
while (choice == null) {
|
|
38
|
+
const raw = await ask(prompt + ' ');
|
|
39
|
+
const n = parseInt(raw, 10);
|
|
40
|
+
if (!isNaN(n) && n >= 1 && n <= items.length) choice = items[n - 1];
|
|
41
|
+
else out(' Invalid selection. Pick a number from the list.');
|
|
42
|
+
}
|
|
43
|
+
return choice;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ------------------------- VLC detection -------------------------
|
|
47
|
+
function findVlc() {
|
|
48
|
+
const candidates = [
|
|
49
|
+
process.env.JELLY_VLC,
|
|
50
|
+
'C:\\Program Files\\VideoLAN\\VLC\\vlc.exe',
|
|
51
|
+
'C:\\Program Files (x86)\\VideoLAN\\VLC\\vlc.exe',
|
|
52
|
+
path.join(os.homedir(), 'AppData', 'Local', 'Programs', 'VideoLAN', 'VLC', 'vlc.exe'),
|
|
53
|
+
].filter(Boolean);
|
|
54
|
+
for (const c of candidates) {
|
|
55
|
+
try { if (c && fs.existsSync(c)) return c; } catch {}
|
|
56
|
+
}
|
|
57
|
+
// PATH lookup
|
|
58
|
+
const pathEnv = (process.env.PATH || '').split(path.delimiter);
|
|
59
|
+
for (const dir of pathEnv) {
|
|
60
|
+
try {
|
|
61
|
+
const p = path.join(dir, 'vlc.exe');
|
|
62
|
+
if (fs.existsSync(p)) return p;
|
|
63
|
+
} catch {}
|
|
64
|
+
}
|
|
65
|
+
return 'vlc'; // fall back to PATH-name so spawn gives a clean error
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function playInVlc(stream, title) {
|
|
69
|
+
const vlc = findVlc();
|
|
70
|
+
const args = [
|
|
71
|
+
'--play-and-exit',
|
|
72
|
+
'--meta-title', title,
|
|
73
|
+
'--no-video-title-show',
|
|
74
|
+
];
|
|
75
|
+
if (stream.headers) {
|
|
76
|
+
if (stream.headers['user-agent']) { args.push('--http-user-agent', stream.headers['user-agent']); }
|
|
77
|
+
if (stream.headers.referer) { args.push('--http-referrer', stream.headers.referer); }
|
|
78
|
+
}
|
|
79
|
+
args.push(stream.url);
|
|
80
|
+
info(`Launching VLC: ${vlc}`);
|
|
81
|
+
return new Promise((resolve) => {
|
|
82
|
+
const child = spawn(vlc, args, { stdio: 'ignore', detached: false });
|
|
83
|
+
child.on('error', (e) => { err('VLC failed to start: ' + e.message); resolve({ code: -1, error: e.message }); });
|
|
84
|
+
child.on('exit', (code) => { out(`\n VLC closed (code ${code}).\n`); resolve({ code }); });
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ------------------------- help / version -------------------------
|
|
89
|
+
function help() {
|
|
90
|
+
out(`
|
|
91
|
+
jelly — search and stream movies/series/anime in VLC.
|
|
92
|
+
|
|
93
|
+
Usage:
|
|
94
|
+
jelly [query] [options]
|
|
95
|
+
jelly starts interactively, prompts for a search
|
|
96
|
+
|
|
97
|
+
Options:
|
|
98
|
+
-q, --query <q> search term (same as passing it as the first arg)
|
|
99
|
+
-e, --episode <n> skip the episode picker and play episode n (series)
|
|
100
|
+
--dub prefer the dubbed track for anime
|
|
101
|
+
-V, --version print version
|
|
102
|
+
-h, --help show this help
|
|
103
|
+
`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseArgs(argv) {
|
|
107
|
+
const opts = { query: null, episode: null, dub: false };
|
|
108
|
+
const unknown = [];
|
|
109
|
+
for (let i = 0; i < argv.length; i++) {
|
|
110
|
+
const a = argv[i];
|
|
111
|
+
if (a === '-h' || a === '--help') opts.help = true;
|
|
112
|
+
else if (a === '-V' || a === '--version') opts.version = true;
|
|
113
|
+
else if (a === '--dub') opts.dub = true;
|
|
114
|
+
else if (a === '-q' || a === '--query') opts.query = argv[++i] || null;
|
|
115
|
+
else if (a === '-e' || a === '--episode') opts.episode = parseInt(argv[++i], 10) || null;
|
|
116
|
+
else if (a.startsWith('-')) unknown.push(a);
|
|
117
|
+
else opts.query = (opts.query ? opts.query + ' ' : '') + a;
|
|
118
|
+
}
|
|
119
|
+
opts.unknown = unknown;
|
|
120
|
+
return opts;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ------------------------- main flow -------------------------
|
|
124
|
+
async function run() {
|
|
125
|
+
const argv = process.argv.slice(2);
|
|
126
|
+
const o = parseArgs(argv);
|
|
127
|
+
if (o.help) return help();
|
|
128
|
+
if (o.version) { out(VERSION); return; }
|
|
129
|
+
if (o.unknown.length) { err('Unknown option(s): ' + o.unknown.join(', ')); help(); process.exit(1); }
|
|
130
|
+
|
|
131
|
+
if (o.dub && process.platform !== 'win32') out(' Note: --dub is handled by the anidb provider on any platform.');
|
|
132
|
+
|
|
133
|
+
// 1. Get the query.
|
|
134
|
+
let query = o.query;
|
|
135
|
+
if (!query) {
|
|
136
|
+
query = await ask('\x1b[1;36mSearch for a movie, series, or anime: \x1b[0m');
|
|
137
|
+
}
|
|
138
|
+
query = query.trim();
|
|
139
|
+
if (!query) { err('No search term provided.'); process.exit(1); }
|
|
140
|
+
|
|
141
|
+
// Show both Cinemeta (movies+series) AND anidb (anime) results, merged.
|
|
142
|
+
info(`Searching "${query}"…`);
|
|
143
|
+
const [cinemetaHits, animeHits] = await Promise.allSettled([
|
|
144
|
+
cinemetaSearch(query),
|
|
145
|
+
anidbSearch(query),
|
|
146
|
+
]);
|
|
147
|
+
const cinemetaResults = cinemetaHits.status === 'fulfilled' ? cinemetaHits.value : [];
|
|
148
|
+
const animeResults = animeHits.status === 'fulfilled' ? animeHits.value : [];
|
|
149
|
+
|
|
150
|
+
let items = cinemetaResults
|
|
151
|
+
.map((r) => sortType(r, query))
|
|
152
|
+
.filter(Boolean);
|
|
153
|
+
// Merge anidb-only results (anime not always in Cinemeta) as series candidates.
|
|
154
|
+
const anidbItems = animeResults
|
|
155
|
+
.filter((a) => !items.some((r) => r.type === 'series' && r.title.toLowerCase() === a.title.toLowerCase()))
|
|
156
|
+
.map((a) => ({ type: 'series', id: 'anidb:' + a.id, num: a.num, title: a.title, year: null, anidb: true }))
|
|
157
|
+
.sort((x, y) => titleScore(y.title, query) - titleScore(x.title, query));
|
|
158
|
+
items = items.concat(anidbItems);
|
|
159
|
+
|
|
160
|
+
if (!items.length) { err('No results found.'); process.exit(1); }
|
|
161
|
+
|
|
162
|
+
// 2. Pick a title.
|
|
163
|
+
let target;
|
|
164
|
+
if (items.length === 1) { target = items[0]; }
|
|
165
|
+
else {
|
|
166
|
+
info('Results:');
|
|
167
|
+
target = await pickNumber(
|
|
168
|
+
items,
|
|
169
|
+
(it) => `[${it.type === 'series' ? 'Series' : 'Movie'}] ${it.title}${it.year ? ' (' + it.year + ')' : ''}`,
|
|
170
|
+
'Select a title (#):'
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 3. Resolve episodes / meta.
|
|
175
|
+
let meta = null;
|
|
176
|
+
let episodes = [];
|
|
177
|
+
let tmdbId = null;
|
|
178
|
+
if (target.anidb) {
|
|
179
|
+
// Already an anidb series. Get episodes to allow picking.
|
|
180
|
+
const eps = require('../lib/anidb').episodes;
|
|
181
|
+
episodes = (await eps(target.num)).map((e) => ({ id: e.id, number: e.number, season: 1 }));
|
|
182
|
+
tmdbId = null;
|
|
183
|
+
meta = { name: target.title, type: 'series', moviedb_id: null };
|
|
184
|
+
if (!episodes.length) { err('No episodes for this anime.'); process.exit(1); }
|
|
185
|
+
} else {
|
|
186
|
+
const ttType = target.type === 'series' ? 'series' : 'movie';
|
|
187
|
+
meta = await getMeta(ttType, target.id);
|
|
188
|
+
if (!meta) { err('Could not load title details.'); process.exit(1); }
|
|
189
|
+
tmdbId = meta.moviedb_id;
|
|
190
|
+
if (target.type === 'series') {
|
|
191
|
+
episodes = meta.videos
|
|
192
|
+
.filter((v) => v.season != null && v.episode != null)
|
|
193
|
+
.sort((a, b) => a.season - b.season || a.episode - b.episode)
|
|
194
|
+
.map((v) => ({ id: v.id, number: v.episode, season: v.season, name: v.name }));
|
|
195
|
+
if (episodes.length === 1) { meta = { ...meta, actualType: 'movie' }; } // single-episode series => treat as movie
|
|
196
|
+
} else {
|
|
197
|
+
episodes = [{ id: 'movie', number: 1, season: 1, name: target.title }];
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const isSeries = (meta.type === 'series' || target.type === 'series') &&
|
|
202
|
+
!(episodes.length === 1 && !target.anidb && meta.actualType === 'movie');
|
|
203
|
+
|
|
204
|
+
// 4. Pick the episode (series only).
|
|
205
|
+
let chosenEp = { season: 1, episode: o.episode > 0 ? o.episode : undefined, name: '' };
|
|
206
|
+
if (isSeries) {
|
|
207
|
+
let epOptions = episodes;
|
|
208
|
+
let epPrompt = 'Select episode (#):';
|
|
209
|
+
if (o.episode > 0) {
|
|
210
|
+
const found = episodes.find((e) => e.number === o.episode && e.season === 1);
|
|
211
|
+
if (!found) { err(`Episode ${o.episode} not found.`); process.exit(1); }
|
|
212
|
+
chosenEp = { season: 1, episode: o.episode, name: found.name || `Episode ${o.episode}` };
|
|
213
|
+
} else {
|
|
214
|
+
info(`Episodes for ${meta.name}:`);
|
|
215
|
+
chosenEp = await pickNumber(
|
|
216
|
+
epOptions,
|
|
217
|
+
(e) => `${e.season > 1 ? 'S' + e.season : ''}E${String(e.number).padStart(2, '0')} ${e.name || ''}`.trim(),
|
|
218
|
+
epPrompt
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
} else {
|
|
222
|
+
chosenEp = { season: 1, episode: 1, name: target.title };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const epNumber = chosenEp.episode || chosenEp.number || 1;
|
|
226
|
+
|
|
227
|
+
// 5-7. Resolve → pick → play, then loop (next/replay/change source/quit).
|
|
228
|
+
let currentEp = chosenEp;
|
|
229
|
+
let currentRes = null;
|
|
230
|
+
let lastPicked = null;
|
|
231
|
+
|
|
232
|
+
async function pickAndPlay() {
|
|
233
|
+
// Resolve from ALL providers (anime and movie providers alike).
|
|
234
|
+
info(`Resolving sources for "${meta.name}${isSeries ? ' - Episode ' + currentEp.episode : ''}"… (a few seconds)`);
|
|
235
|
+
currentRes = await resolveSources({
|
|
236
|
+
tmdbId,
|
|
237
|
+
type: target.type === 'movie' && !target.anidb ? 'movie' : 'series',
|
|
238
|
+
season: currentEp.season || 1,
|
|
239
|
+
episode: currentEp.episode || 1,
|
|
240
|
+
title: meta.name,
|
|
241
|
+
year: meta.year,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
if (!currentRes.ok || !currentRes.streams.length) {
|
|
245
|
+
err('No available source.');
|
|
246
|
+
out(' Providers tried: ' + currentRes.tried.map((t) => `${t.provider}${t.ok ? ' ✓' : ' ✗'}${t.error ? ' (' + t.error + ')' : ''}`).join(', '));
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
info('Available sources:');
|
|
251
|
+
const picked = await pickNumber(
|
|
252
|
+
currentRes.streams,
|
|
253
|
+
(s) => `${s.provider} ${s.qualityLabel || 'auto'}${s.label ? ' [' + s.label + ']' : ''}`,
|
|
254
|
+
'Select a source (#):'
|
|
255
|
+
);
|
|
256
|
+
lastPicked = picked;
|
|
257
|
+
const title = `${meta.name}${isSeries ? ' - Episode ' + currentEp.episode : ''}`;
|
|
258
|
+
await playInVlc(picked, title);
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
await pickAndPlay();
|
|
263
|
+
|
|
264
|
+
// Post-play menu (ani-cli style): next / replay / change source / quit.
|
|
265
|
+
if (isSeries) {
|
|
266
|
+
const nextEp = (episodes.find((e) =>
|
|
267
|
+
(e.season || 1) === currentEp.season && e.number === (currentEp.episode || 0) + 1) ||
|
|
268
|
+
episodes.find((e) =>
|
|
269
|
+
e.season > (currentEp.season || 1) && String(e.number) === '1'));
|
|
270
|
+
while (true) {
|
|
271
|
+
const cmdStr = await ask(`\n[${meta.name} ${currentEp.season > 1 ? 'S' + currentEp.season : ''}E${currentEp.episode}] (n)ext (r)eplay (s)ource (q)uit: `);
|
|
272
|
+
const cmd = (cmdStr || 'q').toLowerCase();
|
|
273
|
+
if (cmd === 'q' || cmd === 'quit') { break; }
|
|
274
|
+
if (cmd === 'n' || cmd === 'next') {
|
|
275
|
+
if (!nextEp) { out(' No next episode.'); continue; }
|
|
276
|
+
currentEp = nextEp;
|
|
277
|
+
} else if (cmd === 'r' || cmd === 'replay') {
|
|
278
|
+
if (lastPicked) { await playInVlc(lastPicked, `${meta.name} - Episode ${currentEp.episode}`); continue; }
|
|
279
|
+
} else if (cmd === 's' || cmd === 'source') {
|
|
280
|
+
// re-pick a source for the current episode
|
|
281
|
+
await pickAndPlay();
|
|
282
|
+
continue;
|
|
283
|
+
} else { out(' Unknown command.'); continue; }
|
|
284
|
+
await pickAndPlay();
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function sortType(r, query) {
|
|
290
|
+
const t = r.name || '';
|
|
291
|
+
const score = titleScore(t, query);
|
|
292
|
+
return { ...r, title: t, _score: score };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function titleScore(title, query) {
|
|
296
|
+
const tl = title.toLowerCase();
|
|
297
|
+
const q = query.toLowerCase();
|
|
298
|
+
if (tl === q) return 1000;
|
|
299
|
+
if (tl.startsWith(q)) return 800;
|
|
300
|
+
if (tl.includes(q)) return 600;
|
|
301
|
+
// how many query words appear anywhere in the title
|
|
302
|
+
const words = q.split(/\s+/).filter(Boolean);
|
|
303
|
+
const hits = words.filter((w) => tl.includes(w)).length;
|
|
304
|
+
return hits * 100 + words.length - hits;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (require.main === module) {
|
|
308
|
+
run().catch((e) => { err('Error: ' + (e && e.message)); process.exit(1); });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
module.exports = { run, findVlc, titleScore };
|
package/lib/anidb.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// anidb.app anime provider. Flow (same as Jelly GUI): search -> anime id ->
|
|
3
|
+
// episodes -> language embed (sub=jpn / dub=eng) -> master m3u8.
|
|
4
|
+
const { httpGet } = require('./http');
|
|
5
|
+
|
|
6
|
+
const BASE = 'https://anidb.app';
|
|
7
|
+
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
|
|
8
|
+
const HEADERS = { 'user-agent': UA };
|
|
9
|
+
|
|
10
|
+
function decodeEntities(s) {
|
|
11
|
+
return String(s)
|
|
12
|
+
.replace(/'/g, "'").replace(/"/g, '"')
|
|
13
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Search anidb by title keywords (spaces -> '+'). Returns [{id, num, title, poster}].
|
|
17
|
+
async function search(q) {
|
|
18
|
+
const words = q.split(/\s+/).filter(Boolean);
|
|
19
|
+
if (!words.length) return [];
|
|
20
|
+
const query = words.map(encodeURIComponent).join('+');
|
|
21
|
+
const r = await httpGet(`${BASE}/browse?q=${query}`, HEADERS, 20000);
|
|
22
|
+
if (!r || r.status >= 400) {
|
|
23
|
+
if (r && /Just a moment/i.test(r.body)) throw new Error('anidb blocked by Cloudflare');
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
const out = [];
|
|
27
|
+
const seen = new Set();
|
|
28
|
+
const cardRe = /anime\/([a-z0-9-]+-\d+)"[^>]*title="([^"]+)"[^>]*>/g;
|
|
29
|
+
const imgRe = /src="([^"]+)"[^>]*alt="([^"]+)"/g;
|
|
30
|
+
const posters = new Map();
|
|
31
|
+
let im;
|
|
32
|
+
while ((im = imgRe.exec(r.body))) if (!posters.has(im[2])) posters.set(im[2], im[1]);
|
|
33
|
+
let m;
|
|
34
|
+
while ((m = cardRe.exec(r.body))) {
|
|
35
|
+
const id = m[1];
|
|
36
|
+
const num = Number(id.split('-').pop());
|
|
37
|
+
if (seen.has(num)) continue;
|
|
38
|
+
seen.add(num);
|
|
39
|
+
const title = decodeEntities(m[2]);
|
|
40
|
+
out.push({ id, num, title, poster: posters.get(m[2]) || null });
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function episodes(animeNum) {
|
|
46
|
+
const r = await httpGet(`${BASE}/api/frontend/anime/${animeNum}/episodes`, HEADERS, 20000);
|
|
47
|
+
if (!r || r.status >= 400) return [];
|
|
48
|
+
try {
|
|
49
|
+
const j = JSON.parse(r.body);
|
|
50
|
+
return (j.episodes || [])
|
|
51
|
+
.filter((e) => e && typeof e.number === 'number')
|
|
52
|
+
.map((e) => ({ id: e.id, number: e.number }))
|
|
53
|
+
.sort((a, b) => a.number - b.number);
|
|
54
|
+
} catch { return []; }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function embedUrl(epId, lang) {
|
|
58
|
+
const r = await httpGet(`${BASE}/api/frontend/episode/${epId}/languages`, HEADERS, 20000);
|
|
59
|
+
if (!r || r.status >= 400) return null;
|
|
60
|
+
try {
|
|
61
|
+
const j = JSON.parse(r.body);
|
|
62
|
+
const src = (j.languages || []).find((l) => l.code === lang) ||
|
|
63
|
+
(j.languages || []).find((l) => l.code === 'jpn') ||
|
|
64
|
+
(j.languages || [])[0];
|
|
65
|
+
return src ? src.embed_url : null;
|
|
66
|
+
} catch { return null; }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function masterFromEmbed(embedUrl) {
|
|
70
|
+
if (!embedUrl) return null;
|
|
71
|
+
const e = /^https?:\/\//.test(embedUrl) ? embedUrl : `${BASE}${embedUrl}`;
|
|
72
|
+
const r = await httpGet(e, HEADERS, 20000);
|
|
73
|
+
if (!r || r.status >= 400) return null;
|
|
74
|
+
const m = r.body.match(/file\s*:\s*'([^']+)'/);
|
|
75
|
+
return m ? m[1] : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function resolveStream(ans, episode, mode) {
|
|
79
|
+
const eps = await episodes(ans.num);
|
|
80
|
+
if (!eps.length) return null;
|
|
81
|
+
const target = eps.find((e) => e.number === Number(episode)) || eps[0];
|
|
82
|
+
if (!target) return null;
|
|
83
|
+
const lang = mode === 'dub' ? 'eng' : 'jpn';
|
|
84
|
+
let embed = await embedUrl(target.id, lang);
|
|
85
|
+
if (!embed && mode === 'dub') embed = await embedUrl(target.id, 'jpn');
|
|
86
|
+
const master = await masterFromEmbed(embed);
|
|
87
|
+
return master;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Common provider interface used by the failover engine.
|
|
91
|
+
// opts: { tmdbId, type: 'movie'|'tv'|'series', season, episode, title, year }
|
|
92
|
+
async function resolve({ tmdbId, type, season = 1, episode = 1, title }) {
|
|
93
|
+
if (!title) return { ok: false, error: 'no title', streams: [], subtitles: [], provider: 'anidb' };
|
|
94
|
+
let results;
|
|
95
|
+
try { results = await search(title); }
|
|
96
|
+
catch (e) { return { ok: false, error: e.message, streams: [], subtitles: [], provider: 'anidb' }; }
|
|
97
|
+
if (!results.length) return { ok: false, error: 'no anime match', streams: [], subtitles: [], provider: 'anidb' };
|
|
98
|
+
|
|
99
|
+
const lower = title.toLowerCase();
|
|
100
|
+
const chosen = results.find((r) => r.title.toLowerCase() === lower) ||
|
|
101
|
+
results.find((r) => lower.includes(r.title.toLowerCase()) || r.title.toLowerCase().includes(lower)) ||
|
|
102
|
+
results[0];
|
|
103
|
+
|
|
104
|
+
// Use season only for Series/Cinema meta; anidb episodes are a flat list,
|
|
105
|
+
// so map requested episode (season 1 -> flat episode; else best-effort).
|
|
106
|
+
const flatEp = Number(season) === 1 ? Number(episode) : Number(episode);
|
|
107
|
+
|
|
108
|
+
const [subUrl, dubUrl] = await Promise.all([
|
|
109
|
+
resolveStream(chosen, flatEp, 'sub'),
|
|
110
|
+
resolveStream(chosen, flatEp, 'dub'),
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
const streams = [];
|
|
114
|
+
if (subUrl) streams.push({ url: subUrl, qualityLabel: `Sub · E${flatEp}`, label: 'Sub', headers: { referer: BASE + '/', 'user-agent': UA } });
|
|
115
|
+
if (dubUrl) streams.push({ url: dubUrl, qualityLabel: `Dub · E${flatEp}`, label: 'Dub', headers: { referer: BASE + '/', 'user-agent': UA } });
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
ok: streams.length > 0,
|
|
119
|
+
provider: 'anidb',
|
|
120
|
+
streams,
|
|
121
|
+
subtitles: [],
|
|
122
|
+
error: streams.length ? undefined : 'no source for this episode',
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = { search, episodes, resolve, BASE, name: 'anidb' };
|
package/lib/cinemeta.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Cinemeta: Stremio's official free catalog + meta addon (no API key). Ported
|
|
3
|
+
// from the Jelly GUI. Uses the pure-Node http helper.
|
|
4
|
+
const { httpGet } = require('./http');
|
|
5
|
+
const { filterSafe } = require('./safe-filter');
|
|
6
|
+
|
|
7
|
+
const CINEMETA = 'https://v3-cinemeta.strem.io';
|
|
8
|
+
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
|
|
9
|
+
|
|
10
|
+
function posterOf(meta, size = 'small') {
|
|
11
|
+
return meta.poster || `https://images.metahub.space/poster/${size}/${meta.id}/img`;
|
|
12
|
+
}
|
|
13
|
+
function backgroundOf(meta) {
|
|
14
|
+
return meta.background || `https://images.metahub.space/background/medium/${meta.id}/img`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function fromCatalogMeta(meta) {
|
|
18
|
+
return {
|
|
19
|
+
id: meta.id,
|
|
20
|
+
type: meta.type === 'series' ? 'series' : 'movie',
|
|
21
|
+
name: meta.name,
|
|
22
|
+
year: meta.year || meta.releaseInfo || undefined,
|
|
23
|
+
poster: posterOf(meta),
|
|
24
|
+
background: backgroundOf(meta),
|
|
25
|
+
genres: meta.genres || meta.genre || [],
|
|
26
|
+
description: meta.description || '',
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function getJson(url) {
|
|
31
|
+
const r = await httpGet(url, { 'user-agent': UA }, 20000);
|
|
32
|
+
if (!r || r.status < 200 || r.status >= 300) return null;
|
|
33
|
+
try { return JSON.parse(r.body); } catch { return null; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function _doCatalog(url) {
|
|
37
|
+
return getJson(url).then((data) => {
|
|
38
|
+
if (!data || !data.metas) return [];
|
|
39
|
+
return filterSafe(data.metas.map(fromCatalogMeta));
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Search across movie + series catalogs. Every keyword is matched, so you can
|
|
44
|
+
// search by a few words of the title ("attack titan") like the GUI.
|
|
45
|
+
async function search(query) {
|
|
46
|
+
const q = encodeURIComponent(query);
|
|
47
|
+
const [movie, series] = await Promise.all([
|
|
48
|
+
_doCatalog(`${CINEMETA}/catalog/movie/top/search=${q}.json`),
|
|
49
|
+
_doCatalog(`${CINEMETA}/catalog/series/top/search=${q}.json`),
|
|
50
|
+
]);
|
|
51
|
+
return [...movie, ...series];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Full detail for a tt id. Returns meta (with moviedb_id, videos for series).
|
|
55
|
+
async function getMeta(type, ttId) {
|
|
56
|
+
const data = await getJson(`${CINEMETA}/meta/${type}/${ttId}.json`);
|
|
57
|
+
if (!data || !data.meta) return null;
|
|
58
|
+
const m = data.meta;
|
|
59
|
+
return {
|
|
60
|
+
id: m.id,
|
|
61
|
+
type: m.type === 'series' ? 'series' : 'movie',
|
|
62
|
+
name: m.name,
|
|
63
|
+
year: m.year || m.releaseInfo,
|
|
64
|
+
poster: m.poster || posterOf(m),
|
|
65
|
+
background: m.background || backgroundOf(m),
|
|
66
|
+
imdbRating: m.imdbRating,
|
|
67
|
+
runtime: m.runtime,
|
|
68
|
+
description: m.description,
|
|
69
|
+
genres: m.genres || m.genre || [],
|
|
70
|
+
moviedb_id: m.moviedb_id || null,
|
|
71
|
+
videos: (m.videos || []).map((v) => ({
|
|
72
|
+
id: v.id,
|
|
73
|
+
season: v.season,
|
|
74
|
+
episode: v.episode,
|
|
75
|
+
name: v.name,
|
|
76
|
+
description: v.overview || v.description || '',
|
|
77
|
+
})),
|
|
78
|
+
raw: m,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = {
|
|
83
|
+
CINEMETA,
|
|
84
|
+
search,
|
|
85
|
+
getMeta,
|
|
86
|
+
posterOf,
|
|
87
|
+
backgroundOf,
|
|
88
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Videasy / Wings / SpeedRaceLight PRNG XOR cipher (pure-JS port).
|
|
3
|
+
// Decrypts enc=2 "sources-with-title" responses. No WASM needed for this path.
|
|
4
|
+
// CRITICAL: PRNG state must be a SPARSE Array(61) — `idx in state` is load-bearing.
|
|
5
|
+
|
|
6
|
+
const MAGIC_BYTES = [109, 118, 109, 49]; // "mvm1"
|
|
7
|
+
const PRNG_STATE_SIZE = 61;
|
|
8
|
+
const PRNG_ROUNDS = 8;
|
|
9
|
+
const GOLDEN_RATIO = 2654435769 >>> 0;
|
|
10
|
+
|
|
11
|
+
function hash32(x) {
|
|
12
|
+
x >>>= 0;
|
|
13
|
+
x ^= x >>> 16;
|
|
14
|
+
x = Math.imul(x, 2246822507) >>> 0;
|
|
15
|
+
x ^= x >>> 13;
|
|
16
|
+
x = Math.imul(x, 3266489909) >>> 0;
|
|
17
|
+
x ^= x >>> 16;
|
|
18
|
+
return x >>> 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function rotl(x, n) {
|
|
22
|
+
x >>>= 0;
|
|
23
|
+
n &= 31;
|
|
24
|
+
return n === 0 ? x >>> 0 : ((x << n) | (x >>> (32 - n))) >>> 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function fnv1a(str) {
|
|
28
|
+
let h = 0x811c9dc5 >>> 0;
|
|
29
|
+
for (let i = 0; i < str.length; i++) {
|
|
30
|
+
h = Math.imul(h ^ str.charCodeAt(i), 0x01000193) >>> 0;
|
|
31
|
+
}
|
|
32
|
+
return hash32(h);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function initPrng(seed, mediaId) {
|
|
36
|
+
// Sparse on purpose — do NOT use Array.from({ length }) (dense undefined slots).
|
|
37
|
+
const state = new Array(PRNG_STATE_SIZE);
|
|
38
|
+
let i = hash32(fnv1a(seed) ^ hash32((mediaId >>> 0) ^ GOLDEN_RATIO)) >>> 0;
|
|
39
|
+
for (let r = 0; r < PRNG_ROUNDS; r++) {
|
|
40
|
+
const n = i % PRNG_STATE_SIZE;
|
|
41
|
+
i = rotl((i + GOLDEN_RATIO) >>> 0, 7 + (r & 7));
|
|
42
|
+
state[n] = (i ^ hash32(i)) >>> 0;
|
|
43
|
+
i = hash32((i + n) >>> 0);
|
|
44
|
+
}
|
|
45
|
+
return { state, acc: hash32(i ^ 0xa5a5a5a5) >>> 0 };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function nextPrngWord(ctx, counter) {
|
|
49
|
+
const s = ctx.state;
|
|
50
|
+
let a = ctx.acc;
|
|
51
|
+
const idx = a % PRNG_STATE_SIZE;
|
|
52
|
+
const inRange = +(idx in s);
|
|
53
|
+
const mask = 0 - inRange;
|
|
54
|
+
const sv = (s[idx] ?? 0) >>> 0;
|
|
55
|
+
const m = Math.imul(GOLDEN_RATIO, counter + 1) >>> 0;
|
|
56
|
+
let g = (((a ^ sv ^ m) >>> 0) | ((a & (sv ^ m) & mask) >>> 0)) >>> 0;
|
|
57
|
+
g = (rotl((g + a) >>> 0, idx & 31) ^ rotl(a, Math.imul(idx, 7) & 31)) >>> 0;
|
|
58
|
+
a = hash32((g + GOLDEN_RATIO) >>> 0);
|
|
59
|
+
s[idx] = a >>> 0;
|
|
60
|
+
ctx.acc = a;
|
|
61
|
+
return a >>> 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function generateKeyStream(seed, mediaId, length) {
|
|
65
|
+
const ctx = initPrng(seed, mediaId);
|
|
66
|
+
const out = new Uint8Array(length);
|
|
67
|
+
let idx = 0;
|
|
68
|
+
let ctr = 0;
|
|
69
|
+
while (idx < length) {
|
|
70
|
+
const w = nextPrngWord(ctx, ctr++);
|
|
71
|
+
out[idx++] = w & 0xff;
|
|
72
|
+
if (idx < length) out[idx++] = (w >>> 8) & 0xff;
|
|
73
|
+
if (idx < length) out[idx++] = (w >>> 16) & 0xff;
|
|
74
|
+
if (idx < length) out[idx++] = (w >>> 24) & 0xff;
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function base64urlDecode(input) {
|
|
80
|
+
const base64 = input.replace(/-/g, '+').replace(/_/g, '/')
|
|
81
|
+
.padEnd(Math.ceil(input.length / 4) * 4, '=');
|
|
82
|
+
const binary = Buffer.from(base64, 'base64').toString('binary');
|
|
83
|
+
const bytes = new Uint8Array(binary.length);
|
|
84
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
85
|
+
return bytes;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Returns the decrypted JSON string. Throws on bad magic bytes.
|
|
89
|
+
function decodeWingsdatabasePayload(ciphertext, seed, mediaId) {
|
|
90
|
+
const encrypted = base64urlDecode(ciphertext.trim());
|
|
91
|
+
const keyStream = generateKeyStream(seed, mediaId, encrypted.length);
|
|
92
|
+
for (let i = 0; i < encrypted.length; i++) {
|
|
93
|
+
encrypted[i] = (encrypted[i] ?? 0) ^ (keyStream[i] ?? 0);
|
|
94
|
+
}
|
|
95
|
+
for (let i = 0; i < MAGIC_BYTES.length; i++) {
|
|
96
|
+
if (encrypted[i] !== MAGIC_BYTES[i]) {
|
|
97
|
+
throw new Error('decrypt failed: bad seed or tampered payload');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return Buffer.from(encrypted.subarray(MAGIC_BYTES.length)).toString('utf8');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { decodeWingsdatabasePayload };
|
package/lib/helpers.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Thin fetch helpers used by the providers, backed by pure-Node http.
|
|
3
|
+
const { httpGet } = require('./http');
|
|
4
|
+
|
|
5
|
+
const DEFAULT_UA =
|
|
6
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36';
|
|
7
|
+
|
|
8
|
+
async function fetchText(url, headers = {}, timeoutMs = 20000) {
|
|
9
|
+
const r = await httpGet(url, headers, timeoutMs);
|
|
10
|
+
if (!r) throw new Error('request failed/timeout');
|
|
11
|
+
return { status: r.status, headers: {}, body: r.body };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function fetchJson(url, headers = {}, timeoutMs = 20000) {
|
|
15
|
+
const r = await fetchText(url, headers, timeoutMs);
|
|
16
|
+
let data = null;
|
|
17
|
+
try { data = JSON.parse(r.body); } catch { data = null; }
|
|
18
|
+
return { ok: r.status >= 200 && r.status < 300, status: r.status, data, body: r.body };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = { DEFAULT_UA, fetchText, fetchJson };
|
package/lib/http.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const http = require('http');
|
|
3
|
+
const https = require('https');
|
|
4
|
+
|
|
5
|
+
// Minimal HTTPS GET with UA + timeout. Returns {status, body} or null on error.
|
|
6
|
+
function httpGet(url, headers, timeout = 20000) {
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
const lib = url.startsWith('https') ? https : http;
|
|
9
|
+
const req = lib.get(url, { headers: headers || {} }, (res) => {
|
|
10
|
+
const chunks = [];
|
|
11
|
+
res.on('data', (c) => chunks.push(c));
|
|
12
|
+
res.on('end', () => {
|
|
13
|
+
resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString('utf8') });
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
req.setTimeout(timeout, () => {
|
|
17
|
+
req.destroy();
|
|
18
|
+
resolve(null);
|
|
19
|
+
});
|
|
20
|
+
req.on('error', () => resolve(null));
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
module.exports = { httpGet };
|
package/lib/providers.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Unified failover engine (mirrors the Jelly GUI). Tries every provider and
|
|
3
|
+
// collects all available "extracted versions": for anime AND movies, the same
|
|
4
|
+
// provider set runs — videasy, rivestream, vidlink, then anidb.
|
|
5
|
+
const videasy = require('./videasy');
|
|
6
|
+
const rivestream = require('./rivestream');
|
|
7
|
+
const vidlink = require('./vidlink');
|
|
8
|
+
const anidb = require('./anidb');
|
|
9
|
+
|
|
10
|
+
const DEFAULT_ORDER = ['videasy', 'rivestream', 'vidlink', 'anidb'];
|
|
11
|
+
|
|
12
|
+
const PROVIDERS = { videasy, rivestream, vidlink, anidb };
|
|
13
|
+
|
|
14
|
+
// opts: { tmdbId, type: 'movie'|'series'|'tv', season, episode, title, year }
|
|
15
|
+
// Returns { ok, streams: [{provider, qualityLabel, label, url, headers}], tried: [] }
|
|
16
|
+
async function resolveSources(opts, providerOrder = DEFAULT_ORDER) {
|
|
17
|
+
const norm = { ...opts, type: opts.type === 'series' ? 'tv' : opts.type === 'tv' ? 'tv' : 'movie' };
|
|
18
|
+
const tried = [];
|
|
19
|
+
const allStreams = [];
|
|
20
|
+
let anyOk = false;
|
|
21
|
+
|
|
22
|
+
for (const name of providerOrder) {
|
|
23
|
+
const p = PROVIDERS[name];
|
|
24
|
+
if (!p) continue;
|
|
25
|
+
let res;
|
|
26
|
+
try {
|
|
27
|
+
res = await p.resolve({ ...norm });
|
|
28
|
+
} catch (e) {
|
|
29
|
+
tried.push({ provider: name, error: e.message });
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
tried.push({ provider: name, ok: res.ok, error: res.error, count: (res.streams || []).length });
|
|
33
|
+
if (res.ok) anyOk = true;
|
|
34
|
+
for (const s of res.streams || []) {
|
|
35
|
+
allStreams.push({
|
|
36
|
+
provider: name,
|
|
37
|
+
qualityLabel: s.qualityLabel,
|
|
38
|
+
label: s.label,
|
|
39
|
+
url: s.url,
|
|
40
|
+
headers: s.headers,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { ok: anyOk, streams: allStreams, tried };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { resolveSources, PROVIDERS, DEFAULT_ORDER };
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Rivestream provider: MurmurHash secret key + backendfetch loop over services.
|
|
3
|
+
const { fetchJson, DEFAULT_UA } = require('./helpers');
|
|
4
|
+
|
|
5
|
+
const API = 'https://www.rivestream.app/api/backendfetch';
|
|
6
|
+
|
|
7
|
+
// The obfuscation table from the rivestream bundle (66 entries).
|
|
8
|
+
const cArray = [
|
|
9
|
+
'4Z7lUo','gwIVSMD','PLmz2elE2v','Z4OFV0','SZ6RZq6Zc','zhJEFYxrz8','FOm7b0',
|
|
10
|
+
'axHS3q4KDq','o9zuXQ','4Aebt','wgjjWwKKx','rY4VIxqSN','kfjbnSo','2DyrFA1M',
|
|
11
|
+
'YUixDM9B','JQvgEj0','mcuFx6JIek','eoTKe26gL','qaI9EVO1rB','0xl33btZL',
|
|
12
|
+
'1fszuAU','a7jnHzst6P','wQuJkX','cBNhTJlEOf','KNcFWhDvgT','XipDGjST',
|
|
13
|
+
'PCZJlbHoyt','2AYnMZkqd','HIpJh','KH0C3iztrG','W81hjts92','rJhAT','NON7LKoMQ',
|
|
14
|
+
'NMdY3nsKzI','t4En5v','Qq5cOQ9H','Y9nwrp','VX5FYVfsf','cE5SJG','x1vj1',
|
|
15
|
+
'HegbLe','zJ3nmt4OA','gt7rxW57dq','clIE9b','jyJ9g','B5jXjMCSx','cOzZBZTV',
|
|
16
|
+
'FTXGy','Dfh1q1','ny9jqZ2POI','X2NnMn','MBtoyD','qz4Ilys7wB','68lbOMye',
|
|
17
|
+
'3YUJnmxp','1fv5Imona','PlfvvXD7mA','ZarKfHCaPR','owORnX','dQP1YU','dVdkx',
|
|
18
|
+
'qgiK0E','cx9wQ','5F9bGa','7UjkKrp','Yvhrj','wYXez5Dg3','pG4GMU','MwMAu','rFRD5wlM',
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const SERVICES = [
|
|
22
|
+
'apex','pulse','solstice','quasar','horizon','primevids','flowcast',
|
|
23
|
+
'asiacloud','citadel','hindicast','guru',
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
function btoa(str) {
|
|
27
|
+
return Buffer.from(str, 'binary').toString('base64');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Exact port of the rivestream generateSecretKey (murmur-style hashing + cArray splice).
|
|
31
|
+
function generateSecretKey(e) {
|
|
32
|
+
if (e === undefined) return 'rive';
|
|
33
|
+
try {
|
|
34
|
+
let t, n;
|
|
35
|
+
const r = String(e);
|
|
36
|
+
if (isNaN(Number(e))) {
|
|
37
|
+
const sum = r.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
|
38
|
+
t = cArray[sum % cArray.length] || btoa(r);
|
|
39
|
+
n = Math.floor((sum % r.length) / 2);
|
|
40
|
+
} else {
|
|
41
|
+
const i = Number(e);
|
|
42
|
+
t = cArray[i % cArray.length] || btoa(r);
|
|
43
|
+
n = Math.floor((i % r.length) / 2);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const i = r.slice(0, n) + t + r.slice(n);
|
|
47
|
+
|
|
48
|
+
const hash2 = (s) => {
|
|
49
|
+
let t = 0;
|
|
50
|
+
for (let n = 0; n < s.length; n++) {
|
|
51
|
+
const r = s.charCodeAt(n);
|
|
52
|
+
const i = (((t = (r + (t << 6) + (t << 16) - t) >>> 0) << (n % 5)) | (t >>> (32 - (n % 5)))) >>> 0;
|
|
53
|
+
t ^= (i ^ ((r << (n % 7)) | (r >>> (8 - (n % 7))))) >>> 0;
|
|
54
|
+
t = (t + ((t >>> 11) ^ (t << 3))) >>> 0;
|
|
55
|
+
}
|
|
56
|
+
t ^= t >>> 15;
|
|
57
|
+
t = ((65535 & t) * 49842 + ((((t >>> 16) * 49842) & 65535) << 16)) >>> 0;
|
|
58
|
+
t ^= t >>> 13;
|
|
59
|
+
t = ((65535 & t) * 40503 + ((((t >>> 16) * 40503) & 65535) << 16)) >>> 0;
|
|
60
|
+
return (t ^= t >>> 16).toString(16).padStart(8, '0');
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const o = (s) => {
|
|
64
|
+
let t = String(s);
|
|
65
|
+
let n = 3735928559 ^ t.length;
|
|
66
|
+
for (let e = 0; e < t.length; e++) {
|
|
67
|
+
const r = t.charCodeAt(e);
|
|
68
|
+
let rMod = r;
|
|
69
|
+
rMod ^= ((131 * e + 89) ^ (rMod << (e % 5))) & 255;
|
|
70
|
+
n = (((n << 7) | (n >>> 25)) >>> 0) ^ rMod;
|
|
71
|
+
const ii = (65535 & n) * 60205;
|
|
72
|
+
const oo = ((n >>> 16) * 60205) << 16;
|
|
73
|
+
n = (ii + oo) >>> 0;
|
|
74
|
+
n ^= n >>> 11;
|
|
75
|
+
}
|
|
76
|
+
n ^= n >>> 15;
|
|
77
|
+
n = ((65535 & n) * 49842 + (((n >>> 16) * 49842) << 16)) >>> 0;
|
|
78
|
+
n ^= n >>> 13;
|
|
79
|
+
n = ((65535 & n) * 40503 + (((n >>> 16) * 40503) << 16)) >>> 0;
|
|
80
|
+
n ^= n >>> 16;
|
|
81
|
+
n = ((65535 & n) * 10196 + (((n >>> 16) * 10196) << 16)) >>> 0;
|
|
82
|
+
return (n ^= n >>> 15).toString(16).padStart(8, '0');
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
return btoa(o(hash2(i)));
|
|
86
|
+
} catch {
|
|
87
|
+
return 'topSecret';
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Resolve with the service loop. opts: { tmdbId, type: 'movie'|'tv', season?, episode? }
|
|
92
|
+
async function resolve({ tmdbId, type = 'movie', season = 1, episode = 1 }) {
|
|
93
|
+
if (!tmdbId) return { ok: false, error: 'no tmdb id' };
|
|
94
|
+
|
|
95
|
+
const requestID = type === 'movie' ? 'movieVideoProvider' : 'tvVideoProvider';
|
|
96
|
+
const secretKey = generateSecretKey(tmdbId);
|
|
97
|
+
const streams = [];
|
|
98
|
+
let lastError = null;
|
|
99
|
+
|
|
100
|
+
for (const service of SERVICES) {
|
|
101
|
+
let url = `${API}?requestID=${requestID}&id=${tmdbId}`;
|
|
102
|
+
if (type !== 'movie') url += `&season=${season}&episode=${episode}`;
|
|
103
|
+
url += `&service=${service}&secretKey=${secretKey}&proxyMode=noProxy`;
|
|
104
|
+
|
|
105
|
+
const r = await fetchJson(url, { accept: '*/*', 'user-agent': DEFAULT_UA }, 12000).catch(() => null);
|
|
106
|
+
if (!r || !r.ok) { lastError = r ? `http ${r.status}` : 'net error'; continue; }
|
|
107
|
+
|
|
108
|
+
const data = r.data;
|
|
109
|
+
// rivestream returns { data: { stream: { url, type, quality }, subtitles: [] } } or { data: null }
|
|
110
|
+
const stream = data?.data?.stream;
|
|
111
|
+
if (stream && (stream.url || stream.playlist)) {
|
|
112
|
+
const url2 = stream.url || stream.playlist;
|
|
113
|
+
streams.push({
|
|
114
|
+
url: url2,
|
|
115
|
+
qualityLabel: (stream.quality || '') + 'p' || 'auto',
|
|
116
|
+
service,
|
|
117
|
+
headers: { referer: 'https://www.rivestream.app/', 'user-agent': DEFAULT_UA },
|
|
118
|
+
});
|
|
119
|
+
} else {
|
|
120
|
+
lastError = 'no stream for this service';
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
ok: streams.length > 0,
|
|
126
|
+
provider: 'Rivestream',
|
|
127
|
+
streams,
|
|
128
|
+
subtitles: [],
|
|
129
|
+
error: streams.length ? undefined : (lastError || 'no streams'),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = { resolve, generateSecretKey, name: 'Rivestream', SERVICES };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Content-safety filter. Cinemeta does not expose an MPAA certificate, so we
|
|
3
|
+
// catch genuine "18+" (explicit/adult-only) content using genre + title/desc
|
|
4
|
+
// blacklists that are reliable for hardcore/pornographic material. Family-safe
|
|
5
|
+
// mainstream titles (even horror) are NOT blocked. (Ported from Jelly.)
|
|
6
|
+
|
|
7
|
+
const ADULT_GENRES = new Set([
|
|
8
|
+
'adult', 'erotic', 'erotica', 'porn', 'pornographic', 'xxx', 'adults only',
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
const ADULT_KEYWORDS = [
|
|
12
|
+
/\bxxx\b/i,
|
|
13
|
+
/\bhardcore\b/i,
|
|
14
|
+
/\bexplicit\s+adult\b/i,
|
|
15
|
+
/\bno\.?\s?1\s+sex\s+dating\s+fuck\b/i,
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const ADULT_DESC = [
|
|
19
|
+
/\b(explicit sexual|graphic sex|x-rated|pornograph)/
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
function isExplicit(item) {
|
|
23
|
+
if (!item) return false;
|
|
24
|
+
const genres = (item.genres || []).map((g) => String(g).toLowerCase().trim());
|
|
25
|
+
for (const g of genres) {
|
|
26
|
+
if (ADULT_GENRES.has(g)) return true;
|
|
27
|
+
}
|
|
28
|
+
const hay = `${item.name || ''} ${item.description || ''} ${item.genres?.join(' ') || ''}`;
|
|
29
|
+
for (const re of [...ADULT_KEYWORDS, ...ADULT_DESC]) {
|
|
30
|
+
if (re.test(hay)) return true;
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function filterSafe(items) {
|
|
36
|
+
if (!items) return [];
|
|
37
|
+
return items.filter((it) => !isExplicit(it));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { isExplicit, filterSafe, ADULT_GENRES };
|
package/lib/videasy.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Videasy / Wings provider (default): seed -> sources-with-title(enc=2) -> PRNG XOR decrypt.
|
|
3
|
+
const { fetchText, DEFAULT_UA } = require('./helpers');
|
|
4
|
+
const { decodeWingsdatabasePayload } = require('./crypto-videasy');
|
|
5
|
+
|
|
6
|
+
const WINGS = 'https://api.speedracelight.com';
|
|
7
|
+
|
|
8
|
+
async function getSeed(tmdbId) {
|
|
9
|
+
const url = `${WINGS}/seed?mediaId=${tmdbId}`;
|
|
10
|
+
const r = await fetchText(url, {
|
|
11
|
+
accept: '*/*',
|
|
12
|
+
'user-agent': DEFAULT_UA,
|
|
13
|
+
origin: 'https://www.cineby.at',
|
|
14
|
+
referer: 'https://www.cineby.at/',
|
|
15
|
+
}, 8000);
|
|
16
|
+
if (!r || r.status !== 200) return null;
|
|
17
|
+
try {
|
|
18
|
+
const j = JSON.parse(r.body);
|
|
19
|
+
return j.seed || null;
|
|
20
|
+
} catch { return null; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Resolve. opts: { tmdbId, type: 'movie'|'tv', season?, episode?, title, year }
|
|
24
|
+
async function resolve({ tmdbId, type = 'movie', season = 1, episode = 1, title = '', year = '' }) {
|
|
25
|
+
if (!tmdbId) return { ok: false, error: 'no tmdb id' };
|
|
26
|
+
const seed = await getSeed(tmdbId);
|
|
27
|
+
if (!seed) return { ok: false, error: 'seed unavailable' };
|
|
28
|
+
|
|
29
|
+
// Build the sources-with-title query (title/mediaType/tmdbId + seasonId/episodeId for series).
|
|
30
|
+
const params = new URLSearchParams();
|
|
31
|
+
params.set('title', title || '');
|
|
32
|
+
params.set('mediaType', type === 'movie' ? 'movie' : 'tv');
|
|
33
|
+
params.set('tmdbId', String(tmdbId));
|
|
34
|
+
params.set('_t', String(Date.now()));
|
|
35
|
+
if (year) params.set('year', String(year));
|
|
36
|
+
if (type !== 'movie') {
|
|
37
|
+
params.set('seasonId', String(season));
|
|
38
|
+
params.set('episodeId', String(episode));
|
|
39
|
+
}
|
|
40
|
+
params.set('enc', '2');
|
|
41
|
+
params.set('seed', seed);
|
|
42
|
+
|
|
43
|
+
const url = `${WINGS}/cdn/sources-with-title?${params.toString()}`;
|
|
44
|
+
const r = await fetchText(url, {
|
|
45
|
+
accept: '*/*',
|
|
46
|
+
'user-agent': DEFAULT_UA,
|
|
47
|
+
origin: 'https://www.cineby.at',
|
|
48
|
+
referer: 'https://www.cineby.at/',
|
|
49
|
+
}, 15000);
|
|
50
|
+
if (!r || r.status !== 200) return { ok: false, error: `sources http ${r ? r.status : 'err'}` };
|
|
51
|
+
|
|
52
|
+
let payload;
|
|
53
|
+
try {
|
|
54
|
+
const decrypted = decodeWingsdatabasePayload(r.body, seed, tmdbId);
|
|
55
|
+
payload = JSON.parse(decrypted);
|
|
56
|
+
} catch (e) {
|
|
57
|
+
return { ok: false, error: `decrypt failed: ${e.message}` };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const streams = [];
|
|
61
|
+
const sources = payload?.sources || [];
|
|
62
|
+
const headers = { referer: 'https://www.cineby.at/', 'user-agent': DEFAULT_UA };
|
|
63
|
+
for (const s of sources) {
|
|
64
|
+
if (s?.url) {
|
|
65
|
+
streams.push({ url: s.url, qualityLabel: s?.label || s?.quality || 'auto', headers });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const subtitles = (payload?.subtitles || []).map((c) => ({
|
|
69
|
+
url: c?.url,
|
|
70
|
+
language: c?.lang || c?.label,
|
|
71
|
+
type: c?.type,
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
ok: streams.length > 0,
|
|
76
|
+
provider: 'Videasy',
|
|
77
|
+
streams,
|
|
78
|
+
subtitles,
|
|
79
|
+
headers,
|
|
80
|
+
error: streams.length ? undefined : 'no playable source',
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = { resolve, name: 'Videasy', getSeed };
|
package/lib/vidlink.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// VidLink provider: enc-dec.app encryption -> vidlink.pro/api/b streams.
|
|
3
|
+
const { fetchJson } = require('./helpers');
|
|
4
|
+
|
|
5
|
+
const ENC_DEC = 'https://enc-dec.app/api/enc-vidlink';
|
|
6
|
+
const API = 'https://vidlink.pro/api/b';
|
|
7
|
+
const UA =
|
|
8
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36';
|
|
9
|
+
|
|
10
|
+
const encDecCache = new Map();
|
|
11
|
+
|
|
12
|
+
async function encryptTmdbId(tmdbId) {
|
|
13
|
+
if (encDecCache.has(tmdbId)) return encDecCache.get(tmdbId);
|
|
14
|
+
const r = await fetchJson(`${ENC_DEC}?text=${encodeURIComponent(tmdbId)}`, {
|
|
15
|
+
accept: '*/*',
|
|
16
|
+
'user-agent': UA,
|
|
17
|
+
referer: 'https://vidlink.pro/',
|
|
18
|
+
origin: 'https://vidlink.pro',
|
|
19
|
+
}, 20000);
|
|
20
|
+
if (!r.ok || !r.data) return null;
|
|
21
|
+
const token = typeof r.data === 'string' ? r.data.trim() : r.data.id ?? r.data.token ?? r.data.result;
|
|
22
|
+
if (token) encDecCache.set(tmdbId, token);
|
|
23
|
+
return token || null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Resolve a stream. opts: { tmdbId, type: 'movie'|'tv', season?, episode? }
|
|
27
|
+
async function resolve({ tmdbId, type = 'movie', season = 1, episode = 1 }) {
|
|
28
|
+
if (!tmdbId) return { ok: false, error: 'no tmdb id' };
|
|
29
|
+
const token = await encryptTmdbId(tmdbId);
|
|
30
|
+
if (!token) return { ok: false, error: 'enc-dec failed' };
|
|
31
|
+
|
|
32
|
+
const path = type === 'movie' ? `movie/${token}` : `tv/${token}/${season}/${episode}`;
|
|
33
|
+
const url = `${API}/${path}`;
|
|
34
|
+
const headers = {
|
|
35
|
+
accept: '*/*',
|
|
36
|
+
'user-agent': UA,
|
|
37
|
+
referer: 'https://vidlink.pro/',
|
|
38
|
+
origin: 'https://vidlink.pro',
|
|
39
|
+
'x-playback-environment': 'webkit',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const r = await fetchJson(url, headers, 20000);
|
|
43
|
+
if (!r.ok) return { ok: false, error: `vidlink ${r.status}` };
|
|
44
|
+
const stream = r.data?.stream;
|
|
45
|
+
if (!stream) return { ok: false, error: 'no stream for title' };
|
|
46
|
+
|
|
47
|
+
// Collect qualities / playlist into normalized streams.
|
|
48
|
+
const streams = [];
|
|
49
|
+
const playlistHeaders = {
|
|
50
|
+
referer: 'https://vidlink.pro/',
|
|
51
|
+
origin: 'https://vidlink.pro',
|
|
52
|
+
'user-agent': UA,
|
|
53
|
+
...(stream.playlistHeaders || {}),
|
|
54
|
+
...(stream.headers || {}),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
if (stream.type === 'file' && stream.qualities) {
|
|
58
|
+
for (const [quality, file] of Object.entries(stream.qualities)) {
|
|
59
|
+
if (file?.url) streams.push({ url: file.url, qualityLabel: `${quality}p`, headers: playlistHeaders });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (stream.playlist) {
|
|
63
|
+
// multi-quality HLS/DASH: hand the whole playlist to the player so it auto-adapts.
|
|
64
|
+
const ql = stream.playbackMetadata?.resolutions?.length
|
|
65
|
+
? stream.playbackMetadata.resolutions[stream.playbackMetadata.resolutions.length - 1]
|
|
66
|
+
: 'auto';
|
|
67
|
+
streams.push({ url: stream.playlist, qualityLabel: ql || 'auto', headers: playlistHeaders });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const subtitles = (stream.captions || []).map((c) => ({
|
|
71
|
+
url: c.url,
|
|
72
|
+
language: c.language,
|
|
73
|
+
type: c.type,
|
|
74
|
+
}));
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
ok: streams.length > 0,
|
|
78
|
+
provider: 'VidLink',
|
|
79
|
+
streams,
|
|
80
|
+
subtitles,
|
|
81
|
+
headers: playlistHeaders,
|
|
82
|
+
error: streams.length ? undefined : 'no playable source',
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = { resolve, name: 'VidLink' };
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@musabhussainoffical/jelly",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Jelly — a dependency-free CLI streamer (like ani-cli) that searches movies/series/anime, resolves every provider, and plays in VLC.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"anime",
|
|
7
|
+
"cli",
|
|
8
|
+
"ani-cli",
|
|
9
|
+
"stream",
|
|
10
|
+
"vlc",
|
|
11
|
+
"hls",
|
|
12
|
+
"anidb"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"type": "commonjs",
|
|
16
|
+
"bin": {
|
|
17
|
+
"jelly": "bin/jelly.js"
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"main": "lib/anidb.js",
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=16"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"bin",
|
|
28
|
+
"lib",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"test": "node test/smoke.js"
|
|
34
|
+
}
|
|
35
|
+
}
|