Files

284 lines
9.0 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[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("#")),
"",
)
if not variant_rel:
raise RuntimeError("播放列表没有视频地址")
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, 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:
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)