219 lines
7.1 KiB
Python
219 lines
7.1 KiB
Python
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)
|