feat: enhance download manager with folder notes and improved worker settings

This commit is contained in:
Nixevol
2026-09-27 15:30:48 +08:00
parent fdb1f73968
commit 68d27a5caf
5 changed files with 184 additions and 35 deletions
+93 -17
View File
@@ -1,28 +1,54 @@
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 urllib.parse import urlsplit
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 = 2
DEFAULT_WORKERS = 6
MIN_WORKERS = 1
MAX_WORKERS = 8
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:
@@ -103,8 +129,8 @@ class DownloadManager:
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} 之间")
if count < MIN_WORKERS:
raise ValueError(f"同时下载数量至少为 {MIN_WORKERS}")
with self._lock:
self._worker_limit = count
self._save_settings_locked()
@@ -298,6 +324,62 @@ class DownloadManager:
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:
@@ -339,6 +421,8 @@ class DownloadManager:
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)
@@ -348,6 +432,7 @@ class DownloadManager:
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):
@@ -430,17 +515,9 @@ class DownloadManager:
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})
master_text, media_text, items = playlists.expand_variant(row["src"])
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 / "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",
@@ -481,7 +558,6 @@ class DownloadManager:
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)
@@ -574,7 +650,7 @@ class DownloadManager:
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:
if isinstance(workers, int) and workers >= MIN_WORKERS:
self._worker_limit = workers
except (OSError, json.JSONDecodeError):
pass
+73 -8
View File
@@ -147,7 +147,7 @@ def load_episodes(token: str, device: DeviceProfile | None, vid: int) -> list[di
return merged
def expand_variant(master_url: str) -> tuple[str, str, list[str]]:
def expand_variant(master_url: str) -> tuple[str, str, list[dict]]:
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("#")),
@@ -155,16 +155,81 @@ def expand_variant(master_url: str) -> tuple[str, str, list[str]]:
)
if not variant_rel:
raise RuntimeError("播放列表没有视频地址")
if _looks_like_segment(variant_rel):
variant_url = master_url
media_text = master_text
else:
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:
localized, items = localize_media(media_text, variant_url)
if not items:
raise RuntimeError("播放列表没有分片")
return master_text, media_text, segments
return master_text, localized, items
def localize_media(text: str, base: str) -> tuple[str, list[dict]]:
urls = _resource_urls(text, base)
names: list[str] = []
items: list[dict] = []
used: set[str] = set()
known: dict[str, str] = {}
for index, url in enumerate(urls, start=1):
name = known.get(url)
if name is None:
name = _resource_name(url, index, used)
known[url] = name
items.append({"name": name, "url": url})
names.append(name)
return _apply_local_names(text, names), items
def _resource_urls(text: str, base: str) -> list[str]:
urls = []
for line in text.splitlines():
stripped = line.strip()
if not stripped:
continue
if stripped.startswith("#"):
urls.extend(urljoin(base, match) for match in re.findall(r'URI="([^"]*)"', stripped))
continue
urls.append(urljoin(base, stripped))
return urls
def _resource_name(url: str, index: int, used: set[str]) -> str:
raw = Path(url.split("?", 1)[0].rstrip("/")).name
raw = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", raw).strip(" .") or f"part-{index}.ts"
candidate = raw
sequence = index
while candidate in used:
suffix = Path(raw).suffix
stem = Path(raw).stem
candidate = f"{stem}-{sequence}{suffix}"
sequence += 1
used.add(candidate)
return candidate
def _apply_local_names(text: str, names: list[str]) -> str:
index = 0
def take() -> str:
nonlocal index
name = names[index] if index < len(names) else ""
index += 1
return name
lines = []
for line in text.splitlines():
stripped = line.strip()
if not stripped:
lines.append(line)
continue
if stripped.startswith("#"):
lines.append(re.sub(r'URI="([^"]*)"', lambda _match: f'URI="{take()}"', line))
continue
lines.append(take() or stripped)
return "\n".join(lines) + "\n"
def episode_src(token: str, device: DeviceProfile | None, vid: int, cid: int) -> str:
+4 -4
View File
@@ -170,7 +170,7 @@ const settings = ref({
adbMode: "device" as "device" | "settings",
custom: { ...builtinDevice.value },
proxy: emptyProxy(),
workers: 2,
workers: 6,
directory: "",
defaultDirectory: "",
});
@@ -241,7 +241,7 @@ async function loadDeviceDefaults() {
function normalizeWorkers(value: unknown) {
const count = Number(value);
if (!Number.isInteger(count) || count < 1 || count > 8) return 2;
if (!Number.isInteger(count) || count < 1) return 6;
return count;
}
@@ -302,7 +302,7 @@ async function saveSettings() {
const workers = normalizeWorkers(draft.value.workers);
if (workers !== Number(draft.value.workers)) {
settingsTab.value = "download";
settingsError.value = "同时下载数量需要在 1 到 8 之间";
settingsError.value = "同时下载数量至少为 1";
return;
}
const directory = draft.value.directory.trim();
@@ -365,7 +365,7 @@ function restoreCurrent() {
draft.value.proxy = emptyProxy();
return;
}
draft.value.workers = 2;
draft.value.workers = 6;
draft.value.directory = draft.value.defaultDirectory || settings.value.defaultDirectory;
}
+2 -2
View File
@@ -150,11 +150,11 @@ function setDraftField(key: keyof DeviceProfile, value: string | boolean) {
<template #trigger>
<span class="help-dot" tabindex="0">?</span>
</template>
同时进行的下载任务数,范围 1 到 8,默认 2。调低后,多出来的任务会在当前这部下完后停下来。
同时进行的下载任务数,至少 1 个,默认 6。调低后,多出来的任务会在当前这部下完后停下来。
</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-input-number v-model:value="draft.workers" class="worker-input" button-placement="both" :min="1" :precision="0" />
</n-form-item>
</n-form>
</n-tab-pane>
+8
View File
@@ -84,6 +84,10 @@ function toggleSection(section: Section, checked: boolean) {
selected.value = next;
}
function clearSelection() {
selected.value = new Set();
}
function pruneSelection() {
const ids = new Set(sections.value.flatMap((section) => section.items.map((item) => item.id)));
selected.value = new Set([...selected.value].filter((id) => ids.has(id)));
@@ -417,7 +421,10 @@ async function startDownload() {
</div>
</div>
<footer class="download-foot">
<div class="foot-selected">
<n-text>已选 {{ selected.size }} 部</n-text>
<n-button secondary size="small" :disabled="selected.size === 0" @click="clearSelection">取消选择</n-button>
</div>
<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>
@@ -445,6 +452,7 @@ async function startDownload() {
background: #18181c;
}
.foot-actions { display: flex; align-items: center; gap: 8px; min-width: 0; }
.foot-selected { display: flex; align-items: center; gap: 8px; flex: none; }
.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; }