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 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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user