ani-cli-ar 1.8.4__py3-none-any.whl

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.
ani_cli_arabic/cli.py ADDED
@@ -0,0 +1,598 @@
1
+ import os
2
+ import sys
3
+ import subprocess
4
+ import shutil
5
+ import time
6
+ import re
7
+ from ani_cli_arabic.api import AnimeAPI
8
+ from ani_cli_arabic.player import PlayerManager
9
+ from ani_cli_arabic.models import QualityOption
10
+ from ani_cli_arabic.history import HistoryManager
11
+ from ani_cli_arabic.version import APP_VERSION
12
+ from ani_cli_arabic.config import MINIMAL_ASCII_ART, GOODBYE_ART, THEMES
13
+ from rich.console import Console
14
+ from rich.text import Text
15
+ from rich.panel import Panel
16
+ from rich.align import Align
17
+
18
+ class AniCliWrapper:
19
+ def __init__(self, api, player, history, settings, rpc, language_override=None):
20
+ self.api = api
21
+ self.player = player
22
+ self.history = history
23
+ self.settings_manager = settings
24
+ self.rpc = rpc
25
+ self.language_override = language_override
26
+ self.fzf_available = shutil.which('fzf') is not None
27
+ self.console = Console()
28
+
29
+ def _get_language(self):
30
+ return self.language_override or self.settings_manager.get('preferred_language', 'Arabic Sub')
31
+
32
+ def get_theme_color(self, key="ascii"):
33
+ t_name = self.settings_manager.get("theme")
34
+ theme = THEMES.get(t_name, THEMES["blue"])
35
+ return theme.get(key, "blue")
36
+
37
+ def _get_rpc_status_text(self):
38
+ if not self.settings_manager.get('discord_rpc'):
39
+ return Text("")
40
+
41
+ if self.rpc.connected:
42
+ return Text("RPC: Connected", style="green")
43
+ else:
44
+ return Text("")
45
+
46
+ def _die(self, msg):
47
+ """Exit with error message."""
48
+ print(f"\033[1;31m{msg}\033[0m", file=sys.stderr)
49
+ sys.exit(1)
50
+
51
+ def _launcher(self, items, prompt_text, multi=False):
52
+ if not items:
53
+ return []
54
+
55
+ if self.fzf_available:
56
+ # fzf mode
57
+ args = ['fzf', '--ansi', '--reverse', '--cycle', '--prompt', f"{prompt_text} > ", '--bind', 'esc:abort,left:abort']
58
+ if multi:
59
+ args.append('-m')
60
+
61
+ input_str = "\n".join(items)
62
+
63
+ try:
64
+ result = subprocess.run(
65
+ args,
66
+ input=input_str,
67
+ text=True,
68
+ encoding='utf-8',
69
+ stdout=subprocess.PIPE,
70
+ stderr=None # Let fzf render UI to terminal
71
+ )
72
+ if result.returncode == 0:
73
+ out = result.stdout.strip()
74
+ if not out:
75
+ return []
76
+ return out.split('\n')
77
+ return None
78
+ except Exception as e:
79
+ self._die(f"Error running fzf: {e}")
80
+ else:
81
+ print(f"\033[1;36m{prompt_text}\033[0m")
82
+ for i, item in enumerate(items, 1):
83
+ print(f"{i}. {item}")
84
+
85
+ try:
86
+ print("\nEnter selection (e.g. 1, 1-3) or 'b'/'q' to back: ", end='')
87
+ selection = input().strip()
88
+
89
+ if selection.lower() in ['b', 'q', 'back']:
90
+ return None
91
+
92
+ files = []
93
+
94
+ parts = selection.split()
95
+ for part in parts:
96
+ if '-' in part:
97
+ try:
98
+ start, end = map(int, part.split('-'))
99
+ for idx in range(start, end + 1):
100
+ if 1 <= idx <= len(items):
101
+ files.append(items[idx-1])
102
+ except (ValueError, IndexError):
103
+ pass
104
+ elif part.isdigit():
105
+ idx = int(part) - 1
106
+ if 0 <= idx < len(items):
107
+ files.append(items[idx])
108
+ return files
109
+ except Exception:
110
+ return []
111
+
112
+ def get_quality_preference(self, server_data):
113
+ current_ep_data = server_data.get('CurrentEpisode', {})
114
+ qualities = [
115
+ QualityOption("1080p", 'FRFhdQ', "info"),
116
+ QualityOption("720p", 'FRLink', "info"),
117
+ QualityOption("480p", 'FRLowQ', "info"),
118
+ ]
119
+
120
+ available = []
121
+ for q in qualities:
122
+ if current_ep_data.get(q.server_key):
123
+ available.append(q)
124
+ return available
125
+
126
+ def _fetch_english_stream(self, anime_title, episode_num, quality="1080p", dub=False):
127
+ from .scrapers.english import AllAnimeScraper
128
+ scraper = AllAnimeScraper()
129
+ mode = "dub" if dub else "sub"
130
+ try:
131
+ results = scraper.search(anime_title, mode)
132
+ if not results:
133
+ print(f"\033[1;33m[scraper] No results for '{anime_title}'\033[0m")
134
+ return None, {}
135
+ result = self._pick_best_anime_result(scraper, results, mode)
136
+ if result is None:
137
+ return None, {}
138
+ show_id = result["id"]
139
+ ep_num = int(float(episode_num))
140
+ return scraper.resolve_stream_url(show_id, ep_num, mode)
141
+ except Exception as e:
142
+ print(f"\033[1;31m[scraper] Error: {e}\033[0m", file=__import__('sys').stderr)
143
+ return None, {}
144
+
145
+ def _pick_best_anime_result(self, scraper, results, mode):
146
+ if len(results) == 1:
147
+ return results[0]
148
+ candidates = []
149
+ for r in results[:18]:
150
+ try:
151
+ eps = scraper.get_episodes(r["id"], mode)
152
+ except Exception:
153
+ eps = []
154
+ candidates.append((r, len(eps)))
155
+ candidates.sort(key=lambda x: x[1], reverse=True)
156
+ if candidates and candidates[0][1] > 0:
157
+ return candidates[0][0]
158
+ return results[0]
159
+
160
+ def play_video(self, anime, ep, quality_override=None):
161
+ language = self._get_language()
162
+
163
+ if language != 'Arabic Sub':
164
+ is_dub = (language == 'English Dub')
165
+ print(f"\033[1;34mFetching {language} stream for Episode {ep.number}...\033[0m")
166
+ url_and_headers = (None, {})
167
+ with self.console.status(f"[bold cyan]Fetching English stream...[/bold cyan]", spinner="bouncingBar"):
168
+ url_and_headers = self._fetch_english_stream(anime.title_en, ep.number, "best", is_dub)
169
+ if not url_and_headers or not url_and_headers[0]:
170
+ print("\033[1;31mFailed to get English stream URL.\033[0m")
171
+ return False
172
+ direct_url, stream_headers = url_and_headers
173
+ print(f"\033[1;34mPlaying episode {ep.number} ({language})...\033[0m")
174
+ self.player.play(direct_url, f"{anime.title_en} - Episode {ep.number}", headers=stream_headers)
175
+ self.history.mark_watched(anime.id, ep.display_num, anime.title_en)
176
+ self.history.save_history()
177
+ return True
178
+
179
+ server_data = None
180
+
181
+ with self.console.status(f"[bold blue]Fetching links for Episode {ep.number}...[/bold blue]", spinner="dots"):
182
+ server_data = self.api.get_streaming_servers(anime.id, ep.number, anime.type)
183
+
184
+ if not server_data:
185
+ print("\033[1;31mNo servers found.\033[0m")
186
+ return False
187
+
188
+ available = self.get_quality_preference(server_data)
189
+ if not available:
190
+ print("\033[1;31mNo streamable qualities found.\033[0m")
191
+ return False
192
+
193
+ selected_q = None
194
+ if quality_override:
195
+ for q in available:
196
+ if quality_override.lower() in q.name.lower():
197
+ selected_q = q
198
+ break
199
+
200
+ if not selected_q:
201
+ selected_q = available[0]
202
+
203
+ current_ep_data = server_data.get('CurrentEpisode', {})
204
+ server_id = current_ep_data.get(selected_q.server_key)
205
+
206
+ if not server_id:
207
+ print(f"\033[1;31mNo server ID found for quality {selected_q.name}\033[0m")
208
+ return False
209
+
210
+ direct_url = None
211
+ with self.console.status(f"[bold cyan]Extracting {selected_q.name} link...[/bold cyan]", spinner="bouncingBar"):
212
+ mf_url = self.api.build_mediafire_url(server_id)
213
+ if mf_url:
214
+ direct_url = self.api.extract_mediafire_direct(mf_url)
215
+
216
+ if not direct_url:
217
+ print(f"\033[1;31mFailed to extract direct link for {selected_q.name}\033[0m")
218
+ return False
219
+
220
+ print(f"\033[1;34mPlaying episode {ep.number} ({selected_q.name})...\033[0m")
221
+
222
+ self.player.play(direct_url, f"{anime.title_en} - Episode {ep.number}")
223
+
224
+ self.history.mark_watched(anime.id, ep.display_num, anime.title_en)
225
+ self.history.save_history()
226
+ return True
227
+
228
+ def _process_anime_list(self, results, title="Select Anime"):
229
+ anime_map = {}
230
+ display_lines = []
231
+
232
+ # Calculate alignment padding
233
+ max_len = 0
234
+ clean_titles = []
235
+ for res in results:
236
+ t = res.title_en
237
+ if len(t) > 60: t = t[:57] + "..."
238
+ clean_titles.append(t)
239
+ if len(t) > max_len: max_len = len(t)
240
+
241
+ for idx, res in enumerate(results):
242
+ C_CYAN = "\033[36m"
243
+ C_RESET = "\033[0m"
244
+ C_DIM = "\033[90m"
245
+ C_YELLOW = "\033[33m"
246
+ C_GREEN = "\033[32m"
247
+
248
+ t_str = clean_titles[idx]
249
+ padding = " " * (max_len - len(t_str) + 3)
250
+
251
+ # Metadata
252
+ meta = []
253
+ if res.episodes and str(res.episodes) not in ["?", "0"]:
254
+ meta.append(f"{res.episodes} eps")
255
+
256
+ if res.premiered and str(res.premiered) not in ["0", "N/A", "None", "", "?"]:
257
+ meta.append(f"{res.premiered}")
258
+
259
+ meta_str = ""
260
+ if meta:
261
+ meta_str = f" {C_DIM}|{C_RESET} ".join(meta)
262
+ meta_str = f"{C_DIM}[{C_RESET} {meta_str} {C_DIM}]{C_RESET}"
263
+
264
+ score_str = ""
265
+ if res.score and str(res.score) not in ["0", "N/A", "None", ""]:
266
+ score_str = f" {C_YELLOW}★ {res.score}{C_RESET}"
267
+
268
+ line = f"{C_CYAN}{t_str}{C_RESET}{padding}{meta_str}{score_str}"
269
+ display_lines.append(line)
270
+
271
+ # Map the exact line
272
+ anime_map[line] = res
273
+
274
+ ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
275
+ clean_line = ansi_escape.sub('', line)
276
+ anime_map[clean_line] = res
277
+
278
+ while True:
279
+ selection = self._launcher(display_lines, title)
280
+ if selection is None:
281
+ # Back
282
+ break
283
+
284
+ if not selection:
285
+ continue
286
+
287
+ sel_text = selection[0]
288
+ if sel_text not in anime_map:
289
+ # Try stripping ANSI if direct lookup fails
290
+ ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
291
+ sel_text = ansi_escape.sub('', sel_text)
292
+
293
+ if sel_text not in anime_map:
294
+ print("\033[1;31mSelection error: Item not found in map.\033[0m")
295
+ continue
296
+
297
+ selected_anime = anime_map[sel_text]
298
+
299
+ # RPC Update
300
+ if self.rpc:
301
+ self.rpc.update_viewing_anime(selected_anime.title_en, selected_anime.thumbnail)
302
+
303
+ episodes = []
304
+ with self.console.status("[bold blue]Fetching episodes...[/bold blue]", spinner="dots"):
305
+ episodes = self.api.get_episodes(selected_anime.id)
306
+
307
+ if not episodes:
308
+ print("\033[1;31mNo episodes found.\033[0m")
309
+ continue
310
+
311
+ ep_map = {}
312
+ ep_lines = []
313
+ for ep in episodes:
314
+ line = f"Episode {ep.number}"
315
+ ep_lines.append(line)
316
+ ep_map[line] = ep
317
+
318
+ # Loop for Episode Selection
319
+ while True:
320
+ selected_ep_lines = self._launcher(ep_lines, f"Select Episode ({selected_anime.title_en})", multi=True)
321
+ if selected_ep_lines is None:
322
+ # Back to Anime Selection
323
+ break
324
+
325
+ if not selected_ep_lines:
326
+ continue
327
+
328
+ try:
329
+ queue = [ep_map[line] for line in selected_ep_lines if line in ep_map]
330
+ if not queue:
331
+ continue
332
+ except (KeyError, ValueError):
333
+ continue
334
+
335
+ current_idx = 0
336
+ current_quality = None
337
+
338
+ # Playback Loop
339
+ played_idx = 0
340
+ while played_idx < len(queue):
341
+ ep = queue[played_idx]
342
+
343
+ if self.rpc:
344
+ self.rpc.update_watching(selected_anime.title_en, ep.number, selected_anime.thumbnail)
345
+
346
+ if not self.play_video(selected_anime, ep, current_quality):
347
+ pass
348
+ played_idx += 1
349
+
350
+ # Continue queue
351
+ if played_idx < len(queue):
352
+ continue
353
+
354
+ # Interactive Player Menu
355
+ while True:
356
+ menu_opts = ["Next", "Replay", "Previous", "Select", "Change Quality", "Quit"]
357
+ sel = self._launcher(menu_opts, f"Playing episode {ep.number} of {selected_anime.title_en}")
358
+
359
+ if not sel:
360
+ break
361
+
362
+ cmd = sel[0]
363
+
364
+ if cmd == "Next":
365
+ next_ep_num = self._get_next_ep_num(episodes, ep)
366
+ if next_ep_num:
367
+ next_ep = next((e for e in episodes if e.number == next_ep_num), None)
368
+ if next_ep:
369
+ ep = next_ep
370
+ self.play_video(selected_anime, ep, current_quality)
371
+ else:
372
+ print("\033[1;33mNo next episode.\033[0m")
373
+
374
+ elif cmd == "Previous":
375
+ prev_ep_num = self._get_prev_ep_num(episodes, ep)
376
+ if prev_ep_num:
377
+ prev_ep = next((e for e in episodes if e.number == prev_ep_num), None)
378
+ if prev_ep:
379
+ ep = prev_ep
380
+ self.play_video(selected_anime, ep, current_quality)
381
+ else:
382
+ print("\033[1;33mNo previous episode.\033[0m")
383
+
384
+ elif cmd == "Replay":
385
+ self.play_video(selected_anime, ep, current_quality)
386
+
387
+ elif cmd == "Select":
388
+ # Open episode selector again
389
+ new_sel = self._launcher(ep_lines, "Select Episode")
390
+ if new_sel:
391
+ # Convert selection to ep object
392
+ new_ep_line = new_sel[0]
393
+ if new_ep_line in ep_map:
394
+ ep = ep_map[new_ep_line]
395
+ self.play_video(selected_anime, ep, current_quality)
396
+
397
+ elif cmd == "Change Quality":
398
+ qs = ["1080p", "720p", "480p"]
399
+ q_sel = self._launcher(qs, "Select Quality")
400
+ if q_sel:
401
+ current_quality = q_sel[0]
402
+ print(f"\033[1;32mQuality set to {current_quality} (will apply on next play)\033[0m")
403
+ self.play_video(selected_anime, ep, current_quality)
404
+
405
+ elif cmd == "Quit":
406
+ sys.exit(0)
407
+
408
+ # If we break from "while True" (Back/ESC), we exit the queue loop completely
409
+ break
410
+
411
+ # Back to Episode Selection
412
+ continue
413
+
414
+ def _print_header(self):
415
+ ascii_color = self.get_theme_color("ascii")
416
+ self.console.print(MINIMAL_ASCII_ART.strip(), style=ascii_color)
417
+ self.console.print(f" {APP_VERSION} | Made by The NoBy's", style="dim")
418
+
419
+ def run(self, query=None):
420
+ ascii_color = self.get_theme_color("ascii")
421
+ border_color = self.get_theme_color("border")
422
+
423
+ # Initial Clear
424
+ os.system('cls' if os.name == 'nt' else 'clear')
425
+ self._print_header()
426
+
427
+ while True:
428
+ if '-i' not in sys.argv:
429
+ cols = shutil.get_terminal_size().columns
430
+ if cols >= 80:
431
+ return "SWITCH_TO_TUI"
432
+
433
+ if not query:
434
+ # Show RPC Status
435
+ status_text = self._get_rpc_status_text()
436
+ if status_text.plain:
437
+ self.console.print(Text(" ") + status_text)
438
+ self.console.print()
439
+ else:
440
+ self.console.print() # Spacer if no RPC status
441
+
442
+
443
+ # Show Menu / Prompt
444
+ margin = " "
445
+ self.console.print(margin + "[cyan]T[/cyan]: Trending [cyan]P[/cyan]: Popular", style="dim")
446
+ self.console.print(margin + "[cyan]G[/cyan]: Genres [cyan]S[/cyan]: Studios", style="dim")
447
+ self.console.print()
448
+
449
+ # Input
450
+ try:
451
+ self.console.print(f" [{border_color}]╭─  Search (or T,P,S,G,D)[/{border_color}]")
452
+ self.console.print(f" [{border_color}]╰─>[/{border_color}] ", end="")
453
+ query = input().strip()
454
+ except (KeyboardInterrupt, EOFError):
455
+ print()
456
+ return
457
+
458
+ if query.lower() in ['q', 'quit', 'exit']:
459
+ return
460
+
461
+ if not query:
462
+ continue
463
+
464
+ # Command Handling
465
+ cmd = query.lower()
466
+ query = None # Consume query
467
+
468
+ # Clear screen before showing results/submenus
469
+ # os.system('cls' if os.name == 'nt' else 'clear')
470
+
471
+ if cmd == 't':
472
+ with self.console.status("[bold blue]Fetching trending...[/bold blue]", spinner="dots"):
473
+ if self.rpc: self.rpc.update_trending()
474
+ results = self.api.get_trending_anime(limit=100)
475
+ self.console.print(f"[green]Got {len(results)} trending results[/green]")
476
+ self._process_anime_list(results, "Trending Anime")
477
+ # Clear after return
478
+ os.system('cls' if os.name == 'nt' else 'clear')
479
+ self._print_header()
480
+ continue
481
+
482
+ if cmd == 'p':
483
+ with self.console.status("[bold blue]Fetching popular...[/bold blue]", spinner="dots"):
484
+ results = self.api.get_top_rated_anime(limit=100)
485
+ self.console.print(f"[green]Got {len(results)} popular results[/green]")
486
+ self._process_anime_list(results, "Popular Anime")
487
+ os.system('cls' if os.name == 'nt' else 'clear')
488
+ self._print_header()
489
+ continue
490
+
491
+ if cmd == 'g':
492
+ genres = ["Action", "Adventure", "Comedy", "Drama", "Fantasy",
493
+ "Horror", "Mystery", "Romance", "Sci-Fi", "Slice of Life",
494
+ "Sports", "Supernatural", "Thriller", "Isekai", "School"]
495
+ sel = self._launcher(genres, "Select Genre")
496
+ if sel and sel[0]:
497
+ genre = sel[0]
498
+ with self.console.status(f"[bold blue]Fetching {genre} anime...[/bold blue]", spinner="dots"):
499
+ results = self.api.get_anime_list("GENRE", genre, "SERIES", limit=100)
500
+ self.console.print(f"[green]Got {len(results)} results for {genre}[/green]")
501
+ self._process_anime_list(results, f"Genre: {genre}")
502
+
503
+ os.system('cls' if os.name == 'nt' else 'clear')
504
+ self._print_header()
505
+ continue
506
+
507
+ if cmd == 's':
508
+ studios = [
509
+ "Toei Animation", "Sunrise", "Madhouse", "Production I.G", "J.C.Staff",
510
+ "TMS Entertainment", "Studio Pierrot", "Studio Deen", "A-1 Pictures",
511
+ "Bones", "Kyoto Animation", "MAPPA", "Wit Studio", "ufotable",
512
+ "White Fox", "David Production", "Shaft", "Trigger", "CloverWorks",
513
+ "Lerche", "P.A. Works", "CoMix Wave Films", "Gainax", "Tatsunoko Production"
514
+ ]
515
+ studios.sort()
516
+ sel = self._launcher(studios, "Select Studio")
517
+ if sel and sel[0]:
518
+ studio = sel[0]
519
+ with self.console.status(f"[bold blue]Fetching {studio} anime...[/bold blue]", spinner="dots"):
520
+ results = self.api.get_anime_list("STUDIOS", studio, "SERIES", limit=100)
521
+ self.console.print(f"[green]Got {len(results)} results for {studio}[/green]")
522
+ self._process_anime_list(results, f"Studio: {studio}")
523
+
524
+ os.system('cls' if os.name == 'nt' else 'clear')
525
+ self._print_header()
526
+ continue
527
+
528
+ search_q = cmd
529
+
530
+ results = []
531
+ with self.console.status(f"[bold green]Searching for: {search_q}...[/bold green]", spinner="earth"):
532
+ if self.rpc: self.rpc.update_searching()
533
+ results = self.api.search_anime(search_q)
534
+
535
+ if not results:
536
+ print(f"\033[1;31mNo results found for '{search_q}'\033[0m")
537
+ continue
538
+
539
+ self._process_anime_list(results, f"Search: {search_q}")
540
+
541
+ os.system('cls' if os.name == 'nt' else 'clear')
542
+ self._print_header()
543
+
544
+
545
+
546
+
547
+ def _get_next_ep_num(self, all_episodes, current_ep):
548
+ for i, e in enumerate(all_episodes):
549
+ if e.number == current_ep.number:
550
+ if i + 1 < len(all_episodes):
551
+ return all_episodes[i+1].number
552
+ return None
553
+
554
+ def _get_prev_ep_num(self, all_episodes, current_ep):
555
+ for i, e in enumerate(all_episodes):
556
+ if e.number == current_ep.number:
557
+ if i - 1 >= 0:
558
+ return all_episodes[i-1].number
559
+ return None
560
+
561
+ def run_simple_cli(query=None, deps=None):
562
+ if deps:
563
+ cli = AniCliWrapper(deps['api'], deps['player'], deps['history'], deps['settings'], deps['rpc'],
564
+ language_override=deps.get('language_override'))
565
+ else:
566
+ # Fallback for old calls (should not happen with new app structure)
567
+ from ani_cli_arabic.api import AnimeAPI
568
+ from ani_cli_arabic.player import PlayerManager
569
+ from ani_cli_arabic.history import HistoryManager
570
+ from ani_cli_arabic.settings import SettingsManager
571
+ from ani_cli_arabic.discord_rpc import DiscordRPCManager
572
+ cli = AniCliWrapper(AnimeAPI(), PlayerManager(console=None), HistoryManager(), SettingsManager(), DiscordRPCManager())
573
+
574
+ exit_code = 0
575
+ result = None
576
+ try:
577
+ result = cli.run(query)
578
+ if result == "SWITCH_TO_TUI":
579
+ return "SWITCH_TO_TUI"
580
+ except SystemExit as e:
581
+ if isinstance(e.code, int):
582
+ exit_code = e.code
583
+ except KeyboardInterrupt:
584
+ exit_code = 130
585
+ except Exception as e:
586
+ import traceback
587
+ traceback.print_exc()
588
+ print(f"\n\033[1;31mCritical Error: {e}\033[0m")
589
+ input("Press Enter to continue...")
590
+ exit_code = 1
591
+ finally:
592
+ # Check if we are switching modes (no goodbye needed)
593
+ is_switching = result == "SWITCH_TO_TUI"
594
+
595
+ if not is_switching:
596
+ # Print goodbye art with system accent color (cyan) like ani-cli
597
+ print(f"\033[1;36m{GOODBYE_ART.strip()}\033[0m")
598
+ sys.exit(exit_code)