pkg-anime-1 1.2.2__tar.gz

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,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: pkg-anime-1
3
+ Version: 1.2.2
4
+ Summary: Terminal anime downloader with live search, quality/audio selection, threaded downloading, and resume
5
+ Requires-Dist: curl_cffi
6
+ Requires-Dist: beautifulsoup4
7
+ Requires-Dist: rich
8
+ Requires-Dist: InquirerPy
9
+ Requires-Dist: DrissionPage
10
+ Requires-Dist: pycryptodome
11
+ Requires-Dist: tqdm
12
+ Dynamic: requires-dist
13
+ Dynamic: summary
File without changes
@@ -0,0 +1,261 @@
1
+ import os
2
+ import sys
3
+ import time
4
+ import json
5
+ import math
6
+ import platform
7
+ from curl_cffi import requests
8
+ from bs4 import BeautifulSoup
9
+ from rich.console import Console
10
+
11
+ console = Console()
12
+
13
+
14
+ def _is_termux():
15
+ """Detect if running inside Termux (Android)."""
16
+ return (
17
+ os.environ.get("TERMUX_VERSION") is not None
18
+ or os.path.exists("/data/data/com.termux")
19
+ or "com.termux" in os.environ.get("PREFIX", "")
20
+ )
21
+
22
+
23
+ class AnimePaheAPI:
24
+ def __init__(self, base_url="https://animepahe.pw"):
25
+ self.base_url = base_url
26
+ self.api_url = f"{self.base_url}/api"
27
+ self.session = requests.Session(impersonate="chrome120")
28
+ self.config_path = os.path.expanduser("~/.anime_config.json")
29
+ self._load_config()
30
+
31
+ def _load_config(self):
32
+ if os.path.exists(self.config_path):
33
+ with open(self.config_path, "r") as f:
34
+ data = json.load(f)
35
+ cf_clearance = data.get("cf", "")
36
+ user_agent = data.get("ua", "")
37
+
38
+ if cf_clearance:
39
+ self.session.cookies.set(
40
+ "cf_clearance", cf_clearance, domain=".animepahe.pw"
41
+ )
42
+ if user_agent:
43
+ self.session.headers.update({"User-Agent": user_agent})
44
+ return True
45
+ else:
46
+ self.session.headers.update(
47
+ {
48
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
49
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
50
+ }
51
+ )
52
+ return False
53
+
54
+ # ─────────────────────────────────────────────
55
+ # Cloudflare bypass — auto or manual
56
+ # ─────────────────────────────────────────────
57
+
58
+ def _auto_bypass(self):
59
+ console.print("\n[bold yellow][!] Cloudflare block detected.[/bold yellow]")
60
+
61
+ if _is_termux():
62
+ return self._manual_bypass_termux()
63
+
64
+ console.print("[cyan][*] Auto-launching browser to get clearance...[/cyan]")
65
+ return self._browser_bypass()
66
+
67
+ def _manual_bypass_termux(self):
68
+ """Manual cookie entry for Termux / Android (no GUI available)."""
69
+ console.print(
70
+ "\n[bold yellow]Termux detected — Chrome GUI cannot open automatically.[/bold yellow]\n"
71
+ "[cyan]Do this once on your phone:[/cyan]\n"
72
+ " 1. Open [bold]Chrome[/bold] on your Android device\n"
73
+ f" 2. Visit [link]{self.base_url}[/link] and pass the Cloudflare check\n"
74
+ " 3. Open Chrome DevTools (Menu → Desktop site, or use [bold]chrome://inspect[/bold] from PC)\n"
75
+ " OR: install the [bold]Cookie-Editor[/bold] extension via Kiwi Browser\n"
76
+ " 4. Copy the value of the [bold]cf_clearance[/bold] cookie\n"
77
+ )
78
+
79
+ cf_val = input(" Paste cf_clearance cookie value: ").strip()
80
+ if not cf_val:
81
+ console.print("[bold red]No cookie entered. Aborting.[/bold red]")
82
+ return False
83
+
84
+ ua = input(
85
+ " Paste your browser User-Agent (leave blank for default): "
86
+ ).strip()
87
+ if not ua:
88
+ ua = (
89
+ "Mozilla/5.0 (Linux; Android 14) "
90
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
91
+ )
92
+
93
+ new_config = {"ua": ua, "cf": cf_val}
94
+ with open(self.config_path, "w") as f:
95
+ json.dump(new_config, f, indent=2)
96
+
97
+ self.session.cookies.set("cf_clearance", cf_val, domain=".animepahe.pw")
98
+ self.session.headers.update({"User-Agent": ua})
99
+
100
+ console.print(
101
+ "[bold green]✓ Cookie saved. Resuming...[/bold green]\n"
102
+ "[dim](Cookie will be reused automatically on next run)[/dim]\n"
103
+ )
104
+ return True
105
+
106
+ def _browser_bypass(self):
107
+ """Launch a headless Chrome via DrissionPage (WSL / Linux desktop / Windows)."""
108
+ try:
109
+ from DrissionPage import ChromiumPage, ChromiumOptions
110
+ except ImportError:
111
+ console.print("[bold red]DrissionPage not installed. Run: pip install DrissionPage[/bold red]")
112
+ return False
113
+
114
+ os.system("pkill -f chrome 2>/dev/null || true")
115
+ time.sleep(2)
116
+
117
+ co = ChromiumOptions()
118
+
119
+ # Try common Chrome paths — works on WSL, native Linux, Windows WSL
120
+ chrome_paths = [
121
+ "/usr/bin/google-chrome",
122
+ "/usr/bin/chromium-browser",
123
+ "/usr/bin/chromium",
124
+ "google-chrome",
125
+ "chromium",
126
+ ]
127
+ chrome_bin = None
128
+ import shutil
129
+ for p in chrome_paths:
130
+ if shutil.which(p) or os.path.exists(p):
131
+ chrome_bin = p
132
+ break
133
+
134
+ if chrome_bin:
135
+ co.set_paths(browser_path=chrome_bin)
136
+
137
+ co.set_argument("--no-sandbox")
138
+ co.set_argument("--disable-dev-shm-usage")
139
+ co.set_argument("--disable-gpu")
140
+
141
+ try:
142
+ page = ChromiumPage(co)
143
+ page.get(self.base_url)
144
+
145
+ console.print("[*] Waiting up to 30 seconds for clearance...")
146
+ for _ in range(30):
147
+ if (
148
+ "animepahe" in page.title.lower()
149
+ and "just a moment" not in page.title.lower()
150
+ ):
151
+ break
152
+ time.sleep(1)
153
+
154
+ html = page.html
155
+ if "cf-mitigated" in html or "Just a moment" in html:
156
+ raise Exception("Timed out waiting for Cloudflare clearance.")
157
+
158
+ console.print(
159
+ "[bold green][+] SUCCESS! Clearance granted. Harvesting tokens...[/bold green]"
160
+ )
161
+
162
+ cookies_list = page.cookies()
163
+ user_agent = page.user_agent
164
+ cf_val = ""
165
+
166
+ for c in cookies_list:
167
+ name = c.get("name", "")
168
+ if name in ["cf_clearance", "__ddg1_"]:
169
+ cf_val = c.get("value", "")
170
+ break
171
+
172
+ if not cf_val:
173
+ raise Exception("Bypassed, but could not find cf_clearance cookie.")
174
+
175
+ new_config = {"ua": user_agent, "cf": cf_val}
176
+ with open(self.config_path, "w") as f:
177
+ json.dump(new_config, f, indent=2)
178
+
179
+ self.session.cookies.set("cf_clearance", cf_val, domain=".animepahe.pw")
180
+ self.session.headers.update({"User-Agent": user_agent})
181
+
182
+ console.print(
183
+ "[bold green]✓ Session automatically updated. Resuming task...[/bold green]\n"
184
+ )
185
+ return True
186
+
187
+ except Exception as e:
188
+ console.print(f"\n[bold red][!] Auto-Bypass Error:[/bold red] {e}")
189
+ return False
190
+ finally:
191
+ try:
192
+ page.quit()
193
+ except Exception:
194
+ pass
195
+
196
+ # ─────────────────────────────────────────────
197
+ # API methods (unchanged)
198
+ # ─────────────────────────────────────────────
199
+
200
+ def get_all_anime(self):
201
+ response = self.session.get(f"{self.base_url}/anime")
202
+ if response.status_code == 403:
203
+ if self._auto_bypass():
204
+ return self.get_all_anime()
205
+ raise PermissionError("Cloudflare blocked the request.")
206
+
207
+ soup = BeautifulSoup(response.text, "html.parser")
208
+ anime_list = []
209
+ for a in soup.select('div.content-wrapper a[href^="/anime/"]'):
210
+ title = a.get("title", "").strip()
211
+ session = a.get("href", "").split("/")[-1]
212
+ if title and session:
213
+ anime_list.append({"title": title, "session": session})
214
+ return anime_list
215
+
216
+ def search(self, query):
217
+ params = {"m": "search", "q": query}
218
+ response = self.session.get(self.api_url, params=params)
219
+
220
+ if response.status_code == 403:
221
+ if self._auto_bypass():
222
+ return self.search(query)
223
+ raise PermissionError("Cloudflare blocked the request.")
224
+
225
+ return response.json().get("data", [])
226
+
227
+ def get_anime_metadata(self, anime_session):
228
+ params = {"m": "release", "id": anime_session, "sort": "episode_asc", "page": 1}
229
+ response = self.session.get(self.api_url, params=params)
230
+
231
+ if response.status_code == 403:
232
+ if self._auto_bypass():
233
+ return self.get_anime_metadata(anime_session)
234
+ raise PermissionError("Cloudflare blocked the request.")
235
+
236
+ data = response.json()
237
+ return data.get("total", 0)
238
+
239
+ def get_specific_episode_hash(self, anime_session, target_ep):
240
+ target_page = math.ceil(target_ep / 30)
241
+ params = {
242
+ "m": "release",
243
+ "id": anime_session,
244
+ "sort": "episode_asc",
245
+ "page": target_page,
246
+ }
247
+ response = self.session.get(self.api_url, params=params)
248
+
249
+ if response.status_code == 403:
250
+ if self._auto_bypass():
251
+ return self.get_specific_episode_hash(anime_session, target_ep)
252
+ raise PermissionError("Cloudflare blocked the request.")
253
+
254
+ data = response.json()
255
+ episodes = data.get("data", [])
256
+
257
+ for ep in episodes:
258
+ if int(ep["episode"]) == target_ep:
259
+ return ep["session"]
260
+
261
+ return None
@@ -0,0 +1,258 @@
1
+ import os
2
+ import sys
3
+ import argparse
4
+ from rich.console import Console
5
+ from InquirerPy import inquirer
6
+ from InquirerPy.base.control import Choice
7
+
8
+ from anime_pahe.api import AnimePaheAPI
9
+ from anime_pahe.extractor import AnimeExtractor, DEFAULT_THREADS
10
+ from anime_pahe.config import load_config, save_config, show_config, CONFIG_PATH
11
+
12
+ console = Console()
13
+
14
+
15
+ def _resolve_quality(streams, preferred_quality):
16
+ """
17
+ Return the best matching resolution string from available streams.
18
+ preferred_quality: "best" → highest available; "1080" → exact or next best.
19
+ """
20
+ resolutions = sorted(
21
+ {s["resolution"] for s in streams},
22
+ key=lambda r: int(r) if r.isdigit() else 0,
23
+ reverse=True,
24
+ )
25
+ if not resolutions:
26
+ return None
27
+
28
+ if preferred_quality == "best":
29
+ return resolutions[0]
30
+
31
+ # Exact match first
32
+ if preferred_quality in resolutions:
33
+ return preferred_quality
34
+
35
+ # Next-best (closest lower)
36
+ target = int(preferred_quality) if preferred_quality.isdigit() else 0
37
+ for r in resolutions:
38
+ if r.isdigit() and int(r) <= target:
39
+ return r
40
+
41
+ return resolutions[-1] # fallback to lowest
42
+
43
+
44
+ def main():
45
+ cfg = load_config()
46
+
47
+ parser = argparse.ArgumentParser(
48
+ description="Terminal Anime Downloader",
49
+ formatter_class=argparse.RawTextHelpFormatter,
50
+ )
51
+ parser.add_argument("-a", "--anime", type=str, help="Directly search for an anime")
52
+ parser.add_argument(
53
+ "-q", "--quality",
54
+ type=str,
55
+ default=None,
56
+ help="Video quality: best / 1080 / 720 / 480 / 360 (default: from config)",
57
+ )
58
+ parser.add_argument(
59
+ "--audio",
60
+ type=str,
61
+ default=None,
62
+ choices=["jpn", "eng"],
63
+ help="Audio language: jpn or eng (default: from config)",
64
+ )
65
+ parser.add_argument(
66
+ "-t", "--threads",
67
+ type=int,
68
+ default=None,
69
+ help=f"Download threads (default: from config, currently {cfg['threads']})",
70
+ )
71
+ parser.add_argument(
72
+ "-d", "--download-dir",
73
+ type=str,
74
+ default=None,
75
+ help="Directory to save downloads (default: from config)",
76
+ )
77
+ parser.add_argument(
78
+ "--config",
79
+ action="store_true",
80
+ help="Show current config and exit",
81
+ )
82
+ parser.add_argument(
83
+ "--set-quality", type=str, metavar="QUALITY",
84
+ help="Save default quality to config (best/1080/720/480/360)",
85
+ )
86
+ parser.add_argument(
87
+ "--set-audio", type=str, metavar="LANG",
88
+ choices=["jpn", "eng"],
89
+ help="Save default audio language to config",
90
+ )
91
+ parser.add_argument(
92
+ "--set-threads", type=int, metavar="N",
93
+ help="Save default thread count to config",
94
+ )
95
+ parser.add_argument(
96
+ "--set-download-dir", type=str, metavar="PATH",
97
+ help="Save default download directory to config",
98
+ )
99
+ args = parser.parse_args()
100
+
101
+ # ── Config display / update ──────────────────────────────────────────
102
+ if args.config:
103
+ show_config()
104
+ sys.exit(0)
105
+
106
+ cfg_changed = False
107
+ if args.set_quality:
108
+ cfg["quality"] = args.set_quality
109
+ cfg_changed = True
110
+ if args.set_audio:
111
+ cfg["audio"] = args.set_audio
112
+ cfg_changed = True
113
+ if args.set_threads:
114
+ cfg["threads"] = args.set_threads
115
+ cfg_changed = True
116
+ if args.set_download_dir:
117
+ cfg["download_dir"] = args.set_download_dir
118
+ cfg_changed = True
119
+ if cfg_changed:
120
+ save_config(cfg)
121
+ console.print(f"[green]✓ Config saved to {CONFIG_PATH}[/green]")
122
+ show_config()
123
+ sys.exit(0)
124
+
125
+ # ── Resolve effective settings (CLI flag > config > default) ─────────
126
+ quality = args.quality or cfg.get("quality", "best")
127
+ audio_pref= args.audio or cfg.get("audio", "jpn")
128
+ threads = args.threads or cfg.get("threads", DEFAULT_THREADS)
129
+ dl_dir = args.download_dir or cfg.get("download_dir", ".")
130
+
131
+ # ── Init ─────────────────────────────────────────────────────────────
132
+ try:
133
+ api = AnimePaheAPI(base_url=cfg.get("base_url", "https://animepahe.pw"))
134
+ extractor = AnimeExtractor(api.session)
135
+ except Exception as e:
136
+ console.print(f"[bold red]Initialization Error:[/bold red] {e}")
137
+ sys.exit(1)
138
+
139
+ # 1. Fetch Anime List
140
+ query = args.anime
141
+ if query:
142
+ console.print(f"\n[cyan]Searching API for '{query}'...[/cyan]")
143
+ results = api.search(query)
144
+ else:
145
+ console.print("\n[cyan]Fetching master anime directory for live search...[/cyan]")
146
+ results = api.get_all_anime()
147
+
148
+ if not results:
149
+ console.print("[bold yellow]No results found.[/bold yellow]")
150
+ sys.exit(0)
151
+
152
+ # 2. Live fuzzy search
153
+ anime_choices = [Choice(value=res["session"], name=res["title"]) for res in results]
154
+
155
+ session_id = inquirer.fuzzy(
156
+ message="Type to search anime:",
157
+ choices=anime_choices,
158
+ instruction="(Start typing to filter, Enter to select)",
159
+ ).execute()
160
+
161
+ if not session_id:
162
+ sys.exit(0)
163
+
164
+ selected_title = next(res["title"] for res in results if res["session"] == session_id)
165
+
166
+ # 3. Episode count
167
+ console.print(f"\n[cyan]Checking episode count for {selected_title}...[/cyan]")
168
+ total_eps = api.get_anime_metadata(session_id)
169
+
170
+ if total_eps == 0:
171
+ console.print("[bold yellow]No episodes found.[/bold yellow]")
172
+ sys.exit(0)
173
+
174
+ console.print(f"[green]✓ Found {total_eps} episodes available.[/green]")
175
+
176
+ # 4. Episode number input
177
+ typed_ep = inquirer.text(
178
+ message=f"Enter episode number to download (1-{total_eps}):",
179
+ validate=lambda text: text.isdigit() and 1 <= int(text) <= total_eps,
180
+ invalid_message=f"Please enter a valid episode number between 1 and {total_eps}",
181
+ ).execute()
182
+
183
+ if not typed_ep:
184
+ sys.exit(0)
185
+
186
+ # 5. Fetch episode hash
187
+ target_ep = int(typed_ep)
188
+ console.print("[cyan]Locating secure stream hash...[/cyan]")
189
+ ep_session = api.get_specific_episode_hash(session_id, target_ep)
190
+
191
+ if not ep_session:
192
+ console.print(
193
+ f"[bold red]Error:[/bold red] Could not locate stream hash for Episode {target_ep}."
194
+ )
195
+ sys.exit(1)
196
+
197
+ # 6. Fetch available streams
198
+ streams = extractor.get_all_streams(session_id, ep_session)
199
+
200
+ if not streams:
201
+ console.print("[bold red]Error:[/bold red] No streams found for this episode.")
202
+ sys.exit(1)
203
+
204
+ # 7. Resolve quality — show picker only if config says "best" or no exact match
205
+ chosen_res = _resolve_quality(streams, quality)
206
+
207
+ if quality == "best":
208
+ # Show picker so user can confirm / change
209
+ res_choices = sorted(
210
+ {s["resolution"] for s in streams},
211
+ key=lambda r: int(r) if r.isdigit() else 0,
212
+ reverse=True,
213
+ )
214
+ chosen_res = inquirer.select(
215
+ message=f"Select quality (config default: {quality}):",
216
+ choices=[Choice(value=r, name=f"{r}p") for r in res_choices],
217
+ ).execute()
218
+ else:
219
+ console.print(f"[green]Quality: {chosen_res}p (from {'--quality flag' if args.quality else 'config'})[/green]")
220
+
221
+ # 8. Resolve audio — show picker only if preferred not available at chosen res
222
+ audio_options = [s for s in streams if s["resolution"] == chosen_res]
223
+ avail_audios = [s["audio"] for s in audio_options]
224
+
225
+ if audio_pref in avail_audios:
226
+ kwik_link = next(s["url"] for s in audio_options if s["audio"] == audio_pref)
227
+ console.print(
228
+ f"[green]Audio: {audio_pref.upper()} (from {'--audio flag' if args.audio else 'config'})[/green]"
229
+ )
230
+ elif len(audio_options) > 1:
231
+ kwik_link = inquirer.select(
232
+ message=f"'{audio_pref}' not available — pick audio:",
233
+ choices=[Choice(value=s["url"], name=s["audio"].upper()) for s in audio_options],
234
+ ).execute()
235
+ else:
236
+ kwik_link = audio_options[0]["url"]
237
+ console.print(f"[green]Audio: {audio_options[0]['audio'].upper()} (only option)[/green]")
238
+
239
+ # 9. Build output path
240
+ safe_title = "".join(c if c.isalnum() or c in " _-" else "_" for c in selected_title)
241
+ filename = f"{safe_title}_EP{target_ep:02d}_{chosen_res}p.mp4"
242
+ os.makedirs(dl_dir, exist_ok=True)
243
+ out_path = os.path.join(dl_dir, filename)
244
+
245
+ console.print(f"[dim]Saving to: {out_path}[/dim]")
246
+
247
+ # 10. Download
248
+ extractor.download_episode(
249
+ session_id,
250
+ ep_session,
251
+ kwik_link,
252
+ out_filename=out_path,
253
+ threads=threads,
254
+ )
255
+
256
+
257
+ if __name__ == "__main__":
258
+ main()
@@ -0,0 +1,47 @@
1
+ import json
2
+ import os
3
+
4
+ from rich.console import Console
5
+
6
+ console = Console()
7
+
8
+ CONFIG_PATH = os.path.expanduser("~/.anime_pahe_config.json")
9
+
10
+ DEFAULT_CONFIG = {
11
+ "base_url": "https://animepahe.pw",
12
+ "quality": "best",
13
+ "audio": "jpn",
14
+ "threads": 8,
15
+ "download_dir": ".",
16
+ }
17
+
18
+
19
+ def load_config():
20
+ if not os.path.exists(CONFIG_PATH):
21
+ return DEFAULT_CONFIG.copy()
22
+ try:
23
+ with open(CONFIG_PATH, "r") as f:
24
+ user = json.load(f)
25
+ cfg = DEFAULT_CONFIG.copy()
26
+ cfg.update({k: v for k, v in user.items() if k in DEFAULT_CONFIG})
27
+ return cfg
28
+ except Exception:
29
+ return DEFAULT_CONFIG.copy()
30
+
31
+
32
+ def save_config(cfg):
33
+ try:
34
+ with open(CONFIG_PATH, "w") as f:
35
+ json.dump(cfg, f, indent=2)
36
+ except Exception as e:
37
+ console.print(f"[yellow]Warning: could not save config: {e}[/yellow]")
38
+
39
+
40
+ def show_config():
41
+ cfg = load_config()
42
+ console.print("\n[bold cyan]Current config[/bold cyan] ([dim]~/.anime_pahe_config.json[/dim])")
43
+ console.print(f" quality : [green]{cfg['quality']}[/green] (best / 1080 / 720 / 480 / 360)")
44
+ console.print(f" audio : [green]{cfg['audio']}[/green] (jpn / eng)")
45
+ console.print(f" threads : [green]{cfg['threads']}[/green]")
46
+ console.print(f" download_dir: [green]{cfg['download_dir']}[/green]")
47
+ console.print(f" base_url : [green]{cfg['base_url']}[/green]\n")
@@ -0,0 +1,391 @@
1
+ import os
2
+ import re
3
+ import shutil
4
+ import subprocess
5
+ import time
6
+ from concurrent.futures import ThreadPoolExecutor, as_completed
7
+ from urllib.parse import urlparse
8
+
9
+ from bs4 import BeautifulSoup
10
+ from Crypto.Cipher import AES
11
+ from rich.console import Console
12
+ from tqdm import tqdm
13
+
14
+ console = Console()
15
+
16
+ DEFAULT_THREADS = 8
17
+
18
+
19
+ class AnimeExtractor:
20
+ def __init__(self, session):
21
+ self.session = session
22
+ self.base_url = "https://animepahe.pw"
23
+ self.referer = "https://kwik.cx/"
24
+
25
+ # ─────────────────────────────────────────────
26
+ # Step 1: get ALL quality/audio options
27
+ # ─────────────────────────────────────────────
28
+
29
+ def get_all_streams(self, anime_session, episode_session):
30
+ """Return list of dicts: [{resolution, audio, url}, ...] sorted best-first."""
31
+ console.print("[cyan]Fetching available streams...[/cyan]")
32
+ url = f"{self.base_url}/play/{anime_session}/{episode_session}"
33
+ response = self.session.get(url)
34
+
35
+ soup = BeautifulSoup(response.text, "html.parser")
36
+ streams = []
37
+ for btn in soup.find_all("button", attrs={"data-src": True}):
38
+ src = btn.get("data-src", "")
39
+ if "kwik" not in src:
40
+ continue
41
+ res = btn.get("data-resolution", "?")
42
+ audio = btn.get("data-audio", "jpn")
43
+ streams.append({"resolution": res, "audio": audio, "url": src})
44
+
45
+ def _res_key(s):
46
+ try:
47
+ return int(s["resolution"])
48
+ except (ValueError, TypeError):
49
+ return 0
50
+
51
+ streams.sort(key=_res_key, reverse=True)
52
+ return streams
53
+
54
+ # ─────────────────────────────────────────────
55
+ # Step 2: unpack JS → m3u8
56
+ # ─────────────────────────────────────────────
57
+
58
+ def _unpack_javascript(self, args_string):
59
+ match = re.search(
60
+ r"^(.*?)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(['\"])(.*?)\4\.split\('\|'\)",
61
+ args_string,
62
+ re.DOTALL,
63
+ )
64
+ if not match:
65
+ console.print(
66
+ f"\n[bold red]=== DEBUG: RAW ARGS ===[/bold red]\n{args_string[:500]}...\n"
67
+ )
68
+ raise Exception("Could not parse the packed variables.")
69
+
70
+ p_quoted = match.group(1).strip()
71
+ if (p_quoted.startswith("'") and p_quoted.endswith("'")) or (
72
+ p_quoted.startswith('"') and p_quoted.endswith('"')
73
+ ):
74
+ p = p_quoted[1:-1]
75
+ else:
76
+ p = p_quoted
77
+ p = p.replace("\\'", "'").replace('\\"', '"').replace("\\\\", "\\")
78
+
79
+ a = int(match.group(2))
80
+ c = int(match.group(3))
81
+ k = match.group(5).split("|")
82
+
83
+ def e(c_val):
84
+ part1 = "" if c_val < a else e(c_val // a)
85
+ c_mod = c_val % a
86
+ part2 = (
87
+ chr(c_mod + 29)
88
+ if c_mod > 35
89
+ else "0123456789abcdefghijklmnopqrstuvwxyz"[c_mod]
90
+ )
91
+ return part1 + part2
92
+
93
+ unpacked = p
94
+ for i in range(c - 1, -1, -1):
95
+ if k[i]:
96
+ word = e(i)
97
+ unpacked = re.sub(r"\b" + word + r"\b", k[i], unpacked)
98
+ return unpacked
99
+
100
+ def get_m3u8(self, kwik_link):
101
+ console.print("[cyan]Bypassing host encryption...[/cyan]")
102
+ headers = {
103
+ "Referer": f"{self.base_url}/",
104
+ "User-Agent": self.session.headers.get("User-Agent", ""),
105
+ }
106
+ response = self.session.get(kwik_link, headers=headers)
107
+
108
+ if response.status_code == 403:
109
+ raise Exception("Kwik.cx threw a 403 Forbidden. IP might be flagged.")
110
+
111
+ script_matches = re.findall(
112
+ r"eval\(function\(p,a,c,k,e.*?return\s+p\}\(\s*(.*?\.split\('\|'\).*?)\)\)",
113
+ response.text,
114
+ re.DOTALL,
115
+ )
116
+ if not script_matches:
117
+ console.print(
118
+ f"\n[bold yellow]=== DEBUG: PAGE HTML ===[/bold yellow]\n{response.text[:1000]}\n"
119
+ )
120
+ raise Exception("Could not find packed video script signature.")
121
+
122
+ args_string = script_matches[-1]
123
+ try:
124
+ unpacked_js = self._unpack_javascript(args_string)
125
+ except Exception as e:
126
+ raise Exception(f"Failed to unpack Javascript natively: {e}")
127
+
128
+ m3u8_match = re.search(r"source='([^']+)'", unpacked_js)
129
+ if not m3u8_match:
130
+ console.print(
131
+ f"\n[bold red]=== DEBUG: UNPACKED SCRIPT ===[/bold red]\n{unpacked_js[:1000]}\n"
132
+ )
133
+ raise Exception("Failed to extract m3u8 link from unpacked script.")
134
+ return m3u8_match.group(1)
135
+
136
+ # ─────────────────────────────────────────────
137
+ # Step 3: parse m3u8 → segment list + AES key + duration
138
+ # ─────────────────────────────────────────────
139
+
140
+ def _parse_m3u8(self, m3u8_url):
141
+ headers = {"Referer": self.referer}
142
+ resp = self.session.get(m3u8_url, headers=headers)
143
+ if resp.status_code != 200:
144
+ raise Exception(f"Failed to fetch m3u8 playlist (HTTP {resp.status_code})")
145
+
146
+ key_url = None
147
+ segments = []
148
+ media_sequence = 0
149
+ total_duration = 0.0
150
+
151
+ for line in resp.text.splitlines():
152
+ line = line.strip()
153
+ if line.startswith("#EXT-X-MEDIA-SEQUENCE"):
154
+ try:
155
+ media_sequence = int(line.split(":", 1)[1])
156
+ except ValueError:
157
+ pass
158
+ elif line.startswith("#EXT-X-KEY"):
159
+ m = re.search(r'URI="([^"]+)"', line)
160
+ if m:
161
+ key_url = m.group(1)
162
+ elif line.startswith("#EXTINF:"):
163
+ try:
164
+ total_duration += float(line.split(":", 1)[1].split(",")[0])
165
+ except (ValueError, IndexError):
166
+ pass
167
+ elif line and not line.startswith("#"):
168
+ segments.append(line)
169
+
170
+ if not key_url or not segments:
171
+ raise Exception("m3u8 parse failed: could not find AES key URL or segments.")
172
+
173
+ key_resp = self.session.get(key_url, headers=headers)
174
+ if key_resp.status_code != 200:
175
+ raise Exception("Failed to download AES decryption key.")
176
+ aes_key = key_resp.content
177
+
178
+ return aes_key, segments, media_sequence, total_duration
179
+
180
+ # ─────────────────────────────────────────────
181
+ # Step 4: download + AES-decrypt a single segment
182
+ # ─────────────────────────────────────────────
183
+
184
+ def _download_segment(self, seg_url, aes_key, iv, out_path):
185
+ if os.path.exists(out_path) and os.path.getsize(out_path) > 0:
186
+ return os.path.getsize(out_path)
187
+
188
+ headers = {"Referer": self.referer}
189
+ try:
190
+ resp = self.session.get(seg_url, headers=headers)
191
+ if resp.status_code != 200:
192
+ return 0
193
+ data = resp.content
194
+ remainder = len(data) % 16
195
+ if remainder:
196
+ data += b"\x00" * (16 - remainder)
197
+ cipher = AES.new(aes_key, AES.MODE_CBC, iv)
198
+ decrypted = cipher.decrypt(data)
199
+ with open(out_path, "wb") as f:
200
+ f.write(decrypted)
201
+ return len(resp.content)
202
+ except Exception:
203
+ return 0
204
+
205
+ # ─────────────────────────────────────────────
206
+ # Step 5: parallel download with auto-resume
207
+ # ─────────────────────────────────────────────
208
+
209
+ def _download_segments(self, aes_key, segments, media_sequence, seg_dir, threads):
210
+ os.makedirs(seg_dir, exist_ok=True)
211
+
212
+ seg_paths = []
213
+ for i, url in enumerate(segments):
214
+ name = os.path.basename(urlparse(url).path) or f"seg_{i:05d}.ts"
215
+ seg_paths.append((url, os.path.join(seg_dir, name), i))
216
+
217
+ pending = [
218
+ (url, path, idx)
219
+ for url, path, idx in seg_paths
220
+ if not os.path.exists(path) or os.path.getsize(path) == 0
221
+ ]
222
+
223
+ already_done = len(segments) - len(pending)
224
+ if already_done:
225
+ console.print(
226
+ f"[green]Resuming — {already_done}/{len(segments)} segments already done.[/green]"
227
+ )
228
+
229
+ if not pending:
230
+ console.print("[green]All segments already downloaded. Proceeding to merge.[/green]")
231
+ return [p for _, p, _ in seg_paths], True
232
+
233
+ failed = []
234
+ total_bytes = 0
235
+ start = time.time()
236
+
237
+ with tqdm(
238
+ total=len(pending), unit="seg", desc="Downloading", dynamic_ncols=True
239
+ ) as pbar:
240
+ with ThreadPoolExecutor(max_workers=threads) as executor:
241
+ future_map = {}
242
+ for url, path, idx in pending:
243
+ seq = media_sequence + idx
244
+ iv = seq.to_bytes(16, byteorder="big")
245
+ f = executor.submit(self._download_segment, url, aes_key, iv, path)
246
+ future_map[f] = (url, path)
247
+
248
+ for future in as_completed(future_map):
249
+ url, path = future_map[future]
250
+ try:
251
+ n = future.result()
252
+ if n > 0:
253
+ total_bytes += n
254
+ elapsed = time.time() - start
255
+ speed = (total_bytes / 1_048_576) / elapsed if elapsed > 0 else 0
256
+ pbar.set_postfix_str(f"{speed:.2f} MB/s")
257
+ else:
258
+ failed.append(os.path.basename(path))
259
+ except Exception:
260
+ failed.append(os.path.basename(path))
261
+ pbar.update(1)
262
+
263
+ if failed:
264
+ console.print(
265
+ f"[bold yellow][!] {len(failed)} segment(s) failed "
266
+ f"({', '.join(failed[:3])}{'...' if len(failed) > 3 else ''}). "
267
+ "Re-run the same command to resume and retry them.[/bold yellow]"
268
+ )
269
+
270
+ return [p for _, p, _ in seg_paths], len(failed) == 0
271
+
272
+ # ─────────────────────────────────────────────
273
+ # Step 6: ffmpeg concat → final mp4 (with progress bar)
274
+ # ─────────────────────────────────────────────
275
+
276
+ def _merge_segments(self, seg_paths, out_file, seg_dir, cleanup=True, total_duration=0.0):
277
+ list_path = os.path.join(seg_dir, "file.list")
278
+ present = [p for p in seg_paths if os.path.exists(p) and os.path.getsize(p) > 0]
279
+
280
+ with open(list_path, "w") as f:
281
+ for p in present:
282
+ f.write(f"file '{p}'\n")
283
+
284
+ missing = len(seg_paths) - len(present)
285
+ label = (
286
+ f"Compiling {len(present)}/{len(seg_paths)} segs (missing {missing})"
287
+ if missing
288
+ else "Compiling"
289
+ )
290
+
291
+ cmd = [
292
+ "ffmpeg", "-y",
293
+ "-f", "concat", "-safe", "0",
294
+ "-i", list_path,
295
+ "-c", "copy",
296
+ "-progress", "pipe:1",
297
+ "-nostats",
298
+ out_file,
299
+ ]
300
+
301
+ process = subprocess.Popen(
302
+ cmd,
303
+ stdout=subprocess.PIPE,
304
+ stderr=subprocess.DEVNULL,
305
+ universal_newlines=True,
306
+ bufsize=1,
307
+ )
308
+
309
+ # Drive a tqdm bar from ffmpeg's -progress output
310
+ with tqdm(total=100, desc=label, unit="%", dynamic_ncols=True) as pbar:
311
+ last_pct = 0
312
+ for line in process.stdout:
313
+ line = line.strip()
314
+ # out_time_ms is microseconds in ffmpeg (confusingly named)
315
+ if line.startswith("out_time_ms=") and total_duration > 0:
316
+ try:
317
+ us = int(line.split("=", 1)[1])
318
+ pct = min(int(us / (total_duration * 1_000_000) * 100), 100)
319
+ if pct > last_pct:
320
+ pbar.update(pct - last_pct)
321
+ last_pct = pct
322
+ except (ValueError, ZeroDivisionError):
323
+ pass
324
+ elif line == "progress=end":
325
+ if last_pct < 100:
326
+ pbar.update(100 - last_pct)
327
+ last_pct = 100
328
+
329
+ process.wait()
330
+ # Ensure bar reaches 100% even if progress output was incomplete
331
+ if last_pct < 100:
332
+ pbar.update(100 - last_pct)
333
+
334
+ if process.returncode != 0:
335
+ raise subprocess.CalledProcessError(process.returncode, cmd)
336
+
337
+ if cleanup:
338
+ shutil.rmtree(seg_dir, ignore_errors=True)
339
+
340
+ # ─────────────────────────────────────────────
341
+ # Public entry point
342
+ # ─────────────────────────────────────────────
343
+
344
+ def download_episode(
345
+ self,
346
+ anime_session,
347
+ episode_session,
348
+ kwik_link,
349
+ out_filename=None,
350
+ threads=DEFAULT_THREADS,
351
+ ):
352
+ try:
353
+ m3u8_url = self.get_m3u8(kwik_link)
354
+ console.print("\n[bold green]✓ Video stream secured![/bold green]")
355
+
356
+ aes_key, segments, media_sequence, total_duration = self._parse_m3u8(m3u8_url)
357
+ console.print(
358
+ f"[cyan]{len(segments)} segments found. "
359
+ f"Downloading with {threads} threads...[/cyan]\n"
360
+ )
361
+
362
+ if not out_filename:
363
+ out_filename = f"Episode_{episode_session[:8]}.mp4"
364
+
365
+ seg_dir = os.path.join(
366
+ os.path.dirname(os.path.abspath(out_filename)),
367
+ f".segments_{episode_session[:8]}",
368
+ )
369
+
370
+ seg_paths, success = self._download_segments(
371
+ aes_key, segments, media_sequence, seg_dir, threads
372
+ )
373
+
374
+ # Always compile — keep temp dir only when retry is needed
375
+ self._merge_segments(
376
+ seg_paths, out_filename, seg_dir,
377
+ cleanup=success,
378
+ total_duration=total_duration,
379
+ )
380
+
381
+ if success:
382
+ console.print(f"\n[bold green]✓ Done: {out_filename}[/bold green]")
383
+ else:
384
+ console.print(
385
+ f"\n[bold yellow]✓ Partial video saved: {out_filename}[/bold yellow]\n"
386
+ "[yellow]Re-run the same command to download missing segments "
387
+ "and get a clean version.[/yellow]"
388
+ )
389
+
390
+ except Exception as e:
391
+ console.print(f"\n[bold red][!] Extraction Failed:[/bold red] {e}")
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: pkg-anime-1
3
+ Version: 1.2.2
4
+ Summary: Terminal anime downloader with live search, quality/audio selection, threaded downloading, and resume
5
+ Requires-Dist: curl_cffi
6
+ Requires-Dist: beautifulsoup4
7
+ Requires-Dist: rich
8
+ Requires-Dist: InquirerPy
9
+ Requires-Dist: DrissionPage
10
+ Requires-Dist: pycryptodome
11
+ Requires-Dist: tqdm
12
+ Dynamic: requires-dist
13
+ Dynamic: summary
@@ -0,0 +1,12 @@
1
+ setup.py
2
+ anime_pahe/__init__.py
3
+ anime_pahe/api.py
4
+ anime_pahe/cli.py
5
+ anime_pahe/config.py
6
+ anime_pahe/extractor.py
7
+ pkg_anime_1.egg-info/PKG-INFO
8
+ pkg_anime_1.egg-info/SOURCES.txt
9
+ pkg_anime_1.egg-info/dependency_links.txt
10
+ pkg_anime_1.egg-info/entry_points.txt
11
+ pkg_anime_1.egg-info/requires.txt
12
+ pkg_anime_1.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ anime = anime_pahe.cli:main
@@ -0,0 +1,7 @@
1
+ curl_cffi
2
+ beautifulsoup4
3
+ rich
4
+ InquirerPy
5
+ DrissionPage
6
+ pycryptodome
7
+ tqdm
@@ -0,0 +1 @@
1
+ anime_pahe
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,22 @@
1
+ from setuptools import setup, find_packages
2
+
3
+ setup(
4
+ name="pkg-anime-1",
5
+ version="1.2.2",
6
+ description="Terminal anime downloader with live search, quality/audio selection, threaded downloading, and resume",
7
+ packages=find_packages(),
8
+ install_requires=[
9
+ "curl_cffi", # Stealth requests to bypass Kwik 403 blocks
10
+ "beautifulsoup4", # Parse stream buttons and anime list
11
+ "rich", # Terminal UI
12
+ "InquirerPy", # Real-time fuzzy live-search + selection prompts
13
+ "DrissionPage", # Auto-bypasser for Cloudflare
14
+ "pycryptodome", # AES-128 segment decryption
15
+ "tqdm", # Threaded download progress bar
16
+ ],
17
+ entry_points={
18
+ "console_scripts": [
19
+ "anime=anime_pahe.cli:main",
20
+ ],
21
+ },
22
+ )