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
+94 -18
View File
@@ -1,28 +1,54 @@
import html
import json import json
import os import os
import re
import shutil import shutil
import subprocess import subprocess
import threading import threading
import time import time
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from urllib.parse import urlsplit
from server.services import playlist as playlists from server.services import playlist as playlists
from server.services.covers import covers
from server.services.device import DeviceProfile from server.services.device import DeviceProfile
from server.services.ffmpeg_tool import convert_to_mp4, ensure_ffmpeg from server.services.ffmpeg_tool import convert_to_mp4, ensure_ffmpeg
from server.services.http import http from server.services.http import http
from server.services.live import live from server.services.live import live
from server.services.rapt import API_BASE
from server.services.sessions import sessions from server.services.sessions import sessions
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
STATE_DIR = ROOT / "cache" / "downloads" STATE_DIR = ROOT / "cache" / "downloads"
STATE_PATH = STATE_DIR / "jobs.json" STATE_PATH = STATE_DIR / "jobs.json"
SETTINGS_PATH = STATE_DIR / "settings.json" SETTINGS_PATH = STATE_DIR / "settings.json"
DEFAULT_WORKERS = 2 DEFAULT_WORKERS = 6
MIN_WORKERS = 1 MIN_WORKERS = 1
MAX_WORKERS = 8
ACTIVE = {"queued", "downloading", "paused", "failed"} 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: def _now() -> str:
@@ -103,8 +129,8 @@ class DownloadManager:
def set_workers(self, count: int) -> int: def set_workers(self, count: int) -> int:
count = int(count) count = int(count)
if count < MIN_WORKERS or count > MAX_WORKERS: if count < MIN_WORKERS:
raise ValueError(f"同时下载数量需要在 {MIN_WORKERS} 到 {MAX_WORKERS} 之间") raise ValueError(f"同时下载数量至少为 {MIN_WORKERS}")
with self._lock: with self._lock:
self._worker_limit = count self._worker_limit = count
self._save_settings_locked() 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) name = base if base not in used else playlists.safe_name(f"{base} {drama_id}", base)
return Path(directory) / name 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: def _worker(self) -> None:
while True: while True:
with self._lock: with self._lock:
@@ -339,6 +421,8 @@ class DownloadManager:
token = sessions.active_token() token = sessions.active_token()
device = sessions.device_for(token) if token else None device = sessions.device_for(token) if token else None
folder = Path(job["folder"]) 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)) episodes = playlists.load_episodes(token, device, int(drama_id))
with self._lock: with self._lock:
job = self._jobs.get(drama_id) job = self._jobs.get(drama_id)
@@ -348,6 +432,7 @@ class DownloadManager:
rows = [dict(ep) for ep in job["episodes"]] rows = [dict(ep) for ep in job["episodes"]]
self._save_locked() self._save_locked()
folder.mkdir(parents=True, exist_ok=True) 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) self._prepare_playlists(drama_id, folder, rows)
for row in rows: for row in rows:
if self._stopped(drama_id): if self._stopped(drama_id):
@@ -430,17 +515,9 @@ class DownloadManager:
def _write_playlist(self, drama_id: str, folder: Path, row: dict) -> None: def _write_playlist(self, drama_id: str, folder: Path, row: dict) -> None:
source_dir, _mp4_path = self._episode_paths(folder, row) source_dir, _mp4_path = self._episode_paths(folder, row)
master_text, media_text, segments = playlists.expand_variant(row["src"]) master_text, media_text, items = 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.mkdir(parents=True, exist_ok=True)
(source_dir / "video.m3u8").write_text( (source_dir / "video.m3u8").write_text(media_text, encoding="utf-8", newline="\n")
_localize(media_text, [item["name"] for item in items]),
encoding="utf-8",
newline="\n",
)
(source_dir / "playlist.m3u8").write_text( (source_dir / "playlist.m3u8").write_text(
_localize(master_text, ["video.m3u8"]), _localize(master_text, ["video.m3u8"]),
encoding="utf-8", encoding="utf-8",
@@ -481,8 +558,7 @@ class DownloadManager:
self._set_phase(drama_id, row["cid"], "done") self._set_phase(drama_id, row["cid"], "done")
return return
self._set_phase(drama_id, row["cid"], "downloading") self._set_phase(drama_id, row["cid"], "downloading")
if not manifest.is_file(): self._write_playlist(drama_id, folder, row)
self._write_playlist(drama_id, folder, row)
items = json.loads(manifest.read_text(encoding="utf-8")) items = json.loads(manifest.read_text(encoding="utf-8"))
total = len(items) total = len(items)
for index, item in enumerate(items, start=1): for index, item in enumerate(items, start=1):
@@ -574,7 +650,7 @@ class DownloadManager:
if isinstance(data, dict) and data.get("directory"): if isinstance(data, dict) and data.get("directory"):
self._directory = data["directory"] self._directory = data["directory"]
workers = data.get("workers") if isinstance(data, dict) else None 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 self._worker_limit = workers
except (OSError, json.JSONDecodeError): except (OSError, json.JSONDecodeError):
pass pass
+75 -10
View File
@@ -147,7 +147,7 @@ def load_episodes(token: str, device: DeviceProfile | None, vid: int) -> list[di
return merged 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) master_text = http.read_text(master_url)
variant_rel = next( variant_rel = next(
(line.strip() for line in master_text.splitlines() if line.strip() and not line.startswith("#")), (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: if not variant_rel:
raise RuntimeError("播放列表没有视频地址") raise RuntimeError("播放列表没有视频地址")
variant_url = urljoin(master_url, variant_rel) if _looks_like_segment(variant_rel):
media_text = http.read_text(variant_url) variant_url = master_url
segments = [ media_text = master_text
urljoin(variant_url, line.strip()) else:
for line in media_text.splitlines() variant_url = urljoin(master_url, variant_rel)
if line.strip() and not line.startswith("#") media_text = http.read_text(variant_url)
] localized, items = localize_media(media_text, variant_url)
if not segments: if not items:
raise RuntimeError("播放列表没有分片") 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: 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", adbMode: "device" as "device" | "settings",
custom: { ...builtinDevice.value }, custom: { ...builtinDevice.value },
proxy: emptyProxy(), proxy: emptyProxy(),
workers: 2, workers: 6,
directory: "", directory: "",
defaultDirectory: "", defaultDirectory: "",
}); });
@@ -241,7 +241,7 @@ async function loadDeviceDefaults() {
function normalizeWorkers(value: unknown) { function normalizeWorkers(value: unknown) {
const count = Number(value); const count = Number(value);
if (!Number.isInteger(count) || count < 1 || count > 8) return 2; if (!Number.isInteger(count) || count < 1) return 6;
return count; return count;
} }
@@ -302,7 +302,7 @@ async function saveSettings() {
const workers = normalizeWorkers(draft.value.workers); const workers = normalizeWorkers(draft.value.workers);
if (workers !== Number(draft.value.workers)) { if (workers !== Number(draft.value.workers)) {
settingsTab.value = "download"; settingsTab.value = "download";
settingsError.value = "同时下载数量需要在 1 到 8 之间"; settingsError.value = "同时下载数量至少为 1";
return; return;
} }
const directory = draft.value.directory.trim(); const directory = draft.value.directory.trim();
@@ -365,7 +365,7 @@ function restoreCurrent() {
draft.value.proxy = emptyProxy(); draft.value.proxy = emptyProxy();
return; return;
} }
draft.value.workers = 2; draft.value.workers = 6;
draft.value.directory = draft.value.defaultDirectory || settings.value.defaultDirectory; 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> <template #trigger>
<span class="help-dot" tabindex="0">?</span> <span class="help-dot" tabindex="0">?</span>
</template> </template>
同时进行的下载任务数,范围 1 到 8,默认 2。调低后,多出来的任务会在当前这部下完后停下来。 同时进行的下载任务数,至少 1 个,默认 6。调低后,多出来的任务会在当前这部下完后停下来。
</n-tooltip> </n-tooltip>
</span> </span>
</template> </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-item>
</n-form> </n-form>
</n-tab-pane> </n-tab-pane>
+9 -1
View File
@@ -84,6 +84,10 @@ function toggleSection(section: Section, checked: boolean) {
selected.value = next; selected.value = next;
} }
function clearSelection() {
selected.value = new Set();
}
function pruneSelection() { function pruneSelection() {
const ids = new Set(sections.value.flatMap((section) => section.items.map((item) => item.id))); 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))); selected.value = new Set([...selected.value].filter((id) => ids.has(id)));
@@ -417,7 +421,10 @@ async function startDownload() {
</div> </div>
</div> </div>
<footer class="download-foot"> <footer class="download-foot">
<n-text>已选 {{ selected.size }} 部</n-text> <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"> <div class="foot-actions">
<n-text class="foot-dir" depth="3" :title="directory">{{ directory || "未设置下载目录" }}</n-text> <n-text class="foot-dir" depth="3" :title="directory">{{ directory || "未设置下载目录" }}</n-text>
<n-button secondary size="small" @click="chooseDirectory">选择</n-button> <n-button secondary size="small" @click="chooseDirectory">选择</n-button>
@@ -445,6 +452,7 @@ async function startDownload() {
background: #18181c; background: #18181c;
} }
.foot-actions { display: flex; align-items: center; gap: 8px; min-width: 0; } .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-dir { max-width: 420px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.foot-error { display: block; padding: 0 20px 8px; text-align: right; } .foot-error { display: block; padding: 0 20px 8px; text-align: right; }
.catalog-error { margin: 8px 20px 12px; } .catalog-error { margin: 8px 20px 12px; }