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.
@@ -0,0 +1,8 @@
1
+ """
2
+ Entry point for running ani-cli-arabic as a module
3
+ Usage: python -m ani_cli_arabic
4
+ """
5
+ from ani_cli_arabic.app import main
6
+
7
+ if __name__ == "__main__":
8
+ main()
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
+