feat: 补齐桌面端下载播放,并将窗口改为夜间主题和应用图标
@@ -13,3 +13,5 @@ src-tauri/target/
|
|||||||
src-tauri/gen/schemas
|
src-tauri/gen/schemas
|
||||||
vendor/webview2/
|
vendor/webview2/
|
||||||
server/.venv/
|
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.catalog import router as catalog_router
|
||||||
from server.routes.covers import router as covers_router
|
from server.routes.covers import router as covers_router
|
||||||
from server.routes.device import router as device_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.proxy import router as proxy_router
|
||||||
from server.routes.session import router as session_router
|
from server.routes.session import router as session_router
|
||||||
|
from server.services.downloads import downloads
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
@@ -28,8 +32,15 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(device_router)
|
app.include_router(device_router)
|
||||||
app.include_router(catalog_router)
|
app.include_router(catalog_router)
|
||||||
app.include_router(covers_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.include_router(proxy_router)
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def start_downloads() -> None:
|
||||||
|
downloads.start()
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health():
|
def health():
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
fastapi>=0.115
|
fastapi>=0.115
|
||||||
uvicorn>=0.32
|
uvicorn>=0.32
|
||||||
|
websockets>=14
|
||||||
httpx[socks]>=0.28
|
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("不是图片")
|
raise RuntimeError("不是图片")
|
||||||
return response.content, media
|
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:
|
def _close_locked(self) -> None:
|
||||||
if self._client is not None:
|
if self._client is not None:
|
||||||
self._client.close()
|
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
|
import threading
|
||||||
|
|
||||||
from server.services.device import DeviceProfile
|
from server.services.device import DeviceProfile
|
||||||
|
from server.services.live import live
|
||||||
|
|
||||||
|
|
||||||
class SessionStore:
|
class SessionStore:
|
||||||
@@ -26,6 +27,7 @@ class SessionStore:
|
|||||||
self._active = token
|
self._active = token
|
||||||
self._profile = dict(profile)
|
self._profile = dict(profile)
|
||||||
self._revision += 1
|
self._revision += 1
|
||||||
|
live.touch()
|
||||||
|
|
||||||
def set_profile(self, token: str, profile: dict) -> bool:
|
def set_profile(self, token: str, profile: dict) -> bool:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -33,6 +35,7 @@ class SessionStore:
|
|||||||
return False
|
return False
|
||||||
self._profile = dict(profile)
|
self._profile = dict(profile)
|
||||||
self._revision += 1
|
self._revision += 1
|
||||||
|
live.touch()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def active_token(self) -> str:
|
def active_token(self) -> str:
|
||||||
@@ -51,6 +54,7 @@ class SessionStore:
|
|||||||
if self._active == token:
|
if self._active == token:
|
||||||
self._active = ""
|
self._active = ""
|
||||||
self._profile = None
|
self._profile = None
|
||||||
|
live.touch()
|
||||||
|
|
||||||
|
|
||||||
sessions = SessionStore()
|
sessions = SessionStore()
|
||||||
|
|||||||
@@ -2710,6 +2710,8 @@ dependencies = [
|
|||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
|
"webview2-com",
|
||||||
|
"windows-core 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ tauri-build = { version = "2", features = [] }
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = [] }
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
|
|
||||||
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
webview2-com = "0.38"
|
||||||
|
windows-core = "0.61"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Capability for the main window",
|
"description": "Capability for the main window",
|
||||||
"windows": ["main"],
|
"windows": ["main"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
|
"core:window:allow-destroy",
|
||||||
"opener:default"
|
"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">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<title>RaptDrama</title>
|
<title>RaptDrama Download Tools</title>
|
||||||
<style>
|
<style>
|
||||||
html, body { height: 100%; margin: 0; background: #101014; color: #f2f2f2; font: 16px/1.5 "Segoe UI", sans-serif; }
|
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; }
|
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> {
|
fn boot(handle: &AppHandle, window: &tauri::WebviewWindow) -> Result<(), String> {
|
||||||
let root = repo_root();
|
let root = repo_root();
|
||||||
let mut last_error = "后台启动超时".to_string();
|
let mut last_error = "后台启动超时".to_string();
|
||||||
@@ -163,6 +189,7 @@ pub fn run() {
|
|||||||
let window = handle
|
let window = handle
|
||||||
.get_webview_window("main")
|
.get_webview_window("main")
|
||||||
.expect("主窗口");
|
.expect("主窗口");
|
||||||
|
disable_browser_chrome(&window);
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
if let Err(message) = boot(&handle, &window) {
|
if let Err(message) = boot(&handle, &window) {
|
||||||
eprintln!("后台启动失败:{message}");
|
eprintln!("后台启动失败:{message}");
|
||||||
|
|||||||
@@ -19,9 +19,15 @@
|
|||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"label": "main",
|
"label": "main",
|
||||||
"title": "RaptDrama",
|
"title": "RaptDrama Download Tools",
|
||||||
"width": 800,
|
"width": 1280,
|
||||||
"height": 600
|
"height": 800,
|
||||||
|
"minWidth": 1024,
|
||||||
|
"minHeight": 680,
|
||||||
|
"center": true,
|
||||||
|
"theme": "Dark",
|
||||||
|
"backgroundColor": "#101014",
|
||||||
|
"devtools": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>RaptDrama</title>
|
<title>RaptDrama Download Tools</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
|
"hls.js": "^1.7.3",
|
||||||
"naive-ui": "^2.45.3",
|
"naive-ui": "^2.45.3",
|
||||||
"vue": "^3.5.13"
|
"vue": "^3.5.13"
|
||||||
},
|
},
|
||||||
@@ -824,6 +825,12 @@
|
|||||||
"node": ">=12.0.0"
|
"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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "10.0.0",
|
"version": "10.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
|
"hls.js": "^1.7.3",
|
||||||
"naive-ui": "^2.45.3",
|
"naive-ui": "^2.45.3",
|
||||||
"vue": "^3.5.13"
|
"vue": "^3.5.13"
|
||||||
},
|
},
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -1,6 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { darkTheme, dateZhCN, zhCN, type GlobalThemeOverrides } from "naive-ui";
|
import { darkTheme, dateZhCN, zhCN, type GlobalThemeOverrides } from "naive-ui";
|
||||||
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import VideoListPage from "./views/VideoListPage.vue";
|
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";
|
type Method = "guest" | "password" | "adb";
|
||||||
|
|
||||||
@@ -80,20 +84,76 @@ const busy = ref(false);
|
|||||||
const error = ref("");
|
const error = ref("");
|
||||||
const token = ref("");
|
const token = ref("");
|
||||||
const profile = ref<Profile | null>(null);
|
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<{
|
const downloadRef = ref<{
|
||||||
reload: (refresh?: boolean) => void;
|
reload: (refresh?: boolean) => void;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
find: (keyword?: string) => void;
|
find: (keyword?: string) => void;
|
||||||
searchHint: string;
|
searchHint: string;
|
||||||
clearSearch: () => void;
|
clearSearch: () => void;
|
||||||
|
loadDirectory: () => Promise<void>;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const catalogQuery = ref("");
|
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) => {
|
watch(catalogQuery, (value) => {
|
||||||
if (!String(value ?? "").trim()) downloadRef.value?.clearSearch();
|
if (!String(value ?? "").trim()) downloadRef.value?.clearSearch();
|
||||||
});
|
});
|
||||||
let profileTimer = 0;
|
let liveSocket: WebSocket | null = null;
|
||||||
let profilePulling = false;
|
|
||||||
const builtinDevice = ref<DeviceProfile>({
|
const builtinDevice = ref<DeviceProfile>({
|
||||||
brand: "Redmi",
|
brand: "Redmi",
|
||||||
model: "23113RKC6C",
|
model: "23113RKC6C",
|
||||||
@@ -110,10 +170,14 @@ const settings = ref({
|
|||||||
adbMode: "device" as "device" | "settings",
|
adbMode: "device" as "device" | "settings",
|
||||||
custom: { ...builtinDevice.value },
|
custom: { ...builtinDevice.value },
|
||||||
proxy: emptyProxy(),
|
proxy: emptyProxy(),
|
||||||
|
workers: 2,
|
||||||
|
directory: "",
|
||||||
|
defaultDirectory: "",
|
||||||
});
|
});
|
||||||
const settingsOpen = ref(false);
|
|
||||||
const settingsError = ref("");
|
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 draft = ref(copySettings(settings.value));
|
||||||
const rememberPassword = ref(true);
|
const rememberPassword = ref(true);
|
||||||
const savedAccounts = ref<SavedAccount[]>([]);
|
const savedAccounts = ref<SavedAccount[]>([]);
|
||||||
@@ -125,44 +189,24 @@ const methods: { id: Method; label: string }[] = [
|
|||||||
{ id: "adb", label: "ADB" },
|
{ id: "adb", label: "ADB" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const fields: { key: keyof Profile; label: string; format?: (value: unknown) => string }[] = [
|
const accountFields: { key: keyof Profile; label: string }[] = [
|
||||||
{ key: "nickname", label: "昵称" },
|
|
||||||
{ key: "email", label: "邮箱" },
|
{ key: "email", label: "邮箱" },
|
||||||
{ key: "id", label: "用户 ID" },
|
{ 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: "score", label: "积分" },
|
||||||
{ key: "watched", label: "观看" },
|
{ key: "watched", label: "观看" },
|
||||||
{ key: "rewards", label: "奖励" },
|
{ key: "rewards", label: "奖励" },
|
||||||
{ key: "login_day", 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 },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const deviceFields: { key: keyof DeviceProfile; label: string; kind?: "select" | "switch" | "language" }[] = [
|
const bindFields: { key: keyof Profile; label: string }[] = [
|
||||||
{ key: "brand", label: "品牌" },
|
{ key: "email_bind", label: "邮箱" },
|
||||||
{ key: "model", label: "型号" },
|
{ key: "mobile_bind", label: "手机" },
|
||||||
{ key: "os_version", label: "系统版本" },
|
{ key: "google_bind", label: "Google" },
|
||||||
{ key: "device_type", label: "设备类型", kind: "select" },
|
{ key: "apple_bind", label: "Apple" },
|
||||||
{ 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 copySettings<T>(value: T): T {
|
function copySettings<T>(value: T): T {
|
||||||
@@ -195,12 +239,37 @@ async function loadDeviceDefaults() {
|
|||||||
builtinDevice.value = { ...builtinDevice.value, ...(await response.json()) };
|
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 = "";
|
settingsError.value = "";
|
||||||
|
await loadDownloadSettings();
|
||||||
draft.value = copySettings(settings.value);
|
draft.value = copySettings(settings.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSettings() {
|
||||||
|
settingsTab.value = "device";
|
||||||
|
await enterSettings();
|
||||||
settingsOpen.value = true;
|
settingsOpen.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(page, (value) => {
|
||||||
|
if (value === "settings") void enterSettings();
|
||||||
|
});
|
||||||
|
|
||||||
// 打开页面时把本机保存的代理推给后端,避免进程重启后丢失。
|
// 打开页面时把本机保存的代理推给后端,避免进程重启后丢失。
|
||||||
async function applyProxy(proxy: ProxySettings) {
|
async function applyProxy(proxy: ProxySettings) {
|
||||||
const response = await fetch("/api/proxy", {
|
const response = await fetch("/api/proxy", {
|
||||||
@@ -219,33 +288,85 @@ async function saveSettings() {
|
|||||||
const proxy = draft.value.proxy;
|
const proxy = draft.value.proxy;
|
||||||
if (proxy.mode === "custom") {
|
if (proxy.mode === "custom") {
|
||||||
if (!proxy.host.trim()) {
|
if (!proxy.host.trim()) {
|
||||||
|
settingsTab.value = "proxy";
|
||||||
settingsError.value = "请填写代理地址";
|
settingsError.value = "请填写代理地址";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const port = Number(proxy.port);
|
const port = Number(proxy.port);
|
||||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||||
|
settingsTab.value = "proxy";
|
||||||
settingsError.value = "请填写 1 到 65535 的端口";
|
settingsError.value = "请填写 1 到 65535 的端口";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
const workers = normalizeWorkers(draft.value.workers);
|
||||||
await applyProxy(proxy);
|
if (workers !== Number(draft.value.workers)) {
|
||||||
} catch (exc) {
|
settingsTab.value = "download";
|
||||||
settingsError.value = exc instanceof Error ? exc.message : "代理设置失败";
|
settingsError.value = "同时下载数量需要在 1 到 8 之间";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
settings.value = copySettings(draft.value);
|
const directory = draft.value.directory.trim();
|
||||||
localStorage.setItem(DEVICE_KEY, JSON.stringify(settings.value));
|
if (!directory) {
|
||||||
settingsOpen.value = false;
|
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({
|
||||||
|
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) {
|
async function chooseDownloadDirectory() {
|
||||||
draft.value.custom = { ...draft.value.custom, [key]: value };
|
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() {
|
||||||
draft.value.custom = { ...builtinDevice.value };
|
settingsError.value = "";
|
||||||
draft.value.adbMode = "device";
|
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 {
|
function settingsDevice(): DeviceProfile {
|
||||||
@@ -328,14 +449,19 @@ function saveAccount(info: Profile) {
|
|||||||
selectedAccount.value = next.email;
|
selectedAccount.value = next.email;
|
||||||
}
|
}
|
||||||
|
|
||||||
function yesNo(value: unknown) {
|
function profileText(key: keyof Profile) {
|
||||||
return Number(value) ? "已绑定" : "未绑定";
|
const value = profile.value?.[key];
|
||||||
|
if (value === undefined || value === null || value === "") return "—";
|
||||||
|
return String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function textOf(field: (typeof fields)[number]) {
|
function profileNumber(key: keyof Profile) {
|
||||||
const value = profile.value?.[field.key];
|
const value = Number(profile.value?.[key] ?? 0);
|
||||||
if (value === undefined || value === null || value === "") return "—";
|
return Number.isFinite(value) ? String(value) : "0";
|
||||||
return field.format ? field.format(value) : String(value);
|
}
|
||||||
|
|
||||||
|
function profileBound(key: keyof Profile) {
|
||||||
|
return Number(profile.value?.[key] ?? 0) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function deviceLabel(item: Device) {
|
function deviceLabel(item: Device) {
|
||||||
@@ -411,7 +537,7 @@ async function login() {
|
|||||||
sessionStorage.setItem("rapt-token", payload.token);
|
sessionStorage.setItem("rapt-token", payload.token);
|
||||||
rememberSessionDevice(payload.device);
|
rememberSessionDevice(payload.device);
|
||||||
page.value = "download";
|
page.value = "download";
|
||||||
startProfileWatch();
|
startLive();
|
||||||
if (method.value === "password" && rememberPassword.value) saveAccount(payload.profile || {});
|
if (method.value === "password" && rememberPassword.value) saveAccount(payload.profile || {});
|
||||||
} catch (exc) {
|
} catch (exc) {
|
||||||
error.value = exc instanceof Error ? exc.message : "登录失败";
|
error.value = exc instanceof Error ? exc.message : "登录失败";
|
||||||
@@ -427,36 +553,48 @@ const avatarSrc = computed(() => {
|
|||||||
return `https://apis.raptdrama.com${image.startsWith("/") ? image : `/${image}`}`;
|
return `https://apis.raptdrama.com${image.startsWith("/") ? image : `/${image}`}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
function startProfileWatch() {
|
let liveRetry = 0;
|
||||||
stopProfileWatch();
|
|
||||||
profileTimer = window.setInterval(() => {
|
|
||||||
void pullProfile();
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopProfileWatch() {
|
function applyLive(payload: { downloads?: { active: LiveJob[]; done: LiveJob[]; active_count?: number }; profile?: Profile | null }) {
|
||||||
if (profileTimer) window.clearInterval(profileTimer);
|
if (payload.downloads) {
|
||||||
profileTimer = 0;
|
downloadState.value = {
|
||||||
}
|
active: payload.downloads.active || [],
|
||||||
|
done: payload.downloads.done || [],
|
||||||
async function pullProfile() {
|
active_count: payload.downloads.active_count || 0,
|
||||||
if (!token.value || profilePulling) return;
|
};
|
||||||
profilePulling = true;
|
progressCount.value = payload.downloads.active_count || 0;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
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() {
|
async function logout() {
|
||||||
const current = token.value;
|
const current = token.value;
|
||||||
stopProfileWatch();
|
stopLive();
|
||||||
token.value = "";
|
token.value = "";
|
||||||
profile.value = null;
|
profile.value = null;
|
||||||
page.value = "download";
|
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 () => {
|
onMounted(async () => {
|
||||||
|
document.title = "RaptDrama Download Tools";
|
||||||
|
void watchClose();
|
||||||
await loadDeviceDefaults();
|
await loadDeviceDefaults();
|
||||||
loadSettings();
|
loadSettings();
|
||||||
try {
|
try {
|
||||||
@@ -500,10 +682,10 @@ onMounted(async () => {
|
|||||||
profile.value = payload.profile;
|
profile.value = payload.profile;
|
||||||
rememberSessionDevice(payload.device);
|
rememberSessionDevice(payload.device);
|
||||||
page.value = "download";
|
page.value = "download";
|
||||||
startProfileWatch();
|
startLive();
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(stopProfileWatch);
|
onUnmounted(stopLive);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -512,9 +694,9 @@ onUnmounted(stopProfileWatch);
|
|||||||
<n-layout-sider bordered :width="208" content-style="height: 100%; padding: 0">
|
<n-layout-sider bordered :width="208" content-style="height: 100%; padding: 0">
|
||||||
<div class="sider">
|
<div class="sider">
|
||||||
<n-menu
|
<n-menu
|
||||||
:value="page === 'download' ? 'download' : null"
|
:value="menuValue"
|
||||||
:options="[{ label: '视频清单', key: 'download' }]"
|
:options="mainMenu"
|
||||||
@update:value="page = 'download'"
|
@update:value="page = $event"
|
||||||
/>
|
/>
|
||||||
<div class="user-nav" :class="{ active: page === 'profile' }">
|
<div class="user-nav" :class="{ active: page === 'profile' }">
|
||||||
<button type="button" class="user-nav-main" @click="page = 'profile'">
|
<button type="button" class="user-nav-main" @click="page = 'profile'">
|
||||||
@@ -540,7 +722,10 @@ onUnmounted(stopProfileWatch);
|
|||||||
</n-layout-sider>
|
</n-layout-sider>
|
||||||
<n-layout class="main-pane">
|
<n-layout class="main-pane">
|
||||||
<n-layout-header bordered class="bar">
|
<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">
|
<div v-if="page === 'download'" class="bar-search">
|
||||||
<n-input
|
<n-input
|
||||||
v-model:value="catalogQuery"
|
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>
|
<n-text v-if="downloadRef?.searchHint" class="search-hint" depth="3">{{ downloadRef.searchHint }}</n-text>
|
||||||
</div>
|
</div>
|
||||||
<div class="bar-actions">
|
<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>
|
<n-button v-if="page === 'download'" secondary :loading="downloadRef?.busy" @click="downloadRef?.reload(true)">刷新</n-button>
|
||||||
</div>
|
</div>
|
||||||
</n-layout-header>
|
</n-layout-header>
|
||||||
@@ -562,37 +751,73 @@ onUnmounted(stopProfileWatch);
|
|||||||
ref="downloadRef"
|
ref="downloadRef"
|
||||||
:token="token"
|
:token="token"
|
||||||
:device="sessionDevice()"
|
: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 v-show="page === 'profile'" class="profile-page">
|
||||||
<div class="profile-head">
|
<div class="profile-sheet">
|
||||||
<n-avatar round :size="72" :src="avatarSrc" />
|
<div class="profile-head">
|
||||||
<n-text class="profile-name" strong>{{ profile.nickname || "未命名" }}</n-text>
|
<n-avatar round :size="64" :src="avatarSrc" />
|
||||||
<n-tooltip placement="bottom">
|
<div class="profile-id">
|
||||||
<template #trigger>
|
<div class="profile-name-row">
|
||||||
<n-button quaternary circle aria-label="退出" @click="logout">
|
<span class="profile-name">{{ profile.nickname || "未命名" }}</span>
|
||||||
<template #icon>
|
<n-tag v-if="profile.badge" size="small" :bordered="false" type="success">{{ profile.badge }}</n-tag>
|
||||||
<svg class="logout-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
</div>
|
||||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
<span class="profile-sub">{{ profile.email || "未绑定邮箱" }}</span>
|
||||||
<path d="M16 17l5-5-5-5" />
|
</div>
|
||||||
<path d="M21 12H9" />
|
<n-tooltip placement="bottom">
|
||||||
</svg>
|
<template #trigger>
|
||||||
</template>
|
<n-button quaternary circle aria-label="退出" @click="logout">
|
||||||
</n-button>
|
<template #icon>
|
||||||
</template>
|
<svg class="logout-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
退出
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||||
</n-tooltip>
|
<path d="M16 17l5-5-5-5" />
|
||||||
|
<path d="M21 12H9" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
退出
|
||||||
|
</n-tooltip>
|
||||||
|
</div>
|
||||||
|
<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-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>
|
</div>
|
||||||
</n-layout-content>
|
</n-layout-content>
|
||||||
</n-layout>
|
</n-layout>
|
||||||
</n-layout>
|
</n-layout>
|
||||||
<n-layout v-else class="shell" position="absolute">
|
<n-layout v-else class="shell login-shell" position="absolute">
|
||||||
<n-layout-content content-style="padding: 28px 20px">
|
<n-layout-content>
|
||||||
|
<div class="login-center">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<n-tabs v-model:value="method" type="segment" size="small">
|
<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>
|
<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-form-item>
|
||||||
<n-text v-if="method === 'guest'" class="hint" depth="3">将注册一个新的游客账号,并读取它的个人信息。</n-text>
|
<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' && !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>
|
<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>
|
<n-button type="primary" attr-type="submit" :loading="busy">登录</n-button>
|
||||||
</div>
|
</div>
|
||||||
</n-form>
|
</n-form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</n-layout-content>
|
</n-layout-content>
|
||||||
</n-layout>
|
</n-layout>
|
||||||
<n-tooltip v-if="!profile" placement="left">
|
<n-tooltip v-if="!profile" placement="left">
|
||||||
@@ -657,97 +883,50 @@ onUnmounted(stopProfileWatch);
|
|||||||
</template>
|
</template>
|
||||||
设置
|
设置
|
||||||
</n-tooltip>
|
</n-tooltip>
|
||||||
<n-modal v-model:show="settingsOpen" preset="card" title="设置" style="width: 560px">
|
<n-modal v-model:show="settingsOpen" preset="card" title="设置" style="width: 640px">
|
||||||
<n-tabs v-model:value="settingsTab" type="line" size="small">
|
<div class="settings-modal-body">
|
||||||
<n-tab-pane name="device" tab="设备信息" display-directive="if">
|
<n-alert v-if="settingsError" class="hint" type="error" :show-icon="false">{{ settingsError }}</n-alert>
|
||||||
<div class="settings-body">
|
<SettingsPanel v-model:tab="settingsTab" :draft="draft" @choose="chooseDownloadDirectory" />
|
||||||
<n-form label-placement="left" label-width="96" size="small" :show-feedback="false">
|
</div>
|
||||||
<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-alert v-if="settingsError" class="hint" type="error" :show-icon="false">{{ settingsError }}</n-alert>
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="actions">
|
<div class="modal-actions">
|
||||||
<n-button v-if="settingsTab === 'device'" @click="restoreDevice">还原设置</n-button>
|
<n-button :disabled="settingsBusy" @click="restoreCurrent">还原设置</n-button>
|
||||||
<span class="actions-spacer"></span>
|
<span class="modal-spacer"></span>
|
||||||
<n-button @click="settingsOpen = false">取消</n-button>
|
<n-button :disabled="settingsBusy" @click="settingsOpen = false">取消</n-button>
|
||||||
<n-button type="primary" @click="saveSettings">保存</n-button>
|
<n-button type="primary" :loading="settingsBusy" @click="saveSettings">保存</n-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</n-modal>
|
</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>
|
</n-config-provider>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
html, body, #app { height: 100%; margin: 0; }
|
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; }
|
.app { height: 100%; position: relative; }
|
||||||
.shell .n-layout-scroll-container { overflow-y: auto; }
|
.shell .n-layout-scroll-container { overflow-y: auto; }
|
||||||
.shell .main-pane { height: 100%; min-height: 0; }
|
.shell .main-pane { height: 100%; min-height: 0; }
|
||||||
@@ -814,10 +993,77 @@ html, body, #app { height: 100%; margin: 0; }
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
object-position: center;
|
object-position: center;
|
||||||
}
|
}
|
||||||
.profile-page { height: 100%; overflow: auto; box-sizing: border-box; padding: 28px 20px; }
|
.profile-page { height: 100%; overflow: auto; box-sizing: border-box; padding: 20px; }
|
||||||
.profile-head { display: flex; align-items: center; gap: 16px; max-width: 560px; margin: 0 auto 20px; }
|
.profile-sheet {
|
||||||
.profile-name { flex: 1; min-width: 0; font-size: 18px; line-height: 1.2; }
|
max-width: 640px;
|
||||||
.profile-page .n-descriptions { max-width: 560px; margin: 0 auto; }
|
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 {
|
.logout-icon {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
@@ -833,6 +1079,8 @@ html, body, #app { height: 100%; margin: 0; }
|
|||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
height: auto; padding: 12px 20px;
|
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 {
|
.bar-search {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
@@ -844,18 +1092,71 @@ html, body, #app { height: 100%; margin: 0; }
|
|||||||
}
|
}
|
||||||
.search-hint { min-width: 2.5em; }
|
.search-hint { min-width: 2.5em; }
|
||||||
.bar-actions { display: flex; align-items: center; gap: 8px; }
|
.bar-actions { display: flex; align-items: center; gap: 8px; }
|
||||||
.catalog-query { width: 220px; }
|
.nav-label { display: inline-flex; align-items: center; gap: 8px; }
|
||||||
.card { max-width: 560px; margin: 0 auto; }
|
.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-tabs { margin-bottom: 16px; }
|
||||||
.card .n-form-item { margin-bottom: 16px; }
|
.card .n-form-item { margin-bottom: 16px; }
|
||||||
.card .n-avatar { margin-bottom: 16px; }
|
.card .n-avatar { margin-bottom: 16px; }
|
||||||
.device-line { display: flex; gap: 8px; width: 100%; align-items: center; }
|
.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; }
|
.hint { display: block; margin: 8px 0; }
|
||||||
.actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
|
.settings-page {
|
||||||
.actions-spacer { flex: 1; }
|
height: 100%;
|
||||||
.settings-button { position: fixed; right: 20px; bottom: 20px; z-index: 5; }
|
min-height: 0;
|
||||||
.gear-icon { width: 18px; height: 18px; display: block; }
|
display: flex;
|
||||||
.settings-body { max-height: min(58vh, 520px); overflow: auto; padding-right: 4px; }
|
flex-direction: column;
|
||||||
.settings-body .n-form-item { margin-bottom: 16px; }
|
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>
|
</style>
|
||||||
|
|||||||
@@ -16,17 +16,17 @@ declare module 'vue' {
|
|||||||
NButton: typeof import('naive-ui')['NButton']
|
NButton: typeof import('naive-ui')['NButton']
|
||||||
NCheckbox: typeof import('naive-ui')['NCheckbox']
|
NCheckbox: typeof import('naive-ui')['NCheckbox']
|
||||||
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
|
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
|
||||||
NDescriptions: typeof import('naive-ui')['NDescriptions']
|
|
||||||
NDescriptionsItem: typeof import('naive-ui')['NDescriptionsItem']
|
|
||||||
NForm: typeof import('naive-ui')['NForm']
|
NForm: typeof import('naive-ui')['NForm']
|
||||||
NFormItem: typeof import('naive-ui')['NFormItem']
|
NFormItem: typeof import('naive-ui')['NFormItem']
|
||||||
NInput: typeof import('naive-ui')['NInput']
|
NInput: typeof import('naive-ui')['NInput']
|
||||||
|
NInputNumber: typeof import('naive-ui')['NInputNumber']
|
||||||
NLayout: typeof import('naive-ui')['NLayout']
|
NLayout: typeof import('naive-ui')['NLayout']
|
||||||
NLayoutContent: typeof import('naive-ui')['NLayoutContent']
|
NLayoutContent: typeof import('naive-ui')['NLayoutContent']
|
||||||
NLayoutHeader: typeof import('naive-ui')['NLayoutHeader']
|
NLayoutHeader: typeof import('naive-ui')['NLayoutHeader']
|
||||||
NLayoutSider: typeof import('naive-ui')['NLayoutSider']
|
NLayoutSider: typeof import('naive-ui')['NLayoutSider']
|
||||||
NMenu: typeof import('naive-ui')['NMenu']
|
NMenu: typeof import('naive-ui')['NMenu']
|
||||||
NModal: typeof import('naive-ui')['NModal']
|
NModal: typeof import('naive-ui')['NModal']
|
||||||
|
NProgress: typeof import('naive-ui')['NProgress']
|
||||||
NRadio: typeof import('naive-ui')['NRadio']
|
NRadio: typeof import('naive-ui')['NRadio']
|
||||||
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
|
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
|
||||||
NSelect: typeof import('naive-ui')['NSelect']
|
NSelect: typeof import('naive-ui')['NSelect']
|
||||||
@@ -34,6 +34,7 @@ declare module 'vue' {
|
|||||||
NTab: typeof import('naive-ui')['NTab']
|
NTab: typeof import('naive-ui')['NTab']
|
||||||
NTabPane: typeof import('naive-ui')['NTabPane']
|
NTabPane: typeof import('naive-ui')['NTabPane']
|
||||||
NTabs: typeof import('naive-ui')['NTabs']
|
NTabs: typeof import('naive-ui')['NTabs']
|
||||||
|
NTag: typeof import('naive-ui')['NTag']
|
||||||
NText: typeof import('naive-ui')['NText']
|
NText: typeof import('naive-ui')['NText']
|
||||||
NTooltip: typeof import('naive-ui')['NTooltip']
|
NTooltip: typeof import('naive-ui')['NTooltip']
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,17 @@
|
|||||||
import { createApp } from "vue";
|
import { createApp } from "vue";
|
||||||
import App from "./App.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");
|
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 lastQuery = "";
|
||||||
let revealGeneration = 0;
|
let revealGeneration = 0;
|
||||||
const flashSerial = ref(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);
|
const scrollEl = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
function coverOf(image: string) {
|
function coverOf(image: string) {
|
||||||
@@ -275,8 +279,62 @@ async function find(keyword = query.value) {
|
|||||||
if (generation !== revealGeneration) return;
|
if (generation !== revealGeneration) return;
|
||||||
flashKey.value = key;
|
flashKey.value = key;
|
||||||
}
|
}
|
||||||
onMounted(reload);
|
onMounted(() => {
|
||||||
defineExpose({ reload, busy, find, searchHint, clearSearch });
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -286,22 +344,32 @@ defineExpose({ reload, busy, find, searchHint, clearSearch });
|
|||||||
<n-alert v-if="error" class="catalog-error" type="error" :show-icon="false">{{ error }}</n-alert>
|
<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">
|
<section v-for="section in sections" :key="section.key" class="category">
|
||||||
<div class="category-bar">
|
<div class="category-bar">
|
||||||
<n-button quaternary circle :aria-label="isExpanded(section.key) ? '收起' : '展开'" @click="toggleExpanded(section.key)">
|
<n-tooltip placement="top">
|
||||||
<span class="chevron" :class="{ open: isExpanded(section.key) }">▸</span>
|
<template #trigger>
|
||||||
</n-button>
|
<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>
|
<n-text strong>{{ section.name }}</n-text>
|
||||||
<span v-if="section.loading" class="category-spin" aria-hidden="true"></span>
|
<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">{{ section.items.length }} 部</n-text>
|
||||||
<n-text depth="3">已选 {{ selectedCount(section) }}</n-text>
|
<n-text depth="3">已选 {{ selectedCount(section) }}</n-text>
|
||||||
<span class="bar-gap"></span>
|
<span class="bar-gap"></span>
|
||||||
<n-checkbox
|
<n-tooltip placement="top">
|
||||||
:checked="allChecked(section)"
|
<template #trigger>
|
||||||
:indeterminate="someChecked(section)"
|
<n-checkbox
|
||||||
:disabled="section.items.length === 0"
|
:checked="allChecked(section)"
|
||||||
@update:checked="toggleSection(section, $event)"
|
:indeterminate="someChecked(section)"
|
||||||
>
|
:disabled="section.items.length === 0"
|
||||||
全选
|
@update:checked="toggleSection(section, $event)"
|
||||||
</n-checkbox>
|
>
|
||||||
|
全选
|
||||||
|
</n-checkbox>
|
||||||
|
</template>
|
||||||
|
{{ allChecked(section) ? "取消全选" : "全选" }}
|
||||||
|
</n-tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isExpanded(section.key)" class="category-body">
|
<div v-if="isExpanded(section.key)" class="category-body">
|
||||||
<n-text v-if="section.note" depth="3">{{ section.note }}</n-text>
|
<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">
|
<div class="cover">
|
||||||
<img v-if="item.image" :src="coverOf(item.image)" :alt="item.title" />
|
<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>
|
||||||
<div class="pick" @click.stop>
|
<div class="pick" @click.stop>
|
||||||
<n-checkbox :checked="selected.has(item.id)" @update:checked="toggleDrama(item.id, $event)" />
|
<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>
|
||||||
<div class="drama-title">{{ item.title }}</div>
|
<div class="drama-title">{{ item.title }}</div>
|
||||||
<div
|
<div
|
||||||
@@ -335,7 +418,13 @@ defineExpose({ reload, busy, find, searchHint, clearSearch });
|
|||||||
</div>
|
</div>
|
||||||
<footer class="download-foot">
|
<footer class="download-foot">
|
||||||
<n-text>已选 {{ selected.size }} 部</n-text>
|
<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>
|
</footer>
|
||||||
|
<n-text v-if="footError" class="foot-error" type="error">{{ footError }}</n-text>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -355,12 +444,15 @@ defineExpose({ reload, busy, find, searchHint, clearSearch });
|
|||||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
background: #18181c;
|
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; }
|
.catalog-error { margin: 8px 20px 12px; }
|
||||||
.category { margin-bottom: 8px; }
|
.category { margin-bottom: 8px; }
|
||||||
.category-bar {
|
.category-bar {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 1;
|
z-index: 3;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -395,9 +487,35 @@ defineExpose({ reload, busy, find, searchHint, clearSearch });
|
|||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.cover { background: #2a2a2a; }
|
.cover { position: relative; background: #2a2a2a; }
|
||||||
.cover img { display: block; width: 100%; aspect-ratio: 3 / 4; object-fit: cover; }
|
.cover img { display: block; width: 100%; aspect-ratio: 3 / 4; object-fit: cover; }
|
||||||
.cover:not(:has(img)) { aspect-ratio: 3 / 4; }
|
.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 {
|
.pick {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 8px;
|
top: 8px;
|
||||||
|
|||||||