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.
- pkg_anime_1-1.2.2/PKG-INFO +13 -0
- pkg_anime_1-1.2.2/anime_pahe/__init__.py +0 -0
- pkg_anime_1-1.2.2/anime_pahe/api.py +261 -0
- pkg_anime_1-1.2.2/anime_pahe/cli.py +258 -0
- pkg_anime_1-1.2.2/anime_pahe/config.py +47 -0
- pkg_anime_1-1.2.2/anime_pahe/extractor.py +391 -0
- pkg_anime_1-1.2.2/pkg_anime_1.egg-info/PKG-INFO +13 -0
- pkg_anime_1-1.2.2/pkg_anime_1.egg-info/SOURCES.txt +12 -0
- pkg_anime_1-1.2.2/pkg_anime_1.egg-info/dependency_links.txt +1 -0
- pkg_anime_1-1.2.2/pkg_anime_1.egg-info/entry_points.txt +2 -0
- pkg_anime_1-1.2.2/pkg_anime_1.egg-info/requires.txt +7 -0
- pkg_anime_1-1.2.2/pkg_anime_1.egg-info/top_level.txt +1 -0
- pkg_anime_1-1.2.2/setup.cfg +4 -0
- pkg_anime_1-1.2.2/setup.py +22 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
anime_pahe
|
|
@@ -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
|
+
)
|