feat: 增加桌面端登录、视频清单和封面缓存

This commit is contained in:
Nixevol
2026-09-27 03:57:00 +08:00
parent 0c1e04464a
commit cc930cd1ed
69 changed files with 9895 additions and 0 deletions
View File
+190
View File
@@ -0,0 +1,190 @@
import html
import json
import os
import re
import subprocess
import time
from pathlib import Path
from server.services.device import DeviceProfile
PACKAGE = "com.leivideo.raptdrama"
PREFS = f"/data/data/{PACKAGE}/shared_prefs/FlutterSharedPreferences.xml"
REPO_ROOT = Path(__file__).resolve().parents[2]
class Adb:
def __init__(self, executable: str) -> None:
self.executable = executable
@classmethod
def locate(cls, explicit: str = "") -> "Adb":
if explicit:
if not os.path.isfile(explicit):
raise RuntimeError(f"找不到 adb:{explicit}")
return cls(explicit)
name = "adb.exe" if os.name == "nt" else "adb"
path = REPO_ROOT / "tools" / "platform-tools" / name
if not path.is_file():
raise RuntimeError(f"项目内没有 adb:{path}")
return cls(str(path))
def list_devices(self) -> list[dict]:
proc = self._run(["devices", "-l"])
if proc.returncode not in (0, None):
err = proc.stderr.decode("utf-8", "replace").strip() or "adb devices 失败"
raise RuntimeError(err)
items = []
for line in proc.stdout.decode("utf-8", "replace").splitlines()[1:]:
parts = line.split()
if len(parts) < 2 or parts[1] not in ("device", "unauthorized", "offline"):
continue
serial, state = parts[0], parts[1]
model = ""
for part in parts[2:]:
if part.startswith("model:"):
model = part.split(":", 1)[1].replace("_", " ")
if state == "device" and not model:
_, text, _ = self._shell(serial, ["getprop", "ro.product.model"], timeout=8)
model = text.strip()
items.append({
"serial": serial,
"model": model or "未知型号",
"rooted": self._rooted(serial) if state == "device" else False,
"state": state,
})
return items
def read_profile(self, serial: str) -> DeviceProfile:
serial = self._ready(serial)
profile = DeviceProfile.builtin()
brand = self._prop(serial, "ro.product.brand") or self._prop(serial, "ro.product.manufacturer")
model = self._prop(serial, "ro.product.model")
os_version = self._prop(serial, "ro.build.version.release")
characteristics = self._prop(serial, "ro.build.characteristics")
qemu = self._prop(serial, "ro.kernel.qemu") or self._prop(serial, "ro.boot.qemu")
_, android_id, err = self._shell(serial, ["settings", "get", "secure", "android_id"], timeout=8)
if err == "timeout":
raise RuntimeError("读取设备信息超时,请确认设备仍连接后重试。")
android_id = android_id.strip()
version_name, version_code = self._app_version(serial)
if brand:
profile.brand = brand
if model:
profile.model = model
if os_version:
profile.os_version = os_version
if version_name:
profile.app_version = version_name
if version_code:
profile.app_build = version_code
if android_id and android_id.lower() != "null":
profile.device_id = f"rd_{android_id}"
profile.device_type = "pad" if "tablet" in characteristics else "phone"
profile.physical = not (qemu == "1" or serial.startswith("emulator-"))
return profile
def read_token(self, serial: str) -> str:
serial = self._ready(serial)
if not self._app_installed(serial):
raise RuntimeError("这台设备还没有安装 RaptDrama。请先安装 App 并登录,然后再读取登录信息。")
text = self._prefs(serial)
match = re.search(r'name="flutter\.user_data"[^>]*>(.*?)</string>', text, re.S)
if not match:
raise RuntimeError("登录信息里没有 user_data,请先在 App 里登录")
user = json.loads(html.unescape(match.group(1)))
token = user.get("accessToken") or ""
if not token:
raise RuntimeError("当前账号没有 accessToken,请先在 App 里登录")
return token
def _ready(self, serial: str) -> str:
serial = (serial or "").strip()
if ":" in serial:
self._run(["connect", serial], timeout=15)
ready = self._ready_serials()
if serial not in ready:
shown = "、".join(ready) or "无"
raise RuntimeError(f"设备 {serial} 未就绪。当前已连接:{shown}")
return serial
def _ready_serials(self) -> list[str]:
proc = self._run(["devices"])
serials = []
for line in proc.stdout.decode("utf-8", "replace").splitlines()[1:]:
parts = line.split()
if len(parts) >= 2 and parts[1] == "device":
serials.append(parts[0])
return serials
def _app_installed(self, serial: str) -> bool:
_, text, err = self._shell(serial, ["pm", "path", PACKAGE], timeout=8)
if err == "timeout":
raise RuntimeError("检查 App 是否安装超时,请确认设备仍连接后重试。")
return "package:" in text
def _app_version(self, serial: str) -> tuple[str, str]:
_, text, _ = self._shell(serial, ["dumpsys", "package", PACKAGE], timeout=15)
version_name = ""
version_code = ""
for line in text.splitlines():
line = line.strip()
if line.startswith("versionName="):
version_name = line.split("=", 1)[1].strip()
elif line.startswith("versionCode="):
version_code = line.split("=", 1)[1].split()[0]
return version_name, version_code
def _prop(self, serial: str, name: str) -> str:
_, text, err = self._shell(serial, ["getprop", name], timeout=8)
if err == "timeout":
raise RuntimeError("读取设备信息超时,请确认设备仍连接后重试。")
return text.strip()
def _rooted(self, serial: str) -> bool:
_, text, _ = self._shell(serial, ["id"], timeout=5)
if "uid=0" in text:
return True
for args in (["su", "-c", "id"], ["su", "0", "id"]):
_, text, _ = self._shell(serial, args, timeout=4)
if "uid=0" in text:
return True
return False
def _prefs(self, serial: str) -> str:
def usable(text: str) -> bool:
return "flutter.user_data" in text
_, text, _ = self._shell(serial, ["cat", PREFS])
if usable(text):
return text
self._run(["-s", serial, "root"], timeout=15)
if ":" in serial:
self._run(["connect", serial], timeout=15)
for _ in range(10):
time.sleep(0.5)
_, text, _ = self._shell(serial, ["cat", PREFS])
if usable(text):
return text
for args in (["su", "-c", f"cat {PREFS}"], ["su", "0", "cat", PREFS]):
_, text, _ = self._shell(serial, args, timeout=8)
if usable(text):
return text
raise RuntimeError(
"读不到应用私有目录。未 root 的设备无法获取登录信息。"
"请使用已 root 的手机,或带 root 的模拟器。"
)
def _shell(self, serial: str, args: list[str], timeout: int = 15) -> tuple[int, str, str]:
try:
proc = self._run(["-s", serial, "shell", *args], timeout=timeout)
except subprocess.TimeoutExpired:
return 1, "", "timeout"
return (
proc.returncode,
proc.stdout.decode("utf-8", "replace"),
proc.stderr.decode("utf-8", "replace"),
)
def _run(self, args: list[str], timeout: int = 20) -> subprocess.CompletedProcess:
return subprocess.run([self.executable, *args], capture_output=True, timeout=timeout)
+149
View File
@@ -0,0 +1,149 @@
import asyncio
from server.services.covers import covers
from server.services.device import DeviceProfile
from server.services.http import http
from server.services.rapt import API_BASE
from server.services.sessions import sessions
CLASSIFY_MAX_PAGES = 50
RECOMMEND_MAX_PAGES = 80
FIXED_SECTIONS = (
{"key": "new_releases", "name": "New Releases", "note": "", "loading": True},
{"key": "recommend", "name": "Recommend", "note": "", "loading": True},
)
class CatalogService:
def outlines(self, categories: list[dict]) -> list[dict]:
rows = [
{"key": f"type-{item['id']}", "name": item["label"], "note": "", "loading": True}
for item in categories
]
rows.extend(dict(item) for item in FIXED_SECTIONS)
return rows
def _categories(self, token: str, device: DeviceProfile) -> list[dict]:
data = http.request_data("POST", API_BASE + "/app/open/classifyv2", token, device, {})
rows = []
if isinstance(data, list):
for item in data:
if not isinstance(item, dict):
continue
type_id = item.get("id")
label = str(item.get("label") or "").strip()
if type_id is None or not label:
continue
rows.append({"id": type_id, "label": label})
if not rows:
raise RuntimeError("没有获取到分类")
return rows
async def stream(self, token: str, raw_device: dict | None):
token = token.strip()
if not token:
raise RuntimeError("请先登录")
device = sessions.bind(token, DeviceProfile.merge(raw_device))
categories = await asyncio.to_thread(self._categories, token, device)
yield {"type": "outline", "sections": self.outlines(categories)}
jobs = [
(
f"type-{item['id']}",
item["label"],
"/app/open/classify_video",
"POST",
lambda page, type_id=item["id"]: {"page": page, "limit": 10, "type": type_id},
CLASSIFY_MAX_PAGES,
"",
)
for item in categories
]
jobs.append(("new_releases", "New Releases", "/app/open/orgin", "GET", None, 1, ""))
jobs.append((
"recommend",
"Recommend",
"/app/open/recommend",
"POST",
lambda page: {"page": page, "limit": 10},
RECOMMEND_MAX_PAGES,
"",
))
loop = asyncio.get_running_loop()
queue: asyncio.Queue = asyncio.Queue()
async def run(job):
key, name, path, method, body, max_pages, note = job
def work():
try:
for event in self._iter_paged(key, name, token, device, path, method, body, max_pages, note):
loop.call_soon_threadsafe(queue.put_nowait, event)
except Exception as exc:
loop.call_soon_threadsafe(queue.put_nowait, self._event(key, name, [], str(exc) or "获取清单失败", True))
finally:
loop.call_soon_threadsafe(queue.put_nowait, None)
await asyncio.to_thread(work)
tasks = [asyncio.create_task(run(job)) for job in jobs]
finished = 0
try:
while finished < len(tasks):
event = await queue.get()
if event is None:
finished += 1
continue
yield event
yield {"type": "done"}
finally:
for task in tasks:
if not task.done():
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
def _event(self, key: str, name: str, items: list[dict], note: str = "", done: bool = False) -> dict:
return {"type": "page", "key": key, "name": name, "note": note, "items": items, "done": done}
def _drama(self, item: dict) -> dict | None:
vid = item.get("id")
title = item.get("title")
if vid is None or not title:
return None
image = (item.get("image") or "").strip()
return {
"id": vid,
"title": title,
"image": covers.local_url(image) if image else "",
}
def _iter_paged(self, key: str, name: str, token: str, device: DeviceProfile, path: str, method: str, body, max_pages: int, note: str = ""):
seen = set()
page = 1
sent = False
while page <= max_pages:
data = http.request_data(method, API_BASE + path, token, device, body(page) if body else None)
batch = data if isinstance(data, list) else []
if not batch and isinstance(data, dict) and isinstance(data.get("list"), list):
batch = data["list"]
if not batch:
break
fresh = []
for item in batch:
if not isinstance(item, dict):
continue
drama = self._drama(item)
if drama is None or drama["id"] in seen:
continue
seen.add(drama["id"])
fresh.append(drama)
if not fresh:
break
sent = True
yield self._event(key, name, fresh)
if body is None:
break
page += 1
yield self._event(key, name, [], note, True)
catalog = CatalogService()
+51
View File
@@ -0,0 +1,51 @@
import hashlib
import re
import threading
from pathlib import Path
from server.services.http import http
CACHE_DIR = Path(__file__).resolve().parents[2] / "cache" / "covers"
_lock = threading.Lock()
class CoverCache:
def local_url(self, remote: str) -> str:
remote = (remote or "").strip()
if not remote.startswith(("http://", "https://")):
return remote
digest = hashlib.sha256(remote.encode("utf-8")).hexdigest()
CACHE_DIR.mkdir(parents=True, exist_ok=True)
meta = CACHE_DIR / f"{digest}.url"
if not meta.is_file():
meta.write_text(remote, encoding="utf-8")
return f"/api/covers/{digest}"
def read(self, digest: str) -> tuple[bytes, str]:
if not re.fullmatch(r"[0-9a-f]{64}", digest):
raise RuntimeError("封面不存在")
meta = CACHE_DIR / f"{digest}.url"
body = CACHE_DIR / digest
if not meta.is_file():
raise RuntimeError("封面不存在")
if body.is_file() and body.stat().st_size > 0:
return body.read_bytes(), _media_type(body)
remote = meta.read_text(encoding="utf-8").strip()
data, media = http.get_bytes(remote)
with _lock:
CACHE_DIR.mkdir(parents=True, exist_ok=True)
body.write_bytes(data)
(CACHE_DIR / f"{digest}.type").write_text(media, encoding="utf-8")
return data, media
def _media_type(path: Path) -> str:
sidecar = Path(f"{path}.type")
if sidecar.is_file():
media = sidecar.read_text(encoding="utf-8").strip()
if media.startswith("image/"):
return media
return "image/jpeg"
covers = CoverCache()
+73
View File
@@ -0,0 +1,73 @@
from dataclasses import asdict, dataclass
@dataclass
class DeviceProfile:
brand: str
model: str
os_version: str
device_type: str
physical: bool
device_id: str
fingerprint: str
app_version: str
app_build: str
language: str
@classmethod
def builtin(cls) -> "DeviceProfile":
return cls(
brand="Redmi",
model="23113RKC6C",
os_version="12",
device_type="phone",
physical=True,
device_id="rd_c9f0fa17a39f96aa",
fingerprint="0a4e4b212cbb0a2434cf804c8e30ab58",
app_version="1.1.81",
app_build="139",
language="en-US",
)
@classmethod
def merge(cls, raw: dict | None) -> "DeviceProfile":
profile = cls.builtin()
values = asdict(profile)
for key, value in (raw or {}).items():
if key not in values or value is None:
continue
if isinstance(value, str):
value = value.strip()
if not value:
continue
if key == "physical" and isinstance(value, str):
value = value.lower() in ("1", "true", "yes")
values[key] = value
values["physical"] = bool(values["physical"])
return cls(**values)
def as_dict(self) -> dict:
return asdict(self)
def headers(self) -> dict[str, str]:
physical = "true" if self.physical else "false"
return {
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": (
f"RaptDrama/{self.app_version} "
f"(Android {self.os_version}; {self.model}; {self.brand})"
),
"x-device-fingerprint": self.fingerprint,
"x-os-version": str(self.os_version),
"x-device-physical": physical,
"x-device-model": self.model,
"accept-language": self.language,
"accept-language-custom": self.language,
"x-os-name": "Android",
"x-device-brand": self.brand,
"x-device-id": self.device_id,
"x-app-build": str(self.app_build),
"x-device-type": self.device_type,
"x-app-version": self.app_version,
}
+142
View File
@@ -0,0 +1,142 @@
import threading
import time
from urllib.parse import quote
from urllib.request import getproxies
import httpx
from server.services.device import DeviceProfile
class HttpClient:
def __init__(self) -> None:
self._proxy = {
"mode": "off",
"scheme": "http",
"host": "",
"port": "",
"username": "",
"password": "",
}
self._client: httpx.Client | None = None
self._lock = threading.Lock()
def configure_proxy(self, proxy: dict) -> None:
data = proxy or {}
mode = data.get("mode") or "off"
if mode not in ("off", "system", "custom"):
raise RuntimeError("代理方式无效")
scheme = data.get("scheme") or "http"
if scheme not in ("http", "socks5"):
raise RuntimeError("代理协议无效")
host = (data.get("host") or "").strip()
port = str(data.get("port") or "").strip()
if mode == "custom":
if not host:
raise RuntimeError("请填写代理地址")
if not port.isdigit() or not 1 <= int(port) <= 65535:
raise RuntimeError("请填写 1 到 65535 的端口")
with self._lock:
self._proxy = {
"mode": mode,
"scheme": scheme,
"host": host,
"port": port,
"username": (data.get("username") or "").strip(),
"password": data.get("password") or "",
}
self._close_locked()
def request_json(self, method: str, url: str, token: str, device: DeviceProfile, body: dict | None) -> dict:
data = self.request_data(method, url, token, device, body)
if not isinstance(data, dict):
raise RuntimeError("接口没有返回数据")
return data
def request_data(self, method: str, url: str, token: str, device: DeviceProfile, body: dict | None):
headers = device.headers()
if token:
headers["apasstk"] = token
response = self._request(method, url, headers=headers, json=body)
payload = response.json()
if payload.get("code") != 200:
raise RuntimeError(f"{method} {url} -> {payload.get('code')} {payload.get('msg')}")
if "data" not in payload:
raise RuntimeError("接口没有返回数据")
return payload.get("data")
def _request(self, method: str, url: str, headers: dict, json: dict | None) -> httpx.Response:
last_error = None
for attempt in range(6):
try:
return self._client_locked().request(method, url, headers=headers, json=json)
except (httpx.HTTPError, TimeoutError, OSError) as exc:
last_error = exc
time.sleep(min(10, 1.5 * (attempt + 1)))
raise RuntimeError(f"请求失败: {last_error}")
def _client_locked(self) -> httpx.Client:
with self._lock:
if self._client is None or self._client.is_closed:
self._client = httpx.Client(**self._client_kwargs())
return self._client
def get_bytes(self, url: str) -> tuple[bytes, str]:
response = self._client_locked().get(url, headers={"User-Agent": "RaptDrama"})
response.raise_for_status()
media = response.headers.get("content-type", "image/jpeg").split(";", 1)[0].strip() or "image/jpeg"
if not media.startswith("image/"):
raise RuntimeError("不是图片")
return response.content, media
def _close_locked(self) -> None:
if self._client is not None:
self._client.close()
self._client = None
def _client_kwargs(self) -> dict:
kwargs = {
"trust_env": False,
"follow_redirects": True,
"timeout": httpx.Timeout(25.0),
}
mode = self._proxy["mode"]
if mode == "off":
kwargs["proxy"] = None
return kwargs
if mode == "custom":
kwargs["proxy"] = self._proxy_url()
return kwargs
http_proxy, https_proxy = self._system_proxies()
if http_proxy and https_proxy and http_proxy != https_proxy:
kwargs["mounts"] = {
"http://": httpx.HTTPTransport(proxy=http_proxy),
"https://": httpx.HTTPTransport(proxy=https_proxy),
}
return kwargs
kwargs["proxy"] = https_proxy or http_proxy or None
return kwargs
def _proxy_url(self) -> str:
scheme = "socks5h" if self._proxy["scheme"] == "socks5" else "http"
auth = ""
username = self._proxy["username"]
if username:
password = quote(self._proxy["password"] or "", safe="")
auth = f"{quote(username, safe='')}:{password}@"
return f"{scheme}://{auth}{self._proxy['host']}:{self._proxy['port']}"
@staticmethod
def _system_proxies() -> tuple[str, str]:
found = getproxies()
return _with_scheme(found.get("http")), _with_scheme(found.get("https"))
def _with_scheme(value: str | None) -> str:
text = (value or "").strip()
if text and "://" not in text:
return "http://" + text
return text
http = HttpClient()
+57
View File
@@ -0,0 +1,57 @@
from server.services.adb import Adb
from server.services.device import DeviceProfile
from server.services.profile_poller import profile_poller
from server.services.rapt import rapt
from server.services.sessions import sessions
class LoginService:
def guest(self, raw_device: dict | None) -> dict:
device = DeviceProfile.merge(raw_device)
token = rapt.guest_token(device)
return self._finish(token, device)
def password(self, email: str, password: str, raw_device: dict | None) -> dict:
device = DeviceProfile.merge(raw_device)
token = rapt.email_token(email.strip(), password, device)
return self._finish(token, device)
def adb(self, serial: str, adb_path: str, source: str, raw_device: dict | None) -> dict:
if not serial.strip():
raise RuntimeError("请选择设备")
adb = Adb.locate(adb_path.strip())
if source == "device":
device = adb.read_profile(serial.strip())
chosen = DeviceProfile.merge(raw_device) if raw_device else None
if chosen is not None and raw_device:
if (raw_device.get("fingerprint") or "").strip():
device.fingerprint = chosen.fingerprint
if (raw_device.get("language") or "").strip():
device.language = chosen.language
else:
device = DeviceProfile.merge(raw_device)
token = adb.read_token(serial.strip())
return self._finish(token, device)
def current(self, token: str, raw_device: dict | None) -> dict:
token = token.strip()
if not token:
raise RuntimeError("请先登录")
device = sessions.bind(token, DeviceProfile.merge(raw_device))
profile = rapt.user_info(token, device)
self._watch(token, profile)
return {"token": token, "profile": profile, "device": device.as_dict()}
def _finish(self, token: str, device: DeviceProfile) -> dict:
bound = sessions.bind(token, device, replace=True)
profile = rapt.user_info(token, bound)
self._watch(token, profile)
return {"token": token, "profile": profile, "device": bound.as_dict()}
@staticmethod
def _watch(token: str, profile: dict) -> None:
sessions.remember(token, profile)
profile_poller.ensure(token)
login = LoginService()
+61
View File
@@ -0,0 +1,61 @@
import logging
import threading
from server.services.rapt import rapt
from server.services.sessions import sessions
log = logging.getLogger(__name__)
class ProfilePoller:
def __init__(self, interval: float = 15) -> None:
self.interval = interval
self._lock = threading.Lock()
self._token = ""
self._stop: threading.Event | None = None
self._thread: threading.Thread | None = None
def ensure(self, token: str) -> None:
with self._lock:
if self._token == token and self._thread is not None and self._thread.is_alive():
return
if self._stop is not None:
self._stop.set()
stop = threading.Event()
thread = threading.Thread(
target=self._loop,
args=(token, stop),
name="profile-poller",
daemon=True,
)
self._token = token
self._stop = stop
self._thread = thread
thread.start()
def stop_if(self, token: str) -> None:
with self._lock:
if self._token != token:
return
if self._stop is not None:
self._stop.set()
self._token = ""
self._stop = None
self._thread = None
def _loop(self, token: str, stop: threading.Event) -> None:
while not stop.wait(self.interval):
if sessions.active_token() != token:
return
device = sessions.device_for(token)
if device is None:
return
try:
profile = rapt.user_info(token, device)
except Exception:
log.warning("刷新个人信息失败", exc_info=True)
continue
sessions.set_profile(token, profile)
profile_poller = ProfilePoller()
+35
View File
@@ -0,0 +1,35 @@
from server.services.device import DeviceProfile
from server.services.http import http
API_BASE = "https://apis.raptdrama.com"
class RaptApi:
def guest_token(self, device: DeviceProfile) -> str:
data = self._post("/app/open/autologin", "", device, {})
token = data.get("token") or ""
if not token:
raise RuntimeError("游客登录没有返回 token")
return token
def email_token(self, email: str, password: str, device: DeviceProfile) -> str:
data = self._post("/app/open/emailLogin", "", device, {
"email": email,
"password": password,
})
token = data.get("token") or ""
if not token:
raise RuntimeError("邮箱登录没有返回 token")
return token
def user_info(self, token: str, device: DeviceProfile) -> dict:
data = self._post("/app/user/getUserInfo", token, device, {})
data.pop("token", None)
data.pop("accessToken", None)
return data
def _post(self, path: str, token: str, device: DeviceProfile, body: dict) -> dict:
return http.request_json("POST", API_BASE + path, token, device, body)
rapt = RaptApi()
+56
View File
@@ -0,0 +1,56 @@
import threading
from server.services.device import DeviceProfile
class SessionStore:
def __init__(self) -> None:
self._lock = threading.Lock()
self._devices: dict[str, DeviceProfile] = {}
self._active = ""
self._profile: dict | None = None
self._revision = 0
def bind(self, token: str, device: DeviceProfile, replace: bool = False) -> DeviceProfile:
with self._lock:
if replace or token not in self._devices:
self._devices[token] = device
return self._devices[token]
def device_for(self, token: str) -> DeviceProfile | None:
with self._lock:
return self._devices.get(token)
def remember(self, token: str, profile: dict) -> None:
with self._lock:
self._active = token
self._profile = dict(profile)
self._revision += 1
def set_profile(self, token: str, profile: dict) -> bool:
with self._lock:
if token != self._active:
return False
self._profile = dict(profile)
self._revision += 1
return True
def active_token(self) -> str:
with self._lock:
return self._active
def snapshot(self) -> dict | None:
with self._lock:
if not self._active or self._profile is None:
return None
return {"profile": dict(self._profile), "revision": self._revision}
def clear(self, token: str) -> None:
with self._lock:
self._devices.pop(token, None)
if self._active == token:
self._active = ""
self._profile = None
sessions = SessionStore()