@anjishnusengupta/ny-cli 3.1.0 → 4.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.
Files changed (5) hide show
  1. package/README.md +43 -35
  2. package/backend.mjs +137 -39
  3. package/install.sh +1 -1
  4. package/ny-cli +109 -64
  5. package/package.json +1 -1
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  <br/>
8
8
 
9
- [![Version](https://img.shields.io/badge/v3.0.0-a855f7?style=flat-square&label=release)](https://github.com/AnjishnuSengupta/ny-cli/releases)
9
+ [![Version](https://img.shields.io/badge/v4.0.0-a855f7?style=flat-square&label=release)](https://github.com/AnjishnuSengupta/ny-cli/releases)
10
10
  [![npm](https://img.shields.io/npm/v/@anjishnusengupta/ny-cli?style=flat-square&color=22c55e&label=npm)](https://www.npmjs.com/package/@anjishnusengupta/ny-cli)
11
11
  [![License](https://img.shields.io/badge/MIT-3b82f6?style=flat-square&label=license)](LICENSE)
12
12
  [![Stars](https://img.shields.io/github/stars/AnjishnuSengupta/ny-cli?style=flat-square&color=fbbf24)](https://github.com/AnjishnuSengupta/ny-cli/stargazers)
@@ -26,33 +26,33 @@
26
26
 
27
27
  <br/>
28
28
 
29
- ## 🎯 What's New in v3.0.0
29
+ ## 🎯 What's New in v4.0.0
30
30
 
31
31
  <table>
32
32
  <tr>
33
- <td>🔧</td>
34
- <td><b>Self-Hosted Scraping</b></td>
35
- <td>Uses the <code>aniwatch</code> npm package directlyno external API dependency</td>
33
+ <td>🎙️</td>
34
+ <td><b>Sub / Dub Selection</b></td>
35
+ <td>Choose between sub (Japanese + subtitles) or dub (English audio) when you select an anime preference is saved per anime</td>
36
36
  </tr>
37
37
  <tr>
38
38
  <td>⚡</td>
39
- <td><b>Parallel Server Racing</b></td>
40
- <td><code>Promise.any()</code> races HD-1, HD-2, StreamTape & StreamSB simultaneously for fastest response</td>
39
+ <td><b>Background Episode Fetching</b></td>
40
+ <td>Episodes load in the background while you pick sub or dub — zero extra wait time</td>
41
41
  </tr>
42
42
  <tr>
43
- <td>🔄</td>
44
- <td><b>Sub/Dub Fallback</b></td>
45
- <td>Automatically falls back from sub to dub if all sub servers fail</td>
43
+ <td>🌐</td>
44
+ <td><b>Global Network Compatibility</b></td>
45
+ <td>Happy Eyeballs (RFC 6555) dual-stack support works on IPv4-only, IPv6-only, or dual-stack networks worldwide</td>
46
46
  </tr>
47
47
  <tr>
48
- <td>🚫</td>
49
- <td><b>Zero External APIs</b></td>
50
- <td>Everything runs locally via Node.js no cold-start delays or API rate limits</td>
48
+ <td>🛡️</td>
49
+ <td><b>Resilient Connectivity</b></td>
50
+ <td>Mixed IPv4/IPv6 DNS servers, 3-endpoint online check, exponential-backoff retries, crash protection for transient socket errors</td>
51
51
  </tr>
52
52
  <tr>
53
53
  <td>☁️</td>
54
- <td><b>Cloud Sync</b></td>
55
- <td>Watch history syncs between CLI and <a href="https://nyanime.tech">nyanime.tech</a> web app</td>
54
+ <td><b>Category-Aware Cloud Sync</b></td>
55
+ <td>Sub/dub preference syncs to <a href="https://nyanime.tech">nyanime.tech</a> Continue Watching remembers your choice</td>
56
56
  </tr>
57
57
  </table>
58
58
 
@@ -74,9 +74,9 @@
74
74
  │ │
75
75
  │ ▸ HLS Streaming ▸ User Accounts ▸ POSIX Shell │
76
76
  │ ▸ Multi-Server ▸ Watch History ▸ Node.js 18+ │
77
- │ ▸ Sub/Dub Toggle ▸ Cloud Sync ▸ aniwatch pkg │
77
+ │ ▸ Sub/Dub Select ▸ Cloud Sync ▸ aniwatch pkg │
78
78
  │ ▸ Skip Intro ▸ Continue Watch ▸ Self-Hosted │
79
- │ ▸ Auto Subtitles ▸ Random Anime ▸ XDG Dirs
79
+ │ ▸ Auto Subtitles ▸ Random Anime ▸ Dual-Stack
80
80
  │ ▸ MPV/VLC/IINA ▸ Profile System ▸ Zero Config │
81
81
  │ │
82
82
  ╰─────────────────────────────────────────────────────────────────╯
@@ -93,10 +93,12 @@
93
93
 
94
94
  | Feature | Description |
95
95
  |:--------|:------------|
96
+ | **🎙️ Sub/Dub Selection** | Choose sub or dub per anime — preference saved and synced to cloud |
96
97
  | **🔄 Multi-Server Racing** | Races HD-1, HD-2, StreamTape, StreamSB in parallel via `Promise.any()` |
97
98
  | **⏭️ Skip Intro** | Press `s` during intro to skip — uses API-provided timestamps |
98
99
  | **📝 Multi-Language Subs** | Auto-selects English, with all available languages loaded for switching |
99
100
  | **🔁 Sub/Dub Fallback** | If all sub servers fail, automatically retries with dub |
101
+ | **🌐 Dual-Stack Networking** | Happy Eyeballs (RFC 6555) — works on any IPv4, IPv6, or dual-stack network |
100
102
  | **🎚️ Player Support** | MPV (recommended), VLC, IINA — auto-detected or configurable |
101
103
 
102
104
  </details>
@@ -111,8 +113,8 @@
111
113
  | **🔐 Browser Auth** | Login via nyanime.tech — just paste your User ID |
112
114
  | **📜 Watch History** | Track all watched episodes with timestamps |
113
115
  | **☁️ Cloud Sync** | Seamless sync between CLI and nyanime.tech website |
114
- | **📍 Continue Watching** | Resume from exactly where you left off |
115
- | **🎲 Random Mode** | Discover new anime with random selection |
116
+ | **📍 Continue Watching** | Resume from where you left off — remembers your sub/dub choice |
117
+ | **🎲 Random Mode** | Discover new anime with random selection + sub/dub prompt |
116
118
  | **🔍 Quick Search** | Search directly from command line or interactive menu |
117
119
 
118
120
  </details>
@@ -128,22 +130,28 @@
128
130
  <div align="center">
129
131
 
130
132
  ```
131
- ╔══════════════════════════════════════════╗
132
-
133
- ║ $ ny-cli "one piece"
134
-
135
- ║ Searching for 'one piece'...
136
- ║ 1) One Piece (TV, 1120 eps)
137
- ║ 2) One Piece Film: Red (Movie)
138
-
139
- ║ Select [1-20]: 1
140
- ║ Loading episodes...
141
- Episode [1-1120]: 1120
142
-
143
- Starting playback...
144
- One Piece - Episode 1120
145
-
146
- ╚══════════════════════════════════════════╝
133
+ ╔══════════════════════════════════════════════╗
134
+
135
+ ║ $ ny-cli "one piece"
136
+
137
+ ║ Searching for 'one piece'...
138
+ ║ 1) One Piece (TV, 1155 sub / 1143 dub)
139
+ ║ 2) One Piece Film: Red (Movie, 1 sub)
140
+
141
+ ║ Select [1-20]: 1
142
+ ║ Loading episodes...
143
+
144
+ 1) Sub (Japanese audio + subtitles)
145
+ 2) Dub (English audio)
146
+ Sub or Dub? [1/2]: 1
147
+
148
+ ║ One Piece (1155 eps) [SUB] ║
149
+ ║ Episode [1-1155]: 1155 ║
150
+ ║ ║
151
+ ║ ▸ Starting playback... ║
152
+ ║ One Piece - Episode 1155 [SUB] ║
153
+ ║ ║
154
+ ╚══════════════════════════════════════════════╝
147
155
  ```
148
156
 
149
157
  </div>
package/backend.mjs CHANGED
@@ -7,54 +7,141 @@
7
7
  import dns from "node:dns";
8
8
  import https from "node:https";
9
9
  import http from "node:http";
10
+ import os from "node:os";
11
+
12
+ // ── Safety net: prevent unhandled errors from crashing the process ──────
13
+ // The aniwatch library can emit socket errors that aren't caught internally.
14
+ process.on("uncaughtException", (err) => {
15
+ // Silently ignore connection errors during retry/fallback — they're expected
16
+ if (["ENETUNREACH", "ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "EHOSTUNREACH", "EAI_AGAIN"].includes(err.code)) {
17
+ return;
18
+ }
19
+ // For other errors, output JSON so the shell script can parse it
20
+ console.log(JSON.stringify({ error: err.message || "Unexpected error" }));
21
+ process.exit(1);
22
+ });
10
23
 
11
24
  // Use public DNS (Cloudflare + Google) to bypass ISP-level domain blocking.
12
- // dns.resolve{4,6} uses these servers; dns.lookup uses the OS resolver.
13
- dns.setServers(["1.1.1.1", "8.8.8.8", "1.0.0.1", "8.8.4.4"]);
25
+ // Include BOTH IPv4 and IPv6 DNS server addresses so resolution works on:
26
+ // - IPv4-only WiFi / hotspots
27
+ // - IPv6-only carrier networks (T-Mobile, etc.)
28
+ // - Dual-stack environments
29
+ // Order: put the family matching the system first to minimise latency.
30
+ const DNS_V4 = ["1.1.1.1", "8.8.8.8", "1.0.0.1", "8.8.4.4"];
31
+ const DNS_V6 = ["2606:4700:4700::1111", "2001:4860:4860::8888", "2606:4700:4700::1001", "2001:4860:4860::8844"];
14
32
 
15
- // Custom lookup: prefer IPv6 (bypasses SNI-based DPI blocking on many ISPs),
16
- // fall back to IPv4 via dns.resolve4, then OS resolver as last resort.
17
- // Note: IPv4-only consistently fails due to ISP blocking; IPv6 is required.
33
+ // ── Detect system IPv6 connectivity ─────────────────────────────────────
34
+ // Check if the system has a routable (non-link-local, non-loopback) IPv6 address.
35
+ // If there's no IPv6 address, connecting to IPv6 endpoints will fail with ENETUNREACH.
36
+ function systemHasIPv6() {
37
+ try {
38
+ const interfaces = os.networkInterfaces();
39
+ for (const name of Object.keys(interfaces)) {
40
+ for (const addr of interfaces[name]) {
41
+ if (
42
+ addr.family === "IPv6" &&
43
+ !addr.internal &&
44
+ !addr.address.startsWith("fe80") && // skip link-local
45
+ !addr.address.startsWith("::1") // skip loopback
46
+ ) {
47
+ return true;
48
+ }
49
+ }
50
+ }
51
+ } catch {}
52
+ return false;
53
+ }
54
+
55
+ const HAS_IPV6 = systemHasIPv6();
56
+
57
+ // ── Configure DNS servers based on system network capabilities ────────
58
+ // On IPv6-only networks, IPv4 DNS servers (1.1.1.1) are unreachable.
59
+ // On IPv4-only WiFi, IPv6 DNS servers are unreachable.
60
+ // Order the server list so reachable servers come first.
61
+ try {
62
+ const servers = HAS_IPV6
63
+ ? [...DNS_V6, ...DNS_V4] // IPv6 DNS first, then IPv4 fallback
64
+ : [...DNS_V4, ...DNS_V6]; // IPv4 DNS first (most common)
65
+ dns.setServers(servers);
66
+ } catch {
67
+ // If setServers fails (unusual), leave the OS defaults in place.
68
+ }
69
+
70
+ // ── DNS resolution with timeout ─────────────────────────────────────────
71
+ // Wraps dns.resolve{4,6} with a timeout to prevent hanging on unresponsive DNS.
72
+ function resolveWithTimeout(resolver, hostname, timeoutMs = 3000) {
73
+ return new Promise((resolve) => {
74
+ const timer = setTimeout(() => resolve([]), timeoutMs);
75
+ resolver(hostname, (err, addrs) => {
76
+ clearTimeout(timer);
77
+ resolve(err || !addrs ? [] : addrs);
78
+ });
79
+ });
80
+ }
81
+
82
+ // ── Custom DNS lookup (Happy Eyeballs compatible) ───────────────────────
83
+ // When called with `all: true` (by Node.js autoSelectFamily / Happy Eyeballs),
84
+ // returns addresses from BOTH families so Node can race connections and pick
85
+ // whichever connects first. When called for a single address, returns the
86
+ // best-available family based on system capability.
18
87
  function customLookup(hostname, options, callback) {
19
88
  if (typeof options === "function") { callback = options; options = {}; }
20
89
 
21
- // Cache resolved addresses per hostname for the lifetime of this process
22
- const cacheKey = hostname;
23
- if (customLookup._cache && customLookup._cache[cacheKey]) {
24
- const cached = customLookup._cache[cacheKey];
25
- if (options.all) {
26
- return callback(null, [{ address: cached.address, family: cached.family }]);
27
- }
28
- return callback(null, cached.address, cached.family);
29
- }
90
+ const wantAll = !!options.all;
30
91
 
31
- dns.resolve6(hostname, (err6, addr6) => {
32
- if (!err6 && addr6 && addr6.length > 0) {
33
- if (!customLookup._cache) customLookup._cache = {};
34
- customLookup._cache[cacheKey] = { address: addr6[0], family: 6 };
35
- if (options.all) {
36
- return callback(null, addr6.map(a => ({ address: a, family: 6 })));
37
- }
38
- return callback(null, addr6[0], 6);
39
- }
40
- dns.resolve4(hostname, (err4, addr4) => {
41
- if (!err4 && addr4 && addr4.length > 0) {
42
- if (!customLookup._cache) customLookup._cache = {};
43
- customLookup._cache[cacheKey] = { address: addr4[0], family: 4 };
44
- if (options.all) {
45
- return callback(null, addr4.map(a => ({ address: a, family: 4 })));
46
- }
47
- return callback(null, addr4[0], 4);
92
+ if (wantAll) {
93
+ // ── Happy Eyeballs path: resolve both families in parallel ──
94
+ Promise.all([
95
+ HAS_IPV6 ? resolveWithTimeout(dns.resolve6.bind(dns), hostname) : Promise.resolve([]),
96
+ resolveWithTimeout(dns.resolve4.bind(dns), hostname),
97
+ ]).then(([v6, v4]) => {
98
+ const results = [];
99
+ // IPv6 first (preferred when available), then IPv4
100
+ for (const a of v6) results.push({ address: a, family: 6 });
101
+ for (const a of v4) results.push({ address: a, family: 4 });
102
+
103
+ if (results.length > 0) {
104
+ return callback(null, results);
48
105
  }
49
- dns.lookup(hostname, options, callback);
106
+ // All custom DNS failed — fall back to OS resolver
107
+ dns.lookup(hostname, { all: true }, callback);
108
+ }).catch(() => {
109
+ dns.lookup(hostname, { all: true }, callback);
50
110
  });
51
- });
111
+ } else {
112
+ // ── Single-address path ──
113
+ const tryIPv4 = () => {
114
+ resolveWithTimeout(dns.resolve4.bind(dns), hostname).then((v4) => {
115
+ if (v4.length > 0) return callback(null, v4[0], 4);
116
+ // Last resort: OS resolver
117
+ dns.lookup(hostname, options, callback);
118
+ }).catch(() => dns.lookup(hostname, options, callback));
119
+ };
120
+
121
+ if (HAS_IPV6) {
122
+ resolveWithTimeout(dns.resolve6.bind(dns), hostname).then((v6) => {
123
+ if (v6.length > 0) return callback(null, v6[0], 6);
124
+ tryIPv4();
125
+ }).catch(() => tryIPv4());
126
+ } else {
127
+ tryIPv4();
128
+ }
129
+ }
52
130
  }
53
131
 
54
- // Replace global agents so ALL http/https requests use our DNS.
55
- // keepAlive reduces connection overhead for multiple requests to the same host.
56
- http.globalAgent = new http.Agent({ lookup: customLookup, keepAlive: true });
57
- https.globalAgent = new https.Agent({ lookup: customLookup, keepAlive: true });
132
+ // ── Replace global HTTP agents ──────────────────────────────────────────
133
+ // autoSelectFamily enables Node.js "Happy Eyeballs" (RFC 6555): when the
134
+ // lookup returns both IPv4 and IPv6 addresses, Node races connections and
135
+ // uses whichever family connects first. This ensures the app works on
136
+ // IPv4-only WiFi, IPv6-only networks, and dual-stack setups alike.
137
+ const agentOptions = {
138
+ lookup: customLookup,
139
+ keepAlive: true,
140
+ autoSelectFamily: true,
141
+ autoSelectFamilyAttemptTimeout: 2500, // ms before trying the next family
142
+ };
143
+ http.globalAgent = new http.Agent(agentOptions);
144
+ https.globalAgent = new https.Agent(agentOptions);
58
145
 
59
146
  // Dynamic import so aniwatch picks up patched global agents
60
147
  const { HiAnime } = await import("aniwatch");
@@ -64,15 +151,26 @@ const hianime = new HiAnime.Scraper();
64
151
  const action = process.argv[2];
65
152
 
66
153
  // Retry helper: retries an async fn up to `retries` times with a delay between attempts.
67
- async function withRetry(fn, { retries = 2, delay = 1000, label = "" } = {}) {
154
+ // On transient network errors (ENETUNREACH, ECONNRESET, etc.) it retries automatically.
155
+ async function withRetry(fn, { retries = 3, delay = 1200, label = "" } = {}) {
156
+ const TRANSIENT_CODES = new Set([
157
+ "ENETUNREACH", "ECONNREFUSED", "ECONNRESET", "ETIMEDOUT",
158
+ "EHOSTUNREACH", "EAI_AGAIN", "EPIPE", "ERR_SOCKET_CONNECTION_TIMEOUT",
159
+ "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_SOCKET", "ENOTFOUND",
160
+ ]);
68
161
  let lastErr;
69
162
  for (let i = 0; i <= retries; i++) {
70
163
  try {
71
164
  return await fn();
72
165
  } catch (err) {
73
166
  lastErr = err;
167
+ const isTransient = TRANSIENT_CODES.has(err.code) ||
168
+ (err.cause && TRANSIENT_CODES.has(err.cause.code)) ||
169
+ /timeout|ENETUNREACH|ECONNR|socket/i.test(err.message);
74
170
  if (i < retries) {
75
- await new Promise(r => setTimeout(r, delay));
171
+ // Longer back-off for transient network errors
172
+ const backoff = isTransient ? delay * (i + 1) : delay;
173
+ await new Promise(r => setTimeout(r, backoff));
76
174
  }
77
175
  }
78
176
  }
package/install.sh CHANGED
@@ -25,7 +25,7 @@ printf " ╚═╝ ╚═══╝ ╚═╝ ╚═════╝
25
25
  printf "${RESET}\n"
26
26
  printf "${DIM}${CYAN} ⟨ Your Gateway to Anime Streaming ⟩${RESET}\n"
27
27
  printf "${DIM} ─────────────────────────────────────${RESET}\n"
28
- printf "${DIM} v3.1.0 • nyanime.tech${RESET}\n"
28
+ printf "${DIM} v4.0.0 • nyanime.tech${RESET}\n"
29
29
  printf "\n"
30
30
 
31
31
  REPO_URL="https://raw.githubusercontent.com/AnjishnuSengupta/ny-cli/main"
package/ny-cli CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/bin/sh
2
2
  # ══════════════════════════════════════════════════════════════════════════════
3
3
  # NY-CLI - Terminal-based Anime Streaming Client
4
- # Version: 3.1.0
4
+ # Version: 4.0.0
5
5
  # Author: Anjishnu Sengupta
6
6
  # Website: https://nyanime.tech
7
7
  # Repository: https://github.com/AnjishnuSengupta/ny-cli
@@ -10,7 +10,7 @@
10
10
  # Just install and run - no external API dependency!
11
11
  # ══════════════════════════════════════════════════════════════════════════════
12
12
 
13
- VERSION="3.1.0"
13
+ VERSION="4.0.0"
14
14
 
15
15
  # ══════════════════════════════════════════════════════════════════════════════
16
16
  # BACKEND CONFIGURATION (Self-hosted via aniwatch npm package)
@@ -295,9 +295,11 @@ do_logout() {
295
295
  # WATCH HISTORY & CLOUD SYNC
296
296
  # ══════════════════════════════════════════════════════════════════════════════
297
297
 
298
- # Check if online
298
+ # Check if online (try multiple endpoints — google is blocked in some countries)
299
299
  is_online() {
300
- curl -s --connect-timeout 2 --max-time 3 "https://www.google.com" >/dev/null 2>&1
300
+ curl -s --connect-timeout 2 --max-time 3 "https://www.google.com" >/dev/null 2>&1 \
301
+ || curl -s --connect-timeout 2 --max-time 3 "https://cloudflare.com/cdn-cgi/trace" >/dev/null 2>&1 \
302
+ || curl -s --connect-timeout 2 --max-time 3 "https://www.nyanime.tech" >/dev/null 2>&1
301
303
  }
302
304
 
303
305
  # Fetch cloud history and merge with local history
@@ -325,27 +327,29 @@ fetch_and_merge_cloud_history() {
325
327
  local cloud_tmp="${CACHE_DIR}/cloud_merged.tmp"
326
328
  > "$cloud_tmp"
327
329
 
328
- # Extract each history item with slug and title
330
+ # Extract each history item with slug, title, and category
329
331
  printf '%s' "$response" | tr '{' '\n' | grep '"animeSlug"' | while read -r item; do
330
- local slug title ep_num
332
+ local slug title ep_num category
331
333
  slug=$(printf '%s' "$item" | sed -n 's/.*"animeSlug"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
332
334
  title=$(printf '%s' "$item" | sed -n 's/.*"animeTitle"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
333
335
  ep_num=$(printf '%s' "$item" | sed -n 's/.*"episodeNum"[[:space:]]*:[[:space:]]*\([0-9]*\).*/\1/p')
336
+ category=$(printf '%s' "$item" | sed -n 's/.*"category"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
334
337
 
335
338
  if [ -n "$slug" ] && [ -n "$title" ]; then
336
339
  [ -z "$ep_num" ] && ep_num=1
340
+ [ -z "$category" ] && category="sub"
337
341
  # Check if this anime is already in local history
338
342
  if [ -f "$HISTORY_FILE" ]; then
339
343
  if ! grep -q "^${slug}|" "$HISTORY_FILE" 2>/dev/null; then
340
- # Add to local history (new entry from cloud)
344
+ # Add to local history (new entry from cloud) with category
341
345
  local timestamp
342
346
  timestamp=$(date +%s)
343
- printf '%s|%s|%s|%s\n' "$slug" "$title" "$ep_num" "$timestamp" >> "$cloud_tmp"
347
+ printf '%s|%s|%s|%s|%s\n' "$slug" "$title" "$ep_num" "$timestamp" "$category" >> "$cloud_tmp"
344
348
  fi
345
349
  else
346
350
  local timestamp
347
351
  timestamp=$(date +%s)
348
- printf '%s|%s|%s|%s\n' "$slug" "$title" "$ep_num" "$timestamp" >> "$cloud_tmp"
352
+ printf '%s|%s|%s|%s|%s\n' "$slug" "$title" "$ep_num" "$timestamp" "$category" >> "$cloud_tmp"
349
353
  fi
350
354
  fi
351
355
  done
@@ -373,15 +377,15 @@ fetch_and_merge_cloud_history() {
373
377
 
374
378
  # Save watch progress to pending sync file (for offline support)
375
379
  save_pending_sync() {
376
- local anime_slug="$1" anime_title="$2" ep_num="$3"
380
+ local anime_slug="$1" anime_title="$2" ep_num="$3" category="${4:-sub}"
377
381
  local sync_time
378
382
  sync_time=$(date +%s)
379
383
 
380
384
  # Remove existing entry for this anime
381
385
  [ -f "$PENDING_SYNC_FILE" ] && grep -v "^${anime_slug}|" "$PENDING_SYNC_FILE" > "${PENDING_SYNC_FILE}.tmp" 2>/dev/null && mv "${PENDING_SYNC_FILE}.tmp" "$PENDING_SYNC_FILE"
382
386
 
383
- # Add new entry: anime_slug|anime_title|ep_num|sync_time
384
- printf '%s|%s|%s|%s\n' "$anime_slug" "$anime_title" "$ep_num" "$sync_time" >> "$PENDING_SYNC_FILE"
387
+ # Add new entry: anime_slug|anime_title|ep_num|sync_time|category
388
+ printf '%s|%s|%s|%s|%s\n' "$anime_slug" "$anime_title" "$ep_num" "$sync_time" "$category" >> "$PENDING_SYNC_FILE"
385
389
  }
386
390
 
387
391
  # Sync pending history to cloud via nyanime API
@@ -399,25 +403,26 @@ sync_pending_history() {
399
403
  local synced=0
400
404
  local temp_file="${PENDING_SYNC_FILE}.syncing"
401
405
 
402
- # Process each pending entry
403
- while IFS='|' read -r anime_slug anime_title ep_num sync_time; do
406
+ # Process each pending entry (slug|title|ep_num|sync_time|category)
407
+ while IFS='|' read -r anime_slug anime_title ep_num sync_time category; do
404
408
  [ -z "$anime_slug" ] && continue
405
409
  [ -z "$anime_title" ] && anime_title="$anime_slug"
406
410
  [ -z "$ep_num" ] && ep_num=1
411
+ [ -z "$category" ] && category="sub"
407
412
 
408
- # Sync via nyanime API with slug-based format
413
+ # Sync via nyanime API with slug-based format + category
409
414
  local response
410
415
  response=$(curl -s -X POST "${NYANIME_BASE}/api/cli/sync-watch" \
411
416
  -H "Content-Type: application/json" \
412
417
  -H "X-Firebase-UID: ${firebase_uid}" \
413
- -d "{\"animeSlug\": \"${anime_slug}\", \"animeTitle\": \"${anime_title}\", \"episodeNum\": ${ep_num}}" \
418
+ -d "{\"animeSlug\": \"${anime_slug}\", \"animeTitle\": \"${anime_title}\", \"episodeNum\": ${ep_num}, \"category\": \"${category}\"}" \
414
419
  --connect-timeout 5 --max-time 10 2>/dev/null)
415
420
 
416
421
  if printf '%s' "$response" | grep -q '"success"' 2>/dev/null; then
417
422
  synced=$((synced + 1))
418
423
  else
419
424
  # Keep failed entries for retry
420
- printf '%s|%s|%s|%s\n' "$anime_slug" "$anime_title" "$ep_num" "$sync_time" >> "$temp_file"
425
+ printf '%s|%s|%s|%s|%s\n' "$anime_slug" "$anime_title" "$ep_num" "$sync_time" "$category" >> "$temp_file"
421
426
  fi
422
427
  done < "$PENDING_SYNC_FILE"
423
428
 
@@ -433,15 +438,15 @@ sync_pending_history() {
433
438
 
434
439
  # Background sync daemon (called during playback)
435
440
  start_sync_daemon() {
436
- local anime_slug="$1" anime_title="$2" ep_num="$3"
441
+ local anime_slug="$1" anime_title="$2" ep_num="$3" category="${4:-sub}"
437
442
 
438
443
  # Create a background process that syncs every 10 seconds
439
444
  (
440
445
  while true; do
441
446
  sleep "$SYNC_INTERVAL"
442
447
 
443
- # Save to pending sync with slug and title
444
- save_pending_sync "$anime_slug" "$anime_title" "$ep_num"
448
+ # Save to pending sync with slug, title, and category
449
+ save_pending_sync "$anime_slug" "$anime_title" "$ep_num" "$category"
445
450
 
446
451
  # Try to sync if online
447
452
  sync_pending_history >/dev/null 2>&1
@@ -456,7 +461,7 @@ stop_sync_daemon() {
456
461
  }
457
462
 
458
463
  save_to_history() {
459
- local anime_slug="$1" title="$2" episode="$3"
464
+ local anime_slug="$1" title="$2" episode="$3" category="${4:-sub}"
460
465
  local timestamp
461
466
  timestamp=$(date +%s)
462
467
 
@@ -466,9 +471,9 @@ save_to_history() {
466
471
  mv "${HISTORY_FILE}.tmp" "$HISTORY_FILE" 2>/dev/null || true
467
472
  fi
468
473
 
469
- # Create new entry and prepend to history
474
+ # Create new entry and prepend to history (slug|title|episode|timestamp|category)
470
475
  local new_entry
471
- new_entry=$(printf '%s|%s|%s|%s\n' "$anime_slug" "$title" "$episode" "$timestamp")
476
+ new_entry=$(printf '%s|%s|%s|%s|%s\n' "$anime_slug" "$title" "$episode" "$timestamp" "$category")
472
477
 
473
478
  if [ -f "$HISTORY_FILE" ] && [ -s "$HISTORY_FILE" ]; then
474
479
  printf '%s\n' "$new_entry" | cat - "$HISTORY_FILE" > "${HISTORY_FILE}.tmp"
@@ -483,8 +488,8 @@ save_to_history() {
483
488
  mv "${HISTORY_FILE}.tmp" "$HISTORY_FILE"
484
489
  fi
485
490
 
486
- # Save to pending sync for cloud backup (slug, title, episode)
487
- save_pending_sync "$anime_slug" "$title" "$episode"
491
+ # Save to pending sync for cloud backup (slug, title, episode, category)
492
+ save_pending_sync "$anime_slug" "$title" "$episode" "$category"
488
493
 
489
494
  # Try immediate sync if online
490
495
  sync_pending_history >/dev/null 2>&1 &
@@ -498,7 +503,7 @@ save_to_history() {
498
503
  # Usage: call_backend <action> [args...]
499
504
  # Returns JSON to stdout (pino logger noise is filtered out)
500
505
  call_backend() {
501
- NODE_ENV=production node "$BACKEND_SCRIPT" "$@" 2>/dev/null | grep -m1 '"success"\|"error"'
506
+ LOG_LEVEL=silent NODE_ENV=production node "$BACKEND_SCRIPT" "$@" 2>/dev/null | grep -m1 '"success"\|"error"'
502
507
  }
503
508
 
504
509
  # JSON parsing helpers (pure POSIX shell)
@@ -529,13 +534,15 @@ search_anime() {
529
534
 
530
535
  # Parse animes array from response
531
536
  # Each anime has: id, name, type, episodes.sub, episodes.dub
532
- printf '%s' "$response" | tr '{' '\n' | grep '"id"' | while IFS= read -r line; do
537
+ # After tr '{' split, "sub" and "dub" are on the line AFTER "id", so merge them
538
+ printf '%s' "$response" | tr '{' '\n' | awk '/"id"/{buf=$0; getline; print buf $0}' | while IFS= read -r line; do
533
539
  local id name type sub
534
540
  id=$(printf '%s' "$line" | grep -oP '"id"\s*:\s*"\K[^"]+' 2>/dev/null)
535
541
  name=$(printf '%s' "$line" | grep -oP '"name"\s*:\s*"\K[^"]+' 2>/dev/null)
536
542
  type=$(printf '%s' "$line" | grep -oP '"type"\s*:\s*"\K[^"]+' 2>/dev/null)
537
543
  sub=$(printf '%s' "$line" | grep -oP '"sub"\s*:\s*\K[0-9]+' 2>/dev/null)
538
- [ -n "$id" ] && [ -n "$name" ] && printf '%s|%s|%s|%s\n' "$id" "$name" "${type:-TV}" "${sub:-0}"
544
+ dub=$(printf '%s' "$line" | grep -oP '"dub"\s*:\s*\K[0-9]+' 2>/dev/null)
545
+ [ -n "$id" ] && [ -n "$name" ] && printf '%s|%s|%s|%s|%s\n' "$id" "$name" "${type:-TV}" "${sub:-0}" "${dub:-0}"
539
546
  done
540
547
  }
541
548
 
@@ -799,7 +806,10 @@ do_search() {
799
806
  printf '%s\n' "$results" | while IFS='|' read -r id title type eps_sub eps_dub; do
800
807
  local info=""
801
808
  [ -n "$type" ] && info="$type"
802
- [ -n "$eps_sub" ] && [ "$eps_sub" != "0" ] && info="$info, ${eps_sub} eps"
809
+ if [ -n "$eps_sub" ] && [ "$eps_sub" != "0" ]; then
810
+ info="$info, ${eps_sub} sub"
811
+ [ -n "$eps_dub" ] && [ "$eps_dub" != "0" ] && [ "$eps_dub" != "null" ] && info="$info / ${eps_dub} dub"
812
+ fi
803
813
  [ -n "$info" ] && info=" ${DIM}($info)${RESET}"
804
814
  printf '%b\n' " ${WHITE}$(printf '%2d' $i))${RESET} ${title}${info}"
805
815
  i=$((i + 1))
@@ -821,15 +831,41 @@ do_search() {
821
831
  }
822
832
 
823
833
  watch_anime() {
824
- local anime_id="$1" anime_title="$2"
834
+ local anime_id="$1" anime_title="$2" category="${3:-}"
825
835
 
826
- printf "\n"
827
- episodes=$(get_episodes "$anime_id")
828
- [ -z "$episodes" ] && msg_error "No episodes" && return
836
+ # Start fetching episodes in background while we ask sub/dub
837
+ local ep_cache="${CACHE_DIR}/ep_fetch.tmp"
838
+ rm -f "$ep_cache" 2>/dev/null
839
+ msg "Loading episodes..."
840
+ (get_episodes "$anime_id" > "$ep_cache" 2>/dev/null) &
841
+ local ep_fetch_pid=$!
842
+
843
+ # Ask sub/dub preference if not already chosen
844
+ if [ -z "$category" ]; then
845
+ printf "\n"
846
+ printf '%b\n' " ${GREEN}1)${RESET} Sub ${DIM}(Japanese audio + subtitles)${RESET}"
847
+ printf '%b\n' " ${YELLOW}2)${RESET} Dub ${DIM}(English audio)${RESET}"
848
+ prompt "Sub or Dub? [1/2]: "
849
+ read -r cat_choice
850
+ case "$cat_choice" in
851
+ 2|d|D|dub|Dub|DUB) category="dub" ;;
852
+ *) category="sub" ;;
853
+ esac
854
+ fi
855
+
856
+ # Wait for episode fetch to complete
857
+ wait $ep_fetch_pid 2>/dev/null
858
+ episodes=$(cat "$ep_cache" 2>/dev/null)
859
+ rm -f "$ep_cache" 2>/dev/null
860
+
861
+ [ -z "$episodes" ] && msg_error "No episodes found" && return
829
862
 
830
863
  ep_count=$(printf '%s\n' "$episodes" | wc -l)
831
864
 
832
- printf '%b\n' "\n${CYAN}${anime_title}${RESET} ${DIM}(${ep_count} eps)${RESET}"
865
+ local cat_label
866
+ [ "$category" = "dub" ] && cat_label="${YELLOW}DUB${RESET}" || cat_label="${GREEN}SUB${RESET}"
867
+
868
+ printf '%b\n' "\n${CYAN}${anime_title}${RESET} ${DIM}(${ep_count} eps)${RESET} [${cat_label}]"
833
869
  print_line
834
870
 
835
871
  if [ "$ep_count" -gt 20 ]; then
@@ -856,30 +892,33 @@ watch_anime() {
856
892
  [ -z "$ep_line" ] && msg_error "Invalid" && return
857
893
 
858
894
  episode_id=$(printf '%s' "$ep_line" | cut -d'|' -f2)
859
- play_episode "$anime_id" "$anime_title" "$ep_choice" "$episode_id" "$episodes"
895
+ play_episode "$anime_id" "$anime_title" "$ep_choice" "$episode_id" "$episodes" "$category"
860
896
  }
861
897
 
862
898
  play_episode() {
863
- local anime_id="$1" anime_title="$2" ep_num="$3" episode_id="$4" episodes="$5"
899
+ local anime_id="$1" anime_title="$2" ep_num="$3" episode_id="$4" episodes="$5" category="${6:-sub}"
864
900
 
865
901
  printf "\n"
866
902
 
867
- # Get streaming sources with fallback to multiple servers
868
- if ! get_stream_with_fallback "$episode_id" "sub"; then
903
+ # Get streaming sources with the user's chosen category
904
+ if ! get_stream_with_fallback "$episode_id" "$category"; then
869
905
  msg_error "No stream available for this episode"
870
906
  return
871
907
  fi
872
908
 
873
909
  [ -z "$STREAM_URL" ] && msg_error "No stream URL found" && return
874
910
 
875
- # Save to history (slug, title, episode)
876
- save_to_history "$anime_id" "$anime_title" "$ep_num"
911
+ # Save to history with category preference (slug, title, episode, category)
912
+ save_to_history "$anime_id" "$anime_title" "$ep_num" "$category"
877
913
 
878
- # Start background sync daemon during playback (slug, title, episode)
879
- start_sync_daemon "$anime_id" "$anime_title" "$ep_num"
914
+ # Start background sync daemon during playback (slug, title, episode, category)
915
+ start_sync_daemon "$anime_id" "$anime_title" "$ep_num" "$category"
916
+
917
+ local cat_label
918
+ [ "$category" = "dub" ] && cat_label="DUB" || cat_label="SUB"
880
919
 
881
920
  # Play the video with referer header
882
- play_video "$STREAM_URL" "${anime_title} - Episode ${ep_num}" "$STREAM_SUBTITLES" "$STREAM_REFERER"
921
+ play_video "$STREAM_URL" "${anime_title} - Episode ${ep_num} [${cat_label}]" "$STREAM_SUBTITLES" "$STREAM_REFERER"
883
922
 
884
923
  # Stop sync daemon after playback ends
885
924
  stop_sync_daemon
@@ -900,13 +939,13 @@ play_episode() {
900
939
  next_line=$(printf '%s\n' "$episodes" | grep "^${next_ep}|")
901
940
  if [ -n "$next_line" ]; then
902
941
  next_id=$(printf '%s' "$next_line" | cut -d'|' -f2)
903
- play_episode "$anime_id" "$anime_title" "$next_ep" "$next_id" "$episodes"
942
+ play_episode "$anime_id" "$anime_title" "$next_ep" "$next_id" "$episodes" "$category"
904
943
  else
905
944
  msg_info "No more episodes"
906
945
  fi
907
946
  ;;
908
- r) play_episode "$anime_id" "$anime_title" "$ep_num" "$episode_id" "$episodes" ;;
909
- s) watch_anime "$anime_id" "$anime_title" ;;
947
+ r) play_episode "$anime_id" "$anime_title" "$ep_num" "$episode_id" "$episodes" "$category" ;;
948
+ s) watch_anime "$anime_id" "$anime_title" "$category" ;;
910
949
  esac
911
950
  }
912
951
 
@@ -931,8 +970,11 @@ do_continue() {
931
970
  print_line
932
971
 
933
972
  i=1
934
- head -10 "$HISTORY_FILE" | while IFS='|' read -r id title episode ts; do
935
- printf '%b\n' " ${WHITE}$(printf '%2d' $i))${RESET} $title ${DIM}(Ep $episode)${RESET}"
973
+ head -10 "$HISTORY_FILE" | while IFS='|' read -r id title episode ts category; do
974
+ [ -z "$category" ] && category="sub"
975
+ local cat_badge
976
+ [ "$category" = "dub" ] && cat_badge="${YELLOW}DUB${RESET}" || cat_badge="${GREEN}SUB${RESET}"
977
+ printf '%b\n' " ${WHITE}$(printf '%2d' $i))${RESET} $title ${DIM}(Ep $episode)${RESET} [${cat_badge}]"
936
978
  i=$((i + 1))
937
979
  done
938
980
  print_line
@@ -948,17 +990,21 @@ do_continue() {
948
990
  anime_id=$(printf '%s' "$selected" | cut -d'|' -f1)
949
991
  anime_title=$(printf '%s' "$selected" | cut -d'|' -f2)
950
992
  last_ep=$(printf '%s' "$selected" | cut -d'|' -f3)
993
+ category=$(printf '%s' "$selected" | cut -d'|' -f5)
994
+ [ -z "$category" ] && category="sub"
951
995
 
952
996
  episodes=$(get_episodes "$anime_id")
953
997
  next_ep=$((last_ep + 1))
954
998
  next_line=$(printf '%s\n' "$episodes" | grep "^${next_ep}|")
955
999
 
956
1000
  if [ -n "$next_line" ]; then
957
- msg_info "Continuing Episode ${next_ep}"
1001
+ local cat_label
1002
+ [ "$category" = "dub" ] && cat_label="DUB" || cat_label="SUB"
1003
+ msg_info "Continuing Episode ${next_ep} [${cat_label}]"
958
1004
  episode_id=$(printf '%s' "$next_line" | cut -d'|' -f2)
959
- play_episode "$anime_id" "$anime_title" "$next_ep" "$episode_id" "$episodes"
1005
+ play_episode "$anime_id" "$anime_title" "$next_ep" "$episode_id" "$episodes" "$category"
960
1006
  else
961
- watch_anime "$anime_id" "$anime_title"
1007
+ watch_anime "$anime_id" "$anime_title" "$category"
962
1008
  fi
963
1009
  }
964
1010
 
@@ -1019,14 +1065,7 @@ do_random() {
1019
1065
 
1020
1066
  case "$ans" in
1021
1067
  [nN]*) return ;;
1022
- *)
1023
- episodes=$(get_episodes "$anime_id")
1024
- first=$(printf '%s\n' "$episodes" | head -1)
1025
- [ -n "$first" ] && {
1026
- ep_id=$(printf '%s' "$first" | cut -d'|' -f2)
1027
- play_episode "$anime_id" "$anime_title" "1" "$ep_id" "$episodes"
1028
- }
1029
- ;;
1068
+ *) watch_anime "$anime_id" "$anime_title" ;;
1030
1069
  esac
1031
1070
  }
1032
1071
 
@@ -1100,7 +1139,7 @@ main_menu() {
1100
1139
 
1101
1140
  printf '%b\n' "${CYAN}╭────────────────────────────────────────╮${RESET}"
1102
1141
  printf '%b\n' "${CYAN}│${RESET} ${WHITE}1)${RESET} 👤 Profile ${CYAN}│${RESET}"
1103
- printf '%b\n' "${CYAN}│${RESET} ${WHITE}2)${RESET} ▶️ Continue Watching ${CYAN}│${RESET}"
1142
+ printf '%b\n' "${CYAN}│${RESET} ${WHITE}2)${RESET} ▶️ Continue Watching ${CYAN}│${RESET}"
1104
1143
  printf '%b\n' "${CYAN}│${RESET} ${WHITE}3)${RESET} 🔍 Search ${CYAN}│${RESET}"
1105
1144
  printf '%b\n' "${CYAN}│${RESET} ${WHITE}4)${RESET} 📚 Trending ${CYAN}│${RESET}"
1106
1145
  printf '%b\n' "${CYAN}│${RESET} ${WHITE}5)${RESET} 🎲 Random ${CYAN}│${RESET}"
@@ -1168,10 +1207,13 @@ main() {
1168
1207
  printf '%b\n' "\n${CYAN}Results:${RESET}"
1169
1208
  print_line
1170
1209
  i=1
1171
- printf '%s\n' "$results" | while IFS='|' read -r id title type eps; do
1210
+ printf '%s\n' "$results" | while IFS='|' read -r id title type eps_sub eps_dub; do
1172
1211
  local info=""
1173
1212
  [ -n "$type" ] && info="$type"
1174
- [ -n "$eps" ] && [ "$eps" != "0" ] && info="$info, ${eps} eps"
1213
+ if [ -n "$eps_sub" ] && [ "$eps_sub" != "0" ]; then
1214
+ info="$info, ${eps_sub} sub"
1215
+ [ -n "$eps_dub" ] && [ "$eps_dub" != "0" ] && [ "$eps_dub" != "null" ] && info="$info / ${eps_dub} dub"
1216
+ fi
1175
1217
  [ -n "$info" ] && info=" ${DIM}($info)${RESET}"
1176
1218
  printf '%b\n' " ${WHITE}$(printf '%2d' $i))${RESET} ${title}${info}"
1177
1219
  i=$((i + 1))
@@ -1196,10 +1238,13 @@ main() {
1196
1238
  printf '%b\n' "\n${CYAN}Results:${RESET}"
1197
1239
  print_line
1198
1240
  i=1
1199
- printf '%s\n' "$results" | while IFS='|' read -r id title type eps; do
1241
+ printf '%s\n' "$results" | while IFS='|' read -r id title type eps_sub eps_dub; do
1200
1242
  local info=""
1201
1243
  [ -n "$type" ] && info="$type"
1202
- [ -n "$eps" ] && [ "$eps" != "0" ] && info="$info, ${eps} eps"
1244
+ if [ -n "$eps_sub" ] && [ "$eps_sub" != "0" ]; then
1245
+ info="$info, ${eps_sub} sub"
1246
+ [ -n "$eps_dub" ] && [ "$eps_dub" != "0" ] && [ "$eps_dub" != "null" ] && info="$info / ${eps_dub} dub"
1247
+ fi
1203
1248
  [ -n "$info" ] && info=" ${DIM}($info)${RESET}"
1204
1249
  printf '%b\n' " ${WHITE}$(printf '%2d' $i))${RESET} ${title}${info}"
1205
1250
  i=$((i + 1))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anjishnusengupta/ny-cli",
3
- "version": "3.1.0",
3
+ "version": "4.0.0",
4
4
  "description": "Terminal-based anime streaming client — self-hosted scraping via aniwatch",
5
5
  "main": "backend.mjs",
6
6
  "bin": {