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()