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() {