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/app.py ADDED
@@ -0,0 +1,1110 @@
1
+ import sys
2
+ import atexit
3
+ import re
4
+ from pathlib import Path
5
+ from rich.align import Align
6
+ from rich.panel import Panel
7
+ from rich.text import Text
8
+ from rich.prompt import Prompt
9
+ from rich.box import HEAVY
10
+
11
+ from .config import COLOR_PROMPT, COLOR_BORDER
12
+ from .ui import UIManager
13
+ from .api import AnimeAPI, get_trailers_base
14
+ from .monitoring import monitor
15
+ from .player import PlayerManager
16
+ from .discord_rpc import DiscordRPCManager
17
+ from .models import QualityOption
18
+ from .utils import download_file, flush_stdin
19
+ from .history import HistoryManager
20
+ from .settings import SettingsManager
21
+ from .favorites import FavoritesManager
22
+ from .updater import check_for_updates, get_version_status
23
+ from .deps import ensure_dependencies
24
+ from .cli import run_simple_cli
25
+ from .config import GOODBYE_ART
26
+ import shutil
27
+ import argparse
28
+
29
+ class AniCliArApp:
30
+ def __init__(self):
31
+ self.ui = UIManager()
32
+ self.api = AnimeAPI()
33
+ self.rpc = DiscordRPCManager()
34
+ self.settings = SettingsManager()
35
+ self.player = PlayerManager(rpc_manager=self.rpc, console=self.ui.console)
36
+ self.history = HistoryManager()
37
+ self.favorites = FavoritesManager()
38
+ self.version_info = None
39
+ self.current_mode = "tui"
40
+ self.force_cli = False
41
+ self._cleaned_up = False
42
+ self._language_override = None
43
+
44
+ def run(self):
45
+ parser = argparse.ArgumentParser(
46
+ description="ani-cli-arabic: A CLI tool to browse and watch anime in Arabic.",
47
+ formatter_class=argparse.RawTextHelpFormatter
48
+ )
49
+ parser.add_argument('-i', '--interactive', action='store_true', help="Force minimal interactive CLI mode")
50
+ parser.add_argument('-v', '--version', action='store_true', help="Show version information")
51
+ parser.add_argument('--sub', action='store_true', help="Override: use English Subtitled streams")
52
+ parser.add_argument('--dub', action='store_true', help="Override: use English Dubbed streams")
53
+ parser.add_argument('query', nargs='*', help="Anime name to search for")
54
+
55
+ args = parser.parse_args()
56
+
57
+ if args.version:
58
+ from .version import __version__
59
+ print(f"ani-cli-arabic v{__version__}")
60
+ sys.exit(0)
61
+
62
+ self.force_cli = args.interactive
63
+
64
+ if args.sub:
65
+ self._language_override = 'English Sub'
66
+ self.settings.set('preferred_language', 'English Sub')
67
+ elif args.dub:
68
+ self._language_override = 'English Dub'
69
+ self.settings.set('preferred_language', 'English Dub')
70
+
71
+ initial_query = " ".join(args.query) if args.query else None
72
+
73
+ if not ensure_dependencies():
74
+ print("\n[!] Cannot start without required dependencies.")
75
+ input("Press ENTER to exit...")
76
+ sys.exit(1)
77
+
78
+ atexit.register(self.cleanup)
79
+
80
+ import threading
81
+ rpc_connected = {'status': None}
82
+
83
+ if self.settings.get('discord_rpc'):
84
+ def connect_rpc():
85
+ rpc_connected['status'] = self.rpc.connect()
86
+ threading.Thread(target=connect_rpc, daemon=True).start()
87
+
88
+ threading.Thread(target=lambda: monitor.track_app_start(), daemon=True).start()
89
+
90
+ def check_updates_bg():
91
+ try:
92
+ check_for_updates(auto_update=True)
93
+ except Exception:
94
+ pass
95
+ threading.Thread(target=check_updates_bg, daemon=True).start()
96
+
97
+ def check_version_bg():
98
+ try:
99
+ self.version_info = get_version_status()
100
+ except Exception:
101
+ pass
102
+ threading.Thread(target=check_version_bg, daemon=True).start()
103
+
104
+ self.rpc_status = rpc_connected
105
+
106
+ try:
107
+ self.unified_loop(initial_query)
108
+ except KeyboardInterrupt:
109
+ self.handle_exit()
110
+ except Exception as e:
111
+ self.handle_error(e)
112
+ finally:
113
+ self.cleanup()
114
+
115
+ def unified_loop(self, query=None):
116
+ while True:
117
+ is_narrow = shutil.get_terminal_size().columns < 80
118
+
119
+ if self.force_cli or is_narrow:
120
+ self.current_mode = "cli"
121
+ result = self.run_cli_mode(query)
122
+ query = None # Clear query after first run
123
+ if result == "SWITCH_TO_TUI":
124
+ if self.force_cli:
125
+ pass
126
+ continue
127
+ break
128
+ else:
129
+ self.current_mode = "tui"
130
+ result = self.run_tui_mode(query)
131
+ query = None
132
+ if result == "SWITCH_TO_CLI":
133
+ continue
134
+ break
135
+
136
+ def run_cli_mode(self, query=None):
137
+ deps = {
138
+ 'api': self.api,
139
+ 'player': self.player,
140
+ 'history': self.history,
141
+ 'settings': self.settings,
142
+ 'rpc': self.rpc,
143
+ 'language_override': self._language_override
144
+ }
145
+ return run_simple_cli(query, deps=deps)
146
+
147
+ def run_tui_mode(self, query=None):
148
+ while True:
149
+ if '-i' not in sys.argv and shutil.get_terminal_size().columns < 80:
150
+ return "SWITCH_TO_CLI"
151
+
152
+ self.ui.clear()
153
+
154
+ if hasattr(self, 'rpc_status') and self.settings.get('discord_rpc'):
155
+ if self.rpc.connected:
156
+ self.rpc_status['status'] = True
157
+ elif self.rpc_status.get('status') is not None:
158
+ self.rpc_status['status'] = False
159
+
160
+ vertical_space = self.ui.console.height - 14
161
+ top_padding = (vertical_space // 2) - 2
162
+
163
+ if top_padding > 0:
164
+ self.ui.print(Text("\n" * top_padding))
165
+
166
+ self.ui.print(Align.center(self.ui.get_header_renderable()))
167
+ self.ui.print()
168
+
169
+ lang = self._get_language()
170
+ lang_colors = {"Arabic Sub": "green", "English Sub": "cyan", "English Dub": "yellow"}
171
+ lang_style = lang_colors.get(lang, "secondary")
172
+ self.ui.print(Align.center(Text.from_markup(f"Language: [bold {lang_style}]{lang}[/bold {lang_style}]", style="secondary")))
173
+
174
+ if self.settings.get('discord_rpc'):
175
+ if hasattr(self, 'rpc_status'):
176
+ if self.rpc_status['status'] is True:
177
+ self.ui.print(Align.center(Text.from_markup("Discord Rich Presence ✅", style="secondary")))
178
+ elif self.rpc_status['status'] is None:
179
+ self.ui.print(Align.center(Text.from_markup("Discord Rich Presence [dim](connecting...)[/dim]", style="dim")))
180
+ else:
181
+ self.ui.print(Align.center(Text.from_markup("Discord Rich Presence [dim](enabled, not connected)[/dim]", style="dim")))
182
+ else:
183
+ self.ui.print(Align.center(Text.from_markup("Discord Rich Presence [dim](disabled)[/dim]", style="dim")))
184
+
185
+ self.ui.print()
186
+
187
+ keybinds_panel = Panel(
188
+ Text("T: Trending | P: Popular | G: Genres | S: Studios | L: History | F: Favorites | C: Settings | Q: Quit", style="info", justify="center"),
189
+ box=HEAVY,
190
+ border_style=COLOR_BORDER
191
+ )
192
+ self.ui.print(Align.center(keybinds_panel))
193
+ self.ui.print()
194
+
195
+ prompt_string = f" {Text('›', style=COLOR_PROMPT)} "
196
+ pad_width = (self.ui.console.width - 30) // 2
197
+ padding = " " * max(0, pad_width)
198
+
199
+ if self.version_info and self.version_info.get('is_outdated'):
200
+ status_text = f"Dev: v{self.version_info['current']} → Latest: v{self.version_info['latest_pip']} (update available)"
201
+ self.ui.print(Align.center(Text(status_text, style="dim")))
202
+ self.ui.print()
203
+
204
+ flush_stdin()
205
+
206
+ query = Prompt.ask(f"{padding}{prompt_string}", console=self.ui.console).strip().lower()
207
+
208
+ if query in ['q', 'quit', 'exit']:
209
+ break
210
+
211
+ results = []
212
+
213
+ if query == 't':
214
+ self.rpc.update_trending()
215
+ results = self.ui.run_with_loading(
216
+ "Fetching trending anime...",
217
+ self.api.get_trending_anime,
218
+ 0,
219
+ 15
220
+ )
221
+ if results:
222
+ def load_more_trending(current_count):
223
+ return self.api.get_trending_anime(current_count, 15)
224
+ self.handle_anime_selection_with_lazy_load(results, load_more_trending)
225
+ continue
226
+ elif query == 'p':
227
+ self.rpc.update_popular()
228
+ results = self.ui.run_with_loading(
229
+ "Fetching popular anime...",
230
+ self.api.get_top_rated_anime,
231
+ 0,
232
+ 15
233
+ )
234
+ if results:
235
+ def load_more_popular(current_count):
236
+ return self.api.get_top_rated_anime(current_count, 15)
237
+ self.handle_anime_selection_with_lazy_load(results, load_more_popular)
238
+ continue
239
+ elif query == 'g':
240
+ self.rpc.update_genres()
241
+ self.handle_genres()
242
+ continue
243
+ elif query == 's':
244
+ self.rpc.update_studios()
245
+ self.handle_studios()
246
+ continue
247
+ elif query == 'l':
248
+ self.rpc.update_history()
249
+ self.handle_history()
250
+ continue
251
+ elif query == 'f':
252
+ self.rpc.update_favorites()
253
+ self.handle_favorites()
254
+ continue
255
+ elif query == 'c':
256
+ self.rpc.update_settings()
257
+ self.ui.settings_menu(self.settings)
258
+ continue
259
+ elif query == 'a':
260
+ self.ui.show_credits()
261
+ continue
262
+ elif query:
263
+ self.rpc.update_searching()
264
+ results = self.ui.run_with_loading("Searching...", self.api.search_anime, query)
265
+ else:
266
+ continue
267
+
268
+ if not results:
269
+ self.ui.render_message(
270
+ "✗ No Anime Found",
271
+ f"No anime matching '{query}' was found.\n\nTry:\n• Checking spelling\n• Using English name\n• Using alternative titles",
272
+ "error"
273
+ )
274
+ continue
275
+
276
+ self.handle_anime_selection(results)
277
+
278
+ def handle_anime_selection_with_lazy_load(self, results, load_more_callback):
279
+ while True:
280
+ anime_idx = self.ui.anime_selection_menu(results, load_more_callback=load_more_callback)
281
+
282
+ if anime_idx == -1:
283
+ sys.exit(0)
284
+ if anime_idx is None:
285
+ return
286
+
287
+ selected_anime = results[anime_idx]
288
+
289
+ self.rpc.update_viewing_anime(selected_anime.title_en, selected_anime.thumbnail)
290
+
291
+
292
+ episodes = self.ui.run_with_loading(
293
+ "Loading episodes & poster...",
294
+ lambda: self._fetch_episodes_and_poster(selected_anime)
295
+ )
296
+
297
+ if not episodes:
298
+ self.ui.render_message(
299
+ "✗ No Episodes",
300
+ f"No episodes found for '{selected_anime.title_en}'.",
301
+ "error"
302
+ )
303
+ continue
304
+
305
+ self.handle_episode_selection(selected_anime, episodes)
306
+
307
+ def handle_genres(self):
308
+ genres = [
309
+ "Action", "Adventure", "Comedy", "Drama", "Fantasy",
310
+ "Horror", "Mystery", "Romance", "Sci-Fi", "Slice of Life",
311
+ "Sports", "Supernatural", "Thriller", "Isekai", "School"
312
+ ]
313
+
314
+ selected_genre = self.ui.selection_menu(genres, title="Select Genre")
315
+ if selected_genre:
316
+ results = self.ui.run_with_loading(
317
+ f"Fetching {selected_genre} anime...",
318
+ self.api.get_anime_list,
319
+ "GENRE",
320
+ selected_genre,
321
+ "SERIES",
322
+ 0,
323
+ 15
324
+ )
325
+ if results:
326
+ def load_more_genre(current_count):
327
+ return self.api.get_anime_list("GENRE", selected_genre, "SERIES", current_count, 15)
328
+ self.handle_anime_selection_with_lazy_load(results, load_more_genre)
329
+ else:
330
+ self.ui.render_message("Info", f"No anime found for genre: {selected_genre}", "info")
331
+
332
+ def handle_studios(self):
333
+ studios = [
334
+ "Toei Animation", "Sunrise", "Madhouse", "Production I.G", "J.C.Staff",
335
+ "TMS Entertainment", "Studio Pierrot", "Studio Deen", "A-1 Pictures",
336
+ "Bones", "Kyoto Animation", "MAPPA", "Wit Studio", "ufotable",
337
+ "White Fox", "David Production", "Shaft", "Trigger", "CloverWorks",
338
+ "Lerche", "P.A. Works", "CoMix Wave Films", "Gainax", "Tatsunoko Production"
339
+ ]
340
+ studios.sort()
341
+
342
+ selected_studio = self.ui.selection_menu(studios, title="Select Studio")
343
+ if selected_studio:
344
+ results = self.ui.run_with_loading(
345
+ f"Fetching {selected_studio} anime...",
346
+ self.api.get_anime_list,
347
+ "STUDIOS",
348
+ selected_studio,
349
+ "SERIES",
350
+ 0,
351
+ 15
352
+ )
353
+ if results:
354
+ def load_more_studio(current_count):
355
+ return self.api.get_anime_list("STUDIOS", selected_studio, "SERIES", current_count, 15)
356
+ self.handle_anime_selection_with_lazy_load(results, load_more_studio)
357
+ else:
358
+ self.ui.render_message("Info", f"No anime found for studio: {selected_studio}", "info")
359
+
360
+ def handle_history(self):
361
+ history_items = self.history.get_history()
362
+ if not history_items:
363
+ self.ui.render_message("Info", "No history found.", "info")
364
+ return
365
+
366
+ while True:
367
+ selected_idx = self.ui.history_menu(history_items)
368
+ if selected_idx is None:
369
+ break
370
+
371
+ item = history_items[selected_idx]
372
+ # Resume directly without nested loading screen
373
+ self.resume_anime(item)
374
+ # Refresh history after watching
375
+ history_items = self.history.get_history()
376
+
377
+ def _find_episode_index(self, episodes, episode_value):
378
+ if episode_value is None:
379
+ return 0
380
+
381
+ target_raw = str(episode_value).strip()
382
+ if not target_raw:
383
+ return 0
384
+
385
+ for idx, ep in enumerate(episodes):
386
+ if str(ep.display_num) == target_raw or str(ep.number) == target_raw:
387
+ return idx
388
+
389
+ try:
390
+ target_float = float(target_raw)
391
+ for idx, ep in enumerate(episodes):
392
+ try:
393
+ if float(ep.display_num) == target_float:
394
+ return idx
395
+ except (TypeError, ValueError):
396
+ continue
397
+ except (TypeError, ValueError):
398
+ pass
399
+
400
+ return 0
401
+
402
+ def resume_anime(self, history_item):
403
+ results = self.ui.run_with_loading("Resuming...", self.api.search_anime, history_item['title'])
404
+ if not results:
405
+ self.ui.render_message("Error", "Could not find anime details.", "error")
406
+ return
407
+
408
+ target_anime_id = str(history_item.get('anime_id') or history_item.get('id') or "")
409
+ selected_anime = None
410
+ for res in results:
411
+ if target_anime_id and str(res.id) == target_anime_id:
412
+ selected_anime = res
413
+ break
414
+
415
+ if not selected_anime:
416
+ selected_anime = results[0] # Fallback
417
+
418
+ self.rpc.update_viewing_anime(selected_anime.title_en, selected_anime.thumbnail)
419
+ episodes = self.api.get_episodes(selected_anime.id)
420
+
421
+ if episodes:
422
+ initial_idx = self._find_episode_index(episodes, history_item.get('episode'))
423
+ self.handle_episode_selection(selected_anime, episodes, initial_idx=initial_idx)
424
+
425
+ def handle_favorites(self):
426
+ while True:
427
+ fav_items = self.favorites.get_all()
428
+ if not fav_items:
429
+ self.ui.render_message("Info", "No favorites added yet.", "info")
430
+ return
431
+
432
+ result = self.ui.favorites_menu(fav_items)
433
+ if result is None:
434
+ break
435
+
436
+ idx, action = result
437
+ item = fav_items[idx]
438
+
439
+ if action == 'remove':
440
+ self.favorites.remove(item['anime_id'])
441
+ continue
442
+ elif action == 'watch':
443
+ try:
444
+ self.resume_anime(item)
445
+ except Exception as e:
446
+ self.ui.render_message("Error", f"Failed to resume anime: {str(e)}", "error")
447
+
448
+ def handle_anime_selection(self, results):
449
+ while True:
450
+ anime_idx = self.ui.anime_selection_menu(results)
451
+
452
+ if anime_idx == -1:
453
+ sys.exit(0)
454
+ if anime_idx is None:
455
+ return
456
+
457
+ selected_anime = results[anime_idx]
458
+
459
+ self.rpc.update_viewing_anime(selected_anime.title_en, selected_anime.thumbnail)
460
+
461
+
462
+ episodes = self.ui.run_with_loading(
463
+ "Loading episodes & poster...",
464
+ lambda: self._fetch_episodes_and_poster(selected_anime)
465
+ )
466
+
467
+ if not episodes:
468
+ self.ui.render_message(
469
+ "✗ No Episodes",
470
+ f"No episodes found for '{selected_anime.title_en}'",
471
+ "error"
472
+ )
473
+ continue
474
+
475
+ back_pressed = self.handle_episode_selection(selected_anime, episodes)
476
+ if not back_pressed:
477
+ break
478
+
479
+
480
+ def _fetch_episodes_and_poster(self, selected_anime):
481
+ eps = self.api.get_episodes(selected_anime.id)
482
+ if selected_anime.thumbnail:
483
+ screen_height = self.ui.console.height
484
+ target_height = min(screen_height, 50)
485
+ poster_height = target_height - 8
486
+ if poster_height > 0:
487
+ self.ui._generate_poster_ansi(selected_anime.thumbnail, poster_height)
488
+ return eps
489
+
490
+ def play_trailer(self, anime):
491
+ import requests
492
+
493
+ trailer_url = None
494
+
495
+ if anime.trailer and anime.trailer not in ["N/A", "None", None, ""]:
496
+ if anime.trailer.startswith(('http://', 'https://')):
497
+ trailer_url = anime.trailer
498
+ else:
499
+ trailer_url = get_trailers_base() + anime.trailer
500
+
501
+ try:
502
+ check = requests.head(trailer_url, timeout=5)
503
+ if check.status_code == 404:
504
+ trailer_url = None
505
+ except Exception:
506
+ trailer_url = None
507
+
508
+ if not trailer_url and anime.yt_trailer and anime.yt_trailer not in ["N/A", "None", None, ""]:
509
+ if anime.yt_trailer.startswith(('http://', 'https://')):
510
+ trailer_url = anime.yt_trailer
511
+ else:
512
+ trailer_url = f"https://www.youtube.com/watch?v={anime.yt_trailer}"
513
+
514
+ if not trailer_url and anime.mal_id and anime.mal_id not in ["0", "N/A", "None", None, ""]:
515
+ try:
516
+ jikan_response = requests.get(
517
+ f"https://api.jikan.moe/v4/anime/{anime.mal_id}",
518
+ timeout=10
519
+ )
520
+ if jikan_response.status_code == 200:
521
+ jikan_data = jikan_response.json()
522
+ trailer_data = jikan_data.get('data', {}).get('trailer', {})
523
+ embed_url = trailer_data.get('embed_url', '')
524
+
525
+ if embed_url:
526
+ # Extract YouTube ID from embed URL
527
+ match = re.search(r'/embed/([a-zA-Z0-9_-]+)', embed_url)
528
+ if match:
529
+ yt_id = match.group(1)
530
+ trailer_url = f"https://www.youtube.com/watch?v={yt_id}"
531
+ except Exception:
532
+ pass
533
+
534
+ if not trailer_url:
535
+ self.ui.render_message("Error", "No trailer available for this anime.", "error")
536
+ return
537
+
538
+ self.ui.clear()
539
+
540
+ message_text = Text()
541
+ message_text.append("Trailer Launched!\n\n", style="bold green")
542
+ message_text.append("Playing trailer in MPV window...\n", style="info")
543
+ message_text.append("Close MPV to return to episodes list.", style="secondary")
544
+
545
+ panel = Panel(
546
+ Align.center(message_text, vertical="middle"),
547
+ title=Text("Trailer", style="title"),
548
+ box=HEAVY,
549
+ border_style=COLOR_BORDER,
550
+ padding=(2, 6),
551
+ width=60
552
+ )
553
+
554
+ self.ui.console.print(Align.center(panel, vertical="middle", height=self.ui.console.height))
555
+
556
+ self.player.play(trailer_url, f"Trailer - {anime.title_en}")
557
+
558
+ def handle_episode_selection(self, selected_anime, episodes, initial_idx=0):
559
+ current_idx = max(0, min(int(initial_idx or 0), len(episodes) - 1))
560
+
561
+ while True:
562
+ last_watched = self.history.get_last_watched(selected_anime.id)
563
+ is_fav = self.favorites.is_favorite(selected_anime.id)
564
+ default_download_quality = self._get_default_download_quality()
565
+ download_mode = self._get_download_mode()
566
+
567
+ anime_details = {
568
+ 'score': selected_anime.score,
569
+ 'rank': selected_anime.rank,
570
+ 'popularity': selected_anime.popularity,
571
+ 'rating': selected_anime.rating,
572
+ 'type': selected_anime.type,
573
+ 'episodes': selected_anime.episodes,
574
+ 'status': selected_anime.status,
575
+ 'studio': selected_anime.creators,
576
+ 'genres': selected_anime.genres,
577
+ 'trailer': selected_anime.trailer,
578
+ 'yt_trailer': selected_anime.yt_trailer
579
+ }
580
+
581
+ ep_idx = self.ui.episode_selection_menu(
582
+ selected_anime.title_en,
583
+ episodes,
584
+ self.rpc,
585
+ selected_anime.thumbnail,
586
+ last_watched_ep=last_watched,
587
+ is_favorite=is_fav,
588
+ anime_details=anime_details,
589
+ default_download_quality=default_download_quality,
590
+ download_mode=download_mode,
591
+ download_path=self._get_download_directory(),
592
+ initial_selected=current_idx
593
+ )
594
+
595
+ if ep_idx == -1:
596
+ sys.exit(0)
597
+ elif ep_idx is None:
598
+ self.rpc.update_browsing()
599
+ return True
600
+ elif isinstance(ep_idx, tuple) and ep_idx[0] == 'download_current':
601
+ target_idx = ep_idx[1]
602
+ if 0 <= target_idx < len(episodes):
603
+ self.download_episode_with_defaults(selected_anime, episodes[target_idx])
604
+ continue
605
+ elif ep_idx == 'toggle_fav':
606
+ if is_fav:
607
+ self.favorites.remove(selected_anime.id)
608
+ else:
609
+ self.favorites.add(selected_anime.id, selected_anime.title_en, selected_anime.thumbnail)
610
+ continue
611
+ elif ep_idx == 'batch_mode':
612
+ self.handle_batch_download(selected_anime, episodes)
613
+ continue
614
+ elif ep_idx == 'trailer':
615
+ self.play_trailer(selected_anime)
616
+ continue
617
+
618
+ current_idx = ep_idx
619
+
620
+ while True:
621
+ selected_ep = episodes[current_idx]
622
+
623
+ language = self._get_language()
624
+
625
+ if language == 'Arabic Sub':
626
+ server_data = self.ui.run_with_loading(
627
+ "Loading servers...",
628
+ self.api.get_streaming_servers,
629
+ selected_anime.id,
630
+ selected_ep.number,
631
+ selected_anime.type
632
+ )
633
+
634
+ if not server_data:
635
+ self.ui.render_message(
636
+ "✗ No Servers",
637
+ "No servers available for this episode.",
638
+ "error"
639
+ )
640
+ break
641
+
642
+ action_taken = self.handle_quality_selection(selected_anime, selected_ep, server_data)
643
+ else:
644
+ is_dub = (language == 'English Dub')
645
+ action_taken = self.handle_english_stream(selected_anime, selected_ep, dub=is_dub)
646
+
647
+ if action_taken == "watch":
648
+ auto_next = self.settings.get('auto_next')
649
+ if auto_next:
650
+ if current_idx + 1 < len(episodes):
651
+ current_idx += 1
652
+ continue
653
+ else:
654
+ self.ui.render_message("Info", "No more episodes!", "info")
655
+ break
656
+
657
+ next_action = self.ui.post_watch_menu()
658
+
659
+ if next_action == "Next Episode":
660
+ if current_idx + 1 < len(episodes):
661
+ current_idx += 1
662
+ continue
663
+ else:
664
+ self.ui.render_message("Info", "No more episodes!", "info")
665
+ break
666
+ elif next_action == "Previous Episode":
667
+ if current_idx > 0:
668
+ current_idx -= 1
669
+ continue
670
+ else:
671
+ self.ui.render_message("Info", "This is the first episode.", "info")
672
+ break
673
+ elif next_action == "Replay":
674
+ continue
675
+ else:
676
+ break
677
+ elif action_taken == "download":
678
+ # For downloads, return directly to the episodes list instead of watch navigation.
679
+ break
680
+ else:
681
+ break
682
+
683
+ def _get_default_download_quality(self):
684
+ return self.settings.get('default_download_quality') or self.settings.get('default_quality') or "1080p"
685
+
686
+ def _get_download_mode(self):
687
+ return (self.settings.get('download_mode') or "internal").lower()
688
+
689
+ def _get_download_directory(self):
690
+ return self.settings.get('download_directory') or "downloads"
691
+
692
+ def _get_language(self):
693
+ return self._language_override or self.settings.get('preferred_language', 'Arabic Sub')
694
+
695
+ def _extract_quality_tag(self, quality_name):
696
+ quality_match = re.search(r"\b(\d{3,4}p)\b", quality_name or "")
697
+ return quality_match.group(1) if quality_match else (quality_name or "auto")
698
+
699
+ def _fetch_english_stream(self, anime_title, episode_num, quality="1080p", dub=False):
700
+ from .scrapers.english import AllAnimeScraper
701
+ scraper = AllAnimeScraper()
702
+ mode = "dub" if dub else "sub"
703
+ try:
704
+ results = scraper.search(anime_title, mode)
705
+ if not results:
706
+ return None, {}
707
+ result = self._pick_best_anime_result(scraper, results, mode)
708
+ if result is None:
709
+ return None, {}
710
+ show_id = result["id"]
711
+ ep_num = int(float(episode_num))
712
+ direct_url, headers = scraper.resolve_stream_url(show_id, ep_num, mode)
713
+ return direct_url, headers
714
+ except Exception as e:
715
+ print(f"[scraper] Error: {e}", file=__import__('sys').stderr)
716
+ return None, {}
717
+
718
+ def _pick_best_anime_result(self, scraper, results, mode):
719
+ if len(results) == 1:
720
+ return results[0]
721
+ candidates = []
722
+ for r in results[:18]:
723
+ try:
724
+ eps = scraper.get_episodes(r["id"], mode)
725
+ except Exception:
726
+ eps = []
727
+ candidates.append((r, len(eps)))
728
+ candidates.sort(key=lambda x: x[1], reverse=True)
729
+ if candidates and candidates[0][1] > 0:
730
+ return candidates[0][0]
731
+ return results[0]
732
+
733
+ def _pick_default_download_quality_option(self, current_ep_data):
734
+ qualities = [
735
+ QualityOption("1080p", 'FRFhdQ', "info"),
736
+ QualityOption("720p", 'FRLink', "info"),
737
+ QualityOption("480p", 'FRLowQ', "info"),
738
+ ]
739
+
740
+ preferred_quality = self._get_default_download_quality()
741
+
742
+ for quality in qualities:
743
+ if preferred_quality in quality.name and current_ep_data.get(quality.server_key):
744
+ return quality
745
+
746
+ for quality in qualities:
747
+ if current_ep_data.get(quality.server_key):
748
+ return quality
749
+
750
+ return None
751
+
752
+ def resolve_default_download_target(self, selected_anime, selected_ep, show_loading=False):
753
+ if show_loading:
754
+ server_data = self.ui.run_with_loading(
755
+ "Loading servers...",
756
+ self.api.get_streaming_servers,
757
+ selected_anime.id,
758
+ selected_ep.number,
759
+ selected_anime.type
760
+ )
761
+ else:
762
+ server_data = self.api.get_streaming_servers(selected_anime.id, selected_ep.number, selected_anime.type)
763
+
764
+ if not server_data:
765
+ return None, None, "No servers found for this episode."
766
+
767
+ current_ep_data = server_data.get('CurrentEpisode', {})
768
+ selected_quality = self._pick_default_download_quality_option(current_ep_data)
769
+ if not selected_quality:
770
+ return None, None, "No suitable quality found for this episode."
771
+
772
+ server_id = current_ep_data.get(selected_quality.server_key)
773
+ if not server_id:
774
+ return None, None, "Missing server id for selected quality."
775
+
776
+ mediafire_url = self.api.build_mediafire_url(server_id)
777
+ if show_loading:
778
+ direct_url = self.ui.run_with_loading(
779
+ "Extracting direct link...",
780
+ self.api.extract_mediafire_direct,
781
+ mediafire_url
782
+ )
783
+ else:
784
+ direct_url = self.api.extract_mediafire_direct(mediafire_url)
785
+
786
+ if not direct_url:
787
+ return None, None, "Failed to extract direct link from MediaFire."
788
+
789
+ quality_tag = self._extract_quality_tag(selected_quality.name)
790
+ filename = f"{selected_anime.title_en} - Ep {selected_ep.display_num} [{quality_tag}].mp4"
791
+ return direct_url, filename, None
792
+
793
+ def download_episode_with_defaults(self, selected_anime, selected_ep):
794
+ direct_url, filename, error = self.resolve_default_download_target(selected_anime, selected_ep, show_loading=True)
795
+
796
+ if error:
797
+ self.ui.render_message("✗ Download Error", error, "error")
798
+ return False
799
+
800
+ success = download_file(
801
+ direct_url,
802
+ filename,
803
+ self.ui.console,
804
+ mode=self._get_download_mode(),
805
+ download_dir=self._get_download_directory()
806
+ )
807
+
808
+ if success:
809
+ self.history.mark_watched(selected_anime.id, selected_ep.display_num, selected_anime.title_en)
810
+
811
+ return success
812
+
813
+ def handle_batch_download(self, selected_anime, episodes):
814
+ selected_indices = self.ui.batch_selection_menu(episodes)
815
+ if not selected_indices:
816
+ return
817
+
818
+ self.ui.render_timed_message(
819
+ "Batch Download",
820
+ f"Preparing {len(selected_indices)} episode(s) for download...",
821
+ "info",
822
+ duration=1.0
823
+ )
824
+ download_mode = self._get_download_mode()
825
+ download_directory = self._get_download_directory()
826
+ success_count = 0
827
+ failed_count = 0
828
+
829
+ for idx in selected_indices:
830
+ ep = episodes[idx]
831
+ direct_url, filename, error = self.resolve_default_download_target(selected_anime, ep, show_loading=False)
832
+
833
+ if error:
834
+ failed_count += 1
835
+ continue
836
+
837
+ success = download_file(
838
+ direct_url,
839
+ filename,
840
+ self.ui.console,
841
+ mode=download_mode,
842
+ download_dir=download_directory
843
+ )
844
+ if success:
845
+ success_count += 1
846
+ self.history.mark_watched(selected_anime.id, ep.display_num, selected_anime.title_en)
847
+ else:
848
+ failed_count += 1
849
+
850
+ summary_style = "error" if success_count == 0 else "info"
851
+ summary_message = f"Completed: {success_count}"
852
+ if failed_count:
853
+ summary_message += f" | Failed: {failed_count}"
854
+
855
+ self.ui.render_timed_message(
856
+ "Batch Download Finished",
857
+ summary_message,
858
+ summary_style,
859
+ duration=1.6
860
+ )
861
+
862
+ def handle_quality_selection(self, selected_anime, selected_ep, server_data):
863
+ current_ep_data = server_data.get('CurrentEpisode', {})
864
+ qualities = [
865
+ QualityOption("SD • 480p (Low Quality)", 'FRLowQ', "info"),
866
+ QualityOption("HD • 720p (Standard Quality)", 'FRLink', "info"),
867
+ QualityOption("FHD • 1080p (Full HD)", 'FRFhdQ', "info"),
868
+ ]
869
+
870
+ available = [q for q in qualities if current_ep_data.get(q.server_key)]
871
+
872
+ if not available:
873
+ self.ui.render_message(
874
+ "✗ No Links",
875
+ "No MediaFire servers found for this episode.",
876
+ "error"
877
+ )
878
+ return None
879
+
880
+ result = self.ui.quality_selection_menu(
881
+ selected_anime.title_en,
882
+ selected_ep.display_num,
883
+ available,
884
+ self.rpc,
885
+ selected_anime.thumbnail
886
+ )
887
+
888
+ if result == -1:
889
+ sys.exit(0)
890
+ if result is None:
891
+ return None
892
+
893
+ idx, action = result
894
+ quality = available[idx]
895
+ server_id = current_ep_data.get(quality.server_key)
896
+
897
+ direct_url = self.ui.run_with_loading(
898
+ "Extracting direct link...",
899
+ self.api.extract_mediafire_direct,
900
+ self.api.build_mediafire_url(server_id)
901
+ )
902
+
903
+ if direct_url:
904
+ quality_match = re.search(r"\b(\d{3,4}p)\b", quality.name)
905
+ quality_tag = quality_match.group(1) if quality_match else quality.name
906
+ filename = f"{selected_anime.title_en} - Ep {selected_ep.display_num} [{quality_tag}].mp4"
907
+
908
+ if action == 'download':
909
+ success = download_file(
910
+ direct_url,
911
+ filename,
912
+ self.ui.console,
913
+ mode=self._get_download_mode(),
914
+ download_dir=self._get_download_directory()
915
+ )
916
+ if success:
917
+ self.history.mark_watched(selected_anime.id, selected_ep.display_num, selected_anime.title_en)
918
+ return "download"
919
+ return None
920
+ else:
921
+ player_type = self.settings.get('player')
922
+
923
+ from rich.text import Text
924
+ from rich.panel import Panel
925
+ from rich.align import Align
926
+ from rich.box import HEAVY
927
+ from .config import COLOR_BORDER, COLOR_TITLE
928
+
929
+ watching_text = Text()
930
+ watching_text.append("▶ ", style=COLOR_TITLE + " blink")
931
+ watching_text.append(selected_anime.title_en, style="bold")
932
+ watching_text.append("\nEpisode ", style="secondary")
933
+ watching_text.append(str(selected_ep.display_num), style=COLOR_TITLE + " bold")
934
+ watching_text.append(" ◀", style=COLOR_TITLE + " blink")
935
+ watching_text.append(f"\n\n{quality.name}", style="dim")
936
+
937
+ watching_panel = Panel(
938
+ Align.center(watching_text, vertical="middle"),
939
+ title=Text("NOW PLAYING", style=COLOR_TITLE + " bold"),
940
+ box=HEAVY,
941
+ border_style=COLOR_BORDER,
942
+ padding=(2, 4),
943
+ width=60
944
+ )
945
+
946
+ self.ui.clear()
947
+ self.ui.console.print(Align.center(watching_panel, vertical="middle", height=self.ui.console.height))
948
+
949
+ self.rpc.update_watching(selected_anime.title_en, str(selected_ep.display_num), selected_anime.thumbnail)
950
+
951
+ monitor.track_video_play(selected_anime.title_en, str(selected_ep.display_num))
952
+
953
+ self.player.play(direct_url, f"{selected_anime.title_en} - Ep {selected_ep.display_num} ({quality.name})", player_type=player_type)
954
+ self.ui.clear()
955
+ self.history.mark_watched(selected_anime.id, selected_ep.display_num, selected_anime.title_en)
956
+ self.rpc.update_selecting_episode(selected_anime.title_en, selected_anime.thumbnail)
957
+ return "watch"
958
+ else:
959
+ self.ui.render_message(
960
+ "✗ Error",
961
+ "Failed to extract direct link from MediaFire.",
962
+ "error"
963
+ )
964
+ return None
965
+
966
+ def handle_english_stream(self, selected_anime, selected_ep, dub=False):
967
+ from rich.text import Text
968
+ from rich.panel import Panel
969
+ from rich.align import Align
970
+ from rich.box import HEAVY
971
+ from .config import COLOR_BORDER, COLOR_TITLE
972
+
973
+ qualities = [
974
+ QualityOption("FHD • 1080p (Full HD)", '1080p', "info"),
975
+ QualityOption("HD • 720p (Standard Quality)", '720p', "info"),
976
+ QualityOption("SD • 480p (Low Quality)", '480p', "info"),
977
+ ]
978
+
979
+ result = self.ui.quality_selection_menu(
980
+ selected_anime.title_en,
981
+ selected_ep.display_num,
982
+ qualities,
983
+ self.rpc,
984
+ selected_anime.thumbnail
985
+ )
986
+
987
+ if result == -1:
988
+ sys.exit(0)
989
+ if result is None:
990
+ return None
991
+
992
+ idx, action = result
993
+ quality = qualities[idx]
994
+ quality_name = quality.server_key
995
+
996
+ if action == 'download':
997
+ self.ui.render_message("Info", "Download not supported for English streams.", "info")
998
+ return None
999
+
1000
+ url_and_headers = self.ui.run_with_loading(
1001
+ "Fetching English stream...",
1002
+ self._fetch_english_stream,
1003
+ selected_anime.title_en,
1004
+ selected_ep.display_num,
1005
+ "best",
1006
+ dub
1007
+ )
1008
+
1009
+ if not url_and_headers or not url_and_headers[0]:
1010
+ self.ui.render_message("Error", "Failed to get English stream URL.", "error")
1011
+ return None
1012
+
1013
+ direct_url, stream_headers = url_and_headers
1014
+ player_type = self.settings.get('player')
1015
+ suffix = " [English Dub]" if dub else " [English Sub]"
1016
+
1017
+ watching_text = Text()
1018
+ watching_text.append("▶ ", style=COLOR_TITLE + " blink")
1019
+ watching_text.append(selected_anime.title_en, style="bold")
1020
+ watching_text.append(suffix, style="bold green" if dub else "bold cyan")
1021
+ watching_text.append("\nEpisode ", style="secondary")
1022
+ watching_text.append(str(selected_ep.display_num), style=COLOR_TITLE + " bold")
1023
+ watching_text.append(" ◀", style=COLOR_TITLE + " blink")
1024
+ watching_text.append(f"\n\n{quality.name}", style="dim")
1025
+
1026
+ watching_panel = Panel(
1027
+ Align.center(watching_text, vertical="middle"),
1028
+ title=Text("NOW PLAYING", style=COLOR_TITLE + " bold"),
1029
+ box=HEAVY,
1030
+ border_style=COLOR_BORDER,
1031
+ padding=(2, 4),
1032
+ width=60
1033
+ )
1034
+
1035
+ self.ui.clear()
1036
+ self.ui.console.print(Align.center(watching_panel, vertical="middle", height=self.ui.console.height))
1037
+
1038
+ self.rpc.update_watching(selected_anime.title_en, str(selected_ep.display_num), selected_anime.thumbnail)
1039
+ monitor.track_video_play(selected_anime.title_en, str(selected_ep.display_num))
1040
+
1041
+ self.player.play(direct_url, f"{selected_anime.title_en} - Ep {selected_ep.display_num}", player_type=player_type, headers=stream_headers)
1042
+ self.ui.clear()
1043
+ self.history.mark_watched(selected_anime.id, selected_ep.display_num, selected_anime.title_en)
1044
+ self.rpc.update_selecting_episode(selected_anime.title_en, selected_anime.thumbnail)
1045
+ return "watch"
1046
+
1047
+ def handle_exit(self):
1048
+ self.ui.clear()
1049
+
1050
+ panel = Panel(
1051
+ Text("👋 Interrupted - Goodbye!", justify="center", style="info"),
1052
+ title=Text("EXIT", style="title"),
1053
+ box=HEAVY,
1054
+ padding=1,
1055
+ border_style=COLOR_BORDER
1056
+ )
1057
+
1058
+ self.ui.print(Align.center(panel, vertical="middle", height=self.ui.console.height))
1059
+
1060
+ def handle_error(self, e):
1061
+ self.ui.clear()
1062
+ self.ui.console.print_exception()
1063
+
1064
+ panel = Panel(
1065
+ Text(f"✗ Unexpected error: {e}", justify="center", style="error"),
1066
+ title=Text("CRITICAL ERROR", style="title"),
1067
+ box=HEAVY,
1068
+ padding=1,
1069
+ border_style=COLOR_BORDER
1070
+ )
1071
+
1072
+ self.ui.print(Align.center(panel, vertical="middle", height=self.ui.console.height))
1073
+ input("\nPress ENTER to exit...")
1074
+
1075
+ def cleanup(self):
1076
+ if self._cleaned_up:
1077
+ return
1078
+ self._cleaned_up = True
1079
+
1080
+ try:
1081
+ self.rpc.disconnect()
1082
+ except Exception:
1083
+ pass
1084
+
1085
+ try:
1086
+ self.player.cleanup_temp_mpv()
1087
+ except Exception:
1088
+ pass
1089
+
1090
+ # Only show TUI goodbye if we are NOT in CLI mode
1091
+ if self.current_mode != "cli":
1092
+ self.ui.clear()
1093
+ from .config import COLOR_ASCII
1094
+
1095
+ self.ui.print("\n" * 2)
1096
+ self.ui.print(Align.center(Text(GOODBYE_ART, style=COLOR_ASCII)))
1097
+ self.ui.print("\n")
1098
+
1099
+
1100
+ def main():
1101
+ home_dir = Path.home()
1102
+ db_dir = home_dir / ".ani-cli-arabic" / "database"
1103
+ db_dir.mkdir(parents=True, exist_ok=True)
1104
+
1105
+ app = AniCliArApp()
1106
+ app.run()
1107
+
1108
+
1109
+ if __name__ == "__main__":
1110
+ main()