feat: 补齐桌面端下载播放,并将窗口改为夜间主题和应用图标
@@ -13,3 +13,5 @@ src-tauri/target/
|
||||
src-tauri/gen/schemas
|
||||
vendor/webview2/
|
||||
server/.venv/
|
||||
download/
|
||||
tools/ffmpeg/
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
fastapi>=0.115
|
||||
uvicorn>=0.32
|
||||
websockets>=14
|
||||
httpx[socks]>=0.28
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"})
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -2710,6 +2710,8 @@ dependencies = [
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-opener",
|
||||
"webview2-com",
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-destroy",
|
||||
"opener:default"
|
||||
]
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 567 B After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 536 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 777 B After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 1020 B After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 7.0 KiB |
|
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>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
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>
|
||||
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 951 B |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 424 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -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; }
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -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>
|
||||
|
||||
@@ -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']
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||