diff --git a/.gitignore b/.gitignore index a8808d2..bc7f6c0 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ src-tauri/target/ src-tauri/gen/schemas vendor/webview2/ server/.venv/ +download/ +tools/ffmpeg/ diff --git a/server/app.py b/server/app.py index 6229244..90d7e3b 100644 --- a/server/app.py +++ b/server/app.py @@ -10,8 +10,12 @@ from server.routes.auth import router as auth_router from server.routes.catalog import router as catalog_router from server.routes.covers import router as covers_router from server.routes.device import router as device_router +from server.routes.downloads import router as downloads_router +from server.routes.live import router as live_router +from server.routes.play import router as play_router from server.routes.proxy import router as proxy_router from server.routes.session import router as session_router +from server.services.downloads import downloads def create_app() -> FastAPI: @@ -28,8 +32,15 @@ def create_app() -> FastAPI: app.include_router(device_router) app.include_router(catalog_router) app.include_router(covers_router) + app.include_router(downloads_router) + app.include_router(play_router) + app.include_router(live_router) app.include_router(proxy_router) + @app.on_event("startup") + def start_downloads() -> None: + downloads.start() + @app.get("/health") def health(): return {"ok": True} diff --git a/server/requirements.txt b/server/requirements.txt index 490382f..f692f3e 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -1,3 +1,4 @@ fastapi>=0.115 uvicorn>=0.32 +websockets>=14 httpx[socks]>=0.28 diff --git a/server/routes/downloads.py b/server/routes/downloads.py new file mode 100644 index 0000000..94322e5 --- /dev/null +++ b/server/routes/downloads.py @@ -0,0 +1,112 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from server.errors import call +from server.schemas import DeviceBody, device_payload +from server.services.downloads import downloads + +router = APIRouter(prefix="/api/downloads") + + +class DownloadItem(BaseModel): + id: int + title: str = "" + image: str = "" + + +class DownloadStartBody(BaseModel): + token: str = "" + device: DeviceBody | None = None + items: list[DownloadItem] = [] + + +class DirectoryBody(BaseModel): + path: str = "" + + +class DownloadSettingsBody(BaseModel): + directory: str = "" + workers: int | None = None + + +@router.get("") +def list_downloads(): + return downloads.snapshot() + + +@router.post("") +def start_downloads(body: DownloadStartBody): + return downloads.enqueue(body.token, device_payload(body.device), [item.model_dump() for item in body.items]) + + +@router.post("/directory") +def set_directory(body: DirectoryBody): + if not body.path.strip(): + raise HTTPException(status_code=400, detail="请选择目录") + return {"directory": downloads.set_directory(body.path.strip())} + + +@router.post("/pick") +def pick_directory(): + return {"directory": downloads.pick_directory()} + + +@router.post("/choose") +def choose_directory(): + return {"directory": downloads.choose_directory()} + + +@router.get("/settings") +def get_download_settings(): + return downloads.settings_view() + + +@router.post("/settings") +def update_download_settings(body: DownloadSettingsBody): + if body.directory.strip(): + downloads.set_directory(body.directory.strip()) + if body.workers is not None: + try: + downloads.set_workers(body.workers) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return downloads.settings_view() + + +@router.post("/pause-all") +def pause_all_downloads(): + return downloads.pause_all() + + +@router.post("/resume-all") +def resume_all_downloads(): + return downloads.resume_all() + + +@router.post("/{drama_id}/pause") +def pause_download(drama_id: str): + return downloads.pause(drama_id) + + +@router.post("/{drama_id}/resume") +def resume_download(drama_id: str): + return downloads.resume(drama_id) + + +@router.post("/{drama_id}/cancel") +def cancel_download(drama_id: str): + return downloads.cancel(drama_id) + + +@router.post("/{drama_id}/open") +def open_download(drama_id: str): + def action(): + downloads.open_folder(drama_id) + return downloads.snapshot() + + return call(action) + + +@router.delete("/{drama_id}") +def remove_download(drama_id: str): + return downloads.remove(drama_id) diff --git a/server/routes/live.py b/server/routes/live.py new file mode 100644 index 0000000..1225e40 --- /dev/null +++ b/server/routes/live.py @@ -0,0 +1,33 @@ +import asyncio + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +from server.services.downloads import downloads +from server.services.live import live +from server.services.sessions import sessions + +router = APIRouter() + + +def _payload() -> dict: + profile = sessions.snapshot() + return { + "downloads": downloads.snapshot(), + "profile": profile["profile"] if profile else None, + } + + +@router.websocket("/api/live") +async def live_socket(websocket: WebSocket): + await websocket.accept() + seen = -1 + try: + while True: + revision = await asyncio.to_thread(live.wait, seen, 20) + if revision != seen: + seen = revision + await websocket.send_json(_payload()) + else: + await websocket.send_json({"type": "ping"}) + except WebSocketDisconnect: + return diff --git a/server/routes/play.py b/server/routes/play.py new file mode 100644 index 0000000..e71d8d8 --- /dev/null +++ b/server/routes/play.py @@ -0,0 +1,89 @@ +from urllib.parse import urlsplit + +import httpx +from fastapi import APIRouter, HTTPException +from fastapi.responses import Response, StreamingResponse +from pydantic import BaseModel + +from server.schemas import DeviceBody, device_payload +from server.services.device import DeviceProfile +from server.services.http import http +from server.services import playlist as playlists +from server.services.sessions import sessions + +router = APIRouter(prefix="/api/play") + + +class PlayBody(BaseModel): + token: str = "" + device: DeviceBody | None = None + vid: int + + +def _device(token: str, raw: dict | None) -> DeviceProfile | None: + if not token: + return None + if raw: + return sessions.bind(token, DeviceProfile.merge(raw)) + return sessions.device_for(token) + + +def _public_url(url: str) -> str: + parts = urlsplit(url) + if parts.scheme not in ("http", "https") or not parts.hostname: + raise HTTPException(status_code=400, detail="播放地址无效") + return url + + +@router.post("/episodes") +def list_episodes(body: PlayBody): + device = _device(body.token, device_payload(body.device)) + try: + rows = playlists.load_episodes(body.token, device, body.vid) + except RuntimeError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return { + "episodes": [ + { + "cid": ep["cid"], + "idx": ep["idx"], + "title": ep["title"], + "playable": bool(ep.get("src")), + } + for ep in rows + ] + } + + +@router.get("/segment") +def play_segment(url: str): + target = _public_url(url) + try: + upstream = http.open_stream(target) + except (httpx.HTTPError, TimeoutError, OSError, RuntimeError) as exc: + raise HTTPException(status_code=502, detail="分片读取失败") from exc + media = upstream.headers.get("content-type", "video/mp2t").split(";", 1)[0].strip() or "video/mp2t" + + def chunks(): + try: + for chunk in upstream.iter_bytes(64 * 1024): + if chunk: + yield chunk + finally: + upstream.close() + + return StreamingResponse(chunks(), media_type=media) + + +@router.get("/{vid}/{cid}/index.m3u8") +def episode_playlist(vid: int, cid: int): + token = sessions.active_token() + device = sessions.device_for(token) if token else None + src = playlists.episode_src(token, device, vid, cid) + if not src: + raise HTTPException(status_code=404, detail="这一集还没有可播放的地址") + try: + text = playlists.playback_playlist(src) + except RuntimeError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + return Response(text, media_type="application/vnd.apple.mpegurl", headers={"Cache-Control": "no-store"}) diff --git a/server/services/downloads.py b/server/services/downloads.py new file mode 100644 index 0000000..f5fb621 --- /dev/null +++ b/server/services/downloads.py @@ -0,0 +1,668 @@ +import json +import os +import shutil +import subprocess +import threading +import time +from datetime import datetime +from pathlib import Path +from urllib.parse import urlsplit + +from server.services import playlist as playlists +from server.services.device import DeviceProfile +from server.services.ffmpeg_tool import convert_to_mp4, ensure_ffmpeg +from server.services.http import http +from server.services.live import live +from server.services.sessions import sessions + +ROOT = Path(__file__).resolve().parents[2] +STATE_DIR = ROOT / "cache" / "downloads" +STATE_PATH = STATE_DIR / "jobs.json" +SETTINGS_PATH = STATE_DIR / "settings.json" +DEFAULT_WORKERS = 2 +MIN_WORKERS = 1 +MAX_WORKERS = 8 +ACTIVE = {"queued", "downloading", "paused", "failed"} + + +def _now() -> str: + return datetime.now().strftime("%Y-%m-%d %H:%M") + + +def _default_directory() -> str: + return str((Path.cwd() / "download").resolve()) + + +class DownloadManager: + def __init__(self) -> None: + self._lock = threading.Lock() + self._jobs: dict[str, dict] = {} + self._directory = _default_directory() + self._worker_limit = DEFAULT_WORKERS + self._alive_workers = 0 + self._wake = threading.Event() + self._started = False + self._speeds: dict[str, list[tuple[float, int]]] = {} + self._load() + + def start(self) -> None: + with self._lock: + first = not self._started + self._started = True + if first: + for job in self._jobs.values(): + if job["status"] in ("downloading", "failed"): + job["status"] = "queued" + job["error"] = "" + job["command"] = "" + for ep in job.get("episodes") or []: + if ep.get("phase") in ("failed", "downloading", "converting"): + ep["phase"] = "pending" + self._save_locked() + self._spawn_locked() + if first: + threading.Thread(target=self._prepare_ffmpeg, name="ffmpeg-setup", daemon=True).start() + + def _spawn_locked(self) -> None: + while self._alive_workers < self._worker_limit: + self._alive_workers += 1 + threading.Thread(target=self._worker, name="download-worker", daemon=True).start() + + def _prepare_ffmpeg(self) -> None: + try: + ensure_ffmpeg() + except Exception: + return + + def snapshot(self) -> dict: + with self._lock: + active = [] + done = [] + for job in self._jobs.values(): + view = self._view_locked(job) + if job["status"] in ACTIVE: + active.append(view) + elif job["status"] == "completed": + done.append(view) + done.sort(key=lambda item: item.get("completed_at") or "", reverse=True) + return { + "directory": self._directory, + "workers": self._worker_limit, + "active": active, + "done": done, + "active_count": len(active), + } + + def settings_view(self) -> dict: + with self._lock: + return { + "directory": self._directory, + "workers": self._worker_limit, + "default_directory": _default_directory(), + } + + def set_workers(self, count: int) -> int: + count = int(count) + if count < MIN_WORKERS or count > MAX_WORKERS: + raise ValueError(f"同时下载数量需要在 {MIN_WORKERS} 到 {MAX_WORKERS} 之间") + with self._lock: + self._worker_limit = count + self._save_settings_locked() + if self._started: + self._spawn_locked() + self._wake.set() + return count + + def choose_directory(self) -> str: + return _pick_folder(self._directory) + + def set_directory(self, path: str) -> str: + chosen = str(Path(path).expanduser().resolve()) + Path(chosen).mkdir(parents=True, exist_ok=True) + with self._lock: + self._directory = chosen + self._save_settings_locked() + return chosen + + def pick_directory(self) -> str: + chosen = _pick_folder(self._directory) + if not chosen: + return self._directory + return self.set_directory(chosen) + + def enqueue(self, token: str, raw_device: dict | None, items: list[dict]) -> dict: + self.start() + device = sessions.bind(token, DeviceProfile.merge(raw_device)) if token else None + with self._lock: + directory = self._directory + Path(directory).mkdir(parents=True, exist_ok=True) + for item in items: + drama_id = str(item.get("id") or "").strip() + if not drama_id: + continue + self._enqueue_one( + drama_id, + str(item.get("title") or drama_id), + str(item.get("image") or ""), + token, + device, + directory, + ) + self._wake.set() + return self.snapshot() + + def pause_all(self) -> dict: + with self._lock: + changed = False + for job in self._jobs.values(): + if job["status"] in ("queued", "downloading"): + job["status"] = "paused" + job["command"] = "pause" + changed = True + if changed: + self._save_locked() + return self.snapshot() + + def resume_all(self) -> dict: + with self._lock: + changed = False + for job in self._jobs.values(): + if job["status"] not in ("paused", "failed"): + continue + job["status"] = "queued" + job["command"] = "" + job["error"] = "" + for ep in job.get("episodes") or []: + if ep.get("phase") == "failed": + ep["phase"] = "pending" + changed = True + if changed: + self._save_locked() + if changed: + self._wake.set() + return self.snapshot() + + def pause(self, drama_id: str) -> dict: + with self._lock: + job = self._jobs.get(drama_id) + if job and job["status"] in ("queued", "downloading"): + job["status"] = "paused" + job["command"] = "pause" + self._save_locked() + return self.snapshot() + + def resume(self, drama_id: str) -> dict: + with self._lock: + job = self._jobs.get(drama_id) + if job and job["status"] in ("paused", "failed"): + job["status"] = "queued" + job["command"] = "" + job["error"] = "" + for ep in job.get("episodes") or []: + if ep.get("phase") == "failed": + ep["phase"] = "pending" + self._save_locked() + self._wake.set() + return self.snapshot() + + def cancel(self, drama_id: str) -> dict: + with self._lock: + job = self._jobs.get(drama_id) + if job and job["status"] in ACTIVE: + job["command"] = "cancel" + if job["status"] != "downloading": + self._jobs.pop(drama_id, None) + self._save_locked() + self._wake.set() + return self.snapshot() + + def remove(self, drama_id: str) -> dict: + with self._lock: + job = self._jobs.pop(drama_id, None) + self._save_locked() + folder = job.get("folder") if job else "" + if folder and Path(folder).is_dir(): + shutil.rmtree(folder, ignore_errors=True) + return self.snapshot() + + def open_folder(self, drama_id: str) -> None: + with self._lock: + job = self._jobs.get(drama_id) + folder = job.get("folder") if job else "" + if not folder or not Path(folder).is_dir(): + raise RuntimeError("目录不存在") + os.startfile(folder) # noqa: S606 + + def _enqueue_one(self, drama_id: str, title: str, image: str, token: str, device, directory: str) -> None: + episodes = playlists.load_episodes(token, device, int(drama_id)) + with self._lock: + job = self._jobs.get(drama_id) + if job is None: + folder = self._folder_locked(directory, title, drama_id) + job = { + "id": drama_id, + "title": title, + "image": image, + "status": "queued", + "command": "", + "folder": str(folder), + "created_at": _now(), + "completed_at": "", + "episodes": [], + "error": "", + "fraction": 0, + } + self._jobs[drama_id] = job + else: + if title: + job["title"] = title + if image: + job["image"] = image + if job["status"] == "completed": + job["status"] = "queued" + job["completed_at"] = "" + elif job["status"] == "paused": + pass + elif job["status"] not in ACTIVE: + job["status"] = "queued" + self._apply_episodes_locked(job, episodes) + if job["status"] == "paused": + job["command"] = "pause" + self._save_locked() + + def _apply_episodes_locked(self, job: dict, episodes: list[dict]) -> None: + current = {int(ep["cid"]): ep for ep in job.get("episodes") or []} + merged = [] + for ep in episodes: + old = current.get(int(ep["cid"])) + phase = old.get("phase") if old else "pending" + if phase not in ("done", "downloading", "converting"): + phase = "pending" if ep.get("src") else "skipped" + if phase == "done" and not self._mp4_exists(job, ep): + phase = "pending" if ep.get("src") else "skipped" + merged.append({ + "cid": int(ep["cid"]), + "idx": int(ep.get("idx") or 0), + "title": ep.get("title") or "", + "src": ep.get("src") or "", + "phase": phase, + }) + job["episodes"] = merged + if any(ep["phase"] == "pending" for ep in merged) and job["status"] == "completed": + job["status"] = "queued" + job["completed_at"] = "" + + def _folder_locked(self, directory: str, title: str, drama_id: str) -> Path: + base = playlists.safe_name(title, f"drama-{drama_id}") + used = {Path(job["folder"]).name for job in self._jobs.values() if job.get("folder")} + name = base if base not in used else playlists.safe_name(f"{base} {drama_id}", base) + return Path(directory) / name + + def _worker(self) -> None: + while True: + with self._lock: + if self._alive_workers > self._worker_limit: + self._alive_workers -= 1 + return + drama_id = self._claim() + if not drama_id: + self._wake.wait(0.4) + self._wake.clear() + continue + try: + self._run(drama_id) + except Exception as exc: + with self._lock: + job = self._jobs.get(drama_id) + if job is None or job.get("status") == "paused" or job.get("command") in ("pause", "cancel"): + continue + job["status"] = "queued" + job["error"] = str(exc) + self._save_locked() + + def _claim(self) -> str: + with self._lock: + for job in self._jobs.values(): + if job["status"] == "queued": + job["status"] = "downloading" + job["command"] = "" + job["error"] = "" + self._save_locked() + return job["id"] + return "" + + def _run(self, drama_id: str) -> None: + with self._lock: + job = self._jobs.get(drama_id) + if job is None: + return + token = sessions.active_token() + device = sessions.device_for(token) if token else None + folder = Path(job["folder"]) + episodes = playlists.load_episodes(token, device, int(drama_id)) + with self._lock: + job = self._jobs.get(drama_id) + if job is None: + return + self._apply_episodes_locked(job, episodes) + rows = [dict(ep) for ep in job["episodes"]] + self._save_locked() + folder.mkdir(parents=True, exist_ok=True) + self._prepare_playlists(drama_id, folder, rows) + for row in rows: + if self._stopped(drama_id): + return + try: + self._download_episode(drama_id, folder, row) + except RuntimeError as exc: + if self._stopped(drama_id): + return + self._set_phase(drama_id, row["cid"], "failed") + with self._lock: + job = self._jobs.get(drama_id) + if job is not None: + job["error"] = str(exc) + self._save_locked() + with self._lock: + job = self._jobs.get(drama_id) + if job is None or job.get("command") == "cancel": + self._jobs.pop(drama_id, None) + self._save_locked() + return + pending = any(ep["phase"] == "pending" for ep in job["episodes"] if ep.get("src")) + failed = any(ep["phase"] == "failed" for ep in job["episodes"]) + if job["status"] == "paused": + pass + elif pending: + job["status"] = "queued" + elif failed: + job["status"] = "failed" + else: + job["status"] = "completed" + job["completed_at"] = _now() + job["fraction"] = 1 + job["error"] = "" + self._save_locked() + + def _stopped(self, drama_id: str) -> bool: + with self._lock: + job = self._jobs.get(drama_id) + if job is None: + return True + if job.get("command") == "cancel": + self._jobs.pop(drama_id, None) + self._save_locked() + return True + if job.get("command") == "pause" or job["status"] == "paused": + job["status"] = "paused" + job["command"] = "pause" + self._save_locked() + return True + return False + + def _prepare_playlists(self, drama_id: str, folder: Path, rows: list[dict]) -> None: + self._set_stage(drama_id, "统计清单") + try: + for row in rows: + if not row.get("src"): + continue + if self._stopped(drama_id): + raise RuntimeError("已停止") + try: + self._write_playlist(drama_id, folder, row) + except RuntimeError as exc: + if self._stopped(drama_id): + raise + self._set_phase(drama_id, row["cid"], "failed") + with self._lock: + job = self._jobs.get(drama_id) + if job is not None: + job["error"] = str(exc) + self._save_locked() + finally: + self._set_stage(drama_id, "") + + def _episode_paths(self, folder: Path, row: dict) -> tuple[Path, Path]: + episode_name = playlists.safe_name(row.get("title") or "", f"EP.{row.get('idx') or row['cid']}") + source_dir = folder / "source" / episode_name + mp4_path = folder / "MP4" / f"{episode_name}.mp4" + return source_dir, mp4_path + + def _write_playlist(self, drama_id: str, folder: Path, row: dict) -> None: + source_dir, _mp4_path = self._episode_paths(folder, row) + master_text, media_text, segments = playlists.expand_variant(row["src"]) + items = [] + for index, segment in enumerate(segments, start=1): + name = Path(urlsplit(segment).path).name or f"part-{index}.ts" + items.append({"name": name, "url": segment}) + source_dir.mkdir(parents=True, exist_ok=True) + (source_dir / "video.m3u8").write_text( + _localize(media_text, [item["name"] for item in items]), + encoding="utf-8", + newline="\n", + ) + (source_dir / "playlist.m3u8").write_text( + _localize(master_text, ["video.m3u8"]), + encoding="utf-8", + newline="\n", + ) + (source_dir / "segments.json").write_text(json.dumps(items, ensure_ascii=False), encoding="utf-8") + done = sum(1 for item in items if (source_dir / item["name"]).is_file() and (source_dir / item["name"]).stat().st_size > 0) + self._set_segments(drama_id, row["cid"], len(items), done) + + def _set_segments(self, drama_id: str, cid: int, count: int, done: int) -> None: + with self._lock: + job = self._jobs.get(drama_id) + if job is None: + return + for ep in job["episodes"]: + if int(ep["cid"]) == int(cid): + ep["segment_count"] = count + ep["segments_done"] = done + self._save_locked() + + def _set_stage(self, drama_id: str, stage: str) -> None: + with self._lock: + job = self._jobs.get(drama_id) + if job is not None: + job["stage"] = stage + live.touch() + + def _download_episode(self, drama_id: str, folder: Path, row: dict) -> None: + if not row.get("src"): + self._set_phase(drama_id, row["cid"], "skipped") + return + source_dir, mp4_path = self._episode_paths(folder, row) + manifest = source_dir / "segments.json" + if mp4_path.is_file() and mp4_path.stat().st_size > 0: + if manifest.is_file(): + items = json.loads(manifest.read_text(encoding="utf-8")) + self._set_segments(drama_id, row["cid"], len(items), len(items)) + self._set_phase(drama_id, row["cid"], "done") + return + self._set_phase(drama_id, row["cid"], "downloading") + if not manifest.is_file(): + self._write_playlist(drama_id, folder, row) + items = json.loads(manifest.read_text(encoding="utf-8")) + total = len(items) + for index, item in enumerate(items, start=1): + if self._stopped(drama_id): + raise RuntimeError("已停止") + target = source_dir / item["name"] + if not target.is_file() or target.stat().st_size <= 0: + http.download_file( + item["url"], + target, + on_chunk=lambda size, job_id=drama_id: self._note_bytes(job_id, size), + stop=lambda job_id=drama_id: self._stopped(job_id), + ) + done = sum(1 for part in items[:index] if (source_dir / part["name"]).is_file() and (source_dir / part["name"]).stat().st_size > 0) + earlier = sum(1 for part in items[index:] if (source_dir / part["name"]).is_file() and (source_dir / part["name"]).stat().st_size > 0) + self._set_segments(drama_id, row["cid"], total, done + earlier) + if self._stopped(drama_id): + raise RuntimeError("已停止") + self._set_phase(drama_id, row["cid"], "converting") + convert_to_mp4(source_dir, mp4_path) + self._set_phase(drama_id, row["cid"], "done") + self._set_segments(drama_id, row["cid"], total, total) + + def _set_phase(self, drama_id: str, cid: int, phase: str) -> None: + with self._lock: + job = self._jobs.get(drama_id) + if job is None: + return + for ep in job["episodes"]: + if int(ep["cid"]) == int(cid): + ep["phase"] = phase + self._save_locked() + + def _set_fraction(self, drama_id: str, fraction: float) -> None: + with self._lock: + job = self._jobs.get(drama_id) + if job is not None: + job["fraction"] = fraction + + def _note_bytes(self, drama_id: str, size: int) -> None: + now = time.time() + window = self._speeds.setdefault(drama_id, []) + window.append((now, size)) + cutoff = now - 2 + self._speeds[drama_id] = [(stamp, nbytes) for stamp, nbytes in window if stamp >= cutoff] + + def _speed(self, drama_id: str) -> str: + window = self._speeds.get(drama_id) or [] + if len(window) < 1: + return "" + span = max(0.4, window[-1][0] - window[0][0]) + return _format_speed(sum(size for _stamp, size in window) / span) + + def _mp4_exists(self, job: dict, ep: dict) -> bool: + folder = job.get("folder") or "" + if not folder: + return False + name = playlists.safe_name(ep.get("title") or "", f"EP.{ep.get('idx') or ep.get('cid')}") + path = Path(folder) / "MP4" / f"{name}.mp4" + return path.is_file() and path.stat().st_size > 0 + + def _view_locked(self, job: dict) -> dict: + episodes = job.get("episodes") or [] + downloadable = [ep for ep in episodes if ep.get("src")] + total = len(downloadable) + done = sum(1 for ep in downloadable if ep.get("phase") == "done") + parts = sum(int(ep.get("segment_count") or 0) for ep in downloadable) + got = sum(int(ep.get("segments_done") or 0) for ep in downloadable) + progress = round(got / parts * 100) if parts else 0 + return { + "id": job["id"], + "title": job["title"], + "image": job.get("image") or "", + "status": job["status"], + "episode_count": total, + "done_count": done, + "progress": progress, + "speed": self._speed(job["id"]) if job["status"] == "downloading" else "", + "stage": job.get("stage") or "", + "completed_at": job.get("completed_at") or "", + "error": job.get("error") or "", + } + + def _load(self) -> None: + STATE_DIR.mkdir(parents=True, exist_ok=True) + if SETTINGS_PATH.is_file(): + try: + data = json.loads(SETTINGS_PATH.read_text(encoding="utf-8")) + if isinstance(data, dict) and data.get("directory"): + self._directory = data["directory"] + workers = data.get("workers") if isinstance(data, dict) else None + if isinstance(workers, int) and MIN_WORKERS <= workers <= MAX_WORKERS: + self._worker_limit = workers + except (OSError, json.JSONDecodeError): + pass + if not STATE_PATH.is_file(): + return + try: + data = json.loads(STATE_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return + jobs = data.get("jobs") if isinstance(data, dict) else None + if isinstance(jobs, list): + for job in jobs: + if isinstance(job, dict) and job.get("id"): + self._jobs[str(job["id"])] = job + + def _save_locked(self) -> None: + STATE_DIR.mkdir(parents=True, exist_ok=True) + stored = [] + for job in self._jobs.values(): + stored.append({key: value for key, value in job.items() if key != "fraction"}) + temporary = STATE_PATH.with_suffix(".json.tmp") + temporary.write_text(json.dumps({"jobs": stored}, ensure_ascii=False), encoding="utf-8") + temporary.replace(STATE_PATH) + self._save_settings_locked() + live.touch() + + def _save_settings_locked(self) -> None: + STATE_DIR.mkdir(parents=True, exist_ok=True) + SETTINGS_PATH.write_text( + json.dumps({"directory": self._directory, "workers": self._worker_limit}, ensure_ascii=False), + encoding="utf-8", + ) + + +def _localize(text: str, names: list[str]) -> str: + lines = [] + index = 0 + for line in text.splitlines(): + stripped = line.strip() + if stripped and not stripped.startswith("#"): + lines.append(names[index] if index < len(names) else stripped) + index += 1 + else: + lines.append(line) + return "\n".join(lines) + "\n" + + +def _format_speed(value: float) -> str: + if value >= 1024 * 1024: + return f"{value / 1024 / 1024:.1f} MB/s" + if value >= 1024: + return f"{value / 1024:.0f} KB/s" + if value > 0: + return f"{value:.0f} B/s" + return "" + + +def _pick_folder(initial: str) -> str: + script = """ +Add-Type -AssemblyName System.Windows.Forms +$owner = New-Object System.Windows.Forms.Form +$owner.TopMost = $true +$owner.ShowInTaskbar = $false +$owner.Opacity = 0 +$owner.Show() +$owner.Activate() +$dialog = New-Object System.Windows.Forms.FolderBrowserDialog +$dialog.Description = '选择下载目录' +$dialog.ShowNewFolderButton = $true +$dialog.SelectedPath = $env:RAPTD_PICK_INITIAL +$result = $dialog.ShowDialog($owner) +$owner.Close() +if ($result -eq [System.Windows.Forms.DialogResult]::OK) { + [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false + [Console]::Out.WriteLine($dialog.SelectedPath) +} +""" + env = os.environ.copy() + env["RAPTD_PICK_INITIAL"] = initial + completed = subprocess.run( + ["powershell", "-NoProfile", "-STA", "-Command", script], + capture_output=True, + text=True, + encoding="utf-8", + env=env, + check=False, + ) + return (completed.stdout or "").strip() + + +downloads = DownloadManager() diff --git a/server/services/ffmpeg_tool.py b/server/services/ffmpeg_tool.py new file mode 100644 index 0000000..8bf6bdf --- /dev/null +++ b/server/services/ffmpeg_tool.py @@ -0,0 +1,75 @@ +import gzip +import shutil +import subprocess +from pathlib import Path + +import httpx + +FFMPEG_DIR = Path(__file__).resolve().parents[2] / "tools" / "ffmpeg" +FFMPEG_EXE = FFMPEG_DIR / "ffmpeg.exe" +FFMPEG_URL = "https://github.com/eugeneware/ffmpeg-static/releases/download/b6.1.1/ffmpeg-win32-x64.gz" + + +def ffmpeg_path() -> Path | None: + if FFMPEG_EXE.is_file(): + return FFMPEG_EXE + found = shutil.which("ffmpeg") + return Path(found) if found else None + + +def ensure_ffmpeg() -> Path: + current = ffmpeg_path() + if current is not None: + return current + FFMPEG_DIR.mkdir(parents=True, exist_ok=True) + archive = FFMPEG_DIR / "ffmpeg-win32-x64.gz" + try: + with httpx.Client(follow_redirects=True, timeout=120.0, trust_env=False) as client: + response = client.get(FFMPEG_URL) + response.raise_for_status() + archive.write_bytes(response.content) + import gzip + + FFMPEG_EXE.write_bytes(gzip.decompress(archive.read_bytes())) + finally: + archive.unlink(missing_ok=True) + if not FFMPEG_EXE.is_file(): + raise RuntimeError("没有找到 ffmpeg") + return FFMPEG_EXE + + +def convert_to_mp4(episode_dir: Path, mp4_path: Path) -> None: + exe = ensure_ffmpeg() + mp4_path.parent.mkdir(parents=True, exist_ok=True) + temporary = mp4_path.with_name(f"{mp4_path.stem}.part.mp4") + completed = subprocess.run( + [ + str(exe), + "-y", + "-allowed_extensions", + "ALL", + "-i", + "video.m3u8", + "-c", + "copy", + "-movflags", + "+faststart", + "-f", + "mp4", + str(temporary), + ], + cwd=episode_dir, + capture_output=True, + check=False, + ) + if completed.returncode != 0 or not temporary.is_file() or temporary.stat().st_size <= 0: + temporary.unlink(missing_ok=True) + detail = (completed.stderr or b"").decode("utf-8", "replace")[-400:] + raise RuntimeError("转换成 MP4 失败" + (f":{detail}" if detail else "")) + temporary.replace(mp4_path) + + +def unused_zip_helper(path: Path) -> None: + if path.suffix == ".zip": + with zipfile.ZipFile(path) as archive: + archive.extractall(path.parent) diff --git a/server/services/http.py b/server/services/http.py index 459ebf8..f151ec3 100644 --- a/server/services/http.py +++ b/server/services/http.py @@ -89,6 +89,64 @@ class HttpClient: raise RuntimeError("不是图片") return response.content, media + def download_file(self, url: str, dest, on_chunk=None, stop=None) -> int: + from pathlib import Path + + target = Path(dest) + target.parent.mkdir(parents=True, exist_ok=True) + temporary = target.with_name(target.name + ".part") + last_error = None + for _attempt in range(3): + if stop and stop(): + raise RuntimeError("已取消") + try: + with self._client_locked().stream("GET", url, headers={"User-Agent": "RaptDrama"}) as response: + response.raise_for_status() + size = 0 + with temporary.open("wb") as handle: + for chunk in response.iter_bytes(64 * 1024): + if stop and stop(): + raise RuntimeError("已取消") + if not chunk: + continue + handle.write(chunk) + size += len(chunk) + if on_chunk: + on_chunk(len(chunk)) + temporary.replace(target) + return size + except RuntimeError: + temporary.unlink(missing_ok=True) + raise + except (httpx.HTTPError, TimeoutError, OSError) as exc: + last_error = exc + temporary.unlink(missing_ok=True) + time.sleep(0.4) + raise RuntimeError(f"下载失败: {last_error}") + + def read_text(self, url: str) -> str: + last_error = None + for _attempt in range(3): + try: + response = self._client_locked().get(url, headers={"User-Agent": "RaptDrama"}) + response.raise_for_status() + return response.text + except (httpx.HTTPError, TimeoutError, OSError) as exc: + last_error = exc + time.sleep(0.4) + raise RuntimeError(f"下载失败: {last_error}") + + def open_stream(self, url: str) -> httpx.Response: + client = self._client_locked() + request = client.build_request("GET", url, headers={"User-Agent": "RaptDrama"}) + response = client.send(request, stream=True) + try: + response.raise_for_status() + except Exception: + response.close() + raise + return response + def _close_locked(self) -> None: if self._client is not None: self._client.close() diff --git a/server/services/live.py b/server/services/live.py new file mode 100644 index 0000000..f7cee70 --- /dev/null +++ b/server/services/live.py @@ -0,0 +1,21 @@ +import threading + + +class LiveHub: + def __init__(self) -> None: + self._condition = threading.Condition() + self.revision = 0 + + def touch(self) -> None: + with self._condition: + self.revision += 1 + self._condition.notify_all() + + def wait(self, seen: int, timeout: float = 20) -> int: + with self._condition: + if self.revision == seen: + self._condition.wait(timeout) + return self.revision + + +live = LiveHub() diff --git a/server/services/playlist.py b/server/services/playlist.py new file mode 100644 index 0000000..3342435 --- /dev/null +++ b/server/services/playlist.py @@ -0,0 +1,218 @@ +import json +import re +from pathlib import Path +from urllib.parse import quote, urljoin + +from server.services.device import DeviceProfile +from server.services.http import http +from server.services.rapt import API_BASE + +CACHE_DIR = Path(__file__).resolve().parents[2] / "cache" / "playlists" + + +def safe_name(text: str, fallback: str) -> str: + cleaned = re.sub(r'[<>:"/\\|?*\x00-\x1f]', " ", text or "") + cleaned = re.sub(r"\s+", " ", cleaned).strip().rstrip(". ") + return (cleaned[:80] or fallback) + + +def _items(data): + if isinstance(data, list): + return data, len(data) + items = (data or {}).get("list") or [] + return items, (data or {}).get("count") + + +def _normalize(item: dict) -> dict | None: + cid = item.get("cid") or item.get("id") + if cid is None: + return None + idx = item.get("idx") or item.get("number") or 0 + title = str(item.get("msg") or item.get("title") or f"EP.{idx}").strip() + return { + "cid": int(cid), + "idx": int(idx) if str(idx).isdigit() else 0, + "title": title, + "src": str(item.get("src") or "").strip(), + } + + +def _request_playlist(token: str, device: DeviceProfile, vid: int) -> list[dict]: + try: + data = http.request_data("POST", API_BASE + "/app/video/playlist", token, device, { + "vid": vid, + "page": 1, + "before": 1, + }) + items, total = _items(data) + episodes = [row for row in (_normalize(item) for item in items if isinstance(item, dict)) if row] + if episodes and (total is None or len(episodes) >= int(total)): + return episodes + except RuntimeError: + episodes = [] + collected = [] + total = None + for page in range(1, 41): + data = http.request_data("POST", API_BASE + "/app/video/playlistv2", token, device, { + "vid": vid, + "page": page, + "before": 1 if page == 1 else 0, + }) + items, page_total = _items(data) + if page_total is not None: + total = page_total + if not items: + break + for item in items: + if isinstance(item, dict): + row = _normalize(item) + if row: + collected.append(row) + if total is not None and len(collected) >= int(total): + break + return collected or episodes + + +def _cache_path(vid: int) -> Path: + return CACHE_DIR / f"{vid}.json" + + +def _read_cache(vid: int) -> list[dict]: + path = _cache_path(vid) + if not path.is_file(): + return [] + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return [] + rows = data.get("episodes") if isinstance(data, dict) else None + return [row for row in rows if isinstance(row, dict)] if isinstance(rows, list) else [] + + +def _write_cache(vid: int, episodes: list[dict]) -> None: + CACHE_DIR.mkdir(parents=True, exist_ok=True) + payload = { + "episodes": [ + {"cid": ep["cid"], "idx": ep["idx"], "title": ep["title"], "src": ep.get("src") or ""} + for ep in episodes + ] + } + path = _cache_path(vid) + temporary = path.with_suffix(".json.tmp") + temporary.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + temporary.replace(path) + + +def _filled(episodes: list[dict]) -> int: + return sum(1 for ep in episodes if ep.get("src")) + + +def merge_episodes(cached: list[dict], fresh: list[dict]) -> tuple[list[dict], bool]: + by_cid: dict[int, dict] = {} + for source in (cached, fresh): + for ep in source: + cid = int(ep["cid"]) + current = by_cid.get(cid) + if current is None: + by_cid[cid] = { + "cid": cid, + "idx": int(ep.get("idx") or 0), + "title": ep.get("title") or f"EP.{ep.get('idx') or cid}", + "src": ep.get("src") or "", + } + continue + if ep.get("title"): + current["title"] = ep["title"] + if ep.get("idx"): + current["idx"] = int(ep["idx"]) + if ep.get("src"): + current["src"] = ep["src"] + merged = sorted(by_cid.values(), key=lambda ep: (ep["idx"], ep["cid"])) + return merged, _filled(fresh) >= _filled(cached) and _filled(fresh) > 0 + + +def load_episodes(token: str, device: DeviceProfile | None, vid: int) -> list[dict]: + cached = _read_cache(vid) + fresh = [] + if token and device is not None: + try: + fresh = _request_playlist(token, device, vid) + except RuntimeError: + fresh = [] + merged, save = merge_episodes(cached, fresh) + if save: + _write_cache(vid, merged) + elif not cached and merged: + _write_cache(vid, merged) + return merged + + +def expand_variant(master_url: str) -> tuple[str, str, list[str]]: + master_text = http.read_text(master_url) + variant_rel = next( + (line.strip() for line in master_text.splitlines() if line.strip() and not line.startswith("#")), + "", + ) + if not variant_rel: + raise RuntimeError("播放列表没有视频地址") + variant_url = urljoin(master_url, variant_rel) + media_text = http.read_text(variant_url) + segments = [ + urljoin(variant_url, line.strip()) + for line in media_text.splitlines() + if line.strip() and not line.startswith("#") + ] + if not segments: + raise RuntimeError("播放列表没有分片") + return master_text, media_text, segments + + +def episode_src(token: str, device: DeviceProfile | None, vid: int, cid: int) -> str: + for ep in load_episodes(token, device, vid): + if int(ep.get("cid") or 0) == cid and ep.get("src"): + return str(ep["src"]) + return "" + + +def playback_playlist(master_url: str) -> str: + master_text = http.read_text(master_url) + variant_rel = next( + (line.strip() for line in master_text.splitlines() if line.strip() and not line.startswith("#")), + "", + ) + if not variant_rel: + raise RuntimeError("播放列表没有视频地址") + if _looks_like_segment(variant_rel): + return _rewrite_playlist(master_text, master_url) + variant_url = urljoin(master_url, variant_rel) + return _rewrite_playlist(http.read_text(variant_url), variant_url) + + +def _looks_like_segment(url: str) -> bool: + path = url.split("?", 1)[0].lower() + return path.endswith((".ts", ".m4s", ".m4v", ".mp4", ".aac", ".vtt")) + + +def _proxy_url(url: str) -> str: + return "/api/play/segment?url=" + quote(url, safe="") + + +def _rewrite_playlist(text: str, base: str) -> str: + lines = [] + for line in text.splitlines(): + stripped = line.strip() + if not stripped: + lines.append(line) + continue + if stripped.startswith("#"): + lines.append(_rewrite_tag(line, base)) + continue + lines.append(_proxy_url(urljoin(base, stripped))) + return "\n".join(lines) + "\n" + + +def _rewrite_tag(line: str, base: str) -> str: + def replace(match: re.Match) -> str: + return 'URI="' + _proxy_url(urljoin(base, match.group(1))) + '"' + + return re.sub(r'URI="([^"]*)"', replace, line) diff --git a/server/services/sessions.py b/server/services/sessions.py index 372af2c..9876bb0 100644 --- a/server/services/sessions.py +++ b/server/services/sessions.py @@ -1,6 +1,7 @@ import threading from server.services.device import DeviceProfile +from server.services.live import live class SessionStore: @@ -26,6 +27,7 @@ class SessionStore: self._active = token self._profile = dict(profile) self._revision += 1 + live.touch() def set_profile(self, token: str, profile: dict) -> bool: with self._lock: @@ -33,6 +35,7 @@ class SessionStore: return False self._profile = dict(profile) self._revision += 1 + live.touch() return True def active_token(self) -> str: @@ -51,6 +54,7 @@ class SessionStore: if self._active == token: self._active = "" self._profile = None + live.touch() sessions = SessionStore() diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ce3a141..3445e1c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2710,6 +2710,8 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-opener", + "webview2-com", + "windows-core 0.61.2", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7937600..a6ab68f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -20,6 +20,10 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = [] } tauri-plugin-opener = "2" + +[target.'cfg(windows)'.dependencies] +webview2-com = "0.38" +windows-core = "0.61" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 4cdbf49..4c7413b 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -3,8 +3,9 @@ "identifier": "default", "description": "Capability for the main window", "windows": ["main"], - "permissions": [ + "permissions": [ "core:default", + "core:window:allow-destroy", "opener:default" ] } diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png index 24045aa..a708c6f 100644 Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png index 4a537e1..247bee7 100644 Binary files a/src-tauri/icons/128x128@2x.png and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png index 9c0ad67..6fefcce 100644 Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png new file mode 100644 index 0000000..7087c5c Binary files /dev/null and b/src-tauri/icons/64x64.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png index 8266f1b..98b5d54 100644 Binary files a/src-tauri/icons/Square107x107Logo.png and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png index 6037658..0d30d5c 100644 Binary files a/src-tauri/icons/Square142x142Logo.png and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png index 06102e1..6a85d2f 100644 Binary files a/src-tauri/icons/Square150x150Logo.png and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png index 37bec18..dff4e71 100644 Binary files a/src-tauri/icons/Square284x284Logo.png and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png index bcfb4b7..be986a2 100644 Binary files a/src-tauri/icons/Square30x30Logo.png and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png index 45eabe5..950c605 100644 Binary files a/src-tauri/icons/Square310x310Logo.png and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png index d10c199..d2d97b9 100644 Binary files a/src-tauri/icons/Square44x44Logo.png and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png index b17590f..4ea2b4b 100644 Binary files a/src-tauri/icons/Square71x71Logo.png and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png index fadd836..2961b78 100644 Binary files a/src-tauri/icons/Square89x89Logo.png and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png index 5ac2347..286d106 100644 Binary files a/src-tauri/icons/StoreLogo.png and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml b/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..2ffbf24 --- /dev/null +++ b/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..b239ce1 Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..aa6e779 Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..3e0b0ca Binary files /dev/null and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..3f6bbee Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..2b99c4f Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..ea3b07c Binary files /dev/null and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..e935247 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..593e124 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..e1fc3d4 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..7de590f Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..ed26760 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..d731d35 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..ee29ba9 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..cc0634b Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..aa927d1 Binary files /dev/null and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/values/ic_launcher_background.xml b/src-tauri/icons/android/values/ic_launcher_background.xml new file mode 100644 index 0000000..ea9c223 --- /dev/null +++ b/src-tauri/icons/android/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #fff + \ No newline at end of file diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns index 12a5bce..f56995a 100644 Binary files a/src-tauri/icons/icon.icns and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index 06c23c8..b4d5ee8 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index 8b1c465..4480aec 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@1x.png b/src-tauri/icons/ios/AppIcon-20x20@1x.png new file mode 100644 index 0000000..24d7c20 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png new file mode 100644 index 0000000..4e47ea7 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x.png b/src-tauri/icons/ios/AppIcon-20x20@2x.png new file mode 100644 index 0000000..4e47ea7 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@3x.png b/src-tauri/icons/ios/AppIcon-20x20@3x.png new file mode 100644 index 0000000..406b0df Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@1x.png b/src-tauri/icons/ios/AppIcon-29x29@1x.png new file mode 100644 index 0000000..bd940d2 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png new file mode 100644 index 0000000..34f9267 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x.png b/src-tauri/icons/ios/AppIcon-29x29@2x.png new file mode 100644 index 0000000..34f9267 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@3x.png b/src-tauri/icons/ios/AppIcon-29x29@3x.png new file mode 100644 index 0000000..c1e3396 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@1x.png b/src-tauri/icons/ios/AppIcon-40x40@1x.png new file mode 100644 index 0000000..4e47ea7 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png new file mode 100644 index 0000000..86ff0ac Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x.png b/src-tauri/icons/ios/AppIcon-40x40@2x.png new file mode 100644 index 0000000..86ff0ac Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@3x.png b/src-tauri/icons/ios/AppIcon-40x40@3x.png new file mode 100644 index 0000000..833382f Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-512@2x.png b/src-tauri/icons/ios/AppIcon-512@2x.png new file mode 100644 index 0000000..8128f83 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@2x.png b/src-tauri/icons/ios/AppIcon-60x60@2x.png new file mode 100644 index 0000000..833382f Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@3x.png b/src-tauri/icons/ios/AppIcon-60x60@3x.png new file mode 100644 index 0000000..4066e76 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@1x.png b/src-tauri/icons/ios/AppIcon-76x76@1x.png new file mode 100644 index 0000000..6ca06c2 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@2x.png b/src-tauri/icons/ios/AppIcon-76x76@2x.png new file mode 100644 index 0000000..d4c2cc7 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png new file mode 100644 index 0000000..0a5f69f Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/src-tauri/splash/index.html b/src-tauri/splash/index.html index 21f6c17..e176682 100644 --- a/src-tauri/splash/index.html +++ b/src-tauri/splash/index.html @@ -2,7 +2,7 @@ - RaptDrama + RaptDrama Download Tools diff --git a/ui/src/components.d.ts b/ui/src/components.d.ts index bbb6e07..bce6d4c 100644 --- a/ui/src/components.d.ts +++ b/ui/src/components.d.ts @@ -16,17 +16,17 @@ declare module 'vue' { NButton: typeof import('naive-ui')['NButton'] NCheckbox: typeof import('naive-ui')['NCheckbox'] NConfigProvider: typeof import('naive-ui')['NConfigProvider'] - NDescriptions: typeof import('naive-ui')['NDescriptions'] - NDescriptionsItem: typeof import('naive-ui')['NDescriptionsItem'] NForm: typeof import('naive-ui')['NForm'] NFormItem: typeof import('naive-ui')['NFormItem'] NInput: typeof import('naive-ui')['NInput'] + NInputNumber: typeof import('naive-ui')['NInputNumber'] NLayout: typeof import('naive-ui')['NLayout'] NLayoutContent: typeof import('naive-ui')['NLayoutContent'] NLayoutHeader: typeof import('naive-ui')['NLayoutHeader'] NLayoutSider: typeof import('naive-ui')['NLayoutSider'] NMenu: typeof import('naive-ui')['NMenu'] NModal: typeof import('naive-ui')['NModal'] + NProgress: typeof import('naive-ui')['NProgress'] NRadio: typeof import('naive-ui')['NRadio'] NRadioGroup: typeof import('naive-ui')['NRadioGroup'] NSelect: typeof import('naive-ui')['NSelect'] @@ -34,6 +34,7 @@ declare module 'vue' { NTab: typeof import('naive-ui')['NTab'] NTabPane: typeof import('naive-ui')['NTabPane'] NTabs: typeof import('naive-ui')['NTabs'] + NTag: typeof import('naive-ui')['NTag'] NText: typeof import('naive-ui')['NText'] NTooltip: typeof import('naive-ui')['NTooltip'] } diff --git a/ui/src/main.ts b/ui/src/main.ts index b670de8..648a167 100644 --- a/ui/src/main.ts +++ b/ui/src/main.ts @@ -1,4 +1,17 @@ import { createApp } from "vue"; import App from "./App.vue"; +window.addEventListener("contextmenu", (event) => event.preventDefault()); +window.addEventListener("keydown", (event) => { + const key = event.key.toLowerCase(); + const ctrl = event.ctrlKey || event.metaKey; + const refresh = event.key === "F5" || (ctrl && key === "r"); + const devtools = event.key === "F12" || (ctrl && event.shiftKey && (key === "i" || key === "j" || key === "c")); + const viewSource = ctrl && key === "u"; + if (refresh || devtools || viewSource) { + event.preventDefault(); + event.stopPropagation(); + } +}); + createApp(App).mount("#app"); diff --git a/ui/src/views/DownloadProgressPage.vue b/ui/src/views/DownloadProgressPage.vue new file mode 100644 index 0000000..3a23641 --- /dev/null +++ b/ui/src/views/DownloadProgressPage.vue @@ -0,0 +1,175 @@ + + + + + diff --git a/ui/src/views/PlayerPage.vue b/ui/src/views/PlayerPage.vue new file mode 100644 index 0000000..f6b169b --- /dev/null +++ b/ui/src/views/PlayerPage.vue @@ -0,0 +1,178 @@ + + + + + diff --git a/ui/src/views/SettingsPanel.vue b/ui/src/views/SettingsPanel.vue new file mode 100644 index 0000000..7633ea3 --- /dev/null +++ b/ui/src/views/SettingsPanel.vue @@ -0,0 +1,162 @@ + + + diff --git a/ui/src/views/VideoListPage.vue b/ui/src/views/VideoListPage.vue index 7566047..b8c34c8 100644 --- a/ui/src/views/VideoListPage.vue +++ b/ui/src/views/VideoListPage.vue @@ -32,6 +32,10 @@ let searchCursor = 0; let lastQuery = ""; let revealGeneration = 0; const flashSerial = ref(0); +const directory = ref(""); +const starting = ref(false); +const footError = ref(""); +const emit = defineEmits<{ started: []; play: [Drama] }>(); const scrollEl = ref(null); function coverOf(image: string) { @@ -275,8 +279,62 @@ async function find(keyword = query.value) { if (generation !== revealGeneration) return; flashKey.value = key; } -onMounted(reload); -defineExpose({ reload, busy, find, searchHint, clearSearch }); +onMounted(() => { + void reload(); + void loadDirectory(); +}); +defineExpose({ reload, busy, find, searchHint, clearSearch, loadDirectory }); + +async function loadDirectory() { + const response = await fetch("/api/downloads"); + if (!response.ok) return; + const payload = await response.json(); + directory.value = payload.directory || ""; +} + +async function chooseDirectory() { + footError.value = ""; + const response = await fetch("/api/downloads/pick", { method: "POST" }); + if (!response.ok) { + const payload = await response.json().catch(() => ({})); + const detail = typeof payload.detail === "string" ? payload.detail : ""; + if (detail) footError.value = detail; + return; + } + const payload = await response.json(); + if (payload.directory) directory.value = payload.directory; +} + +async function startDownload() { + const seen = new Set(); + const items: { id: number; title: string; image: string }[] = []; + for (const section of sections.value) { + for (const item of section.items) { + if (!selected.value.has(item.id) || seen.has(item.id)) continue; + seen.add(item.id); + items.push({ id: item.id, title: item.title, image: item.image }); + } + } + if (!items.length) return; + starting.value = true; + footError.value = ""; + try { + const response = await fetch("/api/downloads", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: props.token, device: props.device, items }), + }); + const payload = await response.json(); + if (!response.ok) { + throw new Error(typeof payload.detail === "string" ? payload.detail : "加入下载失败"); + } + emit("started"); + } catch (exc) { + footError.value = exc instanceof Error ? exc.message : "加入下载失败"; + } finally { + starting.value = false; + } +} @@ -355,12 +444,15 @@ defineExpose({ reload, busy, find, searchHint, clearSearch }); border-top: 1px solid rgba(255, 255, 255, 0.12); background: #18181c; } +.foot-actions { display: flex; align-items: center; gap: 8px; min-width: 0; } +.foot-dir { max-width: 420px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.foot-error { display: block; padding: 0 20px 8px; text-align: right; } .catalog-error { margin: 8px 20px 12px; } .category { margin-bottom: 8px; } .category-bar { position: sticky; top: 0; - z-index: 1; + z-index: 3; display: flex; align-items: center; gap: 8px; @@ -395,9 +487,35 @@ defineExpose({ reload, busy, find, searchHint, clearSearch }); background: rgba(255, 255, 255, 0.04); cursor: pointer; } -.cover { background: #2a2a2a; } +.cover { position: relative; background: #2a2a2a; } .cover img { display: block; width: 100%; aspect-ratio: 3 / 4; object-fit: cover; } .cover:not(:has(img)) { aspect-ratio: 3 / 4; } +.cover-play-layer { + position: absolute; + inset: 0; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; +} +.cover-play { + position: relative; + pointer-events: auto; + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + border: 0; + border-radius: 50%; + background: rgba(0, 0, 0, 0.55); + color: #fff; + cursor: pointer; +} +.cover-play svg { width: 22px; height: 22px; display: block; } +.cover-play:hover { background: rgba(0, 0, 0, 0.72); } .pick { position: absolute; top: 8px;