@anjishnusengupta/ny-cli 3.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 (6) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +419 -0
  3. package/backend.mjs +189 -0
  4. package/install.sh +161 -0
  5. package/ny-cli +1219 -0
  6. package/package.json +42 -0
package/ny-cli ADDED
@@ -0,0 +1,1219 @@
1
+ #!/bin/sh
2
+ # ══════════════════════════════════════════════════════════════════════════════
3
+ # NY-CLI - Terminal-based Anime Streaming Client
4
+ # Version: 3.0.0
5
+ # Author: Anjishnu Sengupta
6
+ # Website: https://nyanime.tech
7
+ # Repository: https://github.com/AnjishnuSengupta/ny-cli
8
+ #
9
+ # Inspired by ani-cli, powered by aniwatch npm package (self-hosted scraping)
10
+ # Just install and run - no external API dependency!
11
+ # ══════════════════════════════════════════════════════════════════════════════
12
+
13
+ VERSION="3.0.0"
14
+
15
+ # ══════════════════════════════════════════════════════════════════════════════
16
+ # BACKEND CONFIGURATION (Self-hosted via aniwatch npm package)
17
+ # ══════════════════════════════════════════════════════════════════════════════
18
+
19
+ # NyAnime Website (for cloud sync & stream proxy)
20
+ NYANIME_BASE="https://www.nyanime.tech"
21
+
22
+ # Local backend script (uses aniwatch npm package directly)
23
+ # Resolved relative to the ny-cli script location (resolves symlinks)
24
+ SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "$0" 2>/dev/null || echo "$0")")" && pwd)"
25
+ BACKEND_SCRIPT="${SCRIPT_DIR}/backend.mjs"
26
+
27
+ # Firebase Configuration (NyAnime project)
28
+ FIREBASE_PROJECT_ID="nyanime-tech"
29
+ FIRESTORE_BASE="https://firestore.googleapis.com/v1/projects/${FIREBASE_PROJECT_ID}/databases/(default)/documents"
30
+
31
+ # User agent
32
+ USER_AGENT="Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0"
33
+
34
+ # Local storage directories (XDG compliant)
35
+ CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/ny-cli"
36
+ CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/ny-cli"
37
+ DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/ny-cli"
38
+
39
+ # Files
40
+ AUTH_FILE="$CONFIG_DIR/auth"
41
+ HISTORY_FILE="$DATA_DIR/history"
42
+ PENDING_SYNC_FILE="$DATA_DIR/pending_sync"
43
+
44
+ # Player (can be overridden with NYCLI_PLAYER env var)
45
+ PLAYER="${NYCLI_PLAYER:-mpv}"
46
+
47
+ # Sync interval (seconds)
48
+ SYNC_INTERVAL=30
49
+
50
+ # ══════════════════════════════════════════════════════════════════════════════
51
+ # COLORS & STYLING
52
+ # ══════════════════════════════════════════════════════════════════════════════
53
+
54
+ if [ -t 1 ]; then
55
+ RED='\033[1;31m'
56
+ GREEN='\033[1;32m'
57
+ YELLOW='\033[1;33m'
58
+ BLUE='\033[1;34m'
59
+ MAGENTA='\033[1;35m'
60
+ CYAN='\033[1;36m'
61
+ WHITE='\033[1;37m'
62
+ DIM='\033[2m'
63
+ BOLD='\033[1m'
64
+ RESET='\033[0m'
65
+ else
66
+ RED='' GREEN='' YELLOW='' BLUE='' MAGENTA='' CYAN='' WHITE='' DIM='' BOLD='' RESET=''
67
+ fi
68
+
69
+ # ══════════════════════════════════════════════════════════════════════════════
70
+ # ASCII ART BANNER
71
+ # ══════════════════════════════════════════════════════════════════════════════
72
+
73
+ show_banner() {
74
+ clear
75
+ printf '%b' "${MAGENTA}"
76
+ cat << 'EOF'
77
+
78
+ ███╗ ██╗██╗ ██╗ ██████╗██╗ ██╗
79
+ ████╗ ██║╚██╗ ██╔╝ ██╔════╝██║ ██║
80
+ ██╔██╗ ██║ ╚████╔╝ █████╗██║ ██║ ██║
81
+ ██║╚██╗██║ ╚██╔╝ ╚════╝██║ ██║ ██║
82
+ ██║ ╚████║ ██║ ╚██████╗███████╗██║
83
+ ╚═╝ ╚═══╝ ╚═╝ ╚═════╝╚══════╝╚═╝
84
+
85
+ EOF
86
+ printf '%b' "${RESET}"
87
+ printf '%b\n' "${DIM}${CYAN} ⟨ Your Gateway to Anime Streaming ⟩${RESET}"
88
+ printf '%b\n' "${DIM} ─────────────────────────────────────${RESET}"
89
+ printf '%b\n\n' "${DIM} v${VERSION} • ${CYAN}nyanime.tech${RESET}"
90
+ }
91
+
92
+ # ══════════════════════════════════════════════════════════════════════════════
93
+ # UI HELPERS
94
+ # ══════════════════════════════════════════════════════════════════════════════
95
+
96
+ print_line() {
97
+ printf '%b\n' "${CYAN}─────────────────────────────────────────────${RESET}"
98
+ }
99
+
100
+ msg() {
101
+ printf '%b\n' "${CYAN}▸ ${WHITE}$1${RESET}" >&2
102
+ }
103
+
104
+ msg_success() {
105
+ printf '%b\n' "${GREEN}✓ ${WHITE}$1${RESET}"
106
+ }
107
+
108
+ msg_error() {
109
+ printf '%b\n' "${RED}✗ ${WHITE}$1${RESET}" >&2
110
+ }
111
+
112
+ msg_warn() {
113
+ printf '%b\n' "${YELLOW}⚠ ${WHITE}$1${RESET}"
114
+ }
115
+
116
+ msg_info() {
117
+ printf '%b\n' "${BLUE}ℹ ${WHITE}$1${RESET}"
118
+ }
119
+
120
+ prompt() {
121
+ printf '%b' "${CYAN}▸ ${WHITE}$1${RESET}"
122
+ }
123
+
124
+ die() {
125
+ msg_error "$1"
126
+ exit 1
127
+ }
128
+
129
+ # ══════════════════════════════════════════════════════════════════════════════
130
+ # UTILITY FUNCTIONS
131
+ # ══════════════════════════════════════════════════════════════════════════════
132
+
133
+ check_deps() {
134
+ for cmd in curl sed grep node; do
135
+ command -v "$cmd" >/dev/null 2>&1 || die "Missing required dependency: $cmd"
136
+ done
137
+
138
+ # Check that backend.mjs and node_modules exist
139
+ if [ ! -f "$BACKEND_SCRIPT" ]; then
140
+ die "Backend script not found at $BACKEND_SCRIPT. Run install.sh or npm install in the ny-cli directory."
141
+ fi
142
+ if [ ! -d "${SCRIPT_DIR}/node_modules" ]; then
143
+ msg "Installing aniwatch package..."
144
+ (cd "$SCRIPT_DIR" && npm install --production 2>/dev/null) || die "Failed to install dependencies. Run 'npm install' in $SCRIPT_DIR"
145
+ fi
146
+
147
+ if ! command -v "$PLAYER" >/dev/null 2>&1; then
148
+ if command -v mpv >/dev/null 2>&1; then
149
+ PLAYER="mpv"
150
+ elif command -v vlc >/dev/null 2>&1; then
151
+ PLAYER="vlc"
152
+ elif command -v iina >/dev/null 2>&1; then
153
+ PLAYER="iina"
154
+ else
155
+ msg_warn "No video player found. Install mpv:"
156
+ msg_info " Arch: sudo pacman -S mpv"
157
+ msg_info " Ubuntu: sudo apt install mpv"
158
+ msg_info " macOS: brew install mpv"
159
+ fi
160
+ fi
161
+ }
162
+
163
+ init_dirs() {
164
+ mkdir -p "$CONFIG_DIR" "$CACHE_DIR" "$DATA_DIR"
165
+ }
166
+
167
+ urlencode() {
168
+ # URL encode for query string parameters
169
+ printf '%s' "$1" | sed 's/ /%20/g; s/!/%21/g; s/"/%22/g; s/#/%23/g; s/\$/%24/g; s/&/%26/g; s/'\''/%27/g; s/(/%28/g; s/)/%29/g; s/+/%2B/g; s/,/%2C/g; s/:/%3A/g; s/;/%3B/g; s/=/%3D/g; s/?/%3F/g; s/@/%40/g'
170
+ }
171
+
172
+ # Base64 encode (use openssl or base64 command)
173
+ base64_encode() {
174
+ if command -v base64 >/dev/null 2>&1; then
175
+ printf '%s' "$1" | base64 -w0 2>/dev/null || printf '%s' "$1" | base64 2>/dev/null
176
+ elif command -v openssl >/dev/null 2>&1; then
177
+ printf '%s' "$1" | openssl base64 -A 2>/dev/null
178
+ else
179
+ printf '%s' "$1"
180
+ fi
181
+ }
182
+
183
+ # Get proxied stream URL (using nyanime.tech stream proxy)
184
+ get_proxied_url() {
185
+ local url="$1"
186
+ local referer="${2:-https://megacloud.blog/}"
187
+
188
+ # Encode headers as base64 JSON
189
+ local headers_json="{\"Referer\":\"${referer}\"}"
190
+ local headers_b64
191
+ headers_b64=$(base64_encode "$headers_json")
192
+
193
+ # Build proxy URL - URL encode the stream URL
194
+ local encoded_url
195
+ encoded_url=$(printf '%s' "$url" | sed 's/%/%25/g; s/ /%20/g; s/!/%21/g; s/"/%22/g; s/#/%23/g; s/\$/%24/g; s/&/%26/g; s/'\''/%27/g; s/(/%28/g; s/)/%29/g; s/+/%2B/g; s/,/%2C/g; s/:/%3A/g; s/;/%3B/g; s/=/%3D/g; s/?/%3F/g; s/@/%40/g')
196
+
197
+ printf '%s/stream?url=%s&h=%s' "$NYANIME_BASE" "$encoded_url" "$headers_b64"
198
+ }
199
+
200
+ open_url() {
201
+ if command -v xdg-open >/dev/null 2>&1; then
202
+ xdg-open "$1" 2>/dev/null &
203
+ elif command -v open >/dev/null 2>&1; then
204
+ open "$1" 2>/dev/null &
205
+ elif command -v wslview >/dev/null 2>&1; then
206
+ wslview "$1" 2>/dev/null &
207
+ else
208
+ msg_info "Open this URL: $1"
209
+ fi
210
+ }
211
+
212
+ # ══════════════════════════════════════════════════════════════════════════════
213
+ # AUTHENTICATION
214
+ # ══════════════════════════════════════════════════════════════════════════════
215
+
216
+ is_logged_in() {
217
+ [ -f "$AUTH_FILE" ] && [ -s "$AUTH_FILE" ]
218
+ }
219
+
220
+ get_username() {
221
+ is_logged_in && head -1 "$AUTH_FILE"
222
+ }
223
+
224
+ get_token() {
225
+ is_logged_in && sed -n '2p' "$AUTH_FILE"
226
+ }
227
+
228
+ do_login() {
229
+ printf "\n"
230
+ printf '%b\n' "${MAGENTA}╭─────────────────────────────────────────────────╮${RESET}"
231
+ printf '%b\n' "${MAGENTA}│${RESET} ${BOLD}🔐 Login to NyAnime${RESET} ${MAGENTA}│${RESET}"
232
+ printf '%b\n' "${MAGENTA}├─────────────────────────────────────────────────┤${RESET}"
233
+ printf '%b\n' "${MAGENTA}│${RESET} 1. Sign up/login at nyanime.tech ${MAGENTA}│${RESET}"
234
+ printf '%b\n' "${MAGENTA}│${RESET} 2. Go to your Profile page ${MAGENTA}│${RESET}"
235
+ printf '%b\n' "${MAGENTA}│${RESET} 3. Copy your User ID shown there ${MAGENTA}│${RESET}"
236
+ printf '%b\n' "${MAGENTA}│${RESET} 4. Paste it here ${MAGENTA}│${RESET}"
237
+ printf '%b\n' "${MAGENTA}╰─────────────────────────────────────────────────╯${RESET}"
238
+ printf "\n"
239
+
240
+ local login_url="https://www.nyanime.tech/signup"
241
+ msg "Opening browser..."
242
+ open_url "$login_url"
243
+
244
+ msg_info "If browser didn't open, visit: ${login_url}"
245
+ msg_info "After signing in, go to Profile to find your User ID"
246
+ printf "\n"
247
+
248
+ prompt "Your username: "
249
+ read -r username
250
+ [ -z "$username" ] && { msg_error "Login cancelled"; return; }
251
+
252
+ prompt "Paste User ID: "
253
+ read -r firebase_uid
254
+ [ -z "$firebase_uid" ] && { msg_error "Login cancelled"; return; }
255
+
256
+ # Verify the UID exists in Firebase by checking the user document
257
+ msg "Verifying account..."
258
+ local verify_resp
259
+ verify_resp=$(curl -s --connect-timeout 5 --max-time 8 \
260
+ "${FIRESTORE_BASE}/users/${firebase_uid}" 2>/dev/null)
261
+
262
+ # Check if we got a valid response (user exists)
263
+ if printf '%s' "$verify_resp" | grep -q '"fields"' 2>/dev/null; then
264
+ printf '%s\n%s\n' "$username" "$firebase_uid" > "$AUTH_FILE"
265
+ chmod 600 "$AUTH_FILE"
266
+ printf "\n"
267
+ msg_success "Welcome, ${username}! 🎉"
268
+ msg_info "Your watch history will sync to the cloud"
269
+ # Fetch cloud history and merge with local
270
+ fetch_and_merge_cloud_history
271
+ # Try to sync any pending local watch history to cloud
272
+ sync_pending_history
273
+ else
274
+ # User might not exist yet or network error - store locally anyway
275
+ printf '%s\n%s\n' "$username" "$firebase_uid" > "$AUTH_FILE"
276
+ chmod 600 "$AUTH_FILE"
277
+ printf "\n"
278
+ msg_success "Welcome, ${username}!"
279
+ msg_warn "Could not verify account - history will sync when online"
280
+ fi
281
+ }
282
+
283
+ do_logout() {
284
+ if [ -f "$AUTH_FILE" ]; then
285
+ local user
286
+ user=$(get_username)
287
+ rm -f "$AUTH_FILE"
288
+ msg_success "Goodbye, ${user}!"
289
+ else
290
+ msg_info "Not logged in"
291
+ fi
292
+ }
293
+
294
+ # ══════════════════════════════════════════════════════════════════════════════
295
+ # WATCH HISTORY & CLOUD SYNC
296
+ # ══════════════════════════════════════════════════════════════════════════════
297
+
298
+ # Check if online
299
+ is_online() {
300
+ curl -s --connect-timeout 2 --max-time 3 "https://www.google.com" >/dev/null 2>&1
301
+ }
302
+
303
+ # Fetch cloud history and merge with local history
304
+ # This pulls watch history from the website and merges it with local history
305
+ fetch_and_merge_cloud_history() {
306
+ is_logged_in || return 0
307
+ is_online || return 0
308
+
309
+ local firebase_uid
310
+ firebase_uid=$(get_token)
311
+ [ -z "$firebase_uid" ] && return 0
312
+
313
+ msg "Syncing with cloud..."
314
+
315
+ # Fetch history from the CLI history API (GET request)
316
+ local response
317
+ response=$(curl -s --connect-timeout 5 --max-time 10 \
318
+ "${NYANIME_BASE}/api/cli/history" \
319
+ -H "X-Firebase-UID: ${firebase_uid}" \
320
+ 2>/dev/null)
321
+
322
+ # Check if we got valid history from the API
323
+ if printf '%s' "$response" | grep -q '"success"' 2>/dev/null; then
324
+ # Parse API format - contains animeSlug, animeTitle, episodeNum
325
+ local cloud_tmp="${CACHE_DIR}/cloud_merged.tmp"
326
+ > "$cloud_tmp"
327
+
328
+ # Extract each history item with slug and title
329
+ printf '%s' "$response" | tr '{' '\n' | grep '"animeSlug"' | while read -r item; do
330
+ local slug title ep_num
331
+ slug=$(printf '%s' "$item" | sed -n 's/.*"animeSlug"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
332
+ title=$(printf '%s' "$item" | sed -n 's/.*"animeTitle"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
333
+ ep_num=$(printf '%s' "$item" | sed -n 's/.*"episodeNum"[[:space:]]*:[[:space:]]*\([0-9]*\).*/\1/p')
334
+
335
+ if [ -n "$slug" ] && [ -n "$title" ]; then
336
+ [ -z "$ep_num" ] && ep_num=1
337
+ # Check if this anime is already in local history
338
+ if [ -f "$HISTORY_FILE" ]; then
339
+ if ! grep -q "^${slug}|" "$HISTORY_FILE" 2>/dev/null; then
340
+ # Add to local history (new entry from cloud)
341
+ local timestamp
342
+ timestamp=$(date +%s)
343
+ printf '%s|%s|%s|%s\n' "$slug" "$title" "$ep_num" "$timestamp" >> "$cloud_tmp"
344
+ fi
345
+ else
346
+ local timestamp
347
+ timestamp=$(date +%s)
348
+ printf '%s|%s|%s|%s\n' "$slug" "$title" "$ep_num" "$timestamp" >> "$cloud_tmp"
349
+ fi
350
+ fi
351
+ done
352
+
353
+ # Merge cloud items into local history
354
+ if [ -f "$cloud_tmp" ] && [ -s "$cloud_tmp" ]; then
355
+ if [ -f "$HISTORY_FILE" ]; then
356
+ # Append cloud items and deduplicate
357
+ cat "$cloud_tmp" >> "$HISTORY_FILE"
358
+ awk -F'|' '!seen[$1]++' "$HISTORY_FILE" > "${HISTORY_FILE}.tmp"
359
+ mv "${HISTORY_FILE}.tmp" "$HISTORY_FILE"
360
+ else
361
+ mv "$cloud_tmp" "$HISTORY_FILE"
362
+ fi
363
+ local added_count
364
+ added_count=$(wc -l < "$cloud_tmp" 2>/dev/null || echo 0)
365
+ [ "$added_count" -gt 0 ] && msg_success "Added $added_count items from cloud"
366
+ fi
367
+ rm -f "$cloud_tmp" 2>/dev/null
368
+ return 0
369
+ fi
370
+
371
+ msg_info "Cloud sync not available - using local history"
372
+ }
373
+
374
+ # Save watch progress to pending sync file (for offline support)
375
+ save_pending_sync() {
376
+ local anime_slug="$1" anime_title="$2" ep_num="$3"
377
+ local sync_time
378
+ sync_time=$(date +%s)
379
+
380
+ # Remove existing entry for this anime
381
+ [ -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
+
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"
385
+ }
386
+
387
+ # Sync pending history to cloud via nyanime API
388
+ sync_pending_history() {
389
+ [ ! -f "$PENDING_SYNC_FILE" ] && return 0
390
+ [ ! -s "$PENDING_SYNC_FILE" ] && return 0
391
+
392
+ is_logged_in || return 0
393
+ is_online || return 0
394
+
395
+ local firebase_uid
396
+ firebase_uid=$(get_token)
397
+ [ -z "$firebase_uid" ] && return 0
398
+
399
+ local synced=0
400
+ local temp_file="${PENDING_SYNC_FILE}.syncing"
401
+
402
+ # Process each pending entry
403
+ while IFS='|' read -r anime_slug anime_title ep_num sync_time; do
404
+ [ -z "$anime_slug" ] && continue
405
+ [ -z "$anime_title" ] && anime_title="$anime_slug"
406
+ [ -z "$ep_num" ] && ep_num=1
407
+
408
+ # Sync via nyanime API with slug-based format
409
+ local response
410
+ response=$(curl -s -X POST "${NYANIME_BASE}/api/cli/sync-watch" \
411
+ -H "Content-Type: application/json" \
412
+ -H "X-Firebase-UID: ${firebase_uid}" \
413
+ -d "{\"animeSlug\": \"${anime_slug}\", \"animeTitle\": \"${anime_title}\", \"episodeNum\": ${ep_num}}" \
414
+ --connect-timeout 5 --max-time 10 2>/dev/null)
415
+
416
+ if printf '%s' "$response" | grep -q '"success"' 2>/dev/null; then
417
+ synced=$((synced + 1))
418
+ else
419
+ # Keep failed entries for retry
420
+ printf '%s|%s|%s|%s\n' "$anime_slug" "$anime_title" "$ep_num" "$sync_time" >> "$temp_file"
421
+ fi
422
+ done < "$PENDING_SYNC_FILE"
423
+
424
+ # Replace pending file with failed entries only
425
+ if [ -f "$temp_file" ]; then
426
+ mv "$temp_file" "$PENDING_SYNC_FILE"
427
+ else
428
+ rm -f "$PENDING_SYNC_FILE"
429
+ fi
430
+
431
+ [ $synced -gt 0 ] && msg_success "Synced $synced items to cloud"
432
+ }
433
+
434
+ # Background sync daemon (called during playback)
435
+ start_sync_daemon() {
436
+ local anime_slug="$1" anime_title="$2" ep_num="$3"
437
+
438
+ # Create a background process that syncs every 10 seconds
439
+ (
440
+ while true; do
441
+ sleep "$SYNC_INTERVAL"
442
+
443
+ # Save to pending sync with slug and title
444
+ save_pending_sync "$anime_slug" "$anime_title" "$ep_num"
445
+
446
+ # Try to sync if online
447
+ sync_pending_history >/dev/null 2>&1
448
+ done
449
+ ) &
450
+ SYNC_PID=$!
451
+ }
452
+
453
+ stop_sync_daemon() {
454
+ [ -n "${SYNC_PID:-}" ] && kill "$SYNC_PID" 2>/dev/null
455
+ SYNC_PID=""
456
+ }
457
+
458
+ save_to_history() {
459
+ local anime_slug="$1" title="$2" episode="$3"
460
+ local timestamp
461
+ timestamp=$(date +%s)
462
+
463
+ # Save to local history - remove ALL existing entries for this anime (deduplicate)
464
+ if [ -f "$HISTORY_FILE" ]; then
465
+ grep -v "^${anime_slug}|" "$HISTORY_FILE" > "${HISTORY_FILE}.tmp" 2>/dev/null
466
+ mv "${HISTORY_FILE}.tmp" "$HISTORY_FILE" 2>/dev/null || true
467
+ fi
468
+
469
+ # Create new entry and prepend to history
470
+ local new_entry
471
+ new_entry=$(printf '%s|%s|%s|%s\n' "$anime_slug" "$title" "$episode" "$timestamp")
472
+
473
+ if [ -f "$HISTORY_FILE" ] && [ -s "$HISTORY_FILE" ]; then
474
+ printf '%s\n' "$new_entry" | cat - "$HISTORY_FILE" > "${HISTORY_FILE}.tmp"
475
+ mv "${HISTORY_FILE}.tmp" "$HISTORY_FILE"
476
+ else
477
+ printf '%s\n' "$new_entry" > "$HISTORY_FILE"
478
+ fi
479
+
480
+ # Keep only the most recent 50 entries and ensure no duplicates
481
+ if [ -f "$HISTORY_FILE" ]; then
482
+ awk -F'|' '!seen[$1]++' "$HISTORY_FILE" | head -50 > "${HISTORY_FILE}.tmp"
483
+ mv "${HISTORY_FILE}.tmp" "$HISTORY_FILE"
484
+ fi
485
+
486
+ # Save to pending sync for cloud backup (slug, title, episode)
487
+ save_pending_sync "$anime_slug" "$title" "$episode"
488
+
489
+ # Try immediate sync if online
490
+ sync_pending_history >/dev/null 2>&1 &
491
+ }
492
+
493
+ # ══════════════════════════════════════════════════════════════════════════════
494
+ # ANIME API - Self-hosted scraping via aniwatch npm package
495
+ # ══════════════════════════════════════════════════════════════════════════════
496
+
497
+ # Call the Node.js backend helper
498
+ # Usage: call_backend <action> [args...]
499
+ # Returns JSON to stdout (pino logger noise is filtered out)
500
+ call_backend() {
501
+ NODE_ENV=production node "$BACKEND_SCRIPT" "$@" 2>/dev/null | grep -m1 '"success"\|"error"'
502
+ }
503
+
504
+ # JSON parsing helpers (pure POSIX shell)
505
+ # Extract a JSON string value by key
506
+ json_get_string() {
507
+ local json="$1" key="$2"
508
+ printf '%s' "$json" | sed -n 's/.*"'"$key"'"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1
509
+ }
510
+
511
+ # Extract a JSON number value by key
512
+ json_get_number() {
513
+ local json="$1" key="$2"
514
+ printf '%s' "$json" | sed -n 's/.*"'"$key"'"[[:space:]]*:[[:space:]]*\([0-9]*\).*/\1/p' | head -1
515
+ }
516
+
517
+ # Search for anime by title
518
+ # Returns: id|name|type|episodes lines
519
+ search_anime() {
520
+ local query="$1"
521
+
522
+ msg "Searching for '$query'..."
523
+
524
+ local response
525
+ response=$(call_backend search "$query")
526
+
527
+ [ -z "$response" ] && return 1
528
+ printf '%s' "$response" | grep -q '"success"' || return 1
529
+
530
+ # Parse animes array from response
531
+ # Each anime has: id, name, type, episodes.sub, episodes.dub
532
+ printf '%s' "$response" | tr '{' '\n' | grep '"id"' | while IFS= read -r line; do
533
+ local id name type sub
534
+ id=$(printf '%s' "$line" | grep -oP '"id"\s*:\s*"\K[^"]+' 2>/dev/null)
535
+ name=$(printf '%s' "$line" | grep -oP '"name"\s*:\s*"\K[^"]+' 2>/dev/null)
536
+ type=$(printf '%s' "$line" | grep -oP '"type"\s*:\s*"\K[^"]+' 2>/dev/null)
537
+ 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}"
539
+ done
540
+ }
541
+
542
+ # Get episodes for an anime
543
+ # Returns: ep_num|episode_id|title lines
544
+ get_episodes() {
545
+ local anime_id="$1"
546
+
547
+ msg "Loading episodes..."
548
+
549
+ local response
550
+ response=$(call_backend episodes "$anime_id")
551
+
552
+ [ -z "$response" ] && return 1
553
+ printf '%s' "$response" | grep -q '"success"' || return 1
554
+
555
+ # Parse episodes array
556
+ printf '%s' "$response" | tr '{' '\n' | grep '"episodeId"' | while IFS= read -r line; do
557
+ local ep_id ep_num title is_filler
558
+ ep_id=$(printf '%s' "$line" | grep -oP '"episodeId"\s*:\s*"\K[^"]+' 2>/dev/null)
559
+ ep_num=$(printf '%s' "$line" | grep -oP '"number"\s*:\s*\K[0-9]+' 2>/dev/null)
560
+ title=$(printf '%s' "$line" | grep -oP '"title"\s*:\s*"\K[^"]+' 2>/dev/null)
561
+ is_filler="0"
562
+ printf '%s' "$line" | grep -q '"isFiller"[[:space:]]*:[[:space:]]*true' && is_filler="1"
563
+ [ -n "$ep_id" ] && [ -n "$ep_num" ] && printf '%s|%s|%s|%s\n' "$ep_num" "$ep_id" "${title:-Episode $ep_num}" "$is_filler"
564
+ done
565
+ }
566
+
567
+ # Get available servers for an episode
568
+ # Returns: server_name lines
569
+ get_servers() {
570
+ local episode_id="$1"
571
+
572
+ local response
573
+ response=$(call_backend servers "$episode_id")
574
+
575
+ [ -z "$response" ] && return 1
576
+
577
+ # Extract server names from sub array
578
+ printf '%s' "$response" | grep -o '"serverName"[[:space:]]*:[[:space:]]*"[^"]*"' | \
579
+ sed 's/.*"\([^"]*\)"/\1/' | sort -u
580
+ }
581
+
582
+ # Get streaming sources for an episode (uses multi-server fallback in backend)
583
+ # Sets global variables: STREAM_URL, STREAM_REFERER, STREAM_SUBTITLES, STREAM_INTRO_START, STREAM_INTRO_END
584
+ get_stream() {
585
+ local episode_id="$1"
586
+ local category="${2:-sub}"
587
+
588
+ msg "Getting stream (self-hosted scraping, category: $category)..."
589
+
590
+ # Reset globals
591
+ STREAM_URL=""
592
+ STREAM_REFERER=""
593
+ STREAM_SUBTITLES=""
594
+ STREAM_INTRO_START=""
595
+ STREAM_INTRO_END=""
596
+
597
+ local response
598
+ response=$(call_backend sources "$episode_id" "$category")
599
+
600
+ [ -z "$response" ] && return 1
601
+ printf '%s' "$response" | grep -q '"success"' || return 1
602
+
603
+ # Handle data wrapper
604
+ if printf '%s' "$response" | grep -q '"data"' 2>/dev/null; then
605
+ response=$(printf '%s' "$response" | sed 's/.*"data"[[:space:]]*:[[:space:]]*//' | sed 's/}[[:space:]]*$//')
606
+ fi
607
+
608
+ # Extract stream URL from sources array
609
+ STREAM_URL=$(printf '%s' "$response" | sed -n 's/.*"sources"[[:space:]]*:[[:space:]]*\[.*"url"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)
610
+
611
+ # Fallback: try extracting m3u8 URL directly
612
+ [ -z "$STREAM_URL" ] && STREAM_URL=$(printf '%s' "$response" | grep -o '"url"[[:space:]]*:[[:space:]]*"[^"]*\.m3u8"' | head -1 | sed 's/.*"\([^"]*\)"/\1/')
613
+
614
+ # Extract referer from headers
615
+ STREAM_REFERER=$(printf '%s' "$response" | grep -o '"Referer"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"/\1/')
616
+ [ -z "$STREAM_REFERER" ] && STREAM_REFERER="https://megacloud.blog/"
617
+
618
+ # Extract ALL subtitles from tracks/subtitles array
619
+ STREAM_SUBTITLES=""
620
+ STREAM_SUBTITLES_ALL=""
621
+
622
+ STREAM_SUBTITLES_ALL=$(printf '%s' "$response" | tr '{' '\n' | grep '"lang"' | grep -v -i 'thumbnails' | while read -r track; do
623
+ lang=$(printf '%s' "$track" | sed -n 's/.*"lang"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
624
+ url=$(printf '%s' "$track" | sed -n 's/.*"url"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
625
+ [ -n "$url" ] && [ -n "$lang" ] && printf '%s|%s\n' "$lang" "$url"
626
+ done)
627
+
628
+ # Set default English subtitle
629
+ STREAM_SUBTITLES=$(printf '%s\n' "$STREAM_SUBTITLES_ALL" | grep -i '^English|' | head -1 | cut -d'|' -f2)
630
+ [ -z "$STREAM_SUBTITLES" ] && STREAM_SUBTITLES=$(printf '%s\n' "$STREAM_SUBTITLES_ALL" | head -1 | cut -d'|' -f2)
631
+
632
+ # Extract intro timestamps
633
+ STREAM_INTRO_START=$(printf '%s' "$response" | sed -n 's/.*"intro"[[:space:]]*:[[:space:]]*{[^}]*"start"[[:space:]]*:[[:space:]]*\([0-9]*\).*/\1/p' | head -1)
634
+ STREAM_INTRO_END=$(printf '%s' "$response" | sed -n 's/.*"intro"[[:space:]]*:[[:space:]]*{[^}]*"end"[[:space:]]*:[[:space:]]*\([0-9]*\).*/\1/p' | head -1)
635
+
636
+ [ -n "$STREAM_URL" ] && return 0
637
+ return 1
638
+ }
639
+
640
+ # Get stream with category fallback (sub → dub)
641
+ get_stream_with_fallback() {
642
+ local episode_id="$1"
643
+ local category="${2:-sub}"
644
+
645
+ # The backend already handles multi-server fallback and sub→dub fallback
646
+ get_stream "$episode_id" "$category"
647
+ }
648
+
649
+ # Get trending/home anime
650
+ # Returns: id|name lines
651
+ get_trending() {
652
+ msg "Fetching trending..."
653
+
654
+ local response
655
+ response=$(call_backend home)
656
+
657
+ [ -z "$response" ] && return 1
658
+
659
+ # Handle data wrapper
660
+ if printf '%s' "$response" | grep -q '"data"' 2>/dev/null; then
661
+ response=$(printf '%s' "$response" | sed 's/.*"data"[[:space:]]*:[[:space:]]*//' | sed 's/}[[:space:]]*$//')
662
+ fi
663
+
664
+ # Parse trending animes
665
+ printf '%s' "$response" | tr '{' '\n' | grep '"id"' | while read -r line; do
666
+ local id name
667
+ id=$(json_get_string "$line" "id")
668
+ name=$(json_get_string "$line" "name")
669
+ [ -n "$id" ] && [ -n "$name" ] && printf '%s|%s\n' "$id" "$name"
670
+ done | head -15
671
+ }
672
+
673
+ # ══════════════════════════════════════════════════════════════════════════════
674
+ # VIDEO PLAYER
675
+ # ══════════════════════════════════════════════════════════════════════════════
676
+
677
+ # Build the stream URL (optionally through nyanime proxy for CORS bypass)
678
+ build_stream_url() {
679
+ local url="$1"
680
+ local use_proxy="${2:-0}"
681
+
682
+ if [ "$use_proxy" = "1" ] && [ -n "$NYANIME_BASE" ]; then
683
+ # Use nyanime.tech stream proxy for CORS bypass
684
+ local headers_json="{\"Referer\": \"${STREAM_REFERER:-https://megacloud.blog/}\"}"
685
+ local headers_b64
686
+ headers_b64=$(printf '%s' "$headers_json" | base64 -w 0 2>/dev/null || printf '%s' "$headers_json" | base64 2>/dev/null)
687
+ printf '%s/stream?url=%s&h=%s' "$NYANIME_BASE" "$(urlencode "$url")" "$headers_b64"
688
+ else
689
+ printf '%s' "$url"
690
+ fi
691
+ }
692
+
693
+ play_video() {
694
+ local url="$1" title="$2" subtitle="$3" referer="${4:-https://megacloud.blog/}"
695
+
696
+ [ -z "$url" ] && die "No stream available"
697
+
698
+ msg_success "Starting playback..."
699
+ printf '%b\n' " ${DIM}${title}${RESET}"
700
+
701
+ # Show intro skip info if available
702
+ if [ -n "$STREAM_INTRO_START" ] && [ -n "$STREAM_INTRO_END" ] && [ "$STREAM_INTRO_END" != "0" ]; then
703
+ printf '%b\n\n' " ${DIM}Intro: ${STREAM_INTRO_START}s - ${STREAM_INTRO_END}s (press 's' to skip)${RESET}"
704
+ else
705
+ printf '\n'
706
+ fi
707
+
708
+ case "$PLAYER" in
709
+ mpv*)
710
+ # Build mpv command with proper argument handling
711
+ local mpv_cmd="$PLAYER"
712
+ mpv_cmd="$mpv_cmd --force-media-title='$title'"
713
+
714
+ # Use --referrer for HLS streams (passes referer to ffmpeg for segment requests)
715
+ mpv_cmd="$mpv_cmd --referrer='$referer'"
716
+
717
+ # Disable ytdl to prevent interference with HLS
718
+ mpv_cmd="$mpv_cmd --ytdl=no"
719
+
720
+ # Add all available subtitles so user can switch between languages
721
+ if [ -n "$STREAM_SUBTITLES_ALL" ]; then
722
+ # Write subtitle URLs to temp file - English first, then others
723
+ local sub_file="${CACHE_DIR}/sub_urls.txt"
724
+ # Put English first so it's the default track
725
+ printf '%s\n' "$STREAM_SUBTITLES_ALL" | grep -i '^English|' | cut -d'|' -f2 > "$sub_file"
726
+ printf '%s\n' "$STREAM_SUBTITLES_ALL" | grep -vi '^English|' | cut -d'|' -f2 >> "$sub_file"
727
+ while IFS= read -r sub_url; do
728
+ [ -n "$sub_url" ] && mpv_cmd="$mpv_cmd --sub-files-append='$sub_url'"
729
+ done < "$sub_file"
730
+ rm -f "$sub_file"
731
+ mpv_cmd="$mpv_cmd --slang=eng,en,English --sid=1 --sub-visibility=yes"
732
+ elif [ -n "$subtitle" ]; then
733
+ # Fallback to single subtitle file
734
+ mpv_cmd="$mpv_cmd --sub-file='$subtitle' --sub-visibility=yes"
735
+ fi
736
+
737
+ # Add script for intro skip if available and intro times are valid
738
+ if [ -n "$STREAM_INTRO_START" ] && [ -n "$STREAM_INTRO_END" ] && [ "$STREAM_INTRO_END" != "0" ]; then
739
+ # Create a temporary script for intro skip
740
+ local skip_script="${CACHE_DIR}/intro_skip.lua"
741
+ cat > "$skip_script" << LUAEOF
742
+ -- Intro skip script
743
+ local intro_start = ${STREAM_INTRO_START}
744
+ local intro_end = ${STREAM_INTRO_END}
745
+ local skipped = false
746
+
747
+ mp.add_key_binding("s", "skip-intro", function()
748
+ local pos = mp.get_property_number("time-pos", 0)
749
+ if pos >= intro_start - 5 and pos <= intro_end then
750
+ mp.set_property_number("time-pos", intro_end)
751
+ mp.osd_message("Skipped intro")
752
+ else
753
+ mp.osd_message("No intro to skip")
754
+ end
755
+ end)
756
+
757
+ mp.observe_property("time-pos", "number", function(_, pos)
758
+ if pos and not skipped and pos >= intro_start and pos <= intro_start + 2 then
759
+ mp.osd_message("Press 's' to skip intro", 5)
760
+ end
761
+ end)
762
+ LUAEOF
763
+ mpv_cmd="$mpv_cmd --script='$skip_script'"
764
+ fi
765
+
766
+ # Run mpv with direct URL (--referrer passes to ffmpeg for HLS segments)
767
+ eval "$mpv_cmd '$url'"
768
+ ;;
769
+ vlc*)
770
+ $PLAYER --meta-title="$title" --http-referrer="$referer" --play-and-exit "$url"
771
+ ;;
772
+ iina*)
773
+ $PLAYER --mpv-force-media-title="$title" --mpv-referrer="$referer" "$url"
774
+ ;;
775
+ *)
776
+ $PLAYER "$url"
777
+ ;;
778
+ esac
779
+ }
780
+
781
+ # ══════════════════════════════════════════════════════════════════════════════
782
+ # MAIN FEATURES
783
+ # ══════════════════════════════════════════════════════════════════════════════
784
+
785
+ do_search() {
786
+ printf "\n"
787
+ prompt "Search anime: "
788
+ read -r query
789
+ [ -z "$query" ] && return
790
+
791
+ printf "\n"
792
+ results=$(search_anime "$query")
793
+
794
+ [ -z "$results" ] && msg_error "No results found for '$query'" && return
795
+
796
+ printf '%b\n' "\n${CYAN}Search Results:${RESET}"
797
+ print_line
798
+ i=1
799
+ printf '%s\n' "$results" | while IFS='|' read -r id title type eps_sub eps_dub; do
800
+ local info=""
801
+ [ -n "$type" ] && info="$type"
802
+ [ -n "$eps_sub" ] && [ "$eps_sub" != "0" ] && info="$info, ${eps_sub} eps"
803
+ [ -n "$info" ] && info=" ${DIM}($info)${RESET}"
804
+ printf '%b\n' " ${WHITE}$(printf '%2d' $i))${RESET} ${title}${info}"
805
+ i=$((i + 1))
806
+ done
807
+ print_line
808
+
809
+ printf "\n"
810
+ prompt "Select [1-20]: "
811
+ read -r choice
812
+ [ "$choice" = "q" ] && return
813
+
814
+ selected=$(printf '%s\n' "$results" | sed -n "${choice}p")
815
+ [ -z "$selected" ] && return
816
+
817
+ anime_id=$(printf '%s' "$selected" | cut -d'|' -f1)
818
+ anime_title=$(printf '%s' "$selected" | cut -d'|' -f2)
819
+
820
+ watch_anime "$anime_id" "$anime_title"
821
+ }
822
+
823
+ watch_anime() {
824
+ local anime_id="$1" anime_title="$2"
825
+
826
+ printf "\n"
827
+ episodes=$(get_episodes "$anime_id")
828
+ [ -z "$episodes" ] && msg_error "No episodes" && return
829
+
830
+ ep_count=$(printf '%s\n' "$episodes" | wc -l)
831
+
832
+ printf '%b\n' "\n${CYAN}${anime_title}${RESET} ${DIM}(${ep_count} eps)${RESET}"
833
+ print_line
834
+
835
+ if [ "$ep_count" -gt 20 ]; then
836
+ printf '%s\n' "$episodes" | head -10 | while IFS='|' read -r num id title; do
837
+ printf '%b\n' " ${WHITE}$(printf '%3s' "$num"))${RESET} Episode $num"
838
+ done
839
+ printf '%b\n' " ${DIM} ... ($((ep_count - 15)) more) ...${RESET}"
840
+ printf '%s\n' "$episodes" | tail -5 | while IFS='|' read -r num id title; do
841
+ printf '%b\n' " ${WHITE}$(printf '%3s' "$num"))${RESET} Episode $num"
842
+ done
843
+ else
844
+ printf '%s\n' "$episodes" | while IFS='|' read -r num id title; do
845
+ printf '%b\n' " ${WHITE}$(printf '%3s' "$num"))${RESET} Episode $num"
846
+ done
847
+ fi
848
+ print_line
849
+
850
+ printf "\n"
851
+ prompt "Episode [1-$ep_count]: "
852
+ read -r ep_choice
853
+ [ "$ep_choice" = "q" ] && return
854
+
855
+ ep_line=$(printf '%s\n' "$episodes" | grep "^${ep_choice}|")
856
+ [ -z "$ep_line" ] && msg_error "Invalid" && return
857
+
858
+ episode_id=$(printf '%s' "$ep_line" | cut -d'|' -f2)
859
+ play_episode "$anime_id" "$anime_title" "$ep_choice" "$episode_id" "$episodes"
860
+ }
861
+
862
+ play_episode() {
863
+ local anime_id="$1" anime_title="$2" ep_num="$3" episode_id="$4" episodes="$5"
864
+
865
+ printf "\n"
866
+
867
+ # Get streaming sources with fallback to multiple servers
868
+ if ! get_stream_with_fallback "$episode_id" "sub"; then
869
+ msg_error "No stream available for this episode"
870
+ return
871
+ fi
872
+
873
+ [ -z "$STREAM_URL" ] && msg_error "No stream URL found" && return
874
+
875
+ # Save to history (slug, title, episode)
876
+ save_to_history "$anime_id" "$anime_title" "$ep_num"
877
+
878
+ # Start background sync daemon during playback (slug, title, episode)
879
+ start_sync_daemon "$anime_id" "$anime_title" "$ep_num"
880
+
881
+ # Play the video with referer header
882
+ play_video "$STREAM_URL" "${anime_title} - Episode ${ep_num}" "$STREAM_SUBTITLES" "$STREAM_REFERER"
883
+
884
+ # Stop sync daemon after playback ends
885
+ stop_sync_daemon
886
+
887
+ # Final sync attempt
888
+ sync_pending_history >/dev/null 2>&1 &
889
+
890
+ printf "\n"
891
+ print_line
892
+ printf '%b\n' " ${WHITE}n)${RESET} Next ${WHITE}r)${RESET} Replay ${WHITE}s)${RESET} Select ${WHITE}q)${RESET} Quit"
893
+ print_line
894
+ prompt "Choice: "
895
+ read -r choice
896
+
897
+ case "$choice" in
898
+ n)
899
+ next_ep=$((ep_num + 1))
900
+ next_line=$(printf '%s\n' "$episodes" | grep "^${next_ep}|")
901
+ if [ -n "$next_line" ]; then
902
+ next_id=$(printf '%s' "$next_line" | cut -d'|' -f2)
903
+ play_episode "$anime_id" "$anime_title" "$next_ep" "$next_id" "$episodes"
904
+ else
905
+ msg_info "No more episodes"
906
+ fi
907
+ ;;
908
+ r) play_episode "$anime_id" "$anime_title" "$ep_num" "$episode_id" "$episodes" ;;
909
+ s) watch_anime "$anime_id" "$anime_title" ;;
910
+ esac
911
+ }
912
+
913
+ do_continue() {
914
+ # First, try to fetch and merge cloud history if logged in
915
+ if is_logged_in; then
916
+ fetch_and_merge_cloud_history >/dev/null 2>&1
917
+ fi
918
+
919
+ # Also clean up any duplicates in local history
920
+ if [ -f "$HISTORY_FILE" ]; then
921
+ awk -F'|' '!seen[$1]++' "$HISTORY_FILE" > "${HISTORY_FILE}.tmp" 2>/dev/null
922
+ mv "${HISTORY_FILE}.tmp" "$HISTORY_FILE" 2>/dev/null || true
923
+ fi
924
+
925
+ [ ! -f "$HISTORY_FILE" ] && msg_info "No history yet" && return
926
+
927
+ # Try to sync any pending history first
928
+ sync_pending_history >/dev/null 2>&1
929
+
930
+ printf '%b\n' "\n${CYAN}Continue Watching:${RESET}"
931
+ print_line
932
+
933
+ 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}"
936
+ i=$((i + 1))
937
+ done
938
+ print_line
939
+
940
+ printf "\n"
941
+ prompt "Select: "
942
+ read -r choice
943
+ [ "$choice" = "q" ] && return
944
+
945
+ selected=$(sed -n "${choice}p" "$HISTORY_FILE")
946
+ [ -z "$selected" ] && return
947
+
948
+ anime_id=$(printf '%s' "$selected" | cut -d'|' -f1)
949
+ anime_title=$(printf '%s' "$selected" | cut -d'|' -f2)
950
+ last_ep=$(printf '%s' "$selected" | cut -d'|' -f3)
951
+
952
+ episodes=$(get_episodes "$anime_id")
953
+ next_ep=$((last_ep + 1))
954
+ next_line=$(printf '%s\n' "$episodes" | grep "^${next_ep}|")
955
+
956
+ if [ -n "$next_line" ]; then
957
+ msg_info "Continuing Episode ${next_ep}"
958
+ episode_id=$(printf '%s' "$next_line" | cut -d'|' -f2)
959
+ play_episode "$anime_id" "$anime_title" "$next_ep" "$episode_id" "$episodes"
960
+ else
961
+ watch_anime "$anime_id" "$anime_title"
962
+ fi
963
+ }
964
+
965
+ do_recommendations() {
966
+ printf "\n"
967
+ trending=$(get_trending)
968
+ [ -z "$trending" ] && msg_error "Failed to fetch" && return
969
+
970
+ printf '%b\n' "\n${CYAN}📚 Trending:${RESET}"
971
+ print_line
972
+
973
+ i=1
974
+ printf '%s\n' "$trending" | while IFS='|' read -r id title; do
975
+ printf '%b\n' " ${WHITE}$(printf '%2d' $i))${RESET} $title"
976
+ i=$((i + 1))
977
+ done
978
+ print_line
979
+
980
+ printf "\n"
981
+ prompt "Select: "
982
+ read -r choice
983
+ [ "$choice" = "q" ] && return
984
+
985
+ selected=$(printf '%s\n' "$trending" | sed -n "${choice}p")
986
+ [ -z "$selected" ] && return
987
+
988
+ anime_id=$(printf '%s' "$selected" | cut -d'|' -f1)
989
+ anime_title=$(printf '%s' "$selected" | cut -d'|' -f2)
990
+ watch_anime "$anime_id" "$anime_title"
991
+ }
992
+
993
+ do_random() {
994
+ printf "\n"
995
+ msg "Finding random anime..."
996
+
997
+ # Generate random search query from common anime terms for true randomness
998
+ local random_terms="a b c d e f g h i j k l m n o p q r s t u v w x y z 1 2 3"
999
+ local term_count=29
1000
+ local rand_idx=$(awk 'BEGIN { srand(); print int(rand() * '"$term_count"') + 1 }')
1001
+ local query=$(printf '%s' "$random_terms" | tr ' ' '\n' | sed -n "${rand_idx}p")
1002
+
1003
+ # Search with random letter/number to get diverse results
1004
+ local results
1005
+ results=$(search_anime "$query" 2>/dev/null)
1006
+ [ -z "$results" ] && { msg_error "Failed to find anime"; return; }
1007
+
1008
+ # Pick random anime from results
1009
+ local count=$(printf '%s\n' "$results" | wc -l)
1010
+ local idx=$(awk 'BEGIN { srand(); print int(rand() * '"$count"') + 1 }')
1011
+ local selected=$(printf '%s\n' "$results" | sed -n "${idx}p")
1012
+
1013
+ local anime_id=$(printf '%s' "$selected" | cut -d'|' -f1)
1014
+ local anime_title=$(printf '%s' "$selected" | cut -d'|' -f2)
1015
+
1016
+ printf '%b\n\n' "\n${YELLOW}🎲 Random:${RESET} ${WHITE}${anime_title}${RESET}"
1017
+ prompt "Watch? [Y/n]: "
1018
+ read -r ans
1019
+
1020
+ case "$ans" in
1021
+ [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
+ ;;
1030
+ esac
1031
+ }
1032
+
1033
+ show_profile() {
1034
+ is_logged_in || { msg_info "Not logged in"; return; }
1035
+
1036
+ local username
1037
+ username=$(get_username)
1038
+
1039
+ printf '%b\n' "\n${MAGENTA}╭────────────────────────────────╮${RESET}"
1040
+ printf '%b\n' "${MAGENTA}│${RESET} ${BOLD}👤 ${username}${RESET}"
1041
+ printf '%b\n\n' "${MAGENTA}╰────────────────────────────────╯${RESET}"
1042
+
1043
+ # Show sync status
1044
+ if [ -f "$PENDING_SYNC_FILE" ] && [ -s "$PENDING_SYNC_FILE" ]; then
1045
+ local pending_count
1046
+ pending_count=$(wc -l < "$PENDING_SYNC_FILE")
1047
+ printf '%b\n' " ${YELLOW}⏳ ${pending_count} items pending sync${RESET}"
1048
+ printf '%b\n\n' " ${DIM}Will sync when online${RESET}"
1049
+ fi
1050
+
1051
+ printf '%b\n\n' " ${WHITE}1)${RESET} Logout ${WHITE}2)${RESET} Sync Now ${WHITE}3)${RESET} Back"
1052
+ prompt "Choice: "
1053
+ read -r c
1054
+ case "$c" in
1055
+ 1) do_logout ;;
1056
+ 2) msg "Syncing..."; sync_pending_history ;;
1057
+ esac
1058
+ }
1059
+
1060
+ # ══════════════════════════════════════════════════════════════════════════════
1061
+ # HELP & MENUS
1062
+ # ══════════════════════════════════════════════════════════════════════════════
1063
+
1064
+ show_help() {
1065
+ printf '%b\n' "
1066
+ ${BOLD}NY-CLI${RESET} v${VERSION} - Terminal Anime Streaming
1067
+
1068
+ ${CYAN}USAGE:${RESET} ny-cli [OPTIONS] [QUERY]
1069
+
1070
+ ${CYAN}OPTIONS:${RESET}
1071
+ -s, --search <q> Search anime
1072
+ -c, --continue Continue watching
1073
+ -r, --random Random anime
1074
+ -t, --trending Show trending
1075
+ -l, --login Login
1076
+ -L, --logout Logout
1077
+ -h, --help Help
1078
+ -v, --version Version
1079
+
1080
+ ${CYAN}EXAMPLES:${RESET}
1081
+ ny-cli Interactive mode
1082
+ ny-cli \"one piece\" Quick search
1083
+ ny-cli -c Continue watching
1084
+
1085
+ ${CYAN}PLAYER CONTROLS (mpv):${RESET}
1086
+ Space Play/Pause f Fullscreen
1087
+ ←/→ Seek 5s q Quit
1088
+ ↑/↓ Seek 60s v Subtitles
1089
+ "
1090
+ }
1091
+
1092
+ main_menu() {
1093
+ while true; do
1094
+ show_banner
1095
+
1096
+ if is_logged_in; then
1097
+ local username
1098
+ username=$(get_username)
1099
+ printf '%b\n\n' "${CYAN}Welcome, ${WHITE}${username}${CYAN}! 🎉${RESET}"
1100
+
1101
+ printf '%b\n' "${CYAN}╭────────────────────────────────────────╮${RESET}"
1102
+ printf '%b\n' "${CYAN}│${RESET} ${WHITE}1)${RESET} 👤 Profile ${CYAN}│${RESET}"
1103
+ printf '%b\n' "${CYAN}│${RESET} ${WHITE}2)${RESET} ▶️ Continue Watching ${CYAN}│${RESET}"
1104
+ printf '%b\n' "${CYAN}│${RESET} ${WHITE}3)${RESET} 🔍 Search ${CYAN}│${RESET}"
1105
+ printf '%b\n' "${CYAN}│${RESET} ${WHITE}4)${RESET} 📚 Trending ${CYAN}│${RESET}"
1106
+ printf '%b\n' "${CYAN}│${RESET} ${WHITE}5)${RESET} 🎲 Random ${CYAN}│${RESET}"
1107
+ printf '%b\n' "${CYAN}│${RESET} ${WHITE}h)${RESET} ❓ Help ${CYAN}│${RESET}"
1108
+ printf '%b\n' "${CYAN}│${RESET} ${WHITE}q)${RESET} 🚪 Exit ${CYAN}│${RESET}"
1109
+ printf '%b\n\n' "${CYAN}╰────────────────────────────────────────╯${RESET}"
1110
+ prompt "Choice: "
1111
+ read -r c
1112
+
1113
+ case "$c" in
1114
+ 1) show_profile ;;
1115
+ 2) do_continue ;;
1116
+ 3) do_search ;;
1117
+ 4) do_recommendations ;;
1118
+ 5) do_random ;;
1119
+ h|H) show_help; printf '%b' "\n${DIM}Press Enter...${RESET}"; read -r _ ;;
1120
+ q|Q) printf '%b\n\n' "\n${MAGENTA}Sayounara! 👋${RESET}"; exit 0 ;;
1121
+ esac
1122
+ else
1123
+ printf '%b\n\n' "${CYAN}Welcome! Sign in for all features.${RESET}"
1124
+
1125
+ printf '%b\n' "${CYAN}╭────────────────────────────────────────╮${RESET}"
1126
+ printf '%b\n' "${CYAN}│${RESET} ${WHITE}1)${RESET} 🔍 Search ${CYAN}│${RESET}"
1127
+ printf '%b\n' "${CYAN}│${RESET} ${WHITE}2)${RESET} 📚 Trending ${CYAN}│${RESET}"
1128
+ printf '%b\n' "${CYAN}│${RESET} ${WHITE}3)${RESET} 🔐 Login ${CYAN}│${RESET}"
1129
+ printf '%b\n' "${CYAN}│${RESET} ${WHITE}h)${RESET} ❓ Help ${CYAN}│${RESET}"
1130
+ printf '%b\n' "${CYAN}│${RESET} ${WHITE}q)${RESET} 🚪 Exit ${CYAN}│${RESET}"
1131
+ printf '%b\n\n' "${CYAN}╰────────────────────────────────────────╯${RESET}"
1132
+ prompt "Choice: "
1133
+ read -r c
1134
+
1135
+ case "$c" in
1136
+ 1) do_search ;;
1137
+ 2) do_recommendations ;;
1138
+ 3) do_login ;;
1139
+ h|H) show_help; printf '%b' "\n${DIM}Press Enter...${RESET}"; read -r _ ;;
1140
+ q|Q) printf '%b\n\n' "\n${MAGENTA}Sayounara! 👋${RESET}"; exit 0 ;;
1141
+ esac
1142
+ fi
1143
+ done
1144
+ }
1145
+
1146
+ # ══════════════════════════════════════════════════════════════════════════════
1147
+ # ENTRY POINT
1148
+ # ══════════════════════════════════════════════════════════════════════════════
1149
+
1150
+ main() {
1151
+ check_deps
1152
+ init_dirs
1153
+
1154
+ case "${1:-}" in
1155
+ -h|--help) show_help; exit 0 ;;
1156
+ -v|--version) printf "ny-cli %s\n" "$VERSION"; exit 0 ;;
1157
+ -l|--login) show_banner; do_login; exit 0 ;;
1158
+ -L|--logout) do_logout; exit 0 ;;
1159
+ -c|--continue) show_banner; do_continue; exit 0 ;;
1160
+ -r|--random) show_banner; do_random; exit 0 ;;
1161
+ -t|--trending) show_banner; do_recommendations; exit 0 ;;
1162
+ -s|--search)
1163
+ shift
1164
+ show_banner
1165
+ [ -n "$1" ] && {
1166
+ results=$(search_anime "$*")
1167
+ [ -n "$results" ] && {
1168
+ printf '%b\n' "\n${CYAN}Results:${RESET}"
1169
+ print_line
1170
+ i=1
1171
+ printf '%s\n' "$results" | while IFS='|' read -r id title type eps; do
1172
+ local info=""
1173
+ [ -n "$type" ] && info="$type"
1174
+ [ -n "$eps" ] && [ "$eps" != "0" ] && info="$info, ${eps} eps"
1175
+ [ -n "$info" ] && info=" ${DIM}($info)${RESET}"
1176
+ printf '%b\n' " ${WHITE}$(printf '%2d' $i))${RESET} ${title}${info}"
1177
+ i=$((i + 1))
1178
+ done
1179
+ print_line
1180
+ printf "\n"
1181
+ prompt "Select: "
1182
+ read -r c
1183
+ selected=$(printf '%s\n' "$results" | sed -n "${c}p")
1184
+ [ -n "$selected" ] && watch_anime "$(printf '%s' "$selected" | cut -d'|' -f1)" "$(printf '%s' "$selected" | cut -d'|' -f2)"
1185
+ } || msg_error "No results"
1186
+ }
1187
+ exit 0
1188
+ ;;
1189
+ "")
1190
+ main_menu
1191
+ ;;
1192
+ *)
1193
+ show_banner
1194
+ results=$(search_anime "$*")
1195
+ [ -n "$results" ] && {
1196
+ printf '%b\n' "\n${CYAN}Results:${RESET}"
1197
+ print_line
1198
+ i=1
1199
+ printf '%s\n' "$results" | while IFS='|' read -r id title type eps; do
1200
+ local info=""
1201
+ [ -n "$type" ] && info="$type"
1202
+ [ -n "$eps" ] && [ "$eps" != "0" ] && info="$info, ${eps} eps"
1203
+ [ -n "$info" ] && info=" ${DIM}($info)${RESET}"
1204
+ printf '%b\n' " ${WHITE}$(printf '%2d' $i))${RESET} ${title}${info}"
1205
+ i=$((i + 1))
1206
+ done
1207
+ print_line
1208
+ printf "\n"
1209
+ prompt "Select: "
1210
+ read -r c
1211
+ selected=$(printf '%s\n' "$results" | sed -n "${c}p")
1212
+ [ -n "$selected" ] && watch_anime "$(printf '%s' "$selected" | cut -d'|' -f1)" "$(printf '%s' "$selected" | cut -d'|' -f2)"
1213
+ } || msg_error "No results"
1214
+ exit 0
1215
+ ;;
1216
+ esac
1217
+ }
1218
+
1219
+ main "$@"