feat: 补齐桌面端下载播放,并将窗口改为夜间主题和应用图标
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote, urljoin
|
||||
|
||||
from server.services.device import DeviceProfile
|
||||
from server.services.http import http
|
||||
from server.services.rapt import API_BASE
|
||||
|
||||
CACHE_DIR = Path(__file__).resolve().parents[2] / "cache" / "playlists"
|
||||
|
||||
|
||||
def safe_name(text: str, fallback: str) -> str:
|
||||
cleaned = re.sub(r'[<>:"/\\|?*\x00-\x1f]', " ", text or "")
|
||||
cleaned = re.sub(r"\s+", " ", cleaned).strip().rstrip(". ")
|
||||
return (cleaned[:80] or fallback)
|
||||
|
||||
|
||||
def _items(data):
|
||||
if isinstance(data, list):
|
||||
return data, len(data)
|
||||
items = (data or {}).get("list") or []
|
||||
return items, (data or {}).get("count")
|
||||
|
||||
|
||||
def _normalize(item: dict) -> dict | None:
|
||||
cid = item.get("cid") or item.get("id")
|
||||
if cid is None:
|
||||
return None
|
||||
idx = item.get("idx") or item.get("number") or 0
|
||||
title = str(item.get("msg") or item.get("title") or f"EP.{idx}").strip()
|
||||
return {
|
||||
"cid": int(cid),
|
||||
"idx": int(idx) if str(idx).isdigit() else 0,
|
||||
"title": title,
|
||||
"src": str(item.get("src") or "").strip(),
|
||||
}
|
||||
|
||||
|
||||
def _request_playlist(token: str, device: DeviceProfile, vid: int) -> list[dict]:
|
||||
try:
|
||||
data = http.request_data("POST", API_BASE + "/app/video/playlist", token, device, {
|
||||
"vid": vid,
|
||||
"page": 1,
|
||||
"before": 1,
|
||||
})
|
||||
items, total = _items(data)
|
||||
episodes = [row for row in (_normalize(item) for item in items if isinstance(item, dict)) if row]
|
||||
if episodes and (total is None or len(episodes) >= int(total)):
|
||||
return episodes
|
||||
except RuntimeError:
|
||||
episodes = []
|
||||
collected = []
|
||||
total = None
|
||||
for page in range(1, 41):
|
||||
data = http.request_data("POST", API_BASE + "/app/video/playlistv2", token, device, {
|
||||
"vid": vid,
|
||||
"page": page,
|
||||
"before": 1 if page == 1 else 0,
|
||||
})
|
||||
items, page_total = _items(data)
|
||||
if page_total is not None:
|
||||
total = page_total
|
||||
if not items:
|
||||
break
|
||||
for item in items:
|
||||
if isinstance(item, dict):
|
||||
row = _normalize(item)
|
||||
if row:
|
||||
collected.append(row)
|
||||
if total is not None and len(collected) >= int(total):
|
||||
break
|
||||
return collected or episodes
|
||||
|
||||
|
||||
def _cache_path(vid: int) -> Path:
|
||||
return CACHE_DIR / f"{vid}.json"
|
||||
|
||||
|
||||
def _read_cache(vid: int) -> list[dict]:
|
||||
path = _cache_path(vid)
|
||||
if not path.is_file():
|
||||
return []
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return []
|
||||
rows = data.get("episodes") if isinstance(data, dict) else None
|
||||
return [row for row in rows if isinstance(row, dict)] if isinstance(rows, list) else []
|
||||
|
||||
|
||||
def _write_cache(vid: int, episodes: list[dict]) -> None:
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"episodes": [
|
||||
{"cid": ep["cid"], "idx": ep["idx"], "title": ep["title"], "src": ep.get("src") or ""}
|
||||
for ep in episodes
|
||||
]
|
||||
}
|
||||
path = _cache_path(vid)
|
||||
temporary = path.with_suffix(".json.tmp")
|
||||
temporary.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def _filled(episodes: list[dict]) -> int:
|
||||
return sum(1 for ep in episodes if ep.get("src"))
|
||||
|
||||
|
||||
def merge_episodes(cached: list[dict], fresh: list[dict]) -> tuple[list[dict], bool]:
|
||||
by_cid: dict[int, dict] = {}
|
||||
for source in (cached, fresh):
|
||||
for ep in source:
|
||||
cid = int(ep["cid"])
|
||||
current = by_cid.get(cid)
|
||||
if current is None:
|
||||
by_cid[cid] = {
|
||||
"cid": cid,
|
||||
"idx": int(ep.get("idx") or 0),
|
||||
"title": ep.get("title") or f"EP.{ep.get('idx') or cid}",
|
||||
"src": ep.get("src") or "",
|
||||
}
|
||||
continue
|
||||
if ep.get("title"):
|
||||
current["title"] = ep["title"]
|
||||
if ep.get("idx"):
|
||||
current["idx"] = int(ep["idx"])
|
||||
if ep.get("src"):
|
||||
current["src"] = ep["src"]
|
||||
merged = sorted(by_cid.values(), key=lambda ep: (ep["idx"], ep["cid"]))
|
||||
return merged, _filled(fresh) >= _filled(cached) and _filled(fresh) > 0
|
||||
|
||||
|
||||
def load_episodes(token: str, device: DeviceProfile | None, vid: int) -> list[dict]:
|
||||
cached = _read_cache(vid)
|
||||
fresh = []
|
||||
if token and device is not None:
|
||||
try:
|
||||
fresh = _request_playlist(token, device, vid)
|
||||
except RuntimeError:
|
||||
fresh = []
|
||||
merged, save = merge_episodes(cached, fresh)
|
||||
if save:
|
||||
_write_cache(vid, merged)
|
||||
elif not cached and merged:
|
||||
_write_cache(vid, merged)
|
||||
return merged
|
||||
|
||||
|
||||
def expand_variant(master_url: str) -> tuple[str, str, list[str]]:
|
||||
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("#")),
|
||||
"",
|
||||
)
|
||||
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:
|
||||
raise RuntimeError("播放列表没有分片")
|
||||
return master_text, media_text, segments
|
||||
|
||||
|
||||
def episode_src(token: str, device: DeviceProfile | None, vid: int, cid: int) -> str:
|
||||
for ep in load_episodes(token, device, vid):
|
||||
if int(ep.get("cid") or 0) == cid and ep.get("src"):
|
||||
return str(ep["src"])
|
||||
return ""
|
||||
|
||||
|
||||
def playback_playlist(master_url: str) -> str:
|
||||
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("#")),
|
||||
"",
|
||||
)
|
||||
if not variant_rel:
|
||||
raise RuntimeError("播放列表没有视频地址")
|
||||
if _looks_like_segment(variant_rel):
|
||||
return _rewrite_playlist(master_text, master_url)
|
||||
variant_url = urljoin(master_url, variant_rel)
|
||||
return _rewrite_playlist(http.read_text(variant_url), variant_url)
|
||||
|
||||
|
||||
def _looks_like_segment(url: str) -> bool:
|
||||
path = url.split("?", 1)[0].lower()
|
||||
return path.endswith((".ts", ".m4s", ".m4v", ".mp4", ".aac", ".vtt"))
|
||||
|
||||
|
||||
def _proxy_url(url: str) -> str:
|
||||
return "/api/play/segment?url=" + quote(url, safe="")
|
||||
|
||||
|
||||
def _rewrite_playlist(text: str, base: str) -> str:
|
||||
lines = []
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
lines.append(line)
|
||||
continue
|
||||
if stripped.startswith("#"):
|
||||
lines.append(_rewrite_tag(line, base))
|
||||
continue
|
||||
lines.append(_proxy_url(urljoin(base, stripped)))
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _rewrite_tag(line: str, base: str) -> str:
|
||||
def replace(match: re.Match) -> str:
|
||||
return 'URI="' + _proxy_url(urljoin(base, match.group(1))) + '"'
|
||||
|
||||
return re.sub(r'URI="([^"]*)"', replace, line)
|
||||
Reference in New Issue
Block a user