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/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)
|