2152 lines
79 KiB
Python
2152 lines
79 KiB
Python
# -*- coding: utf-8 -*-
|
|
# //@name:Catemby多播放
|
|
# //@id:catemby_multi
|
|
# //@version:8
|
|
|
|
import base64
|
|
import hashlib
|
|
import ipaddress
|
|
import json
|
|
import re
|
|
import struct
|
|
import threading
|
|
import time
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
from datetime import datetime
|
|
from urllib.parse import quote, unquote, urlsplit
|
|
|
|
import requests
|
|
|
|
try:
|
|
from com.github.catvod import Proxy as CatVodProxy
|
|
except Exception:
|
|
CatVodProxy = None
|
|
|
|
from base.spider import Spider as BaseSpider
|
|
|
|
|
|
class WafBlockedError(RuntimeError):
|
|
pass
|
|
|
|
|
|
class Spider(BaseSpider):
|
|
name = "Catemby多播放"
|
|
backend_parse = False
|
|
category_mode = False
|
|
categoryMode = False
|
|
|
|
API_BASE = "https://jdforrepam.com/api"
|
|
SOURCE_BASE = "https://catembylegacy.fastcdn.dpdns.org"
|
|
SOURCE_ORIGIN = SOURCE_BASE + "/"
|
|
SIGNATURE_TOKEN = "lpw6vgqzsp"
|
|
SIGNATURE_SALT = (
|
|
"71cf27bb3c0bcdf207b64abecddc970098c7421ee7203b9cdae54478478a199e7"
|
|
"d5a6e1a57691123c1a931c057842fb73ba3b3c83bcd69c17ccf174081e3d8aa"
|
|
)
|
|
|
|
PLAY_PREFIX = "catemby-play:"
|
|
DEFAULT_PIC = SOURCE_BASE + "/favicon.ico"
|
|
CATEGORY_SPECS = (
|
|
("censored", "有码", "0"),
|
|
("uncensored", "无码", "1"),
|
|
("western", "欧美", "2"),
|
|
("fc2", "FC2", "3"),
|
|
)
|
|
TYPE_BY_CATEGORY = {
|
|
item[0]: item[2] for item in CATEGORY_SPECS if item[2] is not None
|
|
}
|
|
PERIODS = (
|
|
("日榜", "daily"),
|
|
("周榜", "weekly"),
|
|
("月榜", "monthly"),
|
|
)
|
|
RESOURCE_FILTERS = (
|
|
("全部可用", "all"),
|
|
("可播放", "can_play"),
|
|
("含磁链", "magnets"),
|
|
("含字幕", "subtitle"),
|
|
)
|
|
SORTS = (
|
|
("热度", "watched_count"),
|
|
("最新", "release"),
|
|
("评分", "score"),
|
|
("想看", "want_watch_count"),
|
|
("磁链", "magnets_count"),
|
|
)
|
|
VIDEO_EXTS = (
|
|
".mp4",
|
|
".mkv",
|
|
".avi",
|
|
".mov",
|
|
".wmv",
|
|
".flv",
|
|
".ts",
|
|
".m2ts",
|
|
".webm",
|
|
".mpg",
|
|
".mpeg",
|
|
".m4v",
|
|
)
|
|
CHALLENGE_MARKERS = (
|
|
"just a moment",
|
|
"/cdn-cgi/challenge-platform",
|
|
"_cf_chl_opt",
|
|
"cf-turnstile",
|
|
"attention required",
|
|
)
|
|
|
|
def __init__(self):
|
|
try:
|
|
super().__init__()
|
|
except Exception:
|
|
pass
|
|
self.timeout = 20
|
|
self.speed_probe_timeout = 3
|
|
self.speed_probe = True
|
|
self.dynamic_tags = True
|
|
self.strict_direct_cards = True
|
|
self.direct_probe_limit = 12
|
|
self.min_direct_minutes = 30
|
|
self.full_probe_cache_ttl = 1800
|
|
self.show_unplayable = False
|
|
self.native_magnet_fallback = True
|
|
self.verify_tls = True
|
|
self.trust_env = True
|
|
self.proxy = ""
|
|
self.list_cache_ttl = 120
|
|
self.detail_cache_ttl = 21600
|
|
self.tag_cache_ttl = 21600
|
|
self.resolver_cache_ttl = 300
|
|
self.health_cache_ttl = 900
|
|
self.image_cache_ttl = 1800
|
|
self.max_variants = 12
|
|
self.max_magnets = 50
|
|
self.max_json_bytes = 2 * 1024 * 1024
|
|
self.max_image_bytes = 4 * 1024 * 1024
|
|
self.max_playlist_bytes = 1024 * 1024
|
|
self.user_agent = (
|
|
"Mozilla/5.0 (Linux; Android 10; TV) AppleWebKit/537.36 "
|
|
"Chrome/120.0 Safari/537.36"
|
|
)
|
|
self.alist_api = ""
|
|
self.alist_token = ""
|
|
self.alist_api_key = ""
|
|
self.alist_source = "catemby"
|
|
self.proxy_site_key = "catemby"
|
|
self.alist_timeout = 120
|
|
self._session = None
|
|
self._cache = {}
|
|
self._health = {}
|
|
self._playlist_cache = {}
|
|
self._image_cache = {}
|
|
self._media_meta_cache = {}
|
|
self._lock = threading.RLock()
|
|
self._reset_session()
|
|
|
|
def getName(self):
|
|
return self.name
|
|
|
|
def init(self, extend=""):
|
|
config = self._parse_dict(extend)
|
|
self.timeout = self._bounded_int(config.get("timeout"), self.timeout, 5, 45)
|
|
self.speed_probe_timeout = self._bounded_int(
|
|
config.get("speed_probe_timeout"), self.speed_probe_timeout, 1, 8
|
|
)
|
|
self.speed_probe = self._bool(config.get("speed_probe"), self.speed_probe)
|
|
self.dynamic_tags = self._bool(
|
|
config.get("dynamic_tags"), self.dynamic_tags
|
|
)
|
|
self.strict_direct_cards = self._bool(
|
|
config.get("strict_direct_cards"), self.strict_direct_cards
|
|
)
|
|
self.direct_probe_limit = self._bounded_int(
|
|
config.get("direct_probe_limit"), self.direct_probe_limit, 4, 24
|
|
)
|
|
self.min_direct_minutes = self._bounded_int(
|
|
config.get("min_direct_minutes"), self.min_direct_minutes, 1, 240
|
|
)
|
|
self.full_probe_cache_ttl = self._bounded_int(
|
|
config.get("full_probe_cache_ttl"),
|
|
self.full_probe_cache_ttl,
|
|
60,
|
|
86400,
|
|
)
|
|
self.show_unplayable = self._bool(
|
|
config.get("show_unplayable"), self.show_unplayable
|
|
)
|
|
self.native_magnet_fallback = self._bool(
|
|
config.get("native_magnet_fallback"), self.native_magnet_fallback
|
|
)
|
|
self.verify_tls = self._bool(config.get("verify_tls"), self.verify_tls)
|
|
self.trust_env = self._bool(config.get("trust_env"), self.trust_env)
|
|
self.list_cache_ttl = self._bounded_int(
|
|
config.get("list_cache_ttl"), self.list_cache_ttl, 0, 1800
|
|
)
|
|
self.detail_cache_ttl = self._bounded_int(
|
|
config.get("detail_cache_ttl"), self.detail_cache_ttl, 0, 86400
|
|
)
|
|
self.tag_cache_ttl = self._bounded_int(
|
|
config.get("tag_cache_ttl"), self.tag_cache_ttl, 0, 86400
|
|
)
|
|
self.resolver_cache_ttl = self._bounded_int(
|
|
config.get("resolver_cache_ttl"), self.resolver_cache_ttl, 0, 1800
|
|
)
|
|
self.health_cache_ttl = self._bounded_int(
|
|
config.get("health_cache_ttl"), self.health_cache_ttl, 30, 7200
|
|
)
|
|
self.max_variants = self._bounded_int(
|
|
config.get("max_variants"), self.max_variants, 1, 20
|
|
)
|
|
self.max_magnets = self._bounded_int(
|
|
config.get("max_magnets"), self.max_magnets, 1, 100
|
|
)
|
|
self.proxy = str(config.get("proxy") or "").strip()
|
|
user_agent = str(config.get("user_agent") or "").strip()
|
|
if user_agent:
|
|
self.user_agent = user_agent
|
|
self.alist_api = str(
|
|
config.get("alist_tvbox_api") or config.get("offline_api") or ""
|
|
).strip().rstrip("/")
|
|
self.alist_token = str(
|
|
config.get("alist_tvbox_token") or config.get("offline_token") or ""
|
|
).strip()
|
|
self.alist_api_key = str(
|
|
config.get("alist_tvbox_api_key") or config.get("offline_api_key") or ""
|
|
).strip()
|
|
self.alist_source = str(
|
|
config.get("alist_tvbox_source") or self.alist_source
|
|
).strip() or "catemby"
|
|
self.proxy_site_key = str(
|
|
config.get("proxy_site_key") or self.proxy_site_key
|
|
).strip() or "catemby"
|
|
self.alist_timeout = self._bounded_int(
|
|
config.get("alist_tvbox_timeout"), self.alist_timeout, 15, 300
|
|
)
|
|
with self._lock:
|
|
self._cache.clear()
|
|
self._health.clear()
|
|
self._playlist_cache.clear()
|
|
self._image_cache.clear()
|
|
self._media_meta_cache.clear()
|
|
self._reset_session()
|
|
|
|
def destroy(self):
|
|
if self._session is not None:
|
|
try:
|
|
self._session.close()
|
|
except Exception:
|
|
pass
|
|
self._session = None
|
|
with self._lock:
|
|
self._cache.clear()
|
|
self._health.clear()
|
|
self._playlist_cache.clear()
|
|
self._image_cache.clear()
|
|
self._media_meta_cache.clear()
|
|
|
|
def isVideoFormat(self, url):
|
|
text = str(url or "").lower()
|
|
return bool(
|
|
re.search(r"\.(?:m3u8|mp4|mkv|webm)(?:$|[?#])", text)
|
|
or "kind=hls" in text
|
|
)
|
|
|
|
def manualVideoCheck(self):
|
|
return False
|
|
|
|
def localProxy(self, param):
|
|
data = param if isinstance(param, dict) else self._parse_dict(param)
|
|
kind = str(data.get("kind") or "").strip().lower()
|
|
token = str(data.get("token") or "").strip()
|
|
try:
|
|
if kind == "image":
|
|
source_url = self._unpack_text(token)
|
|
decoded, mime = self._decoded_image(source_url)
|
|
return [
|
|
200,
|
|
mime,
|
|
decoded,
|
|
{
|
|
"Cache-Control": "public, max-age=1800",
|
|
"Access-Control-Allow-Origin": "*",
|
|
"Content-Length": str(len(decoded)),
|
|
"X-Content-Type-Options": "nosniff",
|
|
},
|
|
]
|
|
if kind == "hls":
|
|
cached = self._playlist_cache_get(token)
|
|
if cached is None:
|
|
return [404, "text/plain; charset=utf-8", b"playlist expired"]
|
|
return [
|
|
200,
|
|
"application/vnd.apple.mpegurl",
|
|
cached,
|
|
{
|
|
"Cache-Control": "no-store",
|
|
"Access-Control-Allow-Origin": "*",
|
|
},
|
|
]
|
|
except Exception as exc:
|
|
return [
|
|
502,
|
|
"text/plain; charset=utf-8",
|
|
("proxy error: %s" % exc).encode("utf-8", errors="replace"),
|
|
]
|
|
return [404, "text/plain; charset=utf-8", b"not found"]
|
|
|
|
def homeContent(self, filter):
|
|
classes = [
|
|
{"type_id": item[0], "type_name": item[1]}
|
|
for item in self.CATEGORY_SPECS
|
|
]
|
|
filters = {}
|
|
tag_map = self._load_all_tags() if self.dynamic_tags else {}
|
|
for type_id, _, content_type in self.CATEGORY_SPECS:
|
|
if content_type is None:
|
|
continue
|
|
rows = [self._filter("sort", "排序", self.SORTS)]
|
|
tags = tag_map.get(content_type) or []
|
|
if tags:
|
|
values = [("全部", "")]
|
|
for group in tags:
|
|
group_name = self._clean_text(
|
|
group.get("category") or group.get("category_id")
|
|
)
|
|
for tag in group.get("tags") or []:
|
|
tag_id = self._safe_filter_value(tag.get("id"))
|
|
tag_name = self._clean_text(tag.get("name") or tag_id)
|
|
if tag_id and tag_name:
|
|
values.append((group_name + "·" + tag_name, tag_id))
|
|
if len(values) > 1:
|
|
rows.append(self._filter("tag", "标签", values[:240]))
|
|
filters[type_id] = rows
|
|
return {"class": classes, "filters": filters}
|
|
|
|
def homeVideoContent(self):
|
|
result = self.categoryContent("censored", "1", False, {})
|
|
return {"list": result.get("list", []), "msg": result.get("msg", "")}
|
|
|
|
def categoryContent(self, tid, pg, filter, extend):
|
|
page = self._page(pg)
|
|
type_id = str(tid or "").strip()
|
|
selected = self._parse_dict(extend)
|
|
try:
|
|
content_type = self.TYPE_BY_CATEGORY.get(type_id)
|
|
if content_type is None:
|
|
return self._empty_page(page, "未知分类")
|
|
sort_by = self._choice(
|
|
selected.get("sort"), self.SORTS, "watched_count"
|
|
)
|
|
tag_id = self._safe_filter_value(selected.get("tag"))
|
|
filter_by = (
|
|
content_type + ":t:" + tag_id + "::::"
|
|
if tag_id
|
|
else content_type + ":t:::::"
|
|
)
|
|
data = self._api(
|
|
"/v1/movies/tags",
|
|
{
|
|
"filter_by": filter_by,
|
|
"sort_by": sort_by,
|
|
"order_by": "desc",
|
|
"page": page,
|
|
"limit": 24,
|
|
},
|
|
self.list_cache_ttl,
|
|
)
|
|
return self._page_result(
|
|
data.get("movies") or [], page, 24, True, self.strict_direct_cards
|
|
)
|
|
except Exception as exc:
|
|
return self._empty_page(page, "分类读取失败: %s" % exc)
|
|
|
|
def searchContent(self, key, quick, pg="1"):
|
|
keyword = self._clean_text(key)
|
|
page = self._page(pg)
|
|
if not keyword:
|
|
return self._empty_page(page)
|
|
try:
|
|
data = self._api(
|
|
"/v2/search",
|
|
{"q": keyword, "page": page, "type": "movie", "limit": 24},
|
|
self.list_cache_ttl,
|
|
)
|
|
return self._page_result(
|
|
data.get("movies") or [], page, 24, True, self.strict_direct_cards
|
|
)
|
|
except Exception as exc:
|
|
return self._empty_page(page, "搜索失败: %s" % exc)
|
|
|
|
def detailContent(self, ids):
|
|
raw_id = ids[0] if isinstance(ids, (list, tuple)) and ids else ids
|
|
movie_id = self._normalize_detail_id(raw_id)
|
|
if not movie_id:
|
|
return {"list": []}
|
|
try:
|
|
detail = self._api(
|
|
"/v4/movies/" + quote(movie_id, safe=""),
|
|
{},
|
|
self.detail_cache_ttl,
|
|
)
|
|
movie = detail.get("movie") or {}
|
|
if not movie:
|
|
raise RuntimeError("详情响应缺少 movie")
|
|
except Exception as exc:
|
|
return {"list": [self._detail_error(movie_id, str(exc))]}
|
|
|
|
magnets = []
|
|
magnet_error = ""
|
|
try:
|
|
magnet_data = self._api(
|
|
"/v1/movies/%s/magnets" % quote(movie_id, safe=""),
|
|
{},
|
|
self.detail_cache_ttl,
|
|
)
|
|
magnets = self._sort_magnets(magnet_data.get("magnets") or [])
|
|
except Exception as exc:
|
|
magnet_error = self._clean_text(exc)
|
|
|
|
variants = []
|
|
resolver_error = ""
|
|
number = self._clean_text(
|
|
movie.get("number") or movie.get("number_letter") or movie_id
|
|
)
|
|
if movie.get("can_play") and number:
|
|
try:
|
|
variants = self._full_direct_variants(
|
|
self._resolve_variants(number), movie.get("duration")
|
|
)
|
|
except Exception as exc:
|
|
resolver_error = self._clean_text(exc)
|
|
|
|
vod = self._build_detail_vod(
|
|
movie_id, movie, variants, magnets, resolver_error, magnet_error
|
|
)
|
|
return {"list": [vod]}
|
|
|
|
def playerContent(self, flag, id, vipFlags):
|
|
payload = self._unpack_play_id(id)
|
|
if not payload:
|
|
return self._player_error("invalid_play_id", "无法识别播放 ID")
|
|
kind = str(payload.get("kind") or "")
|
|
try:
|
|
if kind in ("auto", "variant"):
|
|
code = self._clean_text(payload.get("code"))
|
|
if not code:
|
|
return self._player_error("missing_code", "播放 ID 缺少番号")
|
|
variants = self._full_direct_variants(
|
|
self._resolve_variants(code, fresh=True),
|
|
payload.get("declared_duration"),
|
|
)
|
|
if not variants:
|
|
return self._player_error("resolver_empty", "解析器未返回播放变体")
|
|
if kind == "auto":
|
|
mode = str(payload.get("mode") or "quality")
|
|
if self.speed_probe:
|
|
self._measure_variants(variants)
|
|
ordered = self._sort_variants(variants, mode)
|
|
healthy = [
|
|
item
|
|
for item in ordered
|
|
if (self._health_for_variant(item) or {}).get("ok")
|
|
]
|
|
unknown = [
|
|
item
|
|
for item in ordered
|
|
if self._health_for_variant(item) is None
|
|
]
|
|
candidates = healthy or unknown or ordered
|
|
selected = candidates[0] if candidates else None
|
|
else:
|
|
selected = self._find_variant(variants, payload)
|
|
if selected and self.speed_probe:
|
|
self._measure_variants(variants)
|
|
health = self._health_for_variant(selected)
|
|
if health and not health.get("ok"):
|
|
mode = str(payload.get("mode") or "quality")
|
|
ordered = self._sort_variants(variants, mode)
|
|
healthy = [
|
|
item
|
|
for item in ordered
|
|
if (self._health_for_variant(item) or {}).get("ok")
|
|
]
|
|
if healthy:
|
|
selected = healthy[0]
|
|
if not selected:
|
|
return self._player_error("variant_missing", "目标播放变体已失效")
|
|
return self._variant_player(selected)
|
|
if kind == "preview":
|
|
return self._player_error("preview_rejected", "预览视频已被完整版门禁过滤")
|
|
if kind == "push":
|
|
url = str(payload.get("url") or "").strip()
|
|
if not self._is_public_http_url(url):
|
|
return self._player_error("push_rejected", "分享地址无效")
|
|
return {
|
|
"parse": 0,
|
|
"jx": 0,
|
|
"playUrl": "",
|
|
"url": "push://" + url,
|
|
"header": {},
|
|
}
|
|
if kind == "magnet":
|
|
magnet = self._normalize_magnet(payload.get("magnet"))
|
|
if not magnet:
|
|
return self._player_error("magnet_invalid", "磁力哈希无效")
|
|
return self._magnet_player(magnet)
|
|
if kind == "error":
|
|
return self._player_error(
|
|
str(payload.get("code") or "no_resources"),
|
|
payload.get("message") or "源站暂无可播放资源",
|
|
)
|
|
except WafBlockedError as exc:
|
|
return self._player_error("blocked_by_waf", str(exc))
|
|
except Exception as exc:
|
|
return self._player_error("playback_failed", str(exc))
|
|
return self._player_error("unsupported_play_kind", "不支持的播放方式")
|
|
|
|
def _build_detail_vod(
|
|
self, movie_id, movie, variants, magnets, resolver_error, magnet_error
|
|
):
|
|
number = self._clean_text(
|
|
movie.get("number") or movie.get("number_letter") or movie_id
|
|
)
|
|
title = self._clean_text(
|
|
movie.get("title") or movie.get("origin_title") or number
|
|
)
|
|
display_title = (number + " " + title).strip()
|
|
pic = self._image_proxy_url(
|
|
movie.get("cover_url")
|
|
or movie.get("thumb_url")
|
|
or self._first_preview_image(movie)
|
|
or ""
|
|
)
|
|
groups = []
|
|
content = []
|
|
declared_duration = self._number(movie.get("duration"))
|
|
|
|
if variants:
|
|
smart_items = [
|
|
(
|
|
"画质自动",
|
|
self._pack_play_id(
|
|
{
|
|
"kind": "auto",
|
|
"code": number,
|
|
"mode": "quality",
|
|
"declared_duration": declared_duration,
|
|
}
|
|
),
|
|
),
|
|
(
|
|
"极速自动",
|
|
self._pack_play_id(
|
|
{
|
|
"kind": "auto",
|
|
"code": number,
|
|
"mode": "speed",
|
|
"declared_duration": declared_duration,
|
|
}
|
|
),
|
|
),
|
|
]
|
|
groups.append(("智能线路", smart_items))
|
|
groups.append(
|
|
(
|
|
"画质优先",
|
|
self._variant_entries(
|
|
self._sort_variants(variants, "quality"),
|
|
number,
|
|
"quality",
|
|
declared_duration,
|
|
),
|
|
)
|
|
)
|
|
groups.append(
|
|
(
|
|
"极速优先",
|
|
self._variant_entries(
|
|
self._sort_variants(variants, "speed"),
|
|
number,
|
|
"speed",
|
|
declared_duration,
|
|
),
|
|
)
|
|
)
|
|
push_items = []
|
|
magnet_items = []
|
|
for item in magnets[: self.max_magnets]:
|
|
label = self._magnet_label(item)
|
|
magnet = self._normalize_magnet(item.get("hash") or item.get("magnet"))
|
|
if magnet:
|
|
magnet_items.append(
|
|
(
|
|
label,
|
|
self._pack_play_id(
|
|
{"kind": "magnet", "magnet": magnet, "title": label}
|
|
),
|
|
)
|
|
)
|
|
push_url = str(item.get("pikpak_url") or "").strip()
|
|
if self._is_public_http_url(push_url):
|
|
push_items.append(
|
|
(
|
|
label,
|
|
self._pack_play_id({"kind": "push", "url": push_url}),
|
|
)
|
|
)
|
|
if push_items:
|
|
groups.append(("PikPak分享", push_items))
|
|
if magnet_items:
|
|
groups.append(("磁力完整版", magnet_items))
|
|
|
|
if not groups:
|
|
no_resource_message = (
|
|
"源站当前没有直连、磁力或预览资源;请使用客户端全局搜索番号 %s"
|
|
% number
|
|
)
|
|
groups.append(
|
|
(
|
|
"资源状态",
|
|
[
|
|
(
|
|
"暂无资源 · 搜索 " + number,
|
|
self._pack_play_id(
|
|
{
|
|
"kind": "error",
|
|
"code": "no_resources",
|
|
"message": no_resource_message,
|
|
}
|
|
),
|
|
)
|
|
],
|
|
)
|
|
)
|
|
content.append(no_resource_message)
|
|
|
|
play_from = []
|
|
play_url = []
|
|
for group_name, entries in groups:
|
|
valid = [(self._safe_play_name(n), value) for n, value in entries if value]
|
|
if not valid:
|
|
continue
|
|
play_from.append(group_name)
|
|
play_url.append("#".join("%s$%s" % item for item in valid))
|
|
|
|
summary = self._clean_text(movie.get("summary"))
|
|
if summary:
|
|
content.append(summary)
|
|
maker = self._clean_text(movie.get("maker_name"))
|
|
director = self._clean_text(movie.get("director_name"))
|
|
series = self._clean_text(movie.get("series_name"))
|
|
metadata = " · ".join(item for item in (maker, director, series) if item)
|
|
if metadata:
|
|
content.append(metadata)
|
|
if resolver_error:
|
|
content.append("直连解析暂不可用: " + resolver_error)
|
|
if magnet_error:
|
|
content.append("磁力列表暂不可用: " + magnet_error)
|
|
tags = [
|
|
self._clean_text(item.get("name") if isinstance(item, dict) else item)
|
|
for item in movie.get("tags") or []
|
|
]
|
|
actors = [
|
|
self._clean_text(item.get("name") if isinstance(item, dict) else item)
|
|
for item in movie.get("actors") or []
|
|
]
|
|
duration = self._duration(movie.get("duration"))
|
|
score = self._number(movie.get("score"))
|
|
remarks = []
|
|
if variants:
|
|
remarks.append("完整版直连")
|
|
if magnets:
|
|
remarks.append("磁力%d" % len(magnets))
|
|
if movie.get("has_cnsub") or self._number(movie.get("play_subtitle")) > 0:
|
|
remarks.append("中字")
|
|
return {
|
|
"vod_id": movie_id,
|
|
"vod_name": display_title,
|
|
"vod_pic": pic or self.DEFAULT_PIC,
|
|
"vod_remarks": " · ".join(remarks) or number,
|
|
"vod_content": "\n".join(content),
|
|
"vod_actor": ", ".join(item for item in actors if item),
|
|
"vod_class": ", ".join(item for item in tags if item),
|
|
"vod_director": director,
|
|
"vod_year": str(movie.get("release_date") or "")[:4],
|
|
"vod_area": self._area_name(movie.get("type")),
|
|
"vod_duration": duration,
|
|
"vod_score": score,
|
|
"vod_play_from": "$$$".join(play_from),
|
|
"vod_play_url": "$$$".join(play_url),
|
|
}
|
|
|
|
def _variant_entries(self, variants, code, mode, declared_duration=0):
|
|
entries = []
|
|
for variant in variants[: self.max_variants]:
|
|
payload = {
|
|
"kind": "variant",
|
|
"code": code,
|
|
"fingerprint": variant.get("fingerprint"),
|
|
"index": variant.get("index"),
|
|
"variant": variant.get("variant"),
|
|
"transport": variant.get("transport"),
|
|
"mode": mode,
|
|
"declared_duration": self._number(declared_duration),
|
|
}
|
|
entries.append((self._variant_label(variant), self._pack_play_id(payload)))
|
|
return entries
|
|
|
|
def _resolve_variants(self, code, fresh=False, isolated=False):
|
|
cache_key = "resolver:" + code
|
|
cached = self._cache_get(cache_key)
|
|
if not fresh and cached is not None:
|
|
return cached
|
|
errors = []
|
|
client = self._new_session() if isolated else None
|
|
for resolver_code in self._resolver_code_candidates(code):
|
|
url = (
|
|
self.SOURCE_BASE
|
|
+ "/api/v/resolve?code="
|
|
+ quote(resolver_code, safe="")
|
|
+ "&lang=zh"
|
|
)
|
|
try:
|
|
body = self._request_json_url(
|
|
url,
|
|
{
|
|
"Accept": "application/json",
|
|
"Referer": self.SOURCE_ORIGIN,
|
|
"User-Agent": self.user_agent,
|
|
},
|
|
self.max_json_bytes,
|
|
session=client,
|
|
)
|
|
except Exception as exc:
|
|
errors.append("%s: %s" % (resolver_code, self._clean_text(exc)))
|
|
continue
|
|
raw_variants = body.get("variants")
|
|
if raw_variants is None and isinstance(body.get("data"), dict):
|
|
raw_variants = body["data"].get("variants")
|
|
if raw_variants is None and isinstance(body.get("result"), dict):
|
|
raw_variants = body["result"].get("variants")
|
|
variants = []
|
|
for index, item in enumerate(raw_variants or []):
|
|
normalized = self._normalize_variant(item, index)
|
|
if normalized:
|
|
normalized["resolver_code"] = resolver_code
|
|
variants.append(normalized)
|
|
variants = variants[: self.max_variants]
|
|
if variants:
|
|
self._cache_set(cache_key, variants, self.resolver_cache_ttl)
|
|
if client is not None:
|
|
client.close()
|
|
return variants
|
|
errors.append(resolver_code + ": empty_variants")
|
|
|
|
usable = self._usable_cached_variants(cached)
|
|
if client is not None:
|
|
client.close()
|
|
if usable:
|
|
return usable
|
|
if errors:
|
|
raise RuntimeError("解析器候选均失败: " + " | ".join(errors))
|
|
return []
|
|
|
|
def _full_direct_variants(self, variants, declared_duration=0):
|
|
candidates = [
|
|
item
|
|
for item in variants or []
|
|
if item.get("transport") == "progressive"
|
|
and self._is_public_http_url(item.get("url"))
|
|
]
|
|
if not candidates:
|
|
return []
|
|
accepted = []
|
|
with ThreadPoolExecutor(max_workers=min(4, len(candidates))) as executor:
|
|
jobs = {
|
|
executor.submit(self._probe_full_progressive, item): item
|
|
for item in candidates
|
|
}
|
|
for future in as_completed(jobs):
|
|
item = jobs[future]
|
|
try:
|
|
meta = future.result()
|
|
except Exception:
|
|
continue
|
|
if not meta.get("ok"):
|
|
continue
|
|
enriched = dict(item)
|
|
enriched["duration_seconds"] = meta.get("duration_seconds")
|
|
enriched["bytes_total"] = meta.get("bytes_total")
|
|
enriched["full_probe"] = meta
|
|
accepted.append(enriched)
|
|
order = {item.get("fingerprint"): index for index, item in enumerate(candidates)}
|
|
accepted.sort(key=lambda item: order.get(item.get("fingerprint"), 9999))
|
|
return accepted
|
|
|
|
def _probe_full_progressive(self, variant):
|
|
url = str(variant.get("url") or "").strip()
|
|
if not self._is_public_http_url(url):
|
|
return {"ok": False, "reason": "invalid_url"}
|
|
cache_key = hashlib.sha256(url.encode("utf-8")).hexdigest()
|
|
now = time.time()
|
|
with self._lock:
|
|
cached = self._media_meta_cache.get(cache_key)
|
|
if cached and now - cached[0] <= self.full_probe_cache_ttl:
|
|
return dict(cached[1])
|
|
|
|
client = self._new_session()
|
|
response = None
|
|
result = {"ok": False, "reason": "probe_failed"}
|
|
try:
|
|
headers = self._media_headers(variant.get("page_url"))
|
|
head_headers = dict(headers)
|
|
head_headers["Range"] = "bytes=0-131071"
|
|
response = client.get(
|
|
url,
|
|
headers=head_headers,
|
|
timeout=(self.speed_probe_timeout, max(self.speed_probe_timeout, 8)),
|
|
allow_redirects=True,
|
|
verify=self.verify_tls,
|
|
stream=True,
|
|
)
|
|
final_url = str(response.url or url)
|
|
if not self._is_public_http_url(final_url):
|
|
raise RuntimeError("媒体跳转到非公网地址")
|
|
content_type = str(response.headers.get("Content-Type") or "").lower()
|
|
head = self._read_prefix(response, 131072)
|
|
status = int(response.status_code)
|
|
total = self._response_total_bytes(response.headers, len(head))
|
|
duration = self._mp4_duration_seconds(head)
|
|
has_ftyp = b"ftyp" in head[:64]
|
|
if (
|
|
duration is None
|
|
and total > 1048576
|
|
and 200 <= status < 400
|
|
and "image/" not in content_type
|
|
):
|
|
response.close()
|
|
response = None
|
|
tail_headers = dict(headers)
|
|
tail_headers["Range"] = "bytes=%d-%d" % (
|
|
max(0, total - 1048576),
|
|
total - 1,
|
|
)
|
|
response = client.get(
|
|
final_url,
|
|
headers=tail_headers,
|
|
timeout=(self.speed_probe_timeout, max(self.speed_probe_timeout, 8)),
|
|
allow_redirects=True,
|
|
verify=self.verify_tls,
|
|
stream=True,
|
|
)
|
|
tail_url = str(response.url or final_url)
|
|
if not self._is_public_http_url(tail_url):
|
|
raise RuntimeError("媒体尾部跳转到非公网地址")
|
|
duration = self._mp4_duration_seconds(
|
|
self._read_prefix(response, 1048576)
|
|
)
|
|
minimum = float(self.min_direct_minutes * 60)
|
|
result = {
|
|
"ok": bool(
|
|
200 <= status < 400
|
|
and has_ftyp
|
|
and not content_type.startswith(("image/", "text/html"))
|
|
and duration is not None
|
|
and duration >= minimum
|
|
),
|
|
"status": status,
|
|
"content_type": content_type,
|
|
"bytes_total": total,
|
|
"duration_seconds": duration,
|
|
"duration_minutes": round(duration / 60.0, 2) if duration else 0,
|
|
"minimum_minutes": self.min_direct_minutes,
|
|
"has_ftyp": has_ftyp,
|
|
}
|
|
if duration is None:
|
|
result["reason"] = "duration_unknown"
|
|
elif duration < minimum:
|
|
result["reason"] = "preview_too_short"
|
|
elif not has_ftyp:
|
|
result["reason"] = "not_mp4"
|
|
except Exception as exc:
|
|
result = {"ok": False, "reason": self._clean_text(exc) or "probe_failed"}
|
|
finally:
|
|
if response is not None:
|
|
response.close()
|
|
client.close()
|
|
with self._lock:
|
|
self._media_meta_cache[cache_key] = (time.time(), dict(result))
|
|
self._trim_timed_cache(self._media_meta_cache, 128)
|
|
return result
|
|
|
|
@staticmethod
|
|
def _read_prefix(response, maximum):
|
|
body = bytearray()
|
|
for chunk in response.iter_content(16384):
|
|
if not chunk:
|
|
continue
|
|
remaining = maximum - len(body)
|
|
if remaining <= 0:
|
|
break
|
|
body.extend(chunk[:remaining])
|
|
if len(body) >= maximum:
|
|
break
|
|
return bytes(body)
|
|
|
|
@staticmethod
|
|
def _response_total_bytes(headers, fallback=0):
|
|
content_range = str(headers.get("Content-Range") or "")
|
|
match = re.search(r"/([0-9]+)$", content_range)
|
|
if match:
|
|
return int(match.group(1))
|
|
try:
|
|
return int(headers.get("Content-Length") or fallback or 0)
|
|
except (TypeError, ValueError):
|
|
return int(fallback or 0)
|
|
|
|
@staticmethod
|
|
def _mp4_duration_seconds(raw):
|
|
body = raw or b""
|
|
offset = 0
|
|
while True:
|
|
marker = body.find(b"mvhd", offset)
|
|
if marker < 0:
|
|
return None
|
|
start = marker + 4
|
|
if start + 20 > len(body):
|
|
return None
|
|
version = body[start]
|
|
try:
|
|
if version == 0:
|
|
timescale = struct.unpack(">I", body[start + 12 : start + 16])[0]
|
|
duration = struct.unpack(">I", body[start + 16 : start + 20])[0]
|
|
elif version == 1 and start + 32 <= len(body):
|
|
timescale = struct.unpack(">I", body[start + 20 : start + 24])[0]
|
|
duration = struct.unpack(">Q", body[start + 24 : start + 32])[0]
|
|
else:
|
|
offset = marker + 4
|
|
continue
|
|
except struct.error:
|
|
return None
|
|
if timescale and duration:
|
|
return float(duration) / float(timescale)
|
|
offset = marker + 4
|
|
|
|
@staticmethod
|
|
def _resolver_code_candidates(code):
|
|
value = re.sub(r"\s+", "", str(code or "")).upper()
|
|
candidates = []
|
|
match = re.match(r"^FC2[-_]?([0-9]{5,})$", value)
|
|
if match:
|
|
candidates.append("FC2PPV-" + match.group(1))
|
|
candidates.append(str(code or "").strip())
|
|
result = []
|
|
for item in candidates:
|
|
if item and item not in result:
|
|
result.append(item)
|
|
return result
|
|
|
|
@staticmethod
|
|
def _usable_cached_variants(cached):
|
|
now = time.time() + 5
|
|
return [
|
|
item
|
|
for item in cached or []
|
|
if not item.get("expires_at") or item.get("expires_at") > now
|
|
]
|
|
|
|
def _normalize_variant(self, raw, index):
|
|
if not isinstance(raw, dict):
|
|
return None
|
|
url = str(
|
|
raw.get("sourceUrl")
|
|
or raw.get("source_url")
|
|
or raw.get("playUrl")
|
|
or raw.get("url")
|
|
or ""
|
|
).strip()
|
|
is_data_hls = url.lower().startswith(
|
|
"data:application/vnd.apple.mpegurl"
|
|
)
|
|
if not is_data_hls and not self._is_public_http_url(url):
|
|
return None
|
|
source_type = self._clean_text(raw.get("sourceType")).lower()
|
|
variant_name = self._clean_text(raw.get("variant")).lower()
|
|
quality = self._clean_text(raw.get("quality"))
|
|
label = self._clean_text(raw.get("label") or variant_name or "线路")
|
|
transport = "hls" if is_data_hls or "mpegurl" in source_type else "progressive"
|
|
container = "mp4" if "mp4" in source_type or re.search(r"\.mp4(?:$|[?#])", url, re.I) else ("hls" if transport == "hls" else "unknown")
|
|
height = self._quality_height(quality + " " + label)
|
|
bitrate = self._number(raw.get("bitrate"))
|
|
expires_at = self._expiry_epoch(raw.get("expiresAt") or raw.get("expires_at"))
|
|
if expires_at and expires_at <= time.time() + 5:
|
|
return None
|
|
fingerprint_source = "|".join(
|
|
(variant_name, label, source_type, quality, str(index))
|
|
)
|
|
fingerprint = hashlib.sha256(
|
|
fingerprint_source.encode("utf-8")
|
|
).hexdigest()[:16]
|
|
return {
|
|
"url": url,
|
|
"source_type": source_type,
|
|
"variant": variant_name,
|
|
"quality": quality,
|
|
"label": label,
|
|
"transport": transport,
|
|
"container": container,
|
|
"height": height,
|
|
"bitrate": bitrate,
|
|
"expires_at": expires_at,
|
|
"page_url": str(raw.get("pageUrl") or raw.get("page_url") or ""),
|
|
"index": index,
|
|
"fingerprint": fingerprint,
|
|
}
|
|
|
|
def _sort_variants(self, variants, mode):
|
|
if mode == "speed":
|
|
return sorted(variants, key=self._speed_sort_key)
|
|
return sorted(variants, key=self._quality_sort_key)
|
|
|
|
def _quality_sort_key(self, item):
|
|
name = str(item.get("variant") or "").lower()
|
|
original = 2 if name == "original" else (1 if "original" in name else 0)
|
|
transport = 1 if item.get("container") == "mp4" else 0
|
|
return (
|
|
-original,
|
|
-int(item.get("height") or 0),
|
|
-int(item.get("bitrate") or 0),
|
|
-transport,
|
|
int(item.get("index") or 0),
|
|
)
|
|
|
|
def _speed_sort_key(self, item):
|
|
health = self._health_for_variant(item)
|
|
state = 0 if health and health.get("ok") else (1 if not health else 2)
|
|
rtt = int(health.get("rtt_ms") or 999999) if health else 999999
|
|
transport = 0 if item.get("container") == "mp4" else 1
|
|
return (
|
|
state,
|
|
rtt,
|
|
transport,
|
|
int(item.get("index") or 0),
|
|
)
|
|
|
|
def _measure_variants(self, variants):
|
|
targets = []
|
|
seen = set()
|
|
for item in variants:
|
|
target = self._probe_target(item)
|
|
if not target:
|
|
continue
|
|
key = self._health_key(item, target)
|
|
if key in seen or self._health_get(key) is not None:
|
|
continue
|
|
seen.add(key)
|
|
targets.append((item, target, key))
|
|
if len(targets) >= 4:
|
|
break
|
|
if not targets:
|
|
return
|
|
with ThreadPoolExecutor(max_workers=min(4, len(targets))) as executor:
|
|
jobs = {
|
|
executor.submit(self._probe_media_target, item, target): key
|
|
for item, target, key in targets
|
|
}
|
|
for future in as_completed(jobs):
|
|
key = jobs[future]
|
|
try:
|
|
result = future.result()
|
|
except Exception:
|
|
result = {
|
|
"ok": False,
|
|
"rtt_ms": 999999,
|
|
"checked_at": time.time(),
|
|
}
|
|
self._health_set(key, result)
|
|
|
|
def _probe_media_target(self, item, target):
|
|
started = time.monotonic()
|
|
result = {"ok": False, "rtt_ms": 999999, "checked_at": time.time()}
|
|
client = self._new_session()
|
|
response = None
|
|
try:
|
|
headers = self._media_headers(item.get("page_url"))
|
|
if item.get("transport") == "hls":
|
|
range_headers = dict(headers)
|
|
range_headers["Range"] = "bytes=0-1503"
|
|
response = client.get(
|
|
target,
|
|
headers=range_headers,
|
|
timeout=(self.speed_probe_timeout, self.speed_probe_timeout),
|
|
allow_redirects=True,
|
|
verify=self.verify_tls,
|
|
stream=True,
|
|
)
|
|
final_url = str(response.url or target)
|
|
if not self._is_public_http_url(final_url):
|
|
raise RuntimeError("HLS 分片探测跳转到非公网地址")
|
|
prefix = b""
|
|
for chunk in response.iter_content(512):
|
|
if chunk:
|
|
prefix += chunk
|
|
if len(prefix) >= 1504:
|
|
prefix = prefix[:1504]
|
|
break
|
|
content_type = str(response.headers.get("Content-Type") or "")
|
|
segment_kind = self._hls_segment_kind(prefix, content_type)
|
|
result.update(
|
|
{
|
|
"method": "GET_RANGE_HLS",
|
|
"rtt_ms": int((time.monotonic() - started) * 1000),
|
|
"ok": 200 <= response.status_code < 400 and bool(segment_kind),
|
|
"status": int(response.status_code),
|
|
"content_type": content_type,
|
|
"segment_kind": segment_kind,
|
|
}
|
|
)
|
|
return result
|
|
response = client.head(
|
|
target,
|
|
headers=headers,
|
|
timeout=(self.speed_probe_timeout, self.speed_probe_timeout),
|
|
allow_redirects=True,
|
|
verify=self.verify_tls,
|
|
)
|
|
if response.status_code in (400, 403, 405, 501):
|
|
response.close()
|
|
response = None
|
|
range_headers = dict(headers)
|
|
range_headers["Range"] = "bytes=0-0"
|
|
response = client.get(
|
|
target,
|
|
headers=range_headers,
|
|
timeout=(self.speed_probe_timeout, self.speed_probe_timeout),
|
|
allow_redirects=True,
|
|
verify=self.verify_tls,
|
|
stream=True,
|
|
)
|
|
result["method"] = "GET_RANGE"
|
|
else:
|
|
result["method"] = "HEAD"
|
|
final_url = str(response.url or target)
|
|
if not self._is_public_http_url(final_url):
|
|
raise RuntimeError("媒体探测跳转到非公网地址")
|
|
result["rtt_ms"] = int((time.monotonic() - started) * 1000)
|
|
result["ok"] = 200 <= response.status_code < 400
|
|
result["status"] = int(response.status_code)
|
|
result["accept_ranges"] = str(
|
|
response.headers.get("Accept-Ranges") or ""
|
|
)
|
|
except Exception:
|
|
result["rtt_ms"] = int((time.monotonic() - started) * 1000)
|
|
finally:
|
|
if response is not None:
|
|
response.close()
|
|
client.close()
|
|
return result
|
|
|
|
@staticmethod
|
|
def _hls_segment_kind(raw, content_type=""):
|
|
body = raw or b""
|
|
mime = str(content_type or "").lower()
|
|
if not body or mime.startswith("image/"):
|
|
return ""
|
|
if body.startswith((b"\x89PNG\r\n\x1a\n", b"\xff\xd8\xff", b"GIF87a", b"GIF89a")):
|
|
return ""
|
|
maximum = min(188, len(body))
|
|
for offset in range(maximum):
|
|
if offset + 376 < len(body):
|
|
if (
|
|
body[offset] == 0x47
|
|
and body[offset + 188] == 0x47
|
|
and body[offset + 376] == 0x47
|
|
):
|
|
return "mpeg-ts"
|
|
if any(marker in body[:64] for marker in (b"ftyp", b"styp", b"moof")):
|
|
return "fmp4"
|
|
return ""
|
|
|
|
def _probe_target(self, variant):
|
|
url = str(variant.get("url") or "")
|
|
if self._is_public_http_url(url):
|
|
return url
|
|
if variant.get("transport") == "hls":
|
|
try:
|
|
playlist = self._decode_data_playlist(url).decode("utf-8")
|
|
for line in playlist.splitlines():
|
|
line = line.strip()
|
|
if line and not line.startswith("#") and self._is_public_http_url(line):
|
|
return line
|
|
except Exception:
|
|
return ""
|
|
return ""
|
|
|
|
def _health_for_variant(self, variant):
|
|
target = self._probe_target(variant)
|
|
if not target:
|
|
return None
|
|
return self._health_get(self._health_key(variant, target))
|
|
|
|
def _health_key(self, variant, target):
|
|
return "%s:%s" % (
|
|
variant.get("transport") or "unknown",
|
|
(urlsplit(target).hostname or "").lower(),
|
|
)
|
|
|
|
def _find_variant(self, variants, payload):
|
|
fingerprint = str(payload.get("fingerprint") or "")
|
|
for item in variants:
|
|
if fingerprint and item.get("fingerprint") == fingerprint:
|
|
return item
|
|
variant_name = str(payload.get("variant") or "")
|
|
transport = str(payload.get("transport") or "")
|
|
if variant_name:
|
|
for item in variants:
|
|
if item.get("variant") != variant_name:
|
|
continue
|
|
if transport and item.get("transport") != transport:
|
|
continue
|
|
return item
|
|
index = self._bounded_int(payload.get("index"), -1, -1, 1000)
|
|
for item in variants:
|
|
if item.get("index") == index:
|
|
return item
|
|
return None
|
|
|
|
def _variant_player(self, variant):
|
|
url = str(variant.get("url") or "")
|
|
referer = str(variant.get("page_url") or self.SOURCE_ORIGIN)
|
|
if variant.get("transport") == "hls" and url.lower().startswith("data:"):
|
|
playlist = self._decode_data_playlist(url)
|
|
token = hashlib.sha256(playlist).hexdigest()[:24]
|
|
with self._lock:
|
|
self._playlist_cache[token] = (time.time(), playlist)
|
|
self._trim_timed_cache(self._playlist_cache, 16)
|
|
proxy_url = self._local_proxy_url("hls", token)
|
|
return self._direct_player(proxy_url, "m3u8", referer)
|
|
if not self._is_public_http_url(url):
|
|
return self._player_error("media_rejected", "媒体地址无效")
|
|
media_type = "m3u8" if variant.get("transport") == "hls" else "mp4"
|
|
return self._direct_player(url, media_type, referer)
|
|
|
|
def _direct_player(self, url, media_type, referer):
|
|
result = {
|
|
"parse": 0,
|
|
"jx": 0,
|
|
"playUrl": "",
|
|
"url": url,
|
|
"header": self._media_headers(referer),
|
|
"type": media_type,
|
|
}
|
|
if media_type == "m3u8":
|
|
result["format"] = "application/x-mpegURL"
|
|
return result
|
|
|
|
def _media_headers(self, referer=""):
|
|
value = str(referer or self.SOURCE_ORIGIN).strip()
|
|
if not self._is_public_http_url(value):
|
|
value = self.SOURCE_ORIGIN
|
|
return {
|
|
"User-Agent": self.user_agent,
|
|
"Referer": value,
|
|
"Origin": self._origin(value),
|
|
}
|
|
|
|
def _sort_magnets(self, raw_items):
|
|
items = []
|
|
seen = set()
|
|
for index, raw in enumerate(raw_items or []):
|
|
if not isinstance(raw, dict):
|
|
continue
|
|
magnet = self._normalize_magnet(raw.get("hash") or raw.get("magnet"))
|
|
btih = self._extract_btih(magnet)
|
|
if not btih or btih in seen:
|
|
continue
|
|
seen.add(btih)
|
|
item = dict(raw)
|
|
name = self._clean_text(item.get("name"))
|
|
item["magnet"] = magnet
|
|
item["is_subtitle"] = bool(item.get("cnsub")) or self._has_subtitle(name)
|
|
item["is_hd"] = bool(item.get("hd")) or self._has_hd(name)
|
|
item["size_value"] = self._number(item.get("size"))
|
|
item["date_value"] = self._date_value(item.get("created_at"))
|
|
item["files_value"] = int(self._number(item.get("files_count")))
|
|
item["source_index"] = index
|
|
items.append(item)
|
|
return sorted(
|
|
items,
|
|
key=lambda item: (
|
|
0 if item.get("is_subtitle") else 1,
|
|
0 if item.get("is_hd") else 1,
|
|
-float(item.get("size_value") or 0),
|
|
-int(item.get("date_value") or 0),
|
|
-int(item.get("files_value") or 0),
|
|
int(item.get("source_index") or 0),
|
|
),
|
|
)
|
|
|
|
def _magnet_label(self, item):
|
|
flags = []
|
|
if item.get("is_subtitle"):
|
|
flags.append("中字")
|
|
if item.get("is_hd"):
|
|
flags.append("HD")
|
|
size = self._format_source_size(item.get("size_value"))
|
|
date = self._clean_text(item.get("created_at"))
|
|
files = int(item.get("files_value") or 0)
|
|
meta = [item for item in (size, date, "%d文件" % files if files else "") if item]
|
|
name = self._clean_text(item.get("name")) or "磁力资源"
|
|
prefix = " ".join("[%s]" % item for item in flags)
|
|
return " | ".join(item for item in (prefix, " · ".join(meta), name) if item)
|
|
|
|
def _magnet_player(self, magnet):
|
|
if not self.alist_api or not self.alist_token:
|
|
if self.native_magnet_fallback:
|
|
return {
|
|
"parse": 0,
|
|
"jx": 0,
|
|
"playUrl": "",
|
|
"url": "push://" + magnet,
|
|
"header": {},
|
|
}
|
|
return self._player_error(
|
|
"magnet_offline_not_configured",
|
|
"磁力已收录;客户端原生磁力兜底已关闭,且未配置 AList 离线服务",
|
|
)
|
|
endpoint = self.alist_api + "/offline_download/" + quote(
|
|
self.alist_token, safe=""
|
|
)
|
|
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
|
if self.alist_api_key:
|
|
headers["X-API-KEY"] = self.alist_api_key
|
|
response = self._session.post(
|
|
endpoint,
|
|
params={"ac": "gui"},
|
|
json={"url": magnet, "type": "magnet", "source": self.alist_source},
|
|
headers=headers,
|
|
timeout=(10, self.alist_timeout),
|
|
verify=self.verify_tls,
|
|
)
|
|
try:
|
|
body = response.json()
|
|
except Exception:
|
|
body = {}
|
|
if response.status_code >= 400:
|
|
detail = self._clean_text(
|
|
body.get("detail") or body.get("message") or response.text
|
|
)
|
|
return self._player_error("offline_http_%d" % response.status_code, detail)
|
|
items = self._offline_video_items(body)
|
|
if not items:
|
|
detail = self._clean_text(
|
|
body.get("detail") or body.get("message") or "离线任务尚未返回视频文件"
|
|
)
|
|
return self._player_error("offline_pending", detail)
|
|
chosen = items[0]
|
|
return {
|
|
"parse": 0,
|
|
"jx": 0,
|
|
"playUrl": "",
|
|
"url": chosen.get("url") or "",
|
|
"header": chosen.get("header") if isinstance(chosen.get("header"), dict) else {},
|
|
}
|
|
|
|
def _offline_video_items(self, body):
|
|
items = []
|
|
seen = set()
|
|
for group in body.get("list") or []:
|
|
if not isinstance(group, dict):
|
|
continue
|
|
for raw in group.get("items") or []:
|
|
if not isinstance(raw, dict):
|
|
continue
|
|
url = str(raw.get("url") or "").strip()
|
|
if not self._is_public_http_url(url) or url in seen:
|
|
continue
|
|
name = self._clean_text(
|
|
raw.get("title") or raw.get("name") or raw.get("path")
|
|
)
|
|
low = name.lower()
|
|
if name and not any(ext in low for ext in self.VIDEO_EXTS):
|
|
continue
|
|
seen.add(url)
|
|
bad = bool(
|
|
re.search(
|
|
r"sample|preview|trailer|广告|廣告|预告|預告|样片|樣片|试看|試看",
|
|
name,
|
|
re.I,
|
|
)
|
|
)
|
|
items.append(
|
|
{
|
|
"url": url,
|
|
"name": name,
|
|
"size": self._number(raw.get("size")),
|
|
"subtitle": self._has_subtitle(name),
|
|
"bad": bad,
|
|
"header": raw.get("header") or {},
|
|
}
|
|
)
|
|
return sorted(
|
|
items,
|
|
key=lambda item: (
|
|
0 if item.get("subtitle") else 1,
|
|
1 if item.get("bad") else 0,
|
|
-float(item.get("size") or 0),
|
|
item.get("name") or "",
|
|
),
|
|
)
|
|
|
|
def _api(self, path, query, ttl, isolated=False):
|
|
url = self.API_BASE.rstrip("/") + "/" + str(path or "").lstrip("/")
|
|
pairs = []
|
|
for key in sorted((query or {}).keys()):
|
|
value = query.get(key)
|
|
if value is None or value == "":
|
|
continue
|
|
pairs.append("%s=%s" % (quote(str(key), safe=""), quote(str(value), safe="")))
|
|
if pairs:
|
|
url += "?" + "&".join(pairs)
|
|
cache_key = "api:" + url
|
|
cached = self._cache_get(cache_key)
|
|
if cached is not None:
|
|
return cached
|
|
headers = {
|
|
"Accept": "application/json",
|
|
"jdsignature": self._signature(),
|
|
"Referer": self.SOURCE_ORIGIN,
|
|
}
|
|
client = self._new_session() if isolated else self._session
|
|
try:
|
|
body = self._request_json_url(
|
|
url, headers, self.max_json_bytes, session=client
|
|
)
|
|
finally:
|
|
if isolated:
|
|
client.close()
|
|
if body.get("success") != 1:
|
|
raise RuntimeError(self._clean_text(body.get("message")) or "API 返回失败")
|
|
data = body.get("data") or {}
|
|
self._cache_set(cache_key, data, ttl)
|
|
return data
|
|
|
|
def _request_json_url(self, url, headers, max_bytes, session=None):
|
|
if not self._is_public_http_url(url):
|
|
raise RuntimeError("已阻止非公网请求")
|
|
client = session or self._session
|
|
last_error = None
|
|
for attempt in range(2):
|
|
response = None
|
|
try:
|
|
response = client.get(
|
|
url,
|
|
headers=headers,
|
|
timeout=(min(self.timeout, 10), self.timeout),
|
|
allow_redirects=True,
|
|
verify=self.verify_tls,
|
|
stream=True,
|
|
)
|
|
final_url = str(response.url or url)
|
|
if not self._is_public_http_url(final_url):
|
|
raise RuntimeError("已阻止外域私网跳转")
|
|
raw = self._read_bounded(response, max_bytes)
|
|
text = raw.decode("utf-8", errors="replace")
|
|
if self._looks_like_challenge(response.status_code, text):
|
|
raise WafBlockedError(
|
|
"Cloudflare 挑战需要可见浏览器或站点授权接口"
|
|
)
|
|
if response.status_code == 429:
|
|
raise RuntimeError("rate_limited")
|
|
if response.status_code >= 500 and attempt == 0:
|
|
time.sleep(0.2)
|
|
continue
|
|
response.raise_for_status()
|
|
parsed = json.loads(text)
|
|
if not isinstance(parsed, dict):
|
|
raise RuntimeError("JSON 顶层不是对象")
|
|
return parsed
|
|
except WafBlockedError:
|
|
raise
|
|
except (requests.RequestException, ValueError, RuntimeError) as exc:
|
|
last_error = exc
|
|
if attempt == 0 and "rate_limited" not in str(exc):
|
|
time.sleep(0.15)
|
|
continue
|
|
finally:
|
|
if response is not None:
|
|
response.close()
|
|
raise RuntimeError("网络请求失败: %s" % last_error)
|
|
|
|
def _load_all_tags(self):
|
|
cached = self._cache_get("all-tags")
|
|
if cached is not None:
|
|
return cached
|
|
result = {}
|
|
with ThreadPoolExecutor(max_workers=4) as executor:
|
|
jobs = {
|
|
executor.submit(
|
|
self._api, "/v1/tags", {"type": content_type}, self.tag_cache_ttl, True
|
|
): content_type
|
|
for content_type in ("0", "1", "2", "3")
|
|
}
|
|
for future in as_completed(jobs):
|
|
content_type = jobs[future]
|
|
try:
|
|
result[content_type] = future.result().get("tags") or []
|
|
except Exception:
|
|
result[content_type] = []
|
|
self._cache_set("all-tags", result, self.tag_cache_ttl)
|
|
return result
|
|
|
|
def _page_result(
|
|
self, raw_movies, page, expected_limit, pageable, direct_check=False
|
|
):
|
|
items = []
|
|
seen = set()
|
|
raw_movies = raw_movies or []
|
|
source_count = len(raw_movies)
|
|
if direct_check:
|
|
raw_movies = self._filter_direct_movies(raw_movies)
|
|
for raw in raw_movies:
|
|
if not isinstance(raw, dict):
|
|
continue
|
|
movie_id = self._clean_text(raw.get("id"))
|
|
if not movie_id or movie_id in seen:
|
|
continue
|
|
if not self.show_unplayable and not self._has_declared_resource(raw):
|
|
continue
|
|
seen.add(movie_id)
|
|
items.append(self._movie_card(raw))
|
|
pagecount = (
|
|
page + 1
|
|
if pageable and source_count >= expected_limit
|
|
else page
|
|
)
|
|
if not pageable:
|
|
pagecount = 1
|
|
page = 1
|
|
limit = expected_limit or len(items) or 1
|
|
return {
|
|
"list": items,
|
|
"page": page,
|
|
"pagecount": pagecount,
|
|
"limit": limit,
|
|
"total": pagecount * limit,
|
|
}
|
|
|
|
def _filter_direct_movies(self, raw_movies):
|
|
rows = [item for item in raw_movies if isinstance(item, dict)]
|
|
if not rows:
|
|
return []
|
|
accepted = {}
|
|
candidates = []
|
|
for item in rows:
|
|
movie_id = self._clean_text(item.get("id"))
|
|
if not movie_id:
|
|
continue
|
|
if self._number(item.get("magnets_count")) > 0:
|
|
kept = dict(item)
|
|
kept["_resource_gate"] = "magnet"
|
|
accepted[movie_id] = kept
|
|
elif len(candidates) < self.direct_probe_limit:
|
|
candidates.append(item)
|
|
if not candidates:
|
|
return [accepted[mid] for mid in [self._clean_text(x.get("id")) for x in rows] if mid in accepted]
|
|
with ThreadPoolExecutor(max_workers=min(4, len(candidates))) as executor:
|
|
jobs = {
|
|
executor.submit(self._raw_has_progressive_direct, item): self._clean_text(
|
|
item.get("id")
|
|
)
|
|
for item in candidates
|
|
}
|
|
for future in as_completed(jobs):
|
|
movie_id = jobs[future]
|
|
try:
|
|
if movie_id and future.result():
|
|
kept = next(
|
|
item
|
|
for item in candidates
|
|
if self._clean_text(item.get("id")) == movie_id
|
|
)
|
|
kept = dict(kept)
|
|
kept["_resource_gate"] = "full_direct"
|
|
kept["_full_direct_verified"] = True
|
|
accepted[movie_id] = kept
|
|
except Exception:
|
|
pass
|
|
return [
|
|
accepted[mid]
|
|
for mid in [self._clean_text(item.get("id")) for item in rows]
|
|
if mid in accepted
|
|
]
|
|
|
|
def _raw_has_progressive_direct(self, raw):
|
|
if not raw.get("can_play"):
|
|
return False
|
|
code = self._clean_text(
|
|
raw.get("number") or raw.get("number_letter") or raw.get("id")
|
|
)
|
|
if not code:
|
|
return False
|
|
variants = self._resolve_variants(code, isolated=True)
|
|
return bool(self._full_direct_variants(variants, raw.get("duration")))
|
|
|
|
def _movie_card(self, raw):
|
|
movie_id = self._clean_text(raw.get("id"))
|
|
number = self._clean_text(
|
|
raw.get("number") or raw.get("number_letter") or movie_id
|
|
)
|
|
title = self._clean_text(
|
|
raw.get("title") or raw.get("origin_title") or number
|
|
)
|
|
remarks = []
|
|
if raw.get("_full_direct_verified"):
|
|
remarks.append("完整版直连")
|
|
magnets = int(self._number(raw.get("magnets_count")))
|
|
if magnets:
|
|
remarks.append("磁力%d" % magnets)
|
|
if raw.get("has_cnsub") or self._number(raw.get("play_subtitle")) > 0:
|
|
remarks.append("中字")
|
|
if not self._has_declared_resource(raw):
|
|
remarks.append("无资源")
|
|
score = self._number(raw.get("score"))
|
|
if score:
|
|
remarks.append("%.1f分" % score)
|
|
return {
|
|
"vod_id": movie_id,
|
|
"vod_name": (number + " " + title).strip(),
|
|
"vod_pic": self._image_proxy_url(
|
|
raw.get("thumb_url")
|
|
or raw.get("cover_url")
|
|
or self._first_preview_image(raw)
|
|
or ""
|
|
)
|
|
or self.DEFAULT_PIC,
|
|
"vod_remarks": " · ".join(remarks) or self._duration(raw.get("duration")),
|
|
}
|
|
|
|
@staticmethod
|
|
def _has_declared_resource(raw):
|
|
if not isinstance(raw, dict):
|
|
return False
|
|
magnets_count = Spider._number(raw.get("magnets_count"))
|
|
return bool(
|
|
raw.get("can_play")
|
|
or raw.get("has_preview_video")
|
|
or raw.get("preview_video_url")
|
|
or raw.get("play_sources")
|
|
or magnets_count > 0
|
|
)
|
|
|
|
@staticmethod
|
|
def _first_preview_image(raw):
|
|
if not isinstance(raw, dict):
|
|
return ""
|
|
for item in raw.get("preview_images") or []:
|
|
if isinstance(item, dict):
|
|
value = item.get("large_url") or item.get("thumb_url") or item.get("url")
|
|
else:
|
|
value = item
|
|
value = str(value or "").strip()
|
|
if value:
|
|
return value
|
|
return ""
|
|
|
|
def _decoded_image(self, url):
|
|
if not self._is_public_http_url(url):
|
|
raise RuntimeError("图片地址无效")
|
|
key = hashlib.sha256(url.encode("utf-8")).hexdigest()
|
|
with self._lock:
|
|
cached = self._image_cache.get(key)
|
|
if cached and time.time() - cached[0] <= self.image_cache_ttl:
|
|
return cached[1], cached[2]
|
|
response = self._session.get(
|
|
url,
|
|
headers={
|
|
"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
|
"Referer": self.SOURCE_ORIGIN,
|
|
"User-Agent": self.user_agent,
|
|
},
|
|
timeout=(min(self.timeout, 10), self.timeout),
|
|
allow_redirects=True,
|
|
verify=self.verify_tls,
|
|
stream=True,
|
|
)
|
|
try:
|
|
if not 200 <= response.status_code < 300:
|
|
raise RuntimeError("图片 HTTP %d" % response.status_code)
|
|
raw = self._read_bounded(response, self.max_image_bytes)
|
|
finally:
|
|
response.close()
|
|
decoded, mime = self._decode_image_bytes(raw)
|
|
with self._lock:
|
|
self._image_cache[key] = (time.time(), decoded, mime)
|
|
self._trim_timed_cache(self._image_cache, 32)
|
|
return decoded, mime
|
|
|
|
def _decode_image_bytes(self, raw):
|
|
mime = self._image_mime(raw)
|
|
if mime:
|
|
return raw, mime
|
|
candidates = []
|
|
if raw:
|
|
key = raw[0]
|
|
candidates.append(bytes(value ^ key for value in raw[1:]))
|
|
for skip in (0, 1, 2):
|
|
if len(raw) > skip:
|
|
candidates.append(bytes(value ^ 0x7F for value in raw[skip:]))
|
|
for candidate in candidates:
|
|
mime = self._image_mime(candidate)
|
|
if mime:
|
|
return candidate, mime
|
|
raise RuntimeError("未知图片编码")
|
|
|
|
@staticmethod
|
|
def _image_mime(raw):
|
|
if raw.startswith(b"\xff\xd8\xff"):
|
|
return "image/jpeg"
|
|
if raw.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
return "image/png"
|
|
if raw.startswith((b"GIF87a", b"GIF89a")):
|
|
return "image/gif"
|
|
if raw.startswith(b"BM"):
|
|
return "image/bmp"
|
|
if len(raw) >= 12 and raw[:4] == b"RIFF" and raw[8:12] == b"WEBP":
|
|
return "image/webp"
|
|
return ""
|
|
|
|
def _decode_data_playlist(self, value):
|
|
text = str(value or "")
|
|
comma = text.find(",")
|
|
if comma <= 0:
|
|
raise RuntimeError("HLS data URI 缺少 payload")
|
|
meta = text[:comma].lower()
|
|
payload = text[comma + 1 :]
|
|
if len(payload) > self.max_playlist_bytes * 2:
|
|
raise RuntimeError("HLS data URI 超过上限")
|
|
if ";base64" in meta:
|
|
raw = base64.b64decode(payload)
|
|
else:
|
|
raw = unquote(payload).encode("utf-8")
|
|
if len(raw) > self.max_playlist_bytes:
|
|
raise RuntimeError("HLS 播放列表超过上限")
|
|
source = raw.decode("utf-8", errors="strict")
|
|
if not source.lstrip().startswith("#EXTM3U"):
|
|
raise RuntimeError("HLS 播放列表缺少 EXTM3U")
|
|
for line in source.splitlines():
|
|
line = line.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
if not self._is_public_http_url(line):
|
|
raise RuntimeError("HLS 含非公网或相对分片地址")
|
|
return source.encode("utf-8")
|
|
|
|
def _image_proxy_url(self, url):
|
|
value = str(url or "").strip()
|
|
if not self._is_public_http_url(value):
|
|
return ""
|
|
return self._local_proxy_url("image", self._pack_text(value))
|
|
|
|
def _local_proxy_url(self, kind, token):
|
|
site_key = quote(
|
|
str(getattr(self, "siteKey", "") or self.proxy_site_key or "catemby"),
|
|
safe="",
|
|
)
|
|
base = self._proxy_base_url()
|
|
separator = "&" if "?" in base else "?"
|
|
return "%s%ssiteKey=%s&kind=%s&token=%s" % (
|
|
base,
|
|
separator,
|
|
site_key,
|
|
quote(kind, safe=""),
|
|
quote(token, safe=""),
|
|
)
|
|
|
|
def _proxy_base_url(self):
|
|
inherited = getattr(super(), "getProxyUrl", None)
|
|
if callable(inherited):
|
|
try:
|
|
value = str(inherited(True) or "").strip()
|
|
if value:
|
|
return value
|
|
except Exception:
|
|
pass
|
|
if CatVodProxy is not None:
|
|
return str(CatVodProxy.getUrl(True)) + "?do=py"
|
|
return "http://127.0.0.1:9978/proxy?do=py"
|
|
|
|
def _new_session(self):
|
|
session = requests.Session()
|
|
session.trust_env = self.trust_env
|
|
session.headers.update(
|
|
{
|
|
"User-Agent": self.user_agent,
|
|
"Accept-Language": "zh-CN,zh;q=0.9,zh-TW;q=0.8,en;q=0.5",
|
|
}
|
|
)
|
|
if self.proxy:
|
|
session.proxies.update({"http": self.proxy, "https": self.proxy})
|
|
return session
|
|
|
|
def _reset_session(self):
|
|
if self._session is not None:
|
|
try:
|
|
self._session.close()
|
|
except Exception:
|
|
pass
|
|
self._session = self._new_session()
|
|
|
|
@staticmethod
|
|
def _signature_static(token, salt):
|
|
timestamp = str(int(time.time()))
|
|
digest = hashlib.md5((timestamp + salt).encode("utf-8")).hexdigest()
|
|
return timestamp + "." + token + "." + digest
|
|
|
|
def _signature(self):
|
|
return self._signature_static(self.SIGNATURE_TOKEN, self.SIGNATURE_SALT)
|
|
|
|
@staticmethod
|
|
def _read_bounded(response, maximum):
|
|
content_length = response.headers.get("Content-Length")
|
|
if content_length:
|
|
try:
|
|
if int(content_length) > maximum:
|
|
raise RuntimeError("响应体超过上限")
|
|
except ValueError:
|
|
pass
|
|
chunks = []
|
|
total = 0
|
|
for chunk in response.iter_content(65536):
|
|
if not chunk:
|
|
continue
|
|
total += len(chunk)
|
|
if total > maximum:
|
|
raise RuntimeError("响应体超过上限")
|
|
chunks.append(chunk)
|
|
return b"".join(chunks)
|
|
|
|
def _looks_like_challenge(self, status, text):
|
|
if int(status or 0) not in (403, 429, 503):
|
|
return False
|
|
lower = str(text or "").lower()
|
|
return any(marker in lower for marker in self.CHALLENGE_MARKERS)
|
|
|
|
def _cache_get(self, key):
|
|
with self._lock:
|
|
item = self._cache.get(key)
|
|
if not item:
|
|
return None
|
|
if item[0] < time.time():
|
|
self._cache.pop(key, None)
|
|
return None
|
|
return item[1]
|
|
|
|
def _cache_set(self, key, value, ttl):
|
|
if ttl <= 0:
|
|
return
|
|
with self._lock:
|
|
self._cache[key] = (time.time() + ttl, value, time.time())
|
|
if len(self._cache) > 128:
|
|
oldest = min(self._cache, key=lambda item: self._cache[item][2])
|
|
self._cache.pop(oldest, None)
|
|
|
|
def _health_get(self, key):
|
|
with self._lock:
|
|
item = self._health.get(key)
|
|
if not item or time.time() - item[0] > self.health_cache_ttl:
|
|
self._health.pop(key, None)
|
|
return None
|
|
return item[1]
|
|
|
|
def _health_set(self, key, value):
|
|
with self._lock:
|
|
self._health[key] = (time.time(), value)
|
|
self._trim_timed_cache(self._health, 24)
|
|
|
|
def _playlist_cache_get(self, token):
|
|
with self._lock:
|
|
item = self._playlist_cache.get(token)
|
|
proxy_ttl = max(30, self.resolver_cache_ttl)
|
|
if not item or time.time() - item[0] > proxy_ttl:
|
|
self._playlist_cache.pop(token, None)
|
|
return None
|
|
return item[1]
|
|
|
|
@staticmethod
|
|
def _trim_timed_cache(cache, maximum):
|
|
while len(cache) > maximum:
|
|
oldest = min(cache, key=lambda key: cache[key][0])
|
|
cache.pop(oldest, None)
|
|
|
|
@staticmethod
|
|
def _pack_text(value):
|
|
raw = str(value or "").encode("utf-8")
|
|
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
|
|
|
@staticmethod
|
|
def _unpack_text(value):
|
|
token = str(value or "").strip()
|
|
token += "=" * (-len(token) % 4)
|
|
try:
|
|
return base64.urlsafe_b64decode(token.encode("ascii")).decode("utf-8")
|
|
except Exception:
|
|
return ""
|
|
|
|
def _pack_play_id(self, payload):
|
|
raw = json.dumps(
|
|
payload or {}, ensure_ascii=False, separators=(",", ":")
|
|
).encode("utf-8")
|
|
return self.PLAY_PREFIX + base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
|
|
|
def _unpack_play_id(self, value):
|
|
text = str(value or "").strip()
|
|
if not text.startswith(self.PLAY_PREFIX):
|
|
return {}
|
|
token = text[len(self.PLAY_PREFIX) :]
|
|
token += "=" * (-len(token) % 4)
|
|
try:
|
|
data = json.loads(base64.urlsafe_b64decode(token).decode("utf-8"))
|
|
return data if isinstance(data, dict) else {}
|
|
except Exception:
|
|
return {}
|
|
|
|
@staticmethod
|
|
def _filter(key, name, values):
|
|
return {
|
|
"key": key,
|
|
"name": name,
|
|
"value": [{"n": item[0], "v": item[1]} for item in values],
|
|
}
|
|
|
|
@staticmethod
|
|
def _parse_dict(value):
|
|
if isinstance(value, dict):
|
|
return value
|
|
if not value:
|
|
return {}
|
|
try:
|
|
parsed = json.loads(str(value))
|
|
return parsed if isinstance(parsed, dict) else {}
|
|
except Exception:
|
|
return {}
|
|
|
|
@staticmethod
|
|
def _choice(value, options, default):
|
|
allowed = {item[1] for item in options}
|
|
text = str(value or "").strip()
|
|
return text if text in allowed else default
|
|
|
|
@staticmethod
|
|
def _safe_filter_value(value):
|
|
text = str(value or "").strip()
|
|
return text if re.match(r"^[A-Za-z0-9._:-]{1,80}$", text) else ""
|
|
|
|
@staticmethod
|
|
def _safe_play_name(value, limit=120):
|
|
text = re.sub(r"\s+", " ", str(value or ""))
|
|
text = text.replace("#", " ").replace("$", " ").strip()
|
|
return text[:limit] or "播放"
|
|
|
|
@staticmethod
|
|
def _clean_text(value):
|
|
return re.sub(r"\s+", " ", str(value or "")).strip()
|
|
|
|
@staticmethod
|
|
def _number(value):
|
|
try:
|
|
return float(value or 0)
|
|
except (TypeError, ValueError):
|
|
return 0.0
|
|
|
|
@staticmethod
|
|
def _bounded_int(value, default, minimum, maximum):
|
|
try:
|
|
number = int(value)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
return max(minimum, min(maximum, number))
|
|
|
|
@staticmethod
|
|
def _bool(value, default=False):
|
|
if value is None:
|
|
return default
|
|
if isinstance(value, bool):
|
|
return value
|
|
return str(value).strip().lower() not in ("0", "false", "no", "off", "")
|
|
|
|
@staticmethod
|
|
def _page(value):
|
|
try:
|
|
return max(1, int(value))
|
|
except (TypeError, ValueError):
|
|
return 1
|
|
|
|
def _normalize_detail_id(self, value):
|
|
text = str(value or "").strip()
|
|
if text.startswith("atvp_detail:"):
|
|
text = text[len("atvp_detail:") :]
|
|
return text if re.match(r"^[A-Za-z0-9._:-]{1,120}$", text) else ""
|
|
|
|
@staticmethod
|
|
def _quality_height(value):
|
|
numbers = [int(item) for item in re.findall(r"(?<!\d)(2160|1440|1080|720|540|480)(?:p)?", str(value or ""), re.I)]
|
|
return max(numbers) if numbers else 0
|
|
|
|
@staticmethod
|
|
def _expiry_epoch(value):
|
|
if value is None or value == "":
|
|
return 0
|
|
try:
|
|
number = float(value)
|
|
if number > 100000000000:
|
|
number /= 1000.0
|
|
return number
|
|
except (TypeError, ValueError):
|
|
pass
|
|
text = str(value).strip().replace("Z", "+00:00")
|
|
try:
|
|
return datetime.fromisoformat(text).timestamp()
|
|
except (TypeError, ValueError):
|
|
return 0
|
|
|
|
@staticmethod
|
|
def _date_value(value):
|
|
digits = re.sub(r"\D", "", str(value or ""))[:14]
|
|
try:
|
|
return int(digits.ljust(14, "0"))
|
|
except ValueError:
|
|
return 0
|
|
|
|
@staticmethod
|
|
def _has_subtitle(value):
|
|
text = re.sub(r"\s+", "", str(value or ""))
|
|
return bool(re.search(r"中文字幕|简体中文|繁体中文|中字|字幕|CHS|CHT|SUB", text, re.I))
|
|
|
|
@staticmethod
|
|
def _has_hd(value):
|
|
return bool(re.search(r"(?:^|[^A-Z0-9])(HD|FHD|UHD|4K|2160P|1080P|720P)(?:[^A-Z0-9]|$)", str(value or ""), re.I))
|
|
|
|
@staticmethod
|
|
def _extract_btih(value):
|
|
text = str(value or "")
|
|
match = re.search(r"btih:([A-F0-9]{40}|[A-Z2-7]{32})", text, re.I)
|
|
if match:
|
|
return match.group(1).upper()
|
|
if re.match(r"^(?:[A-F0-9]{40}|[A-Z2-7]{32})$", text.strip(), re.I):
|
|
return text.strip().upper()
|
|
return ""
|
|
|
|
def _normalize_magnet(self, value):
|
|
btih = self._extract_btih(value)
|
|
return "magnet:?xt=urn:btih:" + btih if btih else ""
|
|
|
|
@staticmethod
|
|
def _format_source_size(value):
|
|
size_mb = float(value or 0)
|
|
if size_mb <= 0:
|
|
return ""
|
|
if size_mb >= 1024:
|
|
return "%.2fGB" % (size_mb / 1024.0)
|
|
return "%dMB" % int(size_mb)
|
|
|
|
@staticmethod
|
|
def _duration(value):
|
|
try:
|
|
number = float(value or 0)
|
|
return "%d分钟" % int(round(number)) if number > 0 else ""
|
|
except (TypeError, ValueError):
|
|
return ""
|
|
|
|
@staticmethod
|
|
def _area_name(value):
|
|
return {"0": "日本", "1": "日本", "2": "欧美", "3": "FC2"}.get(str(value), "")
|
|
|
|
def _variant_label(self, item):
|
|
parts = []
|
|
if item.get("height"):
|
|
parts.append("%dP" % int(item["height"]))
|
|
variant = str(item.get("variant") or "").lower()
|
|
if "original" in variant:
|
|
parts.append("原版")
|
|
elif "reducing_mosaic" in variant:
|
|
parts.append("处理版")
|
|
parts.append("HLS" if item.get("transport") == "hls" else "MP4")
|
|
duration_seconds = self._number(item.get("duration_seconds"))
|
|
if duration_seconds:
|
|
parts.append("%d分钟" % int(round(duration_seconds / 60.0)))
|
|
health = self._health_for_variant(item)
|
|
if health and health.get("ok"):
|
|
parts.append("%dms" % int(health.get("rtt_ms") or 0))
|
|
label = self._clean_text(item.get("label"))
|
|
if label and label not in parts:
|
|
parts.append(label)
|
|
return " ".join(item for item in parts if item)
|
|
|
|
@staticmethod
|
|
def _origin(url):
|
|
parsed = urlsplit(str(url or ""))
|
|
if not parsed.scheme or not parsed.hostname:
|
|
return ""
|
|
port = ":%d" % parsed.port if parsed.port else ""
|
|
return "%s://%s%s" % (parsed.scheme, parsed.hostname, port)
|
|
|
|
@staticmethod
|
|
def _is_public_http_url(value):
|
|
try:
|
|
parsed = urlsplit(str(value or ""))
|
|
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
|
return False
|
|
host = parsed.hostname.strip("[]")
|
|
try:
|
|
address = ipaddress.ip_address(host)
|
|
return not (
|
|
address.is_private
|
|
or address.is_loopback
|
|
or address.is_link_local
|
|
or address.is_multicast
|
|
or address.is_unspecified
|
|
)
|
|
except ValueError:
|
|
return host.lower() != "localhost"
|
|
except Exception:
|
|
return False
|
|
|
|
def _empty_page(self, page, message=""):
|
|
result = {
|
|
"list": [],
|
|
"page": page,
|
|
"pagecount": page,
|
|
"limit": 24,
|
|
"total": 0,
|
|
}
|
|
if message:
|
|
result["msg"] = self._clean_text(message)
|
|
return result
|
|
|
|
def _detail_error(self, movie_id, message):
|
|
text = self._clean_text(message) or "详情读取失败"
|
|
error_id = self._pack_play_id({"kind": "error", "message": text})
|
|
return {
|
|
"vod_id": movie_id or "error",
|
|
"vod_name": "详情读取失败",
|
|
"vod_pic": self.DEFAULT_PIC,
|
|
"vod_content": text,
|
|
"vod_play_from": "错误",
|
|
"vod_play_url": "查看错误$" + error_id,
|
|
}
|
|
|
|
def _player_error(self, code, message):
|
|
text = self._clean_text(message) or "播放失败"
|
|
return {
|
|
"parse": 0,
|
|
"jx": 0,
|
|
"playUrl": "",
|
|
"url": "",
|
|
"header": {},
|
|
"code": code,
|
|
"msg": text,
|
|
"content": text,
|
|
"error": text,
|
|
}
|