diff --git a/.gitignore b/.gitignore index bc7f6c0..86ce1a8 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ dist/ src-tauri/target/ src-tauri/gen/schemas vendor/webview2/ +vendor/platform-tools/ server/.venv/ download/ tools/ffmpeg/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..682c40a --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# RaptDrama Download Tools + +Windows 桌面程序,用来登录 RaptDrama、浏览剧目、播放已缓存的分集,以及把剧集下载成 MP4。 + +## 使用分发包 + +1. 解压 `dist/RaptDrama-Download-Tools-windows-x64.zip`。 +2. 进入 `RaptDrama-Download-Tools`,运行 `raptdrama.exe`。 +3. 压缩包里已经带有 Python、adb、ffmpeg 和 WebView2 离线安装包。本机如果还没有 WebView2,程序启动时会静默安装。 + +程序会在自己的目录里写入: + +- `download/`:下载下来的视频 +- `cache/`:封面、剧目清单和分集播放地址 + +分集播放地址会留在缓存里。账号还能拿到新地址时会更新缓存;VIP 失效后,缓存里已经有地址的集仍然可以播放。签名过期的地址无法继续播放。 + +关闭窗口时,如果还有未完成的下载,会先询问。确定后才会退出。 + +## 开发 + +需要 Windows、Node.js、Rust 和 Python 3.13。 + +```powershell +python -m venv server\.venv +server\.venv\Scripts\python.exe -m pip install -r server\requirements.txt +npm install +npm install --prefix ui +npm run tauri dev +``` + +开发时界面和后台都从仓库目录读取。剧目清单默认使用 `cache/catalog.json`,正式打包会关掉这份缓存。封面缓存和分集地址缓存会保留。 + +## 打包 + +在仓库根目录执行: + +```powershell +python scripts\package.py +``` + +脚本会编译界面和桌面程序,并把本地已经放好的文件拷进压缩包: + +- `dist/cache` 里的 Windows 嵌入式 Python,以及 `server/requirements.txt` 里的依赖 +- `vendor/platform-tools` 里的 adb,拷到压缩包的 `tools/platform-tools` +- `tools/ffmpeg/ffmpeg.exe` +- `vendor/webview2/MicrosoftEdgeWebView2RuntimeInstallerX64.exe` + +adb 和 WebView2 打包时不再下载。完成后得到 `dist/RaptDrama-Download-Tools-windows-x64.zip`。 + +打包脚本是 Python,公共步骤和平台相关的运行时是分开的。目前只接好了 Windows。macOS 和 Linux 以后把对应文件放进 `vendor` 即可。 diff --git a/scripts/package.py b/scripts/package.py new file mode 100644 index 0000000..04603a9 --- /dev/null +++ b/scripts/package.py @@ -0,0 +1,231 @@ +"""打出可分发的压缩包。 + +在仓库根目录运行: + + python scripts/package.py + +Windows 会编译界面和桌面程序,并拷贝本地已有的 Python 缓存、adb、ffmpeg 和 WebView2 离线安装包。 +macOS、Linux 以后可以在对应函数里补上自己的运行时,其余步骤可以复用。 +""" + +from __future__ import annotations + +import gzip +import json +import platform +import shutil +import subprocess +import sys +import urllib.request +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DIST = ROOT / "dist" +CACHE = DIST / "cache" +STAGE_NAME = "RaptDrama-Download-Tools" + +PYTHON_VERSION = "3.13.15" +PYTHON_EMBED_URL = ( + f"https://www.python.org/ftp/python/{PYTHON_VERSION}/python-{PYTHON_VERSION}-embed-amd64.zip" +) +GET_PIP_URL = "https://bootstrap.pypa.io/get-pip.py" +FFMPEG_URL = "https://github.com/eugeneware/ffmpeg-static/releases/download/b6.1.1/ffmpeg-win32-x64.gz" +WEBVIEW_INSTALLER = "MicrosoftEdgeWebView2RuntimeInstallerX64.exe" + + +def log(message: str) -> None: + print(message, flush=True) + + +def run(command: list[str], cwd: Path) -> None: + log("+ " + " ".join(command)) + subprocess.run(command, cwd=cwd, check=True) + + +def download(url: str, dest: Path) -> None: + if dest.is_file() and dest.stat().st_size > 0: + log(f"已有缓存 {dest.name}") + return + dest.parent.mkdir(parents=True, exist_ok=True) + temporary = dest.with_suffix(dest.suffix + ".part") + log(f"下载 {url}") + request = urllib.request.Request(url, headers={"User-Agent": "RaptDrama"}) + try: + with urllib.request.urlopen(request, timeout=120) as response, temporary.open("wb") as handle: + total = int(response.headers.get("Content-Length") or 0) + done = 0 + while True: + chunk = response.read(1024 * 1024) + if not chunk: + break + handle.write(chunk) + done += len(chunk) + if total: + log(f" {dest.name} {done * 100 // total}%") + except Exception: + if temporary.exists(): + temporary.unlink() + raise + temporary.replace(dest) + + +def copy_tree(source: Path, dest: Path, ignore: shutil.IgnorePattern) -> None: + if dest.exists(): + shutil.rmtree(dest) + shutil.copytree(source, dest, ignore=ignore) + + +def build_ui() -> None: + npm = "npm.cmd" if sys.platform == "win32" else "npm" + run([npm, "run", "build"], ROOT / "ui") + if not (DIST / "ui" / "index.html").is_file(): + raise SystemExit("界面没有编译到 dist/ui") + + +def build_desktop() -> Path: + run(["cargo", "build", "--release"], ROOT / "src-tauri") + meta = subprocess.check_output( + ["cargo", "metadata", "--format-version", "1", "--no-deps"], + cwd=ROOT / "src-tauri", + text=True, + ) + target = Path(json.loads(meta)["target_directory"]) / "release" + name = "raptdrama.exe" if sys.platform == "win32" else "raptdrama" + exe = target / name + if not exe.is_file(): + raise SystemExit(f"没有找到桌面程序 {exe}") + return exe + + +def bundle_python(stage: Path) -> None: + if sys.platform != "win32": + raise SystemExit("当前只内置了 Windows 版 Python,其他平台的运行时还没接上") + archive = CACHE / f"python-{PYTHON_VERSION}-embed-amd64.zip" + download(PYTHON_EMBED_URL, archive) + target = stage / "runtime" / "python" + target.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(archive) as packed: + packed.extractall(target) + pth = next(target.glob("python*._pth")) + lines = [] + saw_site = False + for line in pth.read_text(encoding="utf-8").splitlines(): + if line.strip() == "#import site": + lines.append("import site") + saw_site = True + else: + lines.append(line) + if line.strip() == "import site": + saw_site = True + if not any(item.strip().replace("\\", "/") == "Lib/site-packages" for item in lines): + lines.append("Lib/site-packages") + if not saw_site: + lines.append("import site") + pth.write_text("\n".join(lines) + "\n", encoding="utf-8") + (target / "Lib" / "site-packages").mkdir(parents=True, exist_ok=True) + get_pip = CACHE / "get-pip.py" + download(GET_PIP_URL, get_pip) + python = target / "python.exe" + run([str(python), str(get_pip)], target) + run([str(python), "-m", "pip", "install", "-r", str(ROOT / "server" / "requirements.txt")], target) + + +def bundle_adb(stage: Path) -> None: + if sys.platform != "win32": + raise SystemExit("当前只内置了 Windows 版 adb,其他平台的文件还没放进 vendor") + source = ROOT / "vendor" / "platform-tools" + dest = stage / "tools" / "platform-tools" + if dest.exists(): + shutil.rmtree(dest) + required = ["adb.exe", "AdbWinApi.dll", "AdbWinUsbApi.dll"] + missing = [name for name in required if not (source / name).is_file()] + if missing: + raise SystemExit("vendor/platform-tools 里没有 " + "、".join(missing)) + shutil.copytree(source, dest) + + +def bundle_ffmpeg(stage: Path) -> None: + if sys.platform != "win32": + raise SystemExit("当前只内置了 Windows 版 ffmpeg,其他平台的下载地址还没接上") + dest_dir = stage / "tools" / "ffmpeg" + dest_dir.mkdir(parents=True, exist_ok=True) + dest = dest_dir / "ffmpeg.exe" + existing = ROOT / "tools" / "ffmpeg" / "ffmpeg.exe" + if existing.is_file(): + shutil.copy2(existing, dest) + return + archive = CACHE / "ffmpeg-win32-x64.gz" + download(FFMPEG_URL, archive) + with gzip.open(archive, "rb") as packed: + dest.write_bytes(packed.read()) + + +def bundle_webview(stage: Path) -> None: + if sys.platform != "win32": + return + source = ROOT / "vendor" / "webview2" / WEBVIEW_INSTALLER + if not source.is_file(): + raise SystemExit(f"vendor/webview2 里没有 {WEBVIEW_INSTALLER}") + dest = stage / "vendor" / "webview2" + dest.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, dest / source.name) + + +def copy_server(stage: Path) -> None: + copy_tree( + ROOT / "server", + stage / "server", + shutil.ignore_patterns(".venv", "__pycache__", "*.pyc", "*.pyo"), + ) + + +def make_zip(stage: Path) -> Path: + suffix = "windows-x64" if sys.platform == "win32" else platform.system().lower() + archive = DIST / f"{STAGE_NAME}-{suffix}.zip" + temporary = archive.with_name(archive.stem + ".writing.zip") + if temporary.exists(): + temporary.unlink() + log(f"压缩 {archive}") + with zipfile.ZipFile(temporary, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as packed: + for path in stage.rglob("*"): + if not path.is_file() or path.name.lower() == "readme.md": + continue + packed.write(path, Path(STAGE_NAME) / path.relative_to(stage)) + try: + if archive.exists(): + archive.unlink() + temporary.replace(archive) + except PermissionError: + held = archive.with_name(archive.stem + "-new.zip") + if held.exists(): + held.unlink() + temporary.replace(held) + raise SystemExit(f"{archive.name} 正在被占用,新的压缩包写在 {held}") + return archive + + +def main() -> None: + if sys.platform != "win32": + raise SystemExit("目前只完成了 Windows 打包。脚本按平台拆开了,macOS 和 Linux 可以以后补运行时。") + CACHE.mkdir(parents=True, exist_ok=True) + build_ui() + exe = build_desktop() + stage = DIST / STAGE_NAME + if stage.exists(): + shutil.rmtree(stage) + stage.mkdir(parents=True) + shutil.copy2(exe, stage / exe.name) + copy_server(stage) + ui_dest = stage / "dist" / "ui" + shutil.copytree(DIST / "ui", ui_dest) + bundle_python(stage) + bundle_adb(stage) + bundle_ffmpeg(stage) + bundle_webview(stage) + archive = make_zip(stage) + log(f"完成 {archive} ({archive.stat().st_size / 1024 / 1024:.1f} MB)") + + +if __name__ == "__main__": + main() diff --git a/server/services/adb.py b/server/services/adb.py index cacf6fa..90f911f 100644 --- a/server/services/adb.py +++ b/server/services/adb.py @@ -24,10 +24,14 @@ class Adb: raise RuntimeError(f"找不到 adb:{explicit}") return cls(explicit) name = "adb.exe" if os.name == "nt" else "adb" - path = REPO_ROOT / "tools" / "platform-tools" / name - if not path.is_file(): - raise RuntimeError(f"项目内没有 adb:{path}") - return cls(str(path)) + candidates = [ + REPO_ROOT / "tools" / "platform-tools" / name, + REPO_ROOT / "vendor" / "platform-tools" / name, + ] + for path in candidates: + if path.is_file(): + return cls(str(path)) + raise RuntimeError(f"项目内没有 adb:{candidates[0]}") def list_devices(self) -> list[dict]: proc = self._run(["devices", "-l"]) diff --git a/server/services/catalog.py b/server/services/catalog.py index 8dffb72..96d04a5 100644 --- a/server/services/catalog.py +++ b/server/services/catalog.py @@ -10,6 +10,7 @@ from server.services.sessions import sessions CLASSIFY_MAX_PAGES = 50 RECOMMEND_MAX_PAGES = 80 FIXED_SECTIONS = ( + {"key": "trending", "name": "Trending Now", "note": "", "loading": True}, {"key": "new_releases", "name": "New Releases", "note": "", "loading": True}, {"key": "recommend", "name": "Recommend", "note": "", "loading": True}, ) @@ -25,7 +26,7 @@ class CatalogService: return rows def _categories(self, token: str, device: DeviceProfile) -> list[dict]: - data = http.request_data("POST", API_BASE + "/app/open/classifyv2", token, device, {}) + data = http.request_data("GET", API_BASE + "/app/open/classifyv2", token, device, None) rows = [] if isinstance(data, list): for item in data: @@ -72,14 +73,15 @@ class CatalogService: f"type-{item['id']}", item["label"], "/app/open/classify_video", - "POST", - lambda page, type_id=item["id"]: {"page": page, "limit": 10, "type": type_id}, + "GET", + lambda page, type_id=item["id"]: {"type": type_id, "page": page}, CLASSIFY_MAX_PAGES, "", ) for item in categories ] - jobs.append(("new_releases", "New Releases", "/app/open/orgin", "GET", None, 1, "")) + jobs.append(("trending", "Trending Now", "/app/open/orgin?class=trending_now", "GET", None, 1, "")) + jobs.append(("new_releases", "New Releases", "/app/open/orgin?class=new_releases", "GET", None, 1, "")) jobs.append(( "recommend", "Recommend", @@ -148,7 +150,9 @@ class CatalogService: page = 1 sent = False while page <= max_pages: - data = http.request_data(method, API_BASE + path, token, device, body(page) if body else None) + query = body(page) if body and method == "GET" else None + payload = body(page) if body and method != "GET" else None + data = http.request_data(method, API_BASE + path, token, device, payload, query) batch = data if isinstance(data, list) else [] if not batch and isinstance(data, dict) and isinstance(data.get("list"), list): batch = data["list"] diff --git a/server/services/device.py b/server/services/device.py index 21392ec..016c2df 100644 --- a/server/services/device.py +++ b/server/services/device.py @@ -62,8 +62,10 @@ class DeviceProfile: "x-os-version": str(self.os_version), "x-device-physical": physical, "x-device-model": self.model, + "Accept-Encoding": "gzip", "accept-language": self.language, "accept-language-custom": self.language, + "http_accept_language_custom": self.language.lower(), "x-os-name": "Android", "x-device-brand": self.brand, "x-device-id": self.device_id, diff --git a/server/services/http.py b/server/services/http.py index f151ec3..1837176 100644 --- a/server/services/http.py +++ b/server/services/http.py @@ -53,11 +53,11 @@ class HttpClient: raise RuntimeError("接口没有返回数据") return data - def request_data(self, method: str, url: str, token: str, device: DeviceProfile, body: dict | None): + def request_data(self, method: str, url: str, token: str, device: DeviceProfile, body: dict | None, query: dict | None = None): headers = device.headers() if token: headers["apasstk"] = token - response = self._request(method, url, headers=headers, json=body) + response = self._request(method, url, headers, body or None, query) payload = response.json() if payload.get("code") != 200: raise RuntimeError(f"{method} {url} -> {payload.get('code')} {payload.get('msg')}") @@ -65,11 +65,18 @@ class HttpClient: raise RuntimeError("接口没有返回数据") return payload.get("data") - def _request(self, method: str, url: str, headers: dict, json: dict | None) -> httpx.Response: + def _request(self, method: str, url: str, headers: dict, body: dict | None, query: dict | None) -> httpx.Response: last_error = None + options: dict = {"headers": headers} + if query: + options["params"] = query + if body: + options["json"] = body + elif method == "POST": + options["content"] = b"" for attempt in range(6): try: - return self._client_locked().request(method, url, headers=headers, json=json) + return self._client_locked().request(method, url, **options) except (httpx.HTTPError, TimeoutError, OSError) as exc: last_error = exc time.sleep(min(10, 1.5 * (attempt + 1))) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 33ae5dd..b3c6e2e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -18,13 +18,64 @@ fn repo_root() -> PathBuf { .expect("项目根目录") } -fn python_command(root: &Path) -> PathBuf { - let bundled = root.join("server").join(".venv").join("Scripts").join("python.exe"); - if bundled.is_file() { - bundled - } else { - PathBuf::from("python") +fn app_root() -> PathBuf { + if cfg!(debug_assertions) { + return repo_root(); } + std::env::current_exe() + .ok() + .and_then(|path| path.parent().map(Path::to_path_buf)) + .filter(|path| path.join("server").is_dir()) + .unwrap_or_else(repo_root) +} + +fn python_command(root: &Path) -> PathBuf { + let candidates = [ + root.join("runtime").join("python").join("python.exe"), + root.join("runtime").join("python").join("bin").join("python3"), + root.join("server").join(".venv").join("Scripts").join("python.exe"), + root.join("server").join(".venv").join("bin").join("python3"), + ]; + for candidate in candidates { + if candidate.is_file() { + return candidate; + } + } + PathBuf::from(if cfg!(windows) { "python" } else { "python3" }) +} + +fn prepare_webview_runtime(root: &Path) { + #[cfg(windows)] + { + if webview_runtime_installed() { + return; + } + let installer = root + .join("vendor") + .join("webview2") + .join("MicrosoftEdgeWebView2RuntimeInstallerX64.exe"); + if installer.is_file() { + let _ = Command::new(installer).args(["/silent", "/install"]).status(); + } + } +} + +#[cfg(windows)] +fn webview_runtime_installed() -> bool { + let keys = [ + r"HKLM\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}", + r"HKLM\SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}", + r"HKCU\SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}", + ]; + keys.iter().any(|key| { + Command::new("reg") + .args(["query", key, "/v", "pv"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|status| status.success()) + .unwrap_or(false) + }) } fn free_port() -> Result { @@ -148,7 +199,7 @@ fn disable_browser_chrome(window: &tauri::WebviewWindow) { } fn boot(handle: &AppHandle, window: &tauri::WebviewWindow) -> Result<(), String> { - let root = repo_root(); + let root = app_root(); let mut last_error = "后台启动超时".to_string(); for _ in 0..3 { let port = free_port()?; @@ -179,6 +230,8 @@ fn boot(handle: &AppHandle, window: &tauri::WebviewWindow) -> Result<(), String> #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + let root = app_root(); + prepare_webview_runtime(&root); tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .setup(|app| {