ff / source_client.py
kklljj's picture
Upload 9 files
c4b9d01 verified
Raw
History Blame Contribute Delete
12.8 kB
import json
import time
import random
import string
import uuid
import asyncio
import httpx
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
from urllib.parse import quote
from config import Config
JST = timezone(timedelta(hours=9))
class SourceClient:
def __init__(self):
self._cid = None
self._cid_ts = 0
self._auth = None
self._auth_ts = 0
self._channels = None
self._channels_ts = 0
self._auth_lock = None
self._auth_lock_loop = None
self._acct_idx = 0
self._device_id = None
self._pool = []
self._pool_lock = None
self._pool_lock_loop = None
def _timeout(self, read=30.0):
return httpx.Timeout(connect=10.0, read=read, write=10.0, pool=10.0)
async def get_cid(self, force=False) -> str:
if not force and self._cid and (time.time() - self._cid_ts) < 86400:
return self._cid
async with httpx.AsyncClient(timeout=self._timeout()) as client:
r = await client.get(Config.get_cid_url())
r.raise_for_status()
data = r.json()
if "cid" not in data:
raise ValueError("cid missing in response")
self._cid = data["cid"]
self._cid_ts = time.time()
return self._cid
def _alock(self):
loop = asyncio.get_running_loop()
if self._auth_lock is None or self._auth_lock_loop is not loop:
self._auth_lock = asyncio.Lock()
self._auth_lock_loop = loop
return self._auth_lock
async def get_auth(self, force=False) -> Dict[str, Any]:
if not force and self._auth and (time.time() - self._auth_ts) < 10800:
return self._auth
async with self._alock():
ttl = 30 if force else 10800
if self._auth and (time.time() - self._auth_ts) < ttl:
return self._auth
return await self._do_login()
async def rotate_account(self) -> Dict[str, Any]:
async with self._alock():
if self._auth and (time.time() - self._auth_ts) < 30:
return self._auth
self._device_id = _gen_device_id(Config.LOGIN_DEVICE_ID)
return await self._do_login()
def _plock(self):
loop = asyncio.get_running_loop()
if self._pool_lock is None or self._pool_lock_loop is not loop:
self._pool_lock = asyncio.Lock()
self._pool_lock_loop = loop
return self._pool_lock
async def _login_device(self, device_id) -> Dict[str, Any]:
cid = await self.get_cid()
acct = {"password": Config.LOGIN_PASSWORD, "app_id": Config.LOGIN_APP_ID, "device_id": device_id}
async with httpx.AsyncClient(timeout=self._timeout()) as client:
r = await client.get(Config.get_login_url_for(cid, acct))
r.raise_for_status()
data = r.json()
if data.get("code") != "OK":
raise ValueError("login failed: " + str(data.get("message", "unknown")))
product = json.loads(data.get("product_config", "{}"))
tok = {
"access_token": data["access_token"],
"refresh_token": data.get("refresh_token", ""),
"acct_cid": data.get("cid", ""),
"app_id": Config.LOGIN_APP_ID,
"device_id": device_id,
"vms_host": str(product.get("vms_host", "")).rstrip("/"),
"vms_uid": str(product.get("vms_uid", "")),
"ts": time.time(),
"rts": time.time(),
}
if not tok["access_token"]:
raise ValueError("no access_token")
return tok
async def _refresh_one(self, tok) -> bool:
if not tok.get("refresh_token"):
return False
url = Config.get_refresh_url(tok.get("refresh_token", ""), tok.get("acct_cid", ""),
tok.get("app_id", ""), tok.get("device_id", ""))
if not url:
return False
headers = {"Referer": Config.REQUIRED_REFERER, "User-Agent": "Mozilla/5.0"}
async with httpx.AsyncClient(timeout=self._timeout()) as client:
r = await client.get(url, headers=headers)
r.raise_for_status()
data = r.json()
if data.get("code") != "OK" or not data.get("access_token"):
return False
tok["access_token"] = data["access_token"]
if data.get("refresh_token"):
tok["refresh_token"] = data["refresh_token"]
tok["ts"] = time.time()
return True
async def build_pool(self, n) -> int:
n = max(1, int(n))
pool: List[Dict[str, Any]] = []
for _ in range(n):
try:
pool.append(await self._login_device(_gen_device_id(Config.LOGIN_DEVICE_ID)))
except Exception:
pass
if not pool:
try:
pool.append(await self._login_device(Config.LOGIN_DEVICE_ID or _gen_device_id("")))
except Exception:
pass
self._pool = pool
return len(pool)
def pool_size(self) -> int:
return len(self._pool)
def token_at(self, i) -> str:
p = self._pool
if not p:
return ""
return p[i % len(p)].get("access_token", "")
async def _fallback_token(self) -> str:
try:
auth = await self.get_auth()
return auth.get("access_token", "")
except Exception:
return ""
async def refresh_token_at(self, i) -> str:
async with self._plock():
p = self._pool
if not p:
return await self._fallback_token()
tok = p[i % len(p)]
if time.time() - tok.get("ts", 0) < 8:
return tok.get("access_token", "")
try:
await self._refresh_one(tok)
except Exception:
pass
return tok.get("access_token", "")
async def rotate_pool_at(self, i) -> str:
async with self._plock():
p = self._pool
if not p:
return await self._fallback_token()
if len(p) <= 1:
tok = p[0]
if time.time() - tok.get("rts", 0) >= 30:
tok["rts"] = time.time()
try:
await self._refresh_one(tok)
except Exception:
pass
return tok.get("access_token", "")
self._rr = (getattr(self, "_rr", i) + 1) % len(p)
if self._rr == i % len(p):
self._rr = (self._rr + 1) % len(p)
return p[self._rr].get("access_token", "")
async def _do_login(self, _retry=0) -> Dict[str, Any]:
cid = await self.get_cid(force=(_retry > 0))
if self._device_id:
acct = {"password": Config.LOGIN_PASSWORD, "app_id": Config.LOGIN_APP_ID, "device_id": self._device_id}
login_url = Config.get_login_url_for(cid, acct)
else:
accts = Config.login_accounts()
if accts:
acct = accts[self._acct_idx % len(accts)]
login_url = Config.get_login_url_for(cid, acct)
else:
login_url = Config.get_login_url(cid)
async with httpx.AsyncClient(timeout=self._timeout()) as client:
r = await client.get(login_url)
r.raise_for_status()
data = r.json()
if data.get("code") != "OK":
msg = str(data.get("message", "unknown"))
if "cid" in msg.lower() and _retry < 2:
return await self._do_login(_retry=_retry + 1)
raise ValueError("login failed: " + msg)
product = json.loads(data.get("product_config", "{}"))
auth = {
"access_token": data["access_token"],
"vms_host": str(product["vms_host"]).rstrip("/"),
"vms_uid": str(product["vms_uid"]),
}
if not all(auth.values()):
raise ValueError("incomplete auth")
self._auth = auth
self._auth_ts = time.time()
return auth
async def get_channels(self, force=False) -> List[dict]:
if not force and self._channels and (time.time() - self._channels_ts) < 86400:
return self._channels
auth = await self.get_auth()
url = Config.get_list_url(auth["vms_uid"], with_epg=False)
headers = {"Referer": Config.REQUIRED_REFERER, "User-Agent": "Mozilla/5.0"}
async with httpx.AsyncClient(timeout=self._timeout()) as client:
r = await client.get(url, headers=headers)
if r.status_code in (401, 403):
await self.get_auth(force=True)
return await self.get_channels(force=True)
r.raise_for_status()
data = r.json()
channels = [c for c in data.get("result", [])
if c.get("id") and c.get("no") and c.get("name") and c.get("playpath")]
if not channels:
raise ValueError("no channels")
self._channels = channels
self._channels_ts = time.time()
return channels
async def get_all_epg(self) -> Dict[str, List[dict]]:
auth = await self.get_auth()
url = Config.get_list_url(auth["vms_uid"], with_epg=True)
headers = {"Referer": Config.REQUIRED_REFERER, "User-Agent": "Mozilla/5.0"}
timeout = httpx.Timeout(connect=10.0, read=float(Config.EPG_READ_TIMEOUT), write=30.0, pool=10.0)
async with httpx.AsyncClient(timeout=timeout) as client:
r = await client.get(url, headers=headers)
r.raise_for_status()
data = r.json()
result: Dict[str, List[dict]] = {}
for ch in data.get("result", []):
cid = ch.get("id")
if not cid:
continue
raw = ch.get("record_epg")
if not raw:
result[str(cid)] = []
continue
try:
epg = json.loads(raw)
except Exception:
result[str(cid)] = []
continue
programs = []
for i, p in enumerate(epg):
if not p.get("time"):
continue
if not p.get("time_end"):
if i + 1 < len(epg) and epg[i + 1].get("time"):
p["time_end"] = epg[i + 1]["time"]
else:
continue
programs.append(p)
result[str(cid)] = programs
return result
def make_vod_m3u8(self, program: dict, auth: dict) -> str:
path = program.get("path")
if not path:
return program.get("m3u8") or ""
path = str(path).strip().lstrip("/")
if not path.startswith("query/"):
path = "query/" + path
if path.endswith(".m3u8"):
path = path[:-5]
token = quote(auth.get("access_token", ""), safe="")
host = Config.UPSTREAM_HOST_VOD.rstrip("/")
if not host or not token:
return ""
return host + "/" + path + ".m3u8?type=vod&__cross_domain_user=" + token
def _gen_device_id(template) -> str:
t = str(template or "").strip()
if t and "-" in t and len(t.replace("-", "")) == 32:
u = str(uuid.uuid4())
return u.upper() if t == t.upper() else u
digits = "0123456789abcdef"
if t and all(c in digits for c in t.lower()):
out = "".join(random.choice(digits) for _ in range(len(t)))
return out.upper() if t == t.upper() else out
if t and t.isalnum():
pool = string.ascii_lowercase + string.digits
return "".join(random.choice(pool) for _ in range(len(t)))
return uuid.uuid4().hex
def normalize_ts(t) -> Optional[float]:
if t is None:
return None
try:
s = str(t).strip()
if len(s) == 14 and s.isdigit():
return datetime.strptime(s, "%Y%m%d%H%M%S").replace(tzinfo=JST).timestamp()
v = float(s)
return v / 1000.0 if v > 9999999999 else v
except Exception:
return None
source = SourceClient()