diff --git a/server/services/downloads.py b/server/services/downloads.py
index f5fb621..a4a180b 100644
--- a/server/services/downloads.py
+++ b/server/services/downloads.py
@@ -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)
", "\n", text)
+ text = re.sub(r"(?i)
", "\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,8 +558,7 @@ 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)
+ 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):
@@ -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
diff --git a/server/services/playlist.py b/server/services/playlist.py
index 3342435..caf15fc 100644
--- a/server/services/playlist.py
+++ b/server/services/playlist.py
@@ -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("播放列表没有视频地址")
- 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:
+ 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)
+ 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:
diff --git a/ui/src/App.vue b/ui/src/App.vue
index a075972..caa7c9d 100644
--- a/ui/src/App.vue
+++ b/ui/src/App.vue
@@ -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;
}
diff --git a/ui/src/views/SettingsPanel.vue b/ui/src/views/SettingsPanel.vue
index 7633ea3..52364af 100644
--- a/ui/src/views/SettingsPanel.vue
+++ b/ui/src/views/SettingsPanel.vue
@@ -150,11 +150,11 @@ function setDraftField(key: keyof DeviceProfile, value: string | boolean) {
?
- 同时进行的下载任务数,范围 1 到 8,默认 2。调低后,多出来的任务会在当前这部下完后停下来。
+ 同时进行的下载任务数,至少 1 个,默认 6。调低后,多出来的任务会在当前这部下完后停下来。
-
+
diff --git a/ui/src/views/VideoListPage.vue b/ui/src/views/VideoListPage.vue
index b8c34c8..6987a74 100644
--- a/ui/src/views/VideoListPage.vue
+++ b/ui/src/views/VideoListPage.vue
@@ -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() {