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_ar-1.8.4.dist-info/METADATA +348 -0
- ani_cli_ar-1.8.4.dist-info/RECORD +27 -0
- ani_cli_ar-1.8.4.dist-info/WHEEL +5 -0
- ani_cli_ar-1.8.4.dist-info/entry_points.txt +3 -0
- ani_cli_ar-1.8.4.dist-info/licenses/LICENSE +674 -0
- ani_cli_ar-1.8.4.dist-info/top_level.txt +1 -0
- ani_cli_arabic/__init__.py +3 -0
- ani_cli_arabic/__main__.py +8 -0
- ani_cli_arabic/api.py +309 -0
- ani_cli_arabic/app.py +1110 -0
- ani_cli_arabic/cli.py +598 -0
- ani_cli_arabic/config.py +88 -0
- ani_cli_arabic/deps.py +453 -0
- ani_cli_arabic/discord_rpc.py +361 -0
- ani_cli_arabic/favorites.py +72 -0
- ani_cli_arabic/history.py +72 -0
- ani_cli_arabic/models.py +36 -0
- ani_cli_arabic/monitoring.py +92 -0
- ani_cli_arabic/player.py +281 -0
- ani_cli_arabic/scrapers/__init__.py +0 -0
- ani_cli_arabic/scrapers/english.py +316 -0
- ani_cli_arabic/settings.py +54 -0
- ani_cli_arabic/storage.py +28 -0
- ani_cli_arabic/ui.py +1421 -0
- ani_cli_arabic/updater.py +280 -0
- ani_cli_arabic/utils.py +619 -0
- ani_cli_arabic/version.py +10 -0
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()
|