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/api.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
import threading
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
from .models import AnimeResult, Episode
|
|
9
|
+
from .storage import atomic_write_json
|
|
10
|
+
|
|
11
|
+
# Default credentials - can be overridden with environment variables
|
|
12
|
+
# This is for analytics and also api credentials fetching.
|
|
13
|
+
ENDPOINT_URL = "https://api.ani-cli-arabic.dev"
|
|
14
|
+
AUTH_SECRET = "6rK9z0XyW8vQ3J7pL2mN4sB1tH5gD0fA"
|
|
15
|
+
|
|
16
|
+
def _get_endpoint_config() -> tuple[str, str]:
|
|
17
|
+
"""Get API endpoint configuration from environment or hardcoded defaults."""
|
|
18
|
+
import os
|
|
19
|
+
endpoint_url = os.getenv('ANI_CLI_AR_ENDPOINT', ENDPOINT_URL)
|
|
20
|
+
auth_secret = os.getenv('ANI_CLI_AR_AUTH_SECRET', AUTH_SECRET)
|
|
21
|
+
return endpoint_url, auth_secret
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class APICache:
|
|
25
|
+
CACHE_FILENAME = "api_credentials.json"
|
|
26
|
+
|
|
27
|
+
def __init__(self):
|
|
28
|
+
home_dir = Path.home()
|
|
29
|
+
db_dir = home_dir / ".ani-cli-arabic" / "database"
|
|
30
|
+
db_dir.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
self.cache_file = db_dir / self.CACHE_FILENAME
|
|
32
|
+
|
|
33
|
+
@staticmethod
|
|
34
|
+
def _default_keys() -> dict:
|
|
35
|
+
return {
|
|
36
|
+
'ANI_CLI_AR_API_BASE': '',
|
|
37
|
+
'ANI_CLI_AR_TOKEN': '',
|
|
38
|
+
'THUMBNAILS_BASE_URL': '',
|
|
39
|
+
'TRAILERS_BASE_URL': ''
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def _normalize_keys(data: dict) -> dict:
|
|
44
|
+
defaults = APICache._default_keys()
|
|
45
|
+
if not isinstance(data, dict):
|
|
46
|
+
return defaults
|
|
47
|
+
return {key: str(data.get(key, defaults[key]) or '') for key in defaults}
|
|
48
|
+
|
|
49
|
+
def _load_cached_keys(self) -> Optional[dict]:
|
|
50
|
+
if not self.cache_file.exists():
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
with open(self.cache_file, 'r', encoding='utf-8') as cache_handle:
|
|
55
|
+
cached = json.load(cache_handle)
|
|
56
|
+
|
|
57
|
+
normalized = self._normalize_keys(cached)
|
|
58
|
+
if normalized['ANI_CLI_AR_API_BASE'] and normalized['ANI_CLI_AR_TOKEN']:
|
|
59
|
+
return normalized
|
|
60
|
+
except (json.JSONDecodeError, OSError, IOError, ValueError, TypeError):
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
def _save_cached_keys(self, keys: dict) -> None:
|
|
66
|
+
normalized = self._normalize_keys(keys)
|
|
67
|
+
if not normalized['ANI_CLI_AR_API_BASE'] or not normalized['ANI_CLI_AR_TOKEN']:
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
atomic_write_json(self.cache_file, normalized, indent=2, ensure_ascii=False)
|
|
72
|
+
except OSError:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
def _fetch_from_remote(self) -> dict:
|
|
76
|
+
endpoint_url, auth_secret = _get_endpoint_config()
|
|
77
|
+
cached = self._load_cached_keys()
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
response = requests.get(
|
|
81
|
+
f"{endpoint_url}/credentials",
|
|
82
|
+
headers={
|
|
83
|
+
'X-Auth-Key': auth_secret,
|
|
84
|
+
'User-Agent': 'AniCliAr/2.0'
|
|
85
|
+
},
|
|
86
|
+
timeout=10
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
response.raise_for_status()
|
|
90
|
+
remote_keys = self._normalize_keys(response.json())
|
|
91
|
+
if remote_keys['ANI_CLI_AR_API_BASE'] and remote_keys['ANI_CLI_AR_TOKEN']:
|
|
92
|
+
self._save_cached_keys(remote_keys)
|
|
93
|
+
return remote_keys
|
|
94
|
+
except (requests.RequestException, ValueError, TypeError):
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
if cached:
|
|
98
|
+
return cached
|
|
99
|
+
return self._default_keys()
|
|
100
|
+
|
|
101
|
+
def get_keys(self) -> dict:
|
|
102
|
+
return self._fetch_from_remote()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def get_credentials():
|
|
106
|
+
global _credential_manager
|
|
107
|
+
if _credential_manager is None:
|
|
108
|
+
_credential_manager = APICache()
|
|
109
|
+
return _credential_manager.get_keys()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
_credential_manager = None
|
|
113
|
+
_creds = None
|
|
114
|
+
_creds_lock = threading.Lock()
|
|
115
|
+
|
|
116
|
+
def _ensure_creds():
|
|
117
|
+
global _creds, _credential_manager
|
|
118
|
+
if _creds is not None:
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
with _creds_lock:
|
|
122
|
+
if _creds is not None:
|
|
123
|
+
return
|
|
124
|
+
if _credential_manager is None:
|
|
125
|
+
_credential_manager = APICache()
|
|
126
|
+
_creds = _credential_manager.get_keys()
|
|
127
|
+
|
|
128
|
+
def get_api_base():
|
|
129
|
+
_ensure_creds()
|
|
130
|
+
return _creds.get('ANI_CLI_AR_API_BASE', '')
|
|
131
|
+
|
|
132
|
+
def get_api_token():
|
|
133
|
+
_ensure_creds()
|
|
134
|
+
return _creds.get('ANI_CLI_AR_TOKEN', '')
|
|
135
|
+
|
|
136
|
+
def get_thumbnails_base():
|
|
137
|
+
_ensure_creds()
|
|
138
|
+
return _creds.get('THUMBNAILS_BASE_URL', '')
|
|
139
|
+
|
|
140
|
+
def get_trailers_base():
|
|
141
|
+
_ensure_creds()
|
|
142
|
+
return _creds.get('TRAILERS_BASE_URL', '')
|
|
143
|
+
|
|
144
|
+
class AnimeAPI:
|
|
145
|
+
|
|
146
|
+
def _parse_anime_result(self, item: dict) -> AnimeResult:
|
|
147
|
+
thumbnail_filename = item.get('Thumbnail', '')
|
|
148
|
+
thumbnail_url = get_thumbnails_base() + thumbnail_filename if thumbnail_filename else ''
|
|
149
|
+
|
|
150
|
+
return AnimeResult(
|
|
151
|
+
id=item.get('AnimeId', ''),
|
|
152
|
+
title_en=item.get('EN_Title', 'Unknown'),
|
|
153
|
+
title_jp=item.get('JP_Title', ''),
|
|
154
|
+
type=item.get('Type', 'N/A'),
|
|
155
|
+
episodes=str(item.get('Episodes', 'N/A')),
|
|
156
|
+
status=item.get('Status', 'N/A'),
|
|
157
|
+
genres=item.get('Genres', 'N/A'),
|
|
158
|
+
mal_id=item.get('MalId', '0'),
|
|
159
|
+
relation_id=item.get('RelationId', ''),
|
|
160
|
+
score=str(item.get('Score', 'N/A')),
|
|
161
|
+
rank=str(item.get('Rank', 'N/A')),
|
|
162
|
+
popularity=str(item.get('Popularity', 'N/A')),
|
|
163
|
+
rating=item.get('Rating', 'N/A'),
|
|
164
|
+
premiered=item.get('Season', 'N/A'),
|
|
165
|
+
creators=item.get('Creators', 'N/A'),
|
|
166
|
+
duration=str(item.get('Duration', 'N/A')),
|
|
167
|
+
thumbnail=thumbnail_url,
|
|
168
|
+
title_romaji=item.get('EN_Title', ''),
|
|
169
|
+
trailer=item.get('Trailer', ''),
|
|
170
|
+
yt_trailer=item.get('YTTrailer', '')
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def _paginate_requests(self, endpoint: str, limit: int, from_index: int, base_payload: dict) -> List[AnimeResult]:
|
|
174
|
+
all_results = []
|
|
175
|
+
current_from = from_index
|
|
176
|
+
|
|
177
|
+
while len(all_results) < limit:
|
|
178
|
+
payload = base_payload.copy()
|
|
179
|
+
payload['From'] = str(current_from)
|
|
180
|
+
payload['Token'] = get_api_token()
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
response = requests.post(endpoint, data=payload, timeout=10)
|
|
184
|
+
response.raise_for_status()
|
|
185
|
+
data = response.json()
|
|
186
|
+
|
|
187
|
+
if not isinstance(data, list) or not data:
|
|
188
|
+
break
|
|
189
|
+
|
|
190
|
+
batch = [self._parse_anime_result(item) for item in data if isinstance(item, dict)]
|
|
191
|
+
all_results.extend(batch)
|
|
192
|
+
|
|
193
|
+
if len(batch) < 10:
|
|
194
|
+
break
|
|
195
|
+
|
|
196
|
+
current_from += len(batch)
|
|
197
|
+
|
|
198
|
+
except Exception:
|
|
199
|
+
break
|
|
200
|
+
|
|
201
|
+
return all_results[:limit]
|
|
202
|
+
|
|
203
|
+
def get_anime_list(self, filter_type: str = "", filter_data: str = "", anime_type: str = "SERIES", from_index: int = 0, limit: int = 30) -> List[AnimeResult]:
|
|
204
|
+
endpoint = get_api_base() + "anime/load_anime_list_v2.php"
|
|
205
|
+
payload = {
|
|
206
|
+
'UserId': '0',
|
|
207
|
+
'Language': 'English',
|
|
208
|
+
'FilterType': filter_type,
|
|
209
|
+
'FilterData': filter_data,
|
|
210
|
+
'Type': anime_type,
|
|
211
|
+
}
|
|
212
|
+
return self._paginate_requests(endpoint, limit, from_index, payload)
|
|
213
|
+
|
|
214
|
+
def get_latest_anime(self, from_index: int = 0, limit: int = 30) -> List[AnimeResult]:
|
|
215
|
+
endpoint = get_api_base() + "anime/load_latest_anime.php"
|
|
216
|
+
payload = {
|
|
217
|
+
'UserId': '0',
|
|
218
|
+
'Language': 'English',
|
|
219
|
+
}
|
|
220
|
+
return self._paginate_requests(endpoint, limit, from_index, payload)
|
|
221
|
+
|
|
222
|
+
def search_anime(self, query: str) -> List[AnimeResult]:
|
|
223
|
+
series_results = self.get_anime_list(filter_type="SEARCH", filter_data=query, anime_type="SERIES", limit=20)
|
|
224
|
+
movie_results = self.get_anime_list(filter_type="SEARCH", filter_data=query, anime_type="MOVIE", limit=20)
|
|
225
|
+
return series_results + movie_results
|
|
226
|
+
|
|
227
|
+
def get_trending_anime(self, from_index: int = 0, limit: int = 15) -> List[AnimeResult]:
|
|
228
|
+
fetch_limit = limit + from_index + 20
|
|
229
|
+
results = self.get_latest_anime(limit=fetch_limit)
|
|
230
|
+
results_with_pop = [r for r in results if r.popularity and r.popularity.isdigit()]
|
|
231
|
+
results_with_pop.sort(key=lambda x: int(x.popularity))
|
|
232
|
+
return results_with_pop[from_index:from_index + limit]
|
|
233
|
+
|
|
234
|
+
def get_top_rated_anime(self, from_index: int = 0, limit: int = 15) -> List[AnimeResult]:
|
|
235
|
+
return self.get_anime_list(filter_type="SORT", filter_data="HIGHEST_RATE", anime_type="SERIES", from_index=from_index, limit=limit)
|
|
236
|
+
|
|
237
|
+
def get_episodes(self, anime_id: str) -> List[Episode]:
|
|
238
|
+
endpoint = get_api_base() + "episodes/load_episodes.php"
|
|
239
|
+
payload = {
|
|
240
|
+
'AnimeID': anime_id,
|
|
241
|
+
'Token': get_api_token()
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
response = requests.post(endpoint, data=payload, timeout=10)
|
|
246
|
+
response.raise_for_status()
|
|
247
|
+
data = response.json()
|
|
248
|
+
|
|
249
|
+
if not isinstance(data, list):
|
|
250
|
+
return []
|
|
251
|
+
|
|
252
|
+
episodes = []
|
|
253
|
+
for idx, ep in enumerate(data, 1):
|
|
254
|
+
if not isinstance(ep, dict):
|
|
255
|
+
continue
|
|
256
|
+
|
|
257
|
+
ep_num = ep.get('Episode', str(idx))
|
|
258
|
+
ep_type = ep.get('Type', 'Episode')
|
|
259
|
+
|
|
260
|
+
if not ep_type or ep_type.strip() == "":
|
|
261
|
+
ep_type = "Episode"
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
display_num_str = str(ep_num)
|
|
265
|
+
if '.' in display_num_str:
|
|
266
|
+
display_num = float(display_num_str)
|
|
267
|
+
else:
|
|
268
|
+
display_num = int(float(display_num_str))
|
|
269
|
+
except (ValueError, TypeError):
|
|
270
|
+
display_num = idx
|
|
271
|
+
episodes.append(Episode(ep_num, ep_type, display_num))
|
|
272
|
+
return episodes
|
|
273
|
+
except (requests.RequestException, ValueError, TypeError):
|
|
274
|
+
return []
|
|
275
|
+
|
|
276
|
+
def get_streaming_servers(self, anime_id: str, episode_num: str, anime_type: str = 'SERIES') -> Optional[Dict]:
|
|
277
|
+
endpoint = get_api_base() + "anime/load_servers.php"
|
|
278
|
+
payload = {
|
|
279
|
+
'UserId': '0',
|
|
280
|
+
'AnimeId': anime_id,
|
|
281
|
+
'Episode': str(episode_num),
|
|
282
|
+
'AnimeType': anime_type,
|
|
283
|
+
'Token': get_api_token()
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
try:
|
|
287
|
+
response = requests.post(endpoint, data=payload, timeout=10)
|
|
288
|
+
response.raise_for_status()
|
|
289
|
+
return response.json()
|
|
290
|
+
except (requests.RequestException, ValueError, TypeError):
|
|
291
|
+
return None
|
|
292
|
+
|
|
293
|
+
def extract_mediafire_direct(self, mf_url: str) -> Optional[str]:
|
|
294
|
+
try:
|
|
295
|
+
headers = {
|
|
296
|
+
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
|
297
|
+
}
|
|
298
|
+
response = requests.get(mf_url, headers=headers, timeout=10)
|
|
299
|
+
response.raise_for_status()
|
|
300
|
+
match = re.search(r'(https://download[^"]+)', response.text)
|
|
301
|
+
return match.group(1) if match else None
|
|
302
|
+
except (requests.RequestException, AttributeError):
|
|
303
|
+
return None
|
|
304
|
+
|
|
305
|
+
def build_mediafire_url(self, server_id: str) -> str:
|
|
306
|
+
if server_id.startswith('http'):
|
|
307
|
+
return server_id
|
|
308
|
+
return f'https://www.mediafire.com/file/{server_id}'
|
|
309
|
+
|