feat: enhance download manager with folder notes and improved worker settings
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user