feat: 补齐桌面端下载播放,并将窗口改为夜间主题和应用图标

This commit is contained in:
Nixevol
2026-09-27 06:16:50 +08:00
parent f010426f94
commit 1b33898fff
81 changed files with 2529 additions and 232 deletions
+2
View File
@@ -13,3 +13,5 @@ src-tauri/target/
src-tauri/gen/schemas
vendor/webview2/
server/.venv/
download/
tools/ffmpeg/
+11
View File
@@ -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}
+1
View File
@@ -1,3 +1,4 @@
fastapi>=0.115
uvicorn>=0.32
websockets>=14
httpx[socks]>=0.28
+112
View File
@@ -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)
+33
View File
@@ -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
+89
View File
@@ -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"})
+668
View File
@@ -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()
+75
View File
@@ -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)
+58
View File
@@ -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()
+21
View File
@@ -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()
+218
View File
@@ -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)
+4
View File
@@ -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()
+2
View File
@@ -2710,6 +2710,8 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-opener",
"webview2-com",
"windows-core 0.61.2",
]
[[package]]
+4
View File
@@ -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"
+1
View File
@@ -5,6 +5,7 @@
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-destroy",
"opener:default"
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 567 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 536 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 777 B

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1020 B

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 848 B

After

Width:  |  Height:  |  Size: 3.2 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 951 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>RaptDrama</title>
<title>RaptDrama Download Tools</title>
<style>
html, body { height: 100%; margin: 0; background: #101014; color: #f2f2f2; font: 16px/1.5 "Segoe UI", sans-serif; }
body { display: flex; align-items: center; justify-content: center; }
+27
View File
@@ -121,6 +121,32 @@ fn stop_child(handle: &AppHandle) {
}
}
fn disable_browser_chrome(window: &tauri::WebviewWindow) {
#[cfg(windows)]
{
use webview2_com::Microsoft::Web::WebView2::Win32::ICoreWebView2Settings3;
use windows_core::Interface;
let _ = window.with_webview(|webview| unsafe {
let Ok(core) = webview.controller().CoreWebView2() else {
return;
};
let Ok(settings) = core.Settings() else {
return;
};
let _ = settings.SetAreDefaultContextMenusEnabled(false);
let _ = settings.SetAreDevToolsEnabled(false);
let _ = settings.SetIsStatusBarEnabled(false);
if let Ok(settings3) = settings.cast::<ICoreWebView2Settings3>() {
let _ = settings3.SetAreBrowserAcceleratorKeysEnabled(false);
}
});
}
#[cfg(not(windows))]
{
let _ = window;
}
}
fn boot(handle: &AppHandle, window: &tauri::WebviewWindow) -> Result<(), String> {
let root = repo_root();
let mut last_error = "后台启动超时".to_string();
@@ -163,6 +189,7 @@ pub fn run() {
let window = handle
.get_webview_window("main")
.expect("主窗口");
disable_browser_chrome(&window);
std::thread::spawn(move || {
if let Err(message) = boot(&handle, &window) {
eprintln!("后台启动失败:{message}");
+9 -3
View File
@@ -19,9 +19,15 @@
"windows": [
{
"label": "main",
"title": "RaptDrama",
"width": 800,
"height": 600
"title": "RaptDrama Download Tools",
"width": 1280,
"height": 800,
"minWidth": 1024,
"minHeight": 680,
"center": true,
"theme": "Dark",
"backgroundColor": "#101014",
"devtools": false
}
],
"security": {
+2 -2
View File
@@ -2,9 +2,9 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RaptDrama</title>
<title>RaptDrama Download Tools</title>
</head>
<body>
+7
View File
@@ -10,6 +10,7 @@
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"hls.js": "^1.7.3",
"naive-ui": "^2.45.3",
"vue": "^3.5.13"
},
@@ -824,6 +825,12 @@
"node": ">=12.0.0"
}
},
"node_modules/hls.js": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.7.3.tgz",
"integrity": "sha512-MsPlx6yVW4Qv4C7mEVou4/gk/5cN2dVxOTnrNjPSmyH2f0Ln+/UkG10Ax1PE6OvrmTVPKgQYozTtBaPOuCzq7A==",
"license": "Apache-2.0"
},
"node_modules/js-tokens": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
+1
View File
@@ -11,6 +11,7 @@
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"hls.js": "^1.7.3",
"naive-ui": "^2.45.3",
"vue": "^3.5.13"
},
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

+488 -187
View File
@@ -1,6 +1,10 @@
<script setup lang="ts">
import { darkTheme, dateZhCN, zhCN, type GlobalThemeOverrides } from "naive-ui";
import { getCurrentWindow } from "@tauri-apps/api/window";
import VideoListPage from "./views/VideoListPage.vue";
import DownloadProgressPage from "./views/DownloadProgressPage.vue";
import SettingsPanel from "./views/SettingsPanel.vue";
import PlayerPage from "./views/PlayerPage.vue";
type Method = "guest" | "password" | "adb";
@@ -80,20 +84,76 @@ const busy = ref(false);
const error = ref("");
const token = ref("");
const profile = ref<Profile | null>(null);
const page = ref<"download" | "profile">("download");
const page = ref<"download" | "profile" | "progress" | "settings" | "play">("download");
const playing = ref<{ id: number; title: string; image: string } | null>(null);
const progressCount = ref(0);
interface LiveJob {
id: string;
title: string;
image: string;
status: string;
episode_count: number;
done_count: number;
progress: number;
speed: string;
stage: string;
completed_at: string;
error: string;
}
const downloadState = ref<{ active: LiveJob[]; done: LiveJob[]; active_count: number }>({
active: [],
done: [],
active_count: 0,
});
const downloadRef = ref<{
reload: (refresh?: boolean) => void;
busy: boolean;
find: (keyword?: string) => void;
searchHint: string;
clearSearch: () => void;
loadDirectory: () => Promise<void>;
} | null>(null);
const catalogQuery = ref("");
const mainMenu = computed(() => [
{ label: "视频清单", key: "download" },
{
key: "progress",
label: () =>
h("span", { class: "nav-label" }, [
"下载进度",
progressCount.value
? h("span", { class: "nav-badge" }, String(progressCount.value))
: null,
]),
},
{ label: "系统设置", key: "settings" },
]);
const pageTitle = computed(() => {
if (page.value === "play") return playing.value?.title || "播放";
if (page.value === "progress") return "下载进度";
if (page.value === "profile") return "个人信息";
if (page.value === "settings") return "系统设置";
return "视频清单";
});
const menuValue = computed(() => {
if (page.value === "profile") return null;
if (page.value === "play") return "download";
return page.value;
});
function openPlay(drama: { id: number; title: string; image: string }) {
playing.value = drama;
page.value = "play";
}
function closePlay() {
page.value = "download";
playing.value = null;
}
watch(catalogQuery, (value) => {
if (!String(value ?? "").trim()) downloadRef.value?.clearSearch();
});
let profileTimer = 0;
let profilePulling = false;
let liveSocket: WebSocket | null = null;
const builtinDevice = ref<DeviceProfile>({
brand: "Redmi",
model: "23113RKC6C",
@@ -110,10 +170,14 @@ const settings = ref({
adbMode: "device" as "device" | "settings",
custom: { ...builtinDevice.value },
proxy: emptyProxy(),
workers: 2,
directory: "",
defaultDirectory: "",
});
const settingsOpen = ref(false);
const settingsError = ref("");
const settingsTab = ref<"device" | "proxy">("device");
const settingsBusy = ref(false);
const settingsOpen = ref(false);
const settingsTab = ref<"device" | "proxy" | "download">("device");
const draft = ref(copySettings(settings.value));
const rememberPassword = ref(true);
const savedAccounts = ref<SavedAccount[]>([]);
@@ -125,44 +189,24 @@ const methods: { id: Method; label: string }[] = [
{ id: "adb", label: "ADB" },
];
const fields: { key: keyof Profile; label: string; format?: (value: unknown) => string }[] = [
{ key: "nickname", label: "昵称" },
const accountFields: { key: keyof Profile; label: string }[] = [
{ key: "email", label: "邮箱" },
{ key: "id", label: "用户 ID" },
{ key: "badge", label: "徽章" },
{ key: "vipday", label: "VIP 天数" },
];
const statFields: { key: keyof Profile; label: string }[] = [
{ key: "vipday", label: "VIP" },
{ key: "score", label: "积分" },
{ key: "watched", label: "观看" },
{ key: "rewards", label: "奖励" },
{ key: "login_day", label: "登录天数" },
{ key: "email_bind", label: "邮箱绑定", format: yesNo },
{ key: "mobile_bind", label: "手机绑定", format: yesNo },
{ key: "google_bind", label: "Google 绑定", format: yesNo },
{ key: "apple_bind", label: "Apple 绑定", format: yesNo },
{ key: "login_day", label: "登录" },
];
const deviceFields: { key: keyof DeviceProfile; label: string; kind?: "select" | "switch" | "language" }[] = [
{ key: "brand", label: "品牌" },
{ key: "model", label: "型号" },
{ key: "os_version", label: "系统版本" },
{ key: "device_type", label: "设备类型", kind: "select" },
{ key: "physical", label: "真机", kind: "switch" },
{ key: "device_id", label: "设备 ID" },
{ key: "fingerprint", label: "指纹" },
{ key: "app_version", label: "App 版本" },
{ key: "app_build", label: "App 构建" },
{ key: "language", label: "语言", kind: "language" },
];
const languageOptions = [
{ label: "English", value: "en-US" },
{ label: "繁體中文", value: "zh-TW" },
{ label: "ภาษาไทย", value: "th-TH" },
{ label: "한국어", value: "ko-KR" },
{ label: "日本語", value: "ja-JP" },
{ label: "हिन्दी", value: "hi-IN" },
{ label: "Bahasa Melayu (Malaysia)", value: "ms-MY" },
{ label: "Português (Brasil)", value: "pt-BR" },
const bindFields: { key: keyof Profile; label: string }[] = [
{ key: "email_bind", label: "邮箱" },
{ key: "mobile_bind", label: "手机" },
{ key: "google_bind", label: "Google" },
{ key: "apple_bind", label: "Apple" },
];
function copySettings<T>(value: T): T {
@@ -195,12 +239,37 @@ async function loadDeviceDefaults() {
builtinDevice.value = { ...builtinDevice.value, ...(await response.json()) };
}
function openSettings() {
function normalizeWorkers(value: unknown) {
const count = Number(value);
if (!Number.isInteger(count) || count < 1 || count > 8) return 2;
return count;
}
async function loadDownloadSettings() {
const response = await fetch("/api/downloads/settings");
if (!response.ok) return;
const payload = await response.json();
settings.value.workers = normalizeWorkers(payload.workers);
settings.value.directory = typeof payload.directory === "string" ? payload.directory : "";
settings.value.defaultDirectory = typeof payload.default_directory === "string" ? payload.default_directory : "";
}
async function enterSettings() {
settingsError.value = "";
await loadDownloadSettings();
draft.value = copySettings(settings.value);
}
async function openSettings() {
settingsTab.value = "device";
await enterSettings();
settingsOpen.value = true;
}
watch(page, (value) => {
if (value === "settings") void enterSettings();
});
// 打开页面时把本机保存的代理推给后端,避免进程重启后丢失。
async function applyProxy(proxy: ProxySettings) {
const response = await fetch("/api/proxy", {
@@ -219,33 +288,85 @@ async function saveSettings() {
const proxy = draft.value.proxy;
if (proxy.mode === "custom") {
if (!proxy.host.trim()) {
settingsTab.value = "proxy";
settingsError.value = "请填写代理地址";
return;
}
const port = Number(proxy.port);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
settingsTab.value = "proxy";
settingsError.value = "请填写 1 到 65535 的端口";
return;
}
}
try {
await applyProxy(proxy);
} catch (exc) {
settingsError.value = exc instanceof Error ? exc.message : "代理设置失败";
const workers = normalizeWorkers(draft.value.workers);
if (workers !== Number(draft.value.workers)) {
settingsTab.value = "download";
settingsError.value = "同时下载数量需要在 1 到 8 之间";
return;
}
const directory = draft.value.directory.trim();
if (!directory) {
settingsTab.value = "download";
settingsError.value = "请选择下载目录";
return;
}
settingsBusy.value = true;
try {
await applyProxy(proxy);
const response = await fetch("/api/downloads/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ directory, workers }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
settingsTab.value = "download";
settingsError.value = typeof payload.detail === "string" ? payload.detail : "下载设置失败";
return;
}
draft.value.directory = payload.directory || directory;
draft.value.workers = normalizeWorkers(payload.workers);
draft.value.defaultDirectory = payload.default_directory || draft.value.defaultDirectory;
settings.value = copySettings(draft.value);
localStorage.setItem(DEVICE_KEY, JSON.stringify(settings.value));
localStorage.setItem(DEVICE_KEY, JSON.stringify({
adbMode: settings.value.adbMode,
custom: settings.value.custom,
proxy: settings.value.proxy,
}));
await downloadRef.value?.loadDirectory();
settingsOpen.value = false;
} catch (exc) {
settingsError.value = exc instanceof Error ? exc.message : "设置保存失败";
} finally {
settingsBusy.value = false;
}
}
function setDraftField(key: keyof DeviceProfile, value: string | boolean) {
draft.value.custom = { ...draft.value.custom, [key]: value };
async function chooseDownloadDirectory() {
settingsError.value = "";
const response = await fetch("/api/downloads/choose", { method: "POST" });
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
settingsError.value = typeof payload.detail === "string" ? payload.detail : "目录选择失败";
return;
}
if (typeof payload.directory === "string" && payload.directory) draft.value.directory = payload.directory;
}
function restoreDevice() {
function restoreCurrent() {
settingsError.value = "";
if (settingsTab.value === "device") {
draft.value.custom = { ...builtinDevice.value };
draft.value.adbMode = "device";
return;
}
if (settingsTab.value === "proxy") {
draft.value.proxy = emptyProxy();
return;
}
draft.value.workers = 2;
draft.value.directory = draft.value.defaultDirectory || settings.value.defaultDirectory;
}
function settingsDevice(): DeviceProfile {
@@ -328,14 +449,19 @@ function saveAccount(info: Profile) {
selectedAccount.value = next.email;
}
function yesNo(value: unknown) {
return Number(value) ? "已绑定" : "未绑定";
function profileText(key: keyof Profile) {
const value = profile.value?.[key];
if (value === undefined || value === null || value === "") return "—";
return String(value);
}
function textOf(field: (typeof fields)[number]) {
const value = profile.value?.[field.key];
if (value === undefined || value === null || value === "") return "—";
return field.format ? field.format(value) : String(value);
function profileNumber(key: keyof Profile) {
const value = Number(profile.value?.[key] ?? 0);
return Number.isFinite(value) ? String(value) : "0";
}
function profileBound(key: keyof Profile) {
return Number(profile.value?.[key] ?? 0) > 0;
}
function deviceLabel(item: Device) {
@@ -411,7 +537,7 @@ async function login() {
sessionStorage.setItem("rapt-token", payload.token);
rememberSessionDevice(payload.device);
page.value = "download";
startProfileWatch();
startLive();
if (method.value === "password" && rememberPassword.value) saveAccount(payload.profile || {});
} catch (exc) {
error.value = exc instanceof Error ? exc.message : "登录失败";
@@ -427,36 +553,48 @@ const avatarSrc = computed(() => {
return `https://apis.raptdrama.com${image.startsWith("/") ? image : `/${image}`}`;
});
function startProfileWatch() {
stopProfileWatch();
profileTimer = window.setInterval(() => {
void pullProfile();
}, 3000);
}
let liveRetry = 0;
function stopProfileWatch() {
if (profileTimer) window.clearInterval(profileTimer);
profileTimer = 0;
}
async function pullProfile() {
if (!token.value || profilePulling) return;
profilePulling = true;
try {
const response = await fetch("/api/profile");
if (!response.ok) return;
const payload = await response.json();
if (payload.profile) profile.value = payload.profile;
} catch {
// 保留上一份资料,下一轮再读缓存
} finally {
profilePulling = false;
function applyLive(payload: { downloads?: { active: LiveJob[]; done: LiveJob[]; active_count?: number }; profile?: Profile | null }) {
if (payload.downloads) {
downloadState.value = {
active: payload.downloads.active || [],
done: payload.downloads.done || [],
active_count: payload.downloads.active_count || 0,
};
progressCount.value = payload.downloads.active_count || 0;
}
if (payload.profile) profile.value = payload.profile;
}
function startLive() {
stopLive();
const protocol = location.protocol === "https:" ? "wss" : "ws";
const socket = new WebSocket(`${protocol}://${location.host}/api/live`);
liveSocket = socket;
socket.onmessage = (event) => {
applyLive(JSON.parse(event.data));
};
socket.onclose = () => {
if (liveSocket !== socket || !token.value) return;
liveRetry = window.setTimeout(() => startLive(), 1000);
};
}
function stopLive() {
if (liveRetry) window.clearTimeout(liveRetry);
liveRetry = 0;
if (liveSocket) {
liveSocket.onclose = null;
liveSocket.close();
liveSocket = null;
}
progressCount.value = 0;
}
async function logout() {
const current = token.value;
stopProfileWatch();
stopLive();
token.value = "";
profile.value = null;
page.value = "download";
@@ -474,7 +612,51 @@ async function logout() {
}
}
const closeAsk = ref(false);
const closeCount = ref(0);
let allowClose = false;
function inTauri() {
return "__TAURI_INTERNALS__" in window;
}
async function watchClose() {
if (!inTauri()) return;
const win = getCurrentWindow();
await win.onCloseRequested((event) => {
if (allowClose) return;
event.preventDefault();
void askClose(win);
});
}
async function askClose(win: ReturnType<typeof getCurrentWindow>) {
try {
const response = await fetch("/api/downloads");
const payload = await response.json();
const count = Number(payload.active_count || 0);
if (count > 0) {
closeCount.value = count;
closeAsk.value = true;
return;
}
} catch {
// 后台不可用时直接关闭
}
allowClose = true;
await win.destroy();
}
async function confirmQuit() {
closeAsk.value = false;
allowClose = true;
if (!inTauri()) return;
await getCurrentWindow().destroy();
}
onMounted(async () => {
document.title = "RaptDrama Download Tools";
void watchClose();
await loadDeviceDefaults();
loadSettings();
try {
@@ -500,10 +682,10 @@ onMounted(async () => {
profile.value = payload.profile;
rememberSessionDevice(payload.device);
page.value = "download";
startProfileWatch();
startLive();
});
onUnmounted(stopProfileWatch);
onUnmounted(stopLive);
</script>
<template>
@@ -512,9 +694,9 @@ onUnmounted(stopProfileWatch);
<n-layout-sider bordered :width="208" content-style="height: 100%; padding: 0">
<div class="sider">
<n-menu
:value="page === 'download' ? 'download' : null"
:options="[{ label: '视频清单', key: 'download' }]"
@update:value="page = 'download'"
:value="menuValue"
:options="mainMenu"
@update:value="page = $event"
/>
<div class="user-nav" :class="{ active: page === 'profile' }">
<button type="button" class="user-nav-main" @click="page = 'profile'">
@@ -540,7 +722,10 @@ onUnmounted(stopProfileWatch);
</n-layout-sider>
<n-layout class="main-pane">
<n-layout-header bordered class="bar">
<n-text strong>{{ page === "download" ? "视频清单" : "个人信息" }}</n-text>
<div class="bar-title">
<n-button v-if="page === 'play'" quaternary size="small" @click="closePlay">返回</n-button>
<n-text strong class="bar-title-text">{{ pageTitle }}</n-text>
</div>
<div v-if="page === 'download'" class="bar-search">
<n-input
v-model:value="catalogQuery"
@@ -553,6 +738,10 @@ onUnmounted(stopProfileWatch);
<n-text v-if="downloadRef?.searchHint" class="search-hint" depth="3">{{ downloadRef.searchHint }}</n-text>
</div>
<div class="bar-actions">
<template v-if="page === 'settings'">
<n-button secondary :disabled="settingsBusy" @click="restoreCurrent">还原设置</n-button>
<n-button type="primary" :loading="settingsBusy" @click="saveSettings">保存</n-button>
</template>
<n-button v-if="page === 'download'" secondary :loading="downloadRef?.busy" @click="downloadRef?.reload(true)">刷新</n-button>
</div>
</n-layout-header>
@@ -562,11 +751,31 @@ onUnmounted(stopProfileWatch);
ref="downloadRef"
:token="token"
:device="sessionDevice()"
@started="page = 'progress'"
@play="openPlay"
/>
<PlayerPage
v-if="page === 'play' && playing"
:token="token"
:device="sessionDevice()"
:drama-id="playing.id"
/>
<DownloadProgressPage v-show="page === 'progress'" :active="downloadState.active" :done="downloadState.done" />
<div v-show="page === 'settings'" class="settings-page">
<n-alert v-if="settingsError" class="hint" type="error" :show-icon="false">{{ settingsError }}</n-alert>
<SettingsPanel v-model:tab="settingsTab" :draft="draft" @choose="chooseDownloadDirectory" />
</div>
<div v-show="page === 'profile'" class="profile-page">
<div class="profile-sheet">
<div class="profile-head">
<n-avatar round :size="72" :src="avatarSrc" />
<n-text class="profile-name" strong>{{ profile.nickname || "未命名" }}</n-text>
<n-avatar round :size="64" :src="avatarSrc" />
<div class="profile-id">
<div class="profile-name-row">
<span class="profile-name">{{ profile.nickname || "未命名" }}</span>
<n-tag v-if="profile.badge" size="small" :bordered="false" type="success">{{ profile.badge }}</n-tag>
</div>
<span class="profile-sub">{{ profile.email || "未绑定邮箱" }}</span>
</div>
<n-tooltip placement="bottom">
<template #trigger>
<n-button quaternary circle aria-label="退出" @click="logout">
@@ -582,17 +791,33 @@ onUnmounted(stopProfileWatch);
退出
</n-tooltip>
</div>
<n-descriptions label-placement="left" :column="1" size="small" label-style="width: 96px">
<n-descriptions-item v-for="field in fields" :key="field.key" :label="field.label">
{{ textOf(field) }}
</n-descriptions-item>
</n-descriptions>
<div class="profile-stats">
<div v-for="item in statFields" :key="item.key" class="profile-stat">
<span class="profile-stat-value">{{ profileNumber(item.key) }}</span>
<span class="profile-stat-label">{{ item.label }}</span>
</div>
</div>
<div class="profile-block">
<div class="profile-block-title">账号</div>
<div v-for="item in accountFields" :key="item.key" class="profile-row">
<span class="profile-row-label">{{ item.label }}</span>
<span class="profile-row-value">{{ profileText(item.key) }}</span>
</div>
</div>
<div class="profile-block">
<div class="profile-block-title">绑定</div>
<div class="profile-binds">
<span v-for="item in bindFields" :key="item.key" class="profile-bind" :class="{ on: profileBound(item.key) }">{{ item.label }}</span>
</div>
</div>
</div>
</div>
</n-layout-content>
</n-layout>
</n-layout>
<n-layout v-else class="shell" position="absolute">
<n-layout-content content-style="padding: 28px 20px">
<n-layout v-else class="shell login-shell" position="absolute">
<n-layout-content>
<div class="login-center">
<div class="card">
<n-tabs v-model:value="method" type="segment" size="small">
<n-tab v-for="item in methods" :key="item.id" :name="item.id">{{ item.label }}</n-tab>
@@ -633,13 +858,14 @@ onUnmounted(stopProfileWatch);
</n-form-item>
<n-text v-if="method === 'guest'" class="hint" depth="3">将注册一个新的游客账号,并读取它的个人信息。</n-text>
<n-text v-if="method === 'adb' && !devicesBusy && devices.length === 0" class="hint" depth="3">没有检测到已连接的设备。</n-text>
<n-text v-if="method === 'adb'" class="hint" depth="3">从已 root 的设备读取当前登录账号。未 root 的设备无法获取登录信息。默认同时读取这台设备的品牌、型号和系统版本,可在设置里修改。</n-text>
<n-text v-if="method === 'adb'" class="hint" depth="3">从已 root 的设备读取当前登录账号。未 root 的设备无法获取登录信息。默认同时读取这台设备的品牌、型号和系统版本,可在右下角设置里修改。</n-text>
<n-alert v-if="error" class="hint" type="error" :show-icon="false">{{ error }}</n-alert>
<div class="actions">
<div class="login-actions">
<n-button type="primary" attr-type="submit" :loading="busy">登录</n-button>
</div>
</n-form>
</div>
</div>
</n-layout-content>
</n-layout>
<n-tooltip v-if="!profile" placement="left">
@@ -657,97 +883,50 @@ onUnmounted(stopProfileWatch);
</template>
设置
</n-tooltip>
<n-modal v-model:show="settingsOpen" preset="card" title="设置" style="width: 560px">
<n-tabs v-model:value="settingsTab" type="line" size="small">
<n-tab-pane name="device" tab="设备信息" display-directive="if">
<div class="settings-body">
<n-form label-placement="left" label-width="96" size="small" :show-feedback="false">
<n-form-item v-for="field in deviceFields" :key="field.key" :label="field.label">
<n-switch
v-if="field.kind === 'switch'"
:value="draft.custom.physical"
@update:value="setDraftField('physical', $event)"
/>
<n-select
v-else-if="field.kind === 'select'"
:value="draft.custom.device_type"
:options="[
{ label: '手机', value: 'phone' },
{ label: '平板', value: 'pad' },
]"
@update:value="setDraftField('device_type', $event)"
/>
<n-select
v-else-if="field.kind === 'language'"
:value="draft.custom.language"
:options="languageOptions"
@update:value="setDraftField('language', $event)"
/>
<n-input
v-else
:value="String(draft.custom[field.key] ?? '')"
@update:value="setDraftField(field.key, $event)"
/>
</n-form-item>
<n-form-item label="ADB">
<n-radio-group v-model:value="draft.adbMode">
<n-radio value="device">从设备读取</n-radio>
<n-radio value="settings">使用上面的设置</n-radio>
</n-radio-group>
</n-form-item>
<n-text class="hint" depth="3">从设备读取时会带上品牌、型号、系统版本、设备 ID 和已安装的 App 版本。指纹和语言仍用当前设置里的值。</n-text>
</n-form>
</div>
</n-tab-pane>
<n-tab-pane name="proxy" tab="代理" display-directive="if">
<div class="settings-body">
<n-form label-placement="left" label-width="96" size="small" :show-feedback="false">
<n-form-item label="方式">
<n-radio-group v-model:value="draft.proxy.mode">
<n-radio value="off">不使用</n-radio>
<n-radio value="system">系统代理</n-radio>
<n-radio value="custom">自定义</n-radio>
</n-radio-group>
</n-form-item>
<template v-if="draft.proxy.mode === 'custom'">
<n-form-item label="协议">
<n-radio-group v-model:value="draft.proxy.scheme">
<n-radio value="http">HTTP</n-radio>
<n-radio value="socks5">SOCKS5</n-radio>
</n-radio-group>
</n-form-item>
<n-form-item label="地址">
<n-input v-model:value="draft.proxy.host" placeholder="代理地址" />
</n-form-item>
<n-form-item label="端口">
<n-input v-model:value="draft.proxy.port" placeholder="1-65535" />
</n-form-item>
<n-form-item label="用户名">
<n-input v-model:value="draft.proxy.username" placeholder="可选" />
</n-form-item>
<n-form-item label="密码">
<n-input v-model:value="draft.proxy.password" type="password" show-password-on="click" placeholder="可选" />
</n-form-item>
</template>
</n-form>
</div>
</n-tab-pane>
</n-tabs>
<n-modal v-model:show="settingsOpen" preset="card" title="设置" style="width: 640px">
<div class="settings-modal-body">
<n-alert v-if="settingsError" class="hint" type="error" :show-icon="false">{{ settingsError }}</n-alert>
<SettingsPanel v-model:tab="settingsTab" :draft="draft" @choose="chooseDownloadDirectory" />
</div>
<template #footer>
<div class="actions">
<n-button v-if="settingsTab === 'device'" @click="restoreDevice">还原设置</n-button>
<span class="actions-spacer"></span>
<n-button @click="settingsOpen = false">取消</n-button>
<n-button type="primary" @click="saveSettings">保存</n-button>
<div class="modal-actions">
<n-button :disabled="settingsBusy" @click="restoreCurrent">还原设置</n-button>
<span class="modal-spacer"></span>
<n-button :disabled="settingsBusy" @click="settingsOpen = false">取消</n-button>
<n-button type="primary" :loading="settingsBusy" @click="saveSettings">保存</n-button>
</div>
</template>
</n-modal>
<n-modal
v-model:show="closeAsk"
preset="dialog"
title="关闭程序"
positive-text="确定"
negative-text="取消"
:mask-closable="false"
@positive-click="confirmQuit"
>
还有 {{ closeCount }} 个未完成的下载,关闭后会中断。确定要关闭吗?
</n-modal>
</n-config-provider>
</template>
<style>
html, body, #app { height: 100%; margin: 0; }
html { color-scheme: dark; }
* {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.28) transparent;
}
*::-webkit-scrollbar { width: 8px; height: 8px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.22);
border-radius: 4px;
}
*::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.36); }
*::-webkit-scrollbar-button { display: none; width: 0; height: 0; }
*::-webkit-scrollbar-corner { background: transparent; }
.app { height: 100%; position: relative; }
.shell .n-layout-scroll-container { overflow-y: auto; }
.shell .main-pane { height: 100%; min-height: 0; }
@@ -814,10 +993,77 @@ html, body, #app { height: 100%; margin: 0; }
object-fit: cover;
object-position: center;
}
.profile-page { height: 100%; overflow: auto; box-sizing: border-box; padding: 28px 20px; }
.profile-head { display: flex; align-items: center; gap: 16px; max-width: 560px; margin: 0 auto 20px; }
.profile-name { flex: 1; min-width: 0; font-size: 18px; line-height: 1.2; }
.profile-page .n-descriptions { max-width: 560px; margin: 0 auto; }
.profile-page { height: 100%; overflow: auto; box-sizing: border-box; padding: 20px; }
.profile-sheet {
max-width: 640px;
margin: 0 auto;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: rgba(255, 255, 255, 0.03);
overflow: hidden;
}
.profile-head { display: flex; align-items: center; gap: 16px; padding: 20px 16px 16px; }
.profile-id { flex: 1; min-width: 0; }
.profile-name-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
.profile-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 18px;
font-weight: 600;
line-height: 1.2;
}
.profile-sub {
display: block;
margin-top: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: rgba(255, 255, 255, 0.45);
font-size: 13px;
}
.profile-stats {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.profile-stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 14px 4px;
}
.profile-stat + .profile-stat { border-left: 1px solid rgba(255, 255, 255, 0.08); }
.profile-stat-value { font-size: 18px; font-weight: 600; line-height: 1.2; }
.profile-stat-label { font-size: 12px; color: rgba(255, 255, 255, 0.45); }
.profile-block { border-top: 1px solid rgba(255, 255, 255, 0.08); }
.profile-block-title { padding: 12px 16px 0; font-size: 13px; color: rgba(255, 255, 255, 0.45); }
.profile-row { display: flex; align-items: center; gap: 12px; min-height: 40px; padding: 8px 16px; }
.profile-row-label { width: 72px; flex: none; color: rgba(255, 255, 255, 0.72); }
.profile-row-value {
flex: 1;
min-width: 0;
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-binds { display: flex; flex-wrap: wrap; gap: 8px; padding: 10px 16px 16px; }
.profile-bind {
min-width: 72px;
height: 28px;
padding: 0 10px;
border-radius: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.45);
font-size: 13px;
}
.profile-bind.on { background: rgba(99, 226, 183, 0.16); color: #63e2b7; }
.logout-icon {
width: 18px;
height: 18px;
@@ -833,6 +1079,8 @@ html, body, #app { height: 100%; margin: 0; }
display: flex; align-items: center; justify-content: space-between;
height: auto; padding: 12px 20px;
}
.bar-title { display: flex; align-items: center; gap: 8px; min-width: 0; }
.bar-title-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bar-search {
position: absolute;
left: 50%;
@@ -844,18 +1092,71 @@ html, body, #app { height: 100%; margin: 0; }
}
.search-hint { min-width: 2.5em; }
.bar-actions { display: flex; align-items: center; gap: 8px; }
.catalog-query { width: 220px; }
.card { max-width: 560px; margin: 0 auto; }
.nav-label { display: inline-flex; align-items: center; gap: 8px; }
.nav-badge {
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: #63e2b7;
color: #101014;
font-size: 12px;
line-height: 18px;
text-align: center;
}
.login-shell,
.login-shell > .n-layout-scroll-container,
.login-shell .n-layout-content,
.login-shell .n-layout-content > .n-layout-scroll-container { height: 100%; }
.login-center {
height: 100%;
display: flex;
align-items: safe center;
justify-content: center;
box-sizing: border-box;
padding: 28px 20px;
}
.card { width: 100%; max-width: 560px; }
.login-actions { display: flex; justify-content: center; margin-top: 16px; }
.login-actions .n-button { min-width: 180px; }
.settings-button { position: fixed; right: 20px; bottom: 20px; z-index: 5; }
.gear-icon { width: 18px; height: 18px; display: block; }
.settings-modal-body { max-height: min(58vh, 520px); overflow: auto; padding-right: 4px; }
.modal-actions { display: flex; align-items: center; gap: 8px; }
.modal-spacer { flex: 1; }
.card .n-tabs { margin-bottom: 16px; }
.card .n-form-item { margin-bottom: 16px; }
.card .n-avatar { margin-bottom: 16px; }
.device-line { display: flex; gap: 8px; width: 100%; align-items: center; }
.device-line .n-select { flex: 1; min-width: 0; }
.device-line .n-select,
.device-line .n-input { flex: 1; min-width: 0; }
.hint { display: block; margin: 8px 0; }
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
.actions-spacer { flex: 1; }
.settings-button { position: fixed; right: 20px; bottom: 20px; z-index: 5; }
.gear-icon { width: 18px; height: 18px; display: block; }
.settings-body { max-height: min(58vh, 520px); overflow: auto; padding-right: 4px; }
.settings-body .n-form-item { margin-bottom: 16px; }
.settings-page {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
box-sizing: border-box;
padding: 8px 20px 20px;
}
.settings-page > .n-tabs { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.settings-page .n-tabs-pane-wrapper { flex: 1; min-height: 0; overflow: auto; }
.settings-form { max-width: 640px; padding-top: 8px; }
.settings-form .n-form-item { margin-bottom: 16px; }
.field-label { display: inline-flex; align-items: center; gap: 6px; }
.worker-input { width: 148px; }
.worker-input .n-input__input-el { text-align: center; }
.help-dot {
width: 16px;
height: 16px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.28);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
line-height: 1;
color: rgba(255, 255, 255, 0.65);
cursor: help;
}
</style>
+3 -2
View File
@@ -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']
}
+13
View File
@@ -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");
+175
View File
@@ -0,0 +1,175 @@
<script setup lang="ts">
interface Job {
id: string;
title: string;
image: string;
status: string;
episode_count: number;
done_count: number;
progress: number;
speed: string;
stage: string;
completed_at: string;
error: string;
}
const props = defineProps<{
active: Job[];
done: Job[];
}>();
const tab = ref<"active" | "done">("active");
const holding = ref(false);
const resumeAll = computed(
() => props.active.length > 0 && props.active.every((job) => job.status === "paused" || job.status === "failed"),
);
function coverOf(image: string) {
if (!image) return "";
if (image.startsWith("/api/") || image.startsWith("http")) return image;
return `https://apis.raptdrama.com${image.startsWith("/") ? image : `/${image}`}`;
}
async function holdAll() {
if (!props.active.length || holding.value) return;
holding.value = true;
try {
await fetch(resumeAll.value ? "/api/downloads/resume-all" : "/api/downloads/pause-all", { method: "POST" });
} finally {
holding.value = false;
}
}
async function act(id: string, action: string) {
await fetch(`/api/downloads/${id}/${action}`, { method: "POST" });
}
async function remove(id: string) {
await fetch(`/api/downloads/${id}`, { method: "DELETE" });
}
</script>
<template>
<div class="progress-page">
<n-tabs v-model:value="tab" type="line" size="small">
<template #suffix>
<n-button
v-if="tab === 'active'"
class="bulk-hold"
size="small"
secondary
:disabled="!props.active.length"
:loading="holding"
@click="holdAll"
>
<template #icon>
<svg v-if="resumeAll" class="job-icon" viewBox="0 0 24 24"><path fill="currentColor" d="M8 5v14l11-7z" /></svg>
<svg v-else class="job-icon" viewBox="0 0 24 24"><path fill="currentColor" d="M6 5h4v14H6zm8 0h4v14h-4z" /></svg>
</template>
{{ resumeAll ? "继续" : "暂停" }}
</n-button>
</template>
<n-tab-pane name="active" :tab="`正在下载${props.active.length ? ` ${props.active.length}` : ''}`">
<div v-if="!props.active.length" class="progress-empty">没有正在下载的剧</div>
<article v-for="job in props.active" :key="job.id" class="job">
<img v-if="coverOf(job.image)" class="job-cover" :src="coverOf(job.image)" alt="" />
<div v-else class="job-cover"></div>
<div class="job-main">
<div class="job-line">
<n-text strong class="job-title">{{ job.title }}</n-text>
<n-text depth="3">{{ job.done_count }}/{{ job.episode_count }} 集</n-text>
<n-text v-if="job.stage" depth="3">{{ job.stage }}</n-text>
<n-text v-else-if="job.speed" depth="3">{{ job.speed }}</n-text>
<n-text v-else-if="job.status === 'paused'" depth="3">已暂停</n-text>
<n-text v-else-if="job.status === 'failed'" depth="3">失败</n-text>
<n-text v-else-if="job.status === 'queued'" depth="3">排队中</n-text>
</div>
<n-progress type="line" :percentage="job.progress" :show-indicator="false" :height="6" />
<n-text v-if="job.error" class="job-error" type="error">{{ job.error }}</n-text>
</div>
<div class="job-actions">
<n-tooltip v-if="job.status === 'paused' || job.status === 'failed'">
<template #trigger>
<n-button quaternary circle aria-label="继续" @click="act(job.id, 'resume')">
<template #icon><svg class="job-icon" viewBox="0 0 24 24"><path fill="currentColor" d="M8 5v14l11-7z" /></svg></template>
</n-button>
</template>
继续
</n-tooltip>
<n-tooltip v-else>
<template #trigger>
<n-button quaternary circle aria-label="暂停" @click="act(job.id, 'pause')">
<template #icon><svg class="job-icon" viewBox="0 0 24 24"><path fill="currentColor" d="M6 5h4v14H6zm8 0h4v14h-4z" /></svg></template>
</n-button>
</template>
暂停
</n-tooltip>
<n-tooltip>
<template #trigger>
<n-button quaternary circle aria-label="取消" @click="act(job.id, 'cancel')">
<template #icon><svg class="job-icon" viewBox="0 0 24 24"><path fill="currentColor" d="M18.3 5.7 12 12l6.3 6.3-1.4 1.4L10.6 13.4 4.3 19.7 2.9 18.3 9.2 12 2.9 5.7 4.3 4.3l6.3 6.3 6.3-6.3z" /></svg></template>
</n-button>
</template>
取消
</n-tooltip>
</div>
</article>
</n-tab-pane>
<n-tab-pane name="done" tab="已完成">
<div v-if="!props.done.length" class="progress-empty">还没有完成的下载</div>
<article v-for="job in props.done" :key="job.id" class="job">
<img v-if="coverOf(job.image)" class="job-cover" :src="coverOf(job.image)" alt="" />
<div v-else class="job-cover"></div>
<div class="job-main">
<div class="job-line">
<n-text strong class="job-title">{{ job.title }}</n-text>
<n-text depth="3">{{ job.episode_count }} 集</n-text>
</div>
<n-text depth="3">{{ job.completed_at }}</n-text>
</div>
<div class="job-actions">
<n-tooltip>
<template #trigger>
<n-button quaternary circle aria-label="打开目录" @click="act(job.id, 'open')">
<template #icon><svg class="job-icon" viewBox="0 0 24 24"><path fill="currentColor" d="M10 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8z" /></svg></template>
</n-button>
</template>
打开目录
</n-tooltip>
<n-tooltip>
<template #trigger>
<n-button quaternary circle aria-label="删除" @click="remove(job.id)">
<template #icon><svg class="job-icon" viewBox="0 0 24 24"><path fill="currentColor" d="M9 3h6l1 2h4v2H4V5h4zm1 6h2v8h-2zm4 0h2v8h-2zM7 9h2v8H7z" /></svg></template>
</n-button>
</template>
删除
</n-tooltip>
</div>
</article>
</n-tab-pane>
</n-tabs>
</div>
</template>
<style>
.progress-page { height: 100%; min-height: 0; display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box; padding: 8px 20px 20px; }
.progress-page > .n-tabs { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.progress-page .n-tabs-pane-wrapper { flex: 1; min-height: 0; overflow: auto; }
.bulk-hold { margin-left: 12px; }
.progress-empty { padding: 28px 0; color: rgba(255, 255, 255, 0.45); }
.job {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.job-cover { width: 48px; height: 64px; object-fit: cover; border-radius: 6px; background: #2a2a2a; flex: none; }
.job-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
.job-line { display: flex; align-items: baseline; gap: 10px; min-width: 0; }
.job-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.job-actions { display: flex; gap: 4px; flex: none; }
.job-icon { width: 18px; height: 18px; display: block; }
.job-error { font-size: 12px; }
</style>
+178
View File
@@ -0,0 +1,178 @@
<script setup lang="ts">
import Hls from "hls.js";
interface Episode {
cid: number;
idx: number;
title: string;
playable: boolean;
}
const props = defineProps<{
token: string;
device: object | null;
dramaId: number;
}>();
const episodes = ref<Episode[]>([]);
const current = ref<number | null>(null);
const loading = ref(false);
const error = ref("");
const videoRef = ref<HTMLVideoElement | null>(null);
let hls: Hls | null = null;
const currentTitle = computed(() => episodes.value.find((item) => item.cid === current.value)?.title || "");
async function load() {
loading.value = true;
error.value = "";
try {
const response = await fetch("/api/play/episodes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: props.token, device: props.device, vid: props.dramaId }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
error.value = typeof payload.detail === "string" ? payload.detail : "没有读到分集";
return;
}
episodes.value = Array.isArray(payload.episodes) ? payload.episodes : [];
const first = episodes.value.find((item) => item.playable);
if (!first) {
error.value = "缓存和当前账号都没有可播放的集";
return;
}
select(first);
} catch (exc) {
error.value = exc instanceof Error ? exc.message : "没有读到分集";
} finally {
loading.value = false;
}
}
function select(episode: Episode) {
if (!episode.playable || episode.cid === current.value) return;
current.value = episode.cid;
error.value = "";
void nextTick(() => attach(episode.cid));
}
function attach(cid: number) {
const video = videoRef.value;
if (!video) return;
stop();
const url = `/api/play/${props.dramaId}/${cid}/index.m3u8`;
if (Hls.isSupported()) {
const player = new Hls();
hls = player;
player.loadSource(url);
player.attachMedia(video);
player.on(Hls.Events.MANIFEST_PARSED, () => {
video.play().catch(() => undefined);
});
player.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) error.value = "这一集播放失败";
});
return;
}
video.src = url;
video.play().catch(() => undefined);
}
function stop() {
hls?.destroy();
hls = null;
if (videoRef.value) {
videoRef.value.removeAttribute("src");
videoRef.value.load();
}
}
onMounted(load);
onUnmounted(stop);
</script>
<template>
<div class="play-page">
<div class="play-stage">
<div class="play-video-wrap">
<video ref="videoRef" class="play-video" controls playsinline></video>
<div v-if="loading || error" class="play-status">{{ loading ? "正在读取分集" : error }}</div>
</div>
<div v-if="currentTitle" class="play-now">{{ currentTitle }}</div>
</div>
<aside class="play-side">
<div class="play-side-title">选集</div>
<div class="play-episodes">
<button
v-for="episode in episodes"
:key="episode.cid"
type="button"
class="play-episode"
:class="{ current: episode.cid === current }"
:disabled="!episode.playable"
@click="select(episode)"
>
<span class="play-episode-title">{{ episode.title }}</span>
<span v-if="!episode.playable" class="play-episode-lock">无地址</span>
</button>
<div v-if="!loading && episodes.length === 0" class="play-empty">没有分集</div>
</div>
</aside>
</div>
</template>
<style>
.play-page { height: 100%; min-height: 0; display: flex; overflow: hidden; background: #101014; }
.play-stage { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
.play-video-wrap { position: relative; flex: 1; min-height: 0; background: #000; }
.play-video { display: block; width: 100%; height: 100%; background: #000; }
.play-status {
position: absolute;
left: 16px;
right: 16px;
bottom: 56px;
color: rgba(255, 255, 255, 0.8);
text-align: center;
}
.play-now {
flex: none;
padding: 8px 16px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: rgba(255, 255, 255, 0.72);
}
.play-side {
width: 280px;
flex: none;
min-height: 0;
display: flex;
flex-direction: column;
border-left: 1px solid rgba(255, 255, 255, 0.08);
}
.play-side-title { flex: none; padding: 12px 16px 8px; font-weight: 600; }
.play-episodes { flex: 1; min-height: 0; overflow: auto; padding: 0 8px 12px; }
.play-episode {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
min-height: 36px;
margin: 0 0 4px;
padding: 6px 10px;
border: 0;
border-radius: 6px;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.play-episode.current { background: rgba(99, 226, 183, 0.16); }
.play-episode:disabled { color: rgba(255, 255, 255, 0.35); cursor: default; }
.play-episode-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.play-episode-lock { flex: none; font-size: 12px; }
.play-empty { padding: 8px 10px; color: rgba(255, 255, 255, 0.45); }
</style>
+162
View File
@@ -0,0 +1,162 @@
<script setup lang="ts">
interface DeviceProfile {
brand: string;
model: string;
os_version: string;
device_type: string;
physical: boolean;
device_id: string;
fingerprint: string;
app_version: string;
app_build: string;
language: string;
}
interface ProxySettings {
mode: "off" | "system" | "custom";
scheme: "http" | "socks5";
host: string;
port: string;
username: string;
password: string;
}
interface SettingsDraft {
adbMode: "device" | "settings";
custom: DeviceProfile;
proxy: ProxySettings;
workers: number;
directory: string;
}
const tab = defineModel<"device" | "proxy" | "download">("tab", { required: true });
const props = defineProps<{ draft: SettingsDraft }>();
const emit = defineEmits<{ choose: [] }>();
const deviceFields: { key: keyof DeviceProfile; label: string; kind?: "select" | "switch" | "language" }[] = [
{ key: "brand", label: "品牌" },
{ key: "model", label: "型号" },
{ key: "os_version", label: "系统版本" },
{ key: "device_type", label: "设备类型", kind: "select" },
{ key: "physical", label: "真机", kind: "switch" },
{ key: "device_id", label: "设备 ID" },
{ key: "fingerprint", label: "指纹" },
{ key: "app_version", label: "App 版本" },
{ key: "app_build", label: "App 构建" },
{ key: "language", label: "语言", kind: "language" },
];
const languageOptions = [
{ label: "English", value: "en-US" },
{ label: "繁體中文", value: "zh-TW" },
{ label: "ภาษาไทย", value: "th-TH" },
{ label: "한국어", value: "ko-KR" },
{ label: "日本語", value: "ja-JP" },
{ label: "हिन्दी", value: "hi-IN" },
{ label: "Bahasa Melayu (Malaysia)", value: "ms-MY" },
{ label: "Português (Brasil)", value: "pt-BR" },
];
function setDraftField(key: keyof DeviceProfile, value: string | boolean) {
props.draft.custom = { ...props.draft.custom, [key]: value };
}
</script>
<template>
<n-tabs v-model:value="tab" type="line" size="small">
<n-tab-pane name="device" tab="设备信息" display-directive="if">
<n-form class="settings-form" label-placement="left" label-width="96" size="small" :show-feedback="false">
<n-form-item v-for="field in deviceFields" :key="field.key" :label="field.label">
<n-switch
v-if="field.kind === 'switch'"
:value="draft.custom.physical"
@update:value="setDraftField('physical', $event)"
/>
<n-select
v-else-if="field.kind === 'select'"
:value="draft.custom.device_type"
:options="[
{ label: '手机', value: 'phone' },
{ label: '平板', value: 'pad' },
]"
@update:value="setDraftField('device_type', $event)"
/>
<n-select
v-else-if="field.kind === 'language'"
:value="draft.custom.language"
:options="languageOptions"
@update:value="setDraftField('language', $event)"
/>
<n-input
v-else
:value="String(draft.custom[field.key] ?? '')"
@update:value="setDraftField(field.key, $event)"
/>
</n-form-item>
<n-form-item label="ADB">
<n-radio-group v-model:value="draft.adbMode">
<n-radio value="device">从设备读取</n-radio>
<n-radio value="settings">使用上面的设置</n-radio>
</n-radio-group>
</n-form-item>
<n-text class="hint" depth="3">保存后下次登录生效。当前这次登录已经绑定的设备信息不会中途更换。</n-text>
<n-text class="hint" depth="3">从设备读取时会带上品牌、型号、系统版本、设备 ID 和已安装的 App 版本。指纹和语言仍用当前设置里的值。</n-text>
</n-form>
</n-tab-pane>
<n-tab-pane name="proxy" tab="代理" display-directive="if">
<n-form class="settings-form" label-placement="left" label-width="96" size="small" :show-feedback="false">
<n-form-item label="方式">
<n-radio-group v-model:value="draft.proxy.mode">
<n-radio value="off">不使用</n-radio>
<n-radio value="system">系统代理</n-radio>
<n-radio value="custom">自定义</n-radio>
</n-radio-group>
</n-form-item>
<template v-if="draft.proxy.mode === 'custom'">
<n-form-item label="协议">
<n-radio-group v-model:value="draft.proxy.scheme">
<n-radio value="http">HTTP</n-radio>
<n-radio value="socks5">SOCKS5</n-radio>
</n-radio-group>
</n-form-item>
<n-form-item label="地址">
<n-input v-model:value="draft.proxy.host" placeholder="代理地址" />
</n-form-item>
<n-form-item label="端口">
<n-input v-model:value="draft.proxy.port" placeholder="1-65535" />
</n-form-item>
<n-form-item label="用户名">
<n-input v-model:value="draft.proxy.username" placeholder="可选" />
</n-form-item>
<n-form-item label="密码">
<n-input v-model:value="draft.proxy.password" type="password" show-password-on="click" placeholder="可选" />
</n-form-item>
</template>
</n-form>
</n-tab-pane>
<n-tab-pane name="download" tab="下载" display-directive="if">
<n-form class="settings-form" label-placement="left" label-width="96" size="small" :show-feedback="false">
<n-form-item label="下载目录">
<div class="device-line">
<n-input v-model:value="draft.directory" placeholder="下载目录" />
<n-button @click="emit('choose')">选择</n-button>
</div>
</n-form-item>
<n-form-item>
<template #label>
<span class="field-label">
同时下载
<n-tooltip>
<template #trigger>
<span class="help-dot" tabindex="0">?</span>
</template>
同时进行的下载任务数,范围 1 到 8,默认 2。调低后,多出来的任务会在当前这部下完后停下来。
</n-tooltip>
</span>
</template>
<n-input-number v-model:value="draft.workers" class="worker-input" button-placement="both" :min="1" :max="8" :precision="0" />
</n-form-item>
</n-form>
</n-tab-pane>
</n-tabs>
</template>
+122 -4
View File
@@ -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<HTMLElement | null>(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<number>();
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;
}
}
</script>
<template>
@@ -286,14 +344,21 @@ defineExpose({ reload, busy, find, searchHint, clearSearch });
<n-alert v-if="error" class="catalog-error" type="error" :show-icon="false">{{ error }}</n-alert>
<section v-for="section in sections" :key="section.key" class="category">
<div class="category-bar">
<n-tooltip placement="top">
<template #trigger>
<n-button quaternary circle :aria-label="isExpanded(section.key) ? '收起' : '展开'" @click="toggleExpanded(section.key)">
<span class="chevron" :class="{ open: isExpanded(section.key) }">▸</span>
</n-button>
</template>
{{ isExpanded(section.key) ? "收起" : "展开" }}
</n-tooltip>
<n-text strong>{{ section.name }}</n-text>
<span v-if="section.loading" class="category-spin" aria-hidden="true"></span>
<n-text depth="3">{{ section.items.length }} 部</n-text>
<n-text depth="3">已选 {{ selectedCount(section) }}</n-text>
<span class="bar-gap"></span>
<n-tooltip placement="top">
<template #trigger>
<n-checkbox
:checked="allChecked(section)"
:indeterminate="someChecked(section)"
@@ -302,6 +367,9 @@ defineExpose({ reload, busy, find, searchHint, clearSearch });
>
全选
</n-checkbox>
</template>
{{ allChecked(section) ? "取消全选" : "全选" }}
</n-tooltip>
</div>
<div v-if="isExpanded(section.key)" class="category-body">
<n-text v-if="section.note" depth="3">{{ section.note }}</n-text>
@@ -316,9 +384,24 @@ defineExpose({ reload, busy, find, searchHint, clearSearch });
>
<div class="cover">
<img v-if="item.image" :src="coverOf(item.image)" :alt="item.title" />
<div class="cover-play-layer">
<n-tooltip placement="top">
<template #trigger>
<button type="button" class="cover-play" aria-label="播放" @click.stop="emit('play', item)">
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M9 7.5v9l8-4.5z" /></svg>
</button>
</template>
播放
</n-tooltip>
</div>
</div>
<div class="pick" @click.stop>
<n-tooltip placement="left">
<template #trigger>
<n-checkbox :checked="selected.has(item.id)" @update:checked="toggleDrama(item.id, $event)" />
</template>
{{ selected.has(item.id) ? "取消选择" : "选择" }}
</n-tooltip>
</div>
<div class="drama-title">{{ item.title }}</div>
<div
@@ -335,7 +418,13 @@ defineExpose({ reload, busy, find, searchHint, clearSearch });
</div>
<footer class="download-foot">
<n-text>已选 {{ selected.size }} 部</n-text>
<div class="foot-actions">
<n-text class="foot-dir" depth="3" :title="directory">{{ directory || "未设置下载目录" }}</n-text>
<n-button secondary size="small" @click="chooseDirectory">选择</n-button>
<n-button type="primary" size="small" :disabled="selected.size === 0" :loading="starting" @click="startDownload">下载</n-button>
</div>
</footer>
<n-text v-if="footError" class="foot-error" type="error">{{ footError }}</n-text>
</div>
</template>
@@ -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;