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/player.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import time
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import tempfile
|
|
7
|
+
from typing import Optional
|
|
8
|
+
from .utils import is_bundled
|
|
9
|
+
|
|
10
|
+
class PlayerManager:
|
|
11
|
+
def __init__(self, rpc_manager=None, console=None):
|
|
12
|
+
self.temp_mpv_path = None
|
|
13
|
+
self.rpc_manager = rpc_manager
|
|
14
|
+
self.console = console
|
|
15
|
+
|
|
16
|
+
def get_mpv_path(self) -> Optional[str]:
|
|
17
|
+
if is_bundled():
|
|
18
|
+
exe_name = 'mpv.exe' if os.name == 'nt' else 'mpv'
|
|
19
|
+
bundled_mpv = os.path.join(sys._MEIPASS, 'mpv', exe_name)
|
|
20
|
+
if os.path.exists(bundled_mpv):
|
|
21
|
+
if not self.temp_mpv_path or not os.path.exists(self.temp_mpv_path):
|
|
22
|
+
temp_dir = tempfile.mkdtemp(prefix='anime_browser_mpv_')
|
|
23
|
+
self.temp_mpv_path = os.path.join(temp_dir, exe_name)
|
|
24
|
+
shutil.copy2(bundled_mpv, self.temp_mpv_path)
|
|
25
|
+
|
|
26
|
+
# Ensure executable permissions on Linux/macOS
|
|
27
|
+
if os.name != 'nt':
|
|
28
|
+
st = os.stat(self.temp_mpv_path)
|
|
29
|
+
os.chmod(self.temp_mpv_path, st.st_mode | 0o111)
|
|
30
|
+
|
|
31
|
+
return self.temp_mpv_path
|
|
32
|
+
else:
|
|
33
|
+
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
34
|
+
exe_name = 'mpv.exe' if os.name == 'nt' else 'mpv'
|
|
35
|
+
|
|
36
|
+
dev_mpv = os.path.join(base_dir, 'mpv', exe_name)
|
|
37
|
+
if os.path.exists(dev_mpv):
|
|
38
|
+
return dev_mpv
|
|
39
|
+
|
|
40
|
+
local_mpv = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'mpv', exe_name)
|
|
41
|
+
if os.path.exists(local_mpv):
|
|
42
|
+
return local_mpv
|
|
43
|
+
|
|
44
|
+
# Check system PATH
|
|
45
|
+
if shutil.which('mpv'):
|
|
46
|
+
return 'mpv'
|
|
47
|
+
|
|
48
|
+
return 'mpv'
|
|
49
|
+
|
|
50
|
+
return 'mpv'
|
|
51
|
+
|
|
52
|
+
def cleanup_temp_mpv(self):
|
|
53
|
+
if self.temp_mpv_path and os.path.exists(self.temp_mpv_path):
|
|
54
|
+
try:
|
|
55
|
+
temp_dir = os.path.dirname(self.temp_mpv_path)
|
|
56
|
+
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
57
|
+
except (OSError, PermissionError):
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
def get_available_players(self) -> dict:
|
|
61
|
+
players = {}
|
|
62
|
+
|
|
63
|
+
# Check VLC
|
|
64
|
+
vlc_path = shutil.which('vlc')
|
|
65
|
+
if not vlc_path:
|
|
66
|
+
if os.name == 'nt':
|
|
67
|
+
paths = [
|
|
68
|
+
r"C:\Program Files\VideoLAN\VLC\vlc.exe",
|
|
69
|
+
r"C:\Program Files (x86)\VideoLAN\VLC\vlc.exe"
|
|
70
|
+
]
|
|
71
|
+
for p in paths:
|
|
72
|
+
if os.path.exists(p):
|
|
73
|
+
vlc_path = p
|
|
74
|
+
break
|
|
75
|
+
elif sys.platform == 'darwin':
|
|
76
|
+
paths = [
|
|
77
|
+
"/Applications/VLC.app/Contents/MacOS/VLC",
|
|
78
|
+
os.path.expanduser("~/Applications/VLC.app/Contents/MacOS/VLC")
|
|
79
|
+
]
|
|
80
|
+
for p in paths:
|
|
81
|
+
if os.path.exists(p):
|
|
82
|
+
vlc_path = p
|
|
83
|
+
break
|
|
84
|
+
if vlc_path:
|
|
85
|
+
players['VLC'] = vlc_path
|
|
86
|
+
|
|
87
|
+
# Check MPV
|
|
88
|
+
mpv_path = self.get_mpv_path()
|
|
89
|
+
if mpv_path == 'mpv':
|
|
90
|
+
if shutil.which('mpv'):
|
|
91
|
+
players['MPV'] = 'mpv'
|
|
92
|
+
elif os.path.exists(mpv_path):
|
|
93
|
+
players['MPV'] = mpv_path
|
|
94
|
+
|
|
95
|
+
# Check MPC-HC
|
|
96
|
+
mpc_path = shutil.which('mpc-hc64') or shutil.which('mpc-hc')
|
|
97
|
+
if not mpc_path and os.name == 'nt':
|
|
98
|
+
paths = [
|
|
99
|
+
r"C:\Program Files\MPC-HC\mpc-hc64.exe",
|
|
100
|
+
r"C:\Program Files\MPC-HC\mpc-hc.exe",
|
|
101
|
+
r"C:\Program Files (x86)\MPC-HC\mpc-hc.exe",
|
|
102
|
+
r"C:\Program Files\K-Lite Codec Pack\MPC-HC64\mpc-hc64.exe"
|
|
103
|
+
]
|
|
104
|
+
for p in paths:
|
|
105
|
+
if os.path.exists(p):
|
|
106
|
+
mpc_path = p
|
|
107
|
+
break
|
|
108
|
+
if mpc_path:
|
|
109
|
+
players['MPC-HC'] = mpc_path
|
|
110
|
+
|
|
111
|
+
return players
|
|
112
|
+
|
|
113
|
+
def play(self, url: str, title: str, player_type: str = 'ask', headers: Optional[dict] = None):
|
|
114
|
+
if not url or not (url.startswith("http://") or url.startswith("https://") or url.startswith("rtmp://")):
|
|
115
|
+
msg = "Error: Extracted stream URL is invalid or empty."
|
|
116
|
+
if self.console:
|
|
117
|
+
from rich.text import Text
|
|
118
|
+
self.console.print(Text(msg, style="bold red"))
|
|
119
|
+
else:
|
|
120
|
+
print(msg, file=sys.stderr)
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
available_players = self.get_available_players()
|
|
124
|
+
|
|
125
|
+
if not available_players:
|
|
126
|
+
msg = "No video players found on your computer. Please download and install VLC Media Player from https://www.videolan.org/vlc/"
|
|
127
|
+
if self.console:
|
|
128
|
+
from rich.text import Text
|
|
129
|
+
self.console.print(Text(msg, style="bold red"))
|
|
130
|
+
input("Press Enter to continue...")
|
|
131
|
+
else:
|
|
132
|
+
print(msg, file=sys.stderr)
|
|
133
|
+
input("Press Enter to continue...")
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
player_names = list(available_players.keys())
|
|
137
|
+
selected_player = None
|
|
138
|
+
|
|
139
|
+
if len(player_names) == 1:
|
|
140
|
+
selected_player = player_names[0]
|
|
141
|
+
else:
|
|
142
|
+
if self.console:
|
|
143
|
+
from rich.prompt import Prompt
|
|
144
|
+
from rich.panel import Panel
|
|
145
|
+
from rich.text import Text
|
|
146
|
+
from rich.align import Align
|
|
147
|
+
|
|
148
|
+
options_text = "\n".join([f"[{i+1}] {name}" for i, name in enumerate(player_names)])
|
|
149
|
+
panel = Panel(options_text, title=Text("Select Video Player", style="bold cyan"), border_style="cyan", padding=(1, 4))
|
|
150
|
+
self.console.print()
|
|
151
|
+
self.console.print(Align.center(panel))
|
|
152
|
+
|
|
153
|
+
choice = Prompt.ask(
|
|
154
|
+
"Enter the number of the player",
|
|
155
|
+
choices=[str(i+1) for i in range(len(player_names))],
|
|
156
|
+
default="1",
|
|
157
|
+
console=self.console
|
|
158
|
+
)
|
|
159
|
+
selected_player = player_names[int(choice)-1]
|
|
160
|
+
else:
|
|
161
|
+
print("\nAvailable Video Players:")
|
|
162
|
+
for i, name in enumerate(player_names):
|
|
163
|
+
print(f"{i+1}. {name}")
|
|
164
|
+
|
|
165
|
+
while True:
|
|
166
|
+
try:
|
|
167
|
+
choice = input(f"Choose a video player (1-{len(player_names)}) [1]: ")
|
|
168
|
+
if not choice.strip():
|
|
169
|
+
choice = "1"
|
|
170
|
+
choice_idx = int(choice) - 1
|
|
171
|
+
if 0 <= choice_idx < len(player_names):
|
|
172
|
+
selected_player = player_names[choice_idx]
|
|
173
|
+
break
|
|
174
|
+
print("Invalid choice.")
|
|
175
|
+
except ValueError:
|
|
176
|
+
print("Invalid input.")
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
if selected_player == 'VLC':
|
|
180
|
+
self._play_vlc(url, title, available_players['VLC'], headers)
|
|
181
|
+
elif selected_player == 'MPV':
|
|
182
|
+
self._play_mpv(url, title, available_players['MPV'], headers)
|
|
183
|
+
elif selected_player == 'MPC-HC':
|
|
184
|
+
self._play_mpc(url, title, available_players['MPC-HC'], headers)
|
|
185
|
+
except Exception as e:
|
|
186
|
+
if self.console:
|
|
187
|
+
from rich.text import Text
|
|
188
|
+
self.console.print(Text(f"Error launching player: {str(e)}", style="bold red"))
|
|
189
|
+
input("Press Enter to continue...")
|
|
190
|
+
else:
|
|
191
|
+
print(f"Error launching player: {str(e)}", file=sys.stderr)
|
|
192
|
+
input("Press Enter to continue...")
|
|
193
|
+
|
|
194
|
+
def _play_vlc(self, url: str, title: str, vlc_path: str = None, headers: dict = None):
|
|
195
|
+
if not vlc_path:
|
|
196
|
+
vlc_path = self.get_available_players().get('VLC')
|
|
197
|
+
|
|
198
|
+
if not vlc_path:
|
|
199
|
+
raise FileNotFoundError("VLC not found")
|
|
200
|
+
|
|
201
|
+
vlc_args = [
|
|
202
|
+
vlc_path,
|
|
203
|
+
'--fullscreen',
|
|
204
|
+
'--play-and-exit',
|
|
205
|
+
'--meta-title', title,
|
|
206
|
+
]
|
|
207
|
+
if headers and headers.get('Referer'):
|
|
208
|
+
vlc_args += ['--http-referrer=' + headers['Referer']]
|
|
209
|
+
vlc_args.append(url)
|
|
210
|
+
|
|
211
|
+
subprocess.run(
|
|
212
|
+
vlc_args,
|
|
213
|
+
check=False,
|
|
214
|
+
stdout=subprocess.DEVNULL,
|
|
215
|
+
stderr=subprocess.DEVNULL
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def _play_mpv(self, url: str, title: str, mpv_path: str = None, headers: dict = None):
|
|
219
|
+
if not mpv_path:
|
|
220
|
+
mpv_path = self.get_available_players().get('MPV')
|
|
221
|
+
|
|
222
|
+
if not mpv_path or (mpv_path != 'mpv' and not os.path.exists(mpv_path)):
|
|
223
|
+
raise FileNotFoundError(f"MPV not found at: {mpv_path}")
|
|
224
|
+
|
|
225
|
+
mpv_args = [
|
|
226
|
+
mpv_path,
|
|
227
|
+
'--fullscreen',
|
|
228
|
+
'--keep-open=yes',
|
|
229
|
+
'--cache=yes',
|
|
230
|
+
'--demuxer-max-bytes=256M',
|
|
231
|
+
'--demuxer-max-back-bytes=128M',
|
|
232
|
+
'--cache-secs=30',
|
|
233
|
+
'--hwdec=auto-safe',
|
|
234
|
+
'--sub-auto=fuzzy',
|
|
235
|
+
'--force-media-title=' + title,
|
|
236
|
+
'--force-window=yes',
|
|
237
|
+
]
|
|
238
|
+
if headers:
|
|
239
|
+
ua = headers.get('User-Agent')
|
|
240
|
+
if ua:
|
|
241
|
+
mpv_args += ['--user-agent=' + ua]
|
|
242
|
+
ref = headers.get('Referer')
|
|
243
|
+
if ref:
|
|
244
|
+
mpv_args += ['--referrer=' + ref]
|
|
245
|
+
mpv_args.append(url)
|
|
246
|
+
|
|
247
|
+
result = subprocess.run(
|
|
248
|
+
mpv_args,
|
|
249
|
+
check=False,
|
|
250
|
+
stdout=subprocess.DEVNULL,
|
|
251
|
+
stderr=subprocess.DEVNULL,
|
|
252
|
+
stdin=subprocess.DEVNULL
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
if result.returncode != 0:
|
|
256
|
+
if self.console:
|
|
257
|
+
from rich.text import Text
|
|
258
|
+
self.console.print(Text(f"MPV exited with error code {result.returncode}", style="bold red"))
|
|
259
|
+
input("Press Enter to continue...")
|
|
260
|
+
|
|
261
|
+
def _play_mpc(self, url: str, title: str, mpc_path: str = None, headers: dict = None):
|
|
262
|
+
if not mpc_path:
|
|
263
|
+
mpc_path = self.get_available_players().get('MPC-HC')
|
|
264
|
+
|
|
265
|
+
if not mpc_path:
|
|
266
|
+
raise FileNotFoundError("MPC-HC not found")
|
|
267
|
+
|
|
268
|
+
mpc_args = [
|
|
269
|
+
mpc_path,
|
|
270
|
+
url,
|
|
271
|
+
'/fullscreen',
|
|
272
|
+
'/play',
|
|
273
|
+
'/close'
|
|
274
|
+
]
|
|
275
|
+
|
|
276
|
+
subprocess.run(
|
|
277
|
+
mpc_args,
|
|
278
|
+
check=False,
|
|
279
|
+
stdout=subprocess.DEVNULL,
|
|
280
|
+
stderr=subprocess.DEVNULL
|
|
281
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
import time
|
|
4
|
+
import hashlib
|
|
5
|
+
import base64
|
|
6
|
+
from typing import Optional
|
|
7
|
+
from urllib.parse import urljoin
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
from Cryptodome.Cipher import AES
|
|
11
|
+
|
|
12
|
+
ALLANIME_CDN = "https://cdn.mkissa.net/all/mk/_app/immutable"
|
|
13
|
+
ALLANIME_REFR = "https://mkissa.to"
|
|
14
|
+
ALLANIME_BASE = "allanime.day"
|
|
15
|
+
ALLANIME_API = "https://api.mkissa.net"
|
|
16
|
+
ALLANIME_QUERY_HASH = "f4662f4b7510b26795dd53ef824a0bf1740fbbc5d1273fab18222ac831bca8d0"
|
|
17
|
+
KEYGEN_URL = "https://raw.githubusercontent.com/sdaqo/anipy-cli/refs/heads/key-gen/scripts/keygen/keygen.json"
|
|
18
|
+
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0"
|
|
19
|
+
|
|
20
|
+
PROVIDER_PRIORITY = ["Yt-mp4", "S-Mp4", "Uv-mp4", "Ak", "Default"]
|
|
21
|
+
|
|
22
|
+
_HEX_MAP = {
|
|
23
|
+
"79": "A", "7a": "B", "7b": "C", "7c": "D", "7d": "E", "7e": "F", "7f": "G",
|
|
24
|
+
"70": "H", "71": "I", "72": "J", "73": "K", "74": "L", "75": "M", "76": "N", "77": "O",
|
|
25
|
+
"68": "P", "69": "Q", "6a": "R", "6b": "S", "6c": "T", "6d": "U", "6e": "V", "6f": "W",
|
|
26
|
+
"60": "X", "61": "Y", "62": "Z",
|
|
27
|
+
"59": "a", "5a": "b", "5b": "c", "5c": "d", "5d": "e", "5e": "f", "5f": "g",
|
|
28
|
+
"50": "h", "51": "i", "52": "j", "53": "k", "54": "l", "55": "m", "56": "n", "57": "o",
|
|
29
|
+
"48": "p", "49": "q", "4a": "r", "4b": "s", "4c": "t", "4d": "u", "4e": "v", "4f": "w",
|
|
30
|
+
"40": "x", "41": "y", "42": "z",
|
|
31
|
+
"08": "0", "09": "1", "0a": "2", "0b": "3", "0c": "4", "0d": "5", "0e": "6", "0f": "7",
|
|
32
|
+
"00": "8", "01": "9",
|
|
33
|
+
"15": "-", "16": ".", "67": "_", "46": "~", "02": ":", "17": "/", "07": "?",
|
|
34
|
+
"1b": "#", "63": "[", "65": "]", "78": "@", "19": "!", "1c": "$", "1e": "&",
|
|
35
|
+
"10": "(", "11": ")", "12": "*", "13": "+", "14": ",", "03": ";", "05": "=", "1d": "%",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
def _decode_hex_url(encoded: str) -> str:
|
|
39
|
+
if not encoded.startswith("--"):
|
|
40
|
+
return encoded
|
|
41
|
+
hex_str = encoded[2:]
|
|
42
|
+
out = []
|
|
43
|
+
for i in range(0, len(hex_str), 2):
|
|
44
|
+
pair = hex_str[i:i+2]
|
|
45
|
+
out.append(_HEX_MAP.get(pair, "?"))
|
|
46
|
+
result = "".join(out)
|
|
47
|
+
result = result.replace("/clock", "/clock.json")
|
|
48
|
+
return result
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class AllAnimeScraper:
|
|
52
|
+
def __init__(self):
|
|
53
|
+
self.session = requests.Session()
|
|
54
|
+
self.session.headers.update({"User-Agent": USER_AGENT})
|
|
55
|
+
self._aes_key: Optional[str] = None
|
|
56
|
+
self._epoch: Optional[int] = None
|
|
57
|
+
self._static_key: Optional[str] = None
|
|
58
|
+
|
|
59
|
+
# ------------------------------------------------------------------ #
|
|
60
|
+
# Key derivation – replicates bash fetch_keys() + keygen fallback #
|
|
61
|
+
# ------------------------------------------------------------------ #
|
|
62
|
+
|
|
63
|
+
def _fetch_keys_from_page(self):
|
|
64
|
+
html = self.session.get(ALLANIME_REFR, timeout=15).text
|
|
65
|
+
|
|
66
|
+
m = re.search(r'"epoch":(\d+)', html)
|
|
67
|
+
if not m:
|
|
68
|
+
raise RuntimeError("epoch not found on page")
|
|
69
|
+
self._epoch = int(m.group(1))
|
|
70
|
+
|
|
71
|
+
m = re.search(r'"partB":"([^"]*)"', html)
|
|
72
|
+
if not m:
|
|
73
|
+
raise RuntimeError("partB not found on page")
|
|
74
|
+
part_b_b64 = m.group(1)
|
|
75
|
+
|
|
76
|
+
m = re.search(rf'{re.escape(ALLANIME_CDN)}/entry/app\.[A-Za-z0-9_.-]+\.js', html)
|
|
77
|
+
if not m:
|
|
78
|
+
raise RuntimeError("app.js URL not found on page")
|
|
79
|
+
app_js_url = m.group(0)
|
|
80
|
+
|
|
81
|
+
app_js = self.session.get(app_js_url, timeout=15).text
|
|
82
|
+
chunk_refs = re.findall(r'"[.][.]/chunks/[A-Za-z0-9_.-]+\.js"', app_js)
|
|
83
|
+
js_parts = []
|
|
84
|
+
for ref in chunk_refs[:5]:
|
|
85
|
+
url = f"{ALLANIME_CDN}/{ref.strip('\"').lstrip('../')}"
|
|
86
|
+
try:
|
|
87
|
+
js_parts.append(self.session.get(url, timeout=15).text)
|
|
88
|
+
except requests.RequestException:
|
|
89
|
+
pass
|
|
90
|
+
js_body = "".join(js_parts)
|
|
91
|
+
|
|
92
|
+
m = re.search(r'[0-9a-f]{64}', js_body)
|
|
93
|
+
if not m:
|
|
94
|
+
raise RuntimeError("mask hex not found in JS chunks")
|
|
95
|
+
aa_mask_hex = m.group(0)
|
|
96
|
+
|
|
97
|
+
part_b_hex = base64.b64decode(part_b_b64).hex()
|
|
98
|
+
key_hex = ""
|
|
99
|
+
for i in range(0, 128, 2):
|
|
100
|
+
m_byte = int(aa_mask_hex[i:i+2], 16)
|
|
101
|
+
p_byte = int(part_b_hex[i:i+2], 16)
|
|
102
|
+
key_hex += f"{m_byte ^ p_byte:02x}"
|
|
103
|
+
self._aes_key = key_hex
|
|
104
|
+
|
|
105
|
+
def _fetch_keys_from_keygen(self):
|
|
106
|
+
resp = requests.get(KEYGEN_URL, timeout=15)
|
|
107
|
+
resp.raise_for_status()
|
|
108
|
+
kg = resp.json()
|
|
109
|
+
self._epoch = kg["epoch"]
|
|
110
|
+
self._aes_key = kg["key"]
|
|
111
|
+
self._static_key = kg.get("static_key")
|
|
112
|
+
|
|
113
|
+
def _ensure_keys(self):
|
|
114
|
+
if self._aes_key is not None:
|
|
115
|
+
return
|
|
116
|
+
try:
|
|
117
|
+
self._fetch_keys_from_page()
|
|
118
|
+
except Exception:
|
|
119
|
+
self._fetch_keys_from_keygen()
|
|
120
|
+
|
|
121
|
+
# ---------------------------------------------------------------- #
|
|
122
|
+
# aaReq token generation – matches bash get_aa_req() #
|
|
123
|
+
# ---------------------------------------------------------------- #
|
|
124
|
+
|
|
125
|
+
def _build_aa_req(self) -> str:
|
|
126
|
+
self._ensure_keys()
|
|
127
|
+
ts = int(time.time() * 1000) // 300000 * 300000
|
|
128
|
+
payload = {"v": 1, "ts": ts, "epoch": self._epoch, "qh": ALLANIME_QUERY_HASH}
|
|
129
|
+
iv = hashlib.sha256(
|
|
130
|
+
f"{self._epoch}:{ALLANIME_QUERY_HASH}:{ts}".encode()
|
|
131
|
+
).digest()[:12]
|
|
132
|
+
cipher = AES.new(bytes.fromhex(self._aes_key), AES.MODE_GCM, nonce=iv)
|
|
133
|
+
ct, tag = cipher.encrypt_and_digest(
|
|
134
|
+
json.dumps(payload, separators=(",", ":")).encode()
|
|
135
|
+
)
|
|
136
|
+
return base64.b64encode(b"\x01" + iv + ct + tag).decode()
|
|
137
|
+
|
|
138
|
+
# ---------------------------------------------------------------- #
|
|
139
|
+
# tobeparsed decryption – matches bash process_tobeparsed() #
|
|
140
|
+
# ---------------------------------------------------------------- #
|
|
141
|
+
|
|
142
|
+
def _decrypt_tobeparsed(self, tbp_b64: str):
|
|
143
|
+
raw = base64.b64decode(tbp_b64)
|
|
144
|
+
iv, ct, tag = raw[1:13], raw[13:-16], raw[-16:]
|
|
145
|
+
candidates = [bytes.fromhex(self._aes_key)]
|
|
146
|
+
if self._static_key:
|
|
147
|
+
candidates.append(self._static_key.encode())
|
|
148
|
+
for key in candidates:
|
|
149
|
+
try:
|
|
150
|
+
plain = AES.new(key, AES.MODE_GCM, nonce=iv).decrypt_and_verify(ct, tag)
|
|
151
|
+
return json.loads(plain.decode("utf-8"))
|
|
152
|
+
except (ValueError, KeyError):
|
|
153
|
+
continue
|
|
154
|
+
raise ValueError("tobeparsed could not be decrypted")
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------- #
|
|
157
|
+
# Public API – search / episodes / video #
|
|
158
|
+
# ---------------------------------------------------------------- #
|
|
159
|
+
|
|
160
|
+
def search(self, query: str, mode: str = "sub"):
|
|
161
|
+
gql = (
|
|
162
|
+
"query($search: SearchInput $limit: Int $page: Int "
|
|
163
|
+
"$translationType: VaildTranslationTypeEnumType "
|
|
164
|
+
"$countryOrigin: VaildCountryOriginEnumType) { "
|
|
165
|
+
"shows(search: $search limit: $limit page: $page "
|
|
166
|
+
"translationType: $translationType "
|
|
167
|
+
"countryOrigin: $countryOrigin) { "
|
|
168
|
+
"edges { _id name availableEpisodes airedStart __typename } } }"
|
|
169
|
+
)
|
|
170
|
+
resp = self.session.post(
|
|
171
|
+
f"{ALLANIME_API}/api",
|
|
172
|
+
json={
|
|
173
|
+
"variables": {
|
|
174
|
+
"search": {"allowAdult": False, "allowUnknown": False, "query": query},
|
|
175
|
+
"limit": 40,
|
|
176
|
+
"page": 1,
|
|
177
|
+
"translationType": mode,
|
|
178
|
+
"countryOrigin": "ALL",
|
|
179
|
+
},
|
|
180
|
+
"query": gql,
|
|
181
|
+
},
|
|
182
|
+
headers={"Content-Type": "application/json", "Referer": ALLANIME_REFR},
|
|
183
|
+
timeout=15,
|
|
184
|
+
)
|
|
185
|
+
data = resp.json()
|
|
186
|
+
out = []
|
|
187
|
+
for edge in data.get("data", {}).get("shows", {}).get("edges", []):
|
|
188
|
+
out.append({
|
|
189
|
+
"id": edge["_id"],
|
|
190
|
+
"name": edge["name"],
|
|
191
|
+
"available_episodes": edge.get("availableEpisodes", {}),
|
|
192
|
+
})
|
|
193
|
+
return out
|
|
194
|
+
|
|
195
|
+
def get_episodes(self, show_id: str, mode: str = "sub"):
|
|
196
|
+
gql = "query($showId: String!) { show(_id: $showId) { _id availableEpisodesDetail } }"
|
|
197
|
+
resp = self.session.post(
|
|
198
|
+
f"{ALLANIME_API}/api",
|
|
199
|
+
json={"variables": {"showId": show_id}, "query": gql},
|
|
200
|
+
headers={"Content-Type": "application/json", "Referer": ALLANIME_REFR},
|
|
201
|
+
timeout=15,
|
|
202
|
+
)
|
|
203
|
+
data = resp.json()
|
|
204
|
+
detail = data.get("data", {}).get("show", {}).get("availableEpisodesDetail", {})
|
|
205
|
+
eps = detail.get(mode, detail.get("sub", []))
|
|
206
|
+
return sorted(float(e) for e in eps)
|
|
207
|
+
|
|
208
|
+
def get_video_sources(self, show_id: str, episode_num, mode: str = "sub"):
|
|
209
|
+
aa_req = self._build_aa_req()
|
|
210
|
+
variables = json.dumps({
|
|
211
|
+
"showId": show_id,
|
|
212
|
+
"translationType": mode,
|
|
213
|
+
"episodeString": str(int(float(episode_num))),
|
|
214
|
+
})
|
|
215
|
+
extensions = json.dumps({
|
|
216
|
+
"persistedQuery": {"version": 1, "sha256Hash": ALLANIME_QUERY_HASH},
|
|
217
|
+
"aaReq": aa_req,
|
|
218
|
+
})
|
|
219
|
+
resp = self.session.get(
|
|
220
|
+
f"{ALLANIME_API}/api",
|
|
221
|
+
params={"variables": variables, "extensions": extensions},
|
|
222
|
+
headers={"Referer": ALLANIME_REFR, "Origin": ALLANIME_REFR},
|
|
223
|
+
timeout=15,
|
|
224
|
+
)
|
|
225
|
+
payload = resp.json()
|
|
226
|
+
|
|
227
|
+
data = payload.get("data", {})
|
|
228
|
+
if "tobeparsed" in data:
|
|
229
|
+
try:
|
|
230
|
+
data = self._decrypt_tobeparsed(data["tobeparsed"])
|
|
231
|
+
except ValueError:
|
|
232
|
+
return []
|
|
233
|
+
|
|
234
|
+
sources = []
|
|
235
|
+
episode = data.get("episode") if isinstance(data, dict) else None
|
|
236
|
+
if episode is None:
|
|
237
|
+
return sources
|
|
238
|
+
|
|
239
|
+
for src in episode.get("sourceUrls", []):
|
|
240
|
+
if src.get("sourceName") in PROVIDER_PRIORITY:
|
|
241
|
+
sources.append({
|
|
242
|
+
"source_name": src["sourceName"],
|
|
243
|
+
"source_url": src["sourceUrl"],
|
|
244
|
+
"priority": src.get("priority", 0),
|
|
245
|
+
"type": src.get("type", ""),
|
|
246
|
+
"stype": src.get("stype", ""),
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
sources.sort(key=lambda x: -x["priority"])
|
|
250
|
+
return sources
|
|
251
|
+
|
|
252
|
+
def _fetch_provider_links(self, decoded_path: str) -> list:
|
|
253
|
+
url = f"https://{ALLANIME_BASE}{decoded_path}"
|
|
254
|
+
try:
|
|
255
|
+
resp = self.session.get(
|
|
256
|
+
url,
|
|
257
|
+
headers={"Referer": ALLANIME_REFR, "User-Agent": USER_AGENT},
|
|
258
|
+
timeout=15,
|
|
259
|
+
)
|
|
260
|
+
data = resp.json()
|
|
261
|
+
except Exception:
|
|
262
|
+
return []
|
|
263
|
+
|
|
264
|
+
links = []
|
|
265
|
+
for entry in data.get("links", []):
|
|
266
|
+
raw = entry.get("rawUrls", {})
|
|
267
|
+
vids = raw.get("vids", [])
|
|
268
|
+
for vid in vids:
|
|
269
|
+
vid_url = vid.get("url", "")
|
|
270
|
+
height = vid.get("height", 0)
|
|
271
|
+
links.append({
|
|
272
|
+
"url": vid_url,
|
|
273
|
+
"resolution": height,
|
|
274
|
+
"headers": {"Referer": ALLANIME_REFR, "User-Agent": USER_AGENT},
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
link = entry.get("link", "")
|
|
278
|
+
if not vids and link:
|
|
279
|
+
resolution = 0
|
|
280
|
+
rs = entry.get("resolutionStr", "")
|
|
281
|
+
if rs:
|
|
282
|
+
try:
|
|
283
|
+
resolution = int(rs.replace("p", "").split()[0])
|
|
284
|
+
except (ValueError, IndexError):
|
|
285
|
+
resolution = 0
|
|
286
|
+
links.append({
|
|
287
|
+
"url": link,
|
|
288
|
+
"resolution": resolution,
|
|
289
|
+
"headers": {
|
|
290
|
+
"Referer": entry.get("headers", {}).get("Referer", ALLANIME_REFR),
|
|
291
|
+
"User-Agent": USER_AGENT,
|
|
292
|
+
},
|
|
293
|
+
})
|
|
294
|
+
return links
|
|
295
|
+
|
|
296
|
+
def resolve_stream_url(self, show_id: str, episode_num, mode: str = "sub"):
|
|
297
|
+
sources = self.get_video_sources(show_id, episode_num, mode)
|
|
298
|
+
if not sources:
|
|
299
|
+
return None, {}
|
|
300
|
+
src = sources[0]
|
|
301
|
+
headers = {"Referer": ALLANIME_REFR, "User-Agent": USER_AGENT}
|
|
302
|
+
|
|
303
|
+
if "tools.fast4speed.rsvp" in src["source_url"]:
|
|
304
|
+
return src["source_url"], headers
|
|
305
|
+
|
|
306
|
+
if src.get("stype") == "t":
|
|
307
|
+
return src["source_url"], headers
|
|
308
|
+
|
|
309
|
+
decoded_path = _decode_hex_url(src["source_url"])
|
|
310
|
+
provider_links = self._fetch_provider_links(decoded_path)
|
|
311
|
+
if not provider_links:
|
|
312
|
+
return src["source_url"], headers
|
|
313
|
+
|
|
314
|
+
provider_links.sort(key=lambda x: -x["resolution"])
|
|
315
|
+
best = provider_links[0]
|
|
316
|
+
return best["url"], best["headers"]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from .storage import atomic_write_json
|
|
4
|
+
|
|
5
|
+
class SettingsManager:
|
|
6
|
+
def __init__(self):
|
|
7
|
+
self.config_file = self._get_config_path()
|
|
8
|
+
self.settings = self._load_settings()
|
|
9
|
+
|
|
10
|
+
def _get_config_path(self) -> Path:
|
|
11
|
+
home_dir = Path.home()
|
|
12
|
+
db_dir = home_dir / ".ani-cli-arabic" / "database"
|
|
13
|
+
db_dir.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
return db_dir / "config.json"
|
|
15
|
+
|
|
16
|
+
def _load_settings(self) -> dict:
|
|
17
|
+
defaults = {
|
|
18
|
+
"default_quality": "1080p",
|
|
19
|
+
"default_download_quality": "1080p",
|
|
20
|
+
"download_mode": "internal",
|
|
21
|
+
"download_directory": "downloads",
|
|
22
|
+
"player": "mpv",
|
|
23
|
+
"auto_next": False,
|
|
24
|
+
"discord_rpc": True,
|
|
25
|
+
"theme": "blue",
|
|
26
|
+
"analytics": True,
|
|
27
|
+
"preferred_language": "Arabic Sub"
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if not self.config_file.exists():
|
|
31
|
+
return defaults
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
with open(self.config_file, 'r', encoding='utf-8') as f:
|
|
35
|
+
saved = json.load(f)
|
|
36
|
+
if not isinstance(saved, dict):
|
|
37
|
+
return defaults
|
|
38
|
+
return {**defaults, **saved}
|
|
39
|
+
except (json.JSONDecodeError, IOError, OSError):
|
|
40
|
+
return defaults
|
|
41
|
+
|
|
42
|
+
def save(self):
|
|
43
|
+
try:
|
|
44
|
+
atomic_write_json(self.config_file, self.settings, indent=4, ensure_ascii=False)
|
|
45
|
+
except (IOError, OSError, ValueError, TypeError) as e:
|
|
46
|
+
import sys
|
|
47
|
+
print(f"Warning: Failed to save settings: {e}", file=sys.stderr)
|
|
48
|
+
|
|
49
|
+
def get(self, key, default=None):
|
|
50
|
+
return self.settings.get(key, default)
|
|
51
|
+
|
|
52
|
+
def set(self, key, value):
|
|
53
|
+
self.settings[key] = value
|
|
54
|
+
self.save()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import tempfile
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def atomic_write_json(path: Path, data: Any, indent: int = 4, ensure_ascii: bool = False) -> None:
|
|
9
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
10
|
+
|
|
11
|
+
fd, temp_path = tempfile.mkstemp(
|
|
12
|
+
prefix=f".{path.name}.",
|
|
13
|
+
suffix=".tmp",
|
|
14
|
+
dir=str(path.parent),
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
19
|
+
json.dump(data, handle, indent=indent, ensure_ascii=ensure_ascii)
|
|
20
|
+
handle.flush()
|
|
21
|
+
os.fsync(handle.fileno())
|
|
22
|
+
os.replace(temp_path, path)
|
|
23
|
+
finally:
|
|
24
|
+
try:
|
|
25
|
+
if os.path.exists(temp_path):
|
|
26
|
+
os.remove(temp_path)
|
|
27
|
+
except OSError:
|
|
28
|
+
pass
|