745 lines
28 KiB
Python
745 lines
28 KiB
Python
import html
|
||
import json
|
||
import os
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
import threading
|
||
import time
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
from server.services import playlist as playlists
|
||
from server.services.covers import covers
|
||
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.rapt import API_BASE
|
||
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 = 6
|
||
MIN_WORKERS = 1
|
||
ACTIVE = {"queued", "downloading", "paused", "failed"}
|
||
FOLDER_NOTE = """这部剧的文件说明
|
||
|
||
封面
|
||
和本说明放在一起的封面图片,是这部剧的封面。文件名是封面.jpg 或封面.png,以实际图片格式为准。
|
||
|
||
简介.txt
|
||
这部剧的编号、名称和简介。
|
||
|
||
MP4
|
||
每一集由多个分片合成为一条完整的单集视频,放在这个目录里。一集对应一个文件,可以直接用播放器打开。
|
||
|
||
source
|
||
这里保存每一集下载下来的分片和播放列表。MP4 目录里的完整单集,就是由这些分片合成的。平时观看用 MP4 目录即可。
|
||
"""
|
||
|
||
|
||
def _plain_text(value: str) -> str:
|
||
text = html.unescape(str(value or ""))
|
||
text = re.sub(r"(?i)<br\s*/?>", "\n", text)
|
||
text = re.sub(r"(?i)</p\s*>", "\n", text)
|
||
text = re.sub(r"<[^>]+>", "", text)
|
||
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
||
lines = [re.sub(r"[ \t]+", " ", line).strip() for line in text.split("\n")]
|
||
return "\n".join(line for line in lines if line).strip()
|
||
|
||
|
||
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:
|
||
raise ValueError(f"同时下载数量至少为 {MIN_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 _write_folder_notes(self, folder: Path, image: str, drama_id: str, title: str, token: str, device) -> None:
|
||
(folder / "说明.txt").write_text(FOLDER_NOTE, encoding="utf-8")
|
||
self._save_cover(folder, image)
|
||
self._save_intro(folder, drama_id, title, token, device)
|
||
|
||
def _save_intro(self, folder: Path, drama_id: str, title: str, token: str, device) -> None:
|
||
vid = drama_id
|
||
name = title.strip()
|
||
desc = ""
|
||
if token and device is not None:
|
||
try:
|
||
data = http.request_data(
|
||
"POST",
|
||
API_BASE + "/app/video/videoinfo",
|
||
token,
|
||
device,
|
||
{"vid": int(drama_id)},
|
||
)
|
||
video = data.get("video") if isinstance(data, dict) else None
|
||
if isinstance(video, dict):
|
||
if video.get("id") not in (None, ""):
|
||
vid = video.get("id")
|
||
fetched = str(video.get("title") or "").strip()
|
||
if fetched:
|
||
name = fetched
|
||
desc = _plain_text(video.get("desc") or "")
|
||
except Exception:
|
||
pass
|
||
(folder / "简介.txt").write_text(
|
||
f"ID: {vid}\n名称: {name}\n简介: {desc}\n",
|
||
encoding="utf-8",
|
||
)
|
||
|
||
def _save_cover(self, folder: Path, image: str) -> None:
|
||
digest = (image or "").rstrip("/").rsplit("/", 1)[-1].strip()
|
||
if not digest:
|
||
return
|
||
try:
|
||
data, media = covers.read(digest)
|
||
except Exception:
|
||
return
|
||
if not data:
|
||
return
|
||
kind = media.split(";", 1)[0].strip().lower()
|
||
ext = {
|
||
"image/jpeg": ".jpg",
|
||
"image/png": ".png",
|
||
"image/webp": ".webp",
|
||
"image/gif": ".gif",
|
||
}.get(kind, ".jpg")
|
||
target = folder / f"封面{ext}"
|
||
for old in folder.glob("封面.*"):
|
||
if old.is_file() and old.resolve() != target.resolve():
|
||
old.unlink()
|
||
target.write_bytes(data)
|
||
|
||
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"])
|
||
image = str(job.get("image") or "")
|
||
title = str(job.get("title") or "")
|
||
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._write_folder_notes(folder, image, drama_id, title, token, device)
|
||
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, items = playlists.expand_variant(row["src"])
|
||
source_dir.mkdir(parents=True, exist_ok=True)
|
||
(source_dir / "video.m3u8").write_text(media_text, 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")
|
||
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 workers >= MIN_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()
|