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,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