feat: 增加桌面端登录、视频清单和封面缓存
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from server.routes.adb import router as adb_router
|
||||
from server.routes.auth import router as auth_router
|
||||
from server.routes.catalog import router as catalog_router
|
||||
from server.routes.covers import router as covers_router
|
||||
from server.routes.device import router as device_router
|
||||
from server.routes.proxy import router as proxy_router
|
||||
from server.routes.session import router as session_router
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(title="RaptDrama")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.include_router(auth_router)
|
||||
app.include_router(session_router)
|
||||
app.include_router(adb_router)
|
||||
app.include_router(device_router)
|
||||
app.include_router(catalog_router)
|
||||
app.include_router(covers_router)
|
||||
app.include_router(proxy_router)
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"ok": True}
|
||||
|
||||
static_dir = os.environ.get("RAPTD_STATIC", "").strip()
|
||||
if static_dir and Path(static_dir).is_dir():
|
||||
app.mount("/", StaticFiles(directory=static_dir, html=True), name="ui")
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1,13 @@
|
||||
from collections.abc import Callable
|
||||
from typing import TypeVar
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def call(action: Callable[[], T]) -> T:
|
||||
try:
|
||||
return action()
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
@@ -0,0 +1,23 @@
|
||||
import os
|
||||
import socket
|
||||
|
||||
import uvicorn
|
||||
|
||||
|
||||
def listen_port() -> int:
|
||||
chosen = os.environ.get("RAPTD_PORT", "").strip()
|
||||
if chosen:
|
||||
return int(chosen)
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return sock.getsockname()[1]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
port = listen_port()
|
||||
print(f"http://127.0.0.1:{port}", flush=True)
|
||||
uvicorn.run("server.app:app", host="127.0.0.1", port=port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,3 @@
|
||||
fastapi>=0.115
|
||||
uvicorn>=0.32
|
||||
httpx[socks]>=0.28
|
||||
@@ -0,0 +1,11 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from server.errors import call
|
||||
from server.services.adb import Adb
|
||||
|
||||
router = APIRouter(prefix="/api/adb")
|
||||
|
||||
|
||||
@router.get("/devices")
|
||||
def devices():
|
||||
return call(lambda: {"devices": Adb.locate().list_devices()})
|
||||
@@ -0,0 +1,28 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from server.errors import call
|
||||
from server.schemas import AdbBody, GuestBody, PasswordBody, device_payload
|
||||
from server.services.login import login
|
||||
|
||||
router = APIRouter(prefix="/api/login")
|
||||
|
||||
|
||||
@router.post("/guest")
|
||||
def login_guest(body: GuestBody | None = None):
|
||||
raw = device_payload(body.device) if body else None
|
||||
return call(lambda: login.guest(raw))
|
||||
|
||||
|
||||
@router.post("/password")
|
||||
def login_password(body: PasswordBody):
|
||||
return call(lambda: login.password(body.email, body.password, device_payload(body.device)))
|
||||
|
||||
|
||||
@router.post("/adb")
|
||||
def login_adb(body: AdbBody):
|
||||
return call(lambda: login.adb(
|
||||
body.serial,
|
||||
body.adb,
|
||||
body.device_source,
|
||||
device_payload(body.device),
|
||||
))
|
||||
@@ -0,0 +1,27 @@
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from server.schemas import SessionBody, device_payload
|
||||
from server.services.catalog import catalog
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
@router.post("/catalog")
|
||||
async def load_catalog(body: SessionBody):
|
||||
token = body.token.strip()
|
||||
if not token:
|
||||
raise HTTPException(status_code=400, detail="请先登录")
|
||||
raw_device = device_payload(body.device)
|
||||
|
||||
async def generate():
|
||||
try:
|
||||
async for event in catalog.stream(token, raw_device):
|
||||
yield json.dumps(event, ensure_ascii=False) + "\n"
|
||||
except Exception as exc:
|
||||
message = str(exc) or "获取清单失败"
|
||||
yield json.dumps({"type": "error", "message": message}, ensure_ascii=False) + "\n"
|
||||
|
||||
return StreamingResponse(generate(), media_type="application/x-ndjson")
|
||||
@@ -0,0 +1,19 @@
|
||||
from fastapi import APIRouter, Response
|
||||
|
||||
from server.services.covers import covers
|
||||
|
||||
router = APIRouter(prefix="/api/covers")
|
||||
|
||||
|
||||
@router.get("/{digest}")
|
||||
def cover(digest: str):
|
||||
try:
|
||||
data, media = covers.read(digest)
|
||||
except RuntimeError as exc:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return Response(
|
||||
content=data,
|
||||
media_type=media,
|
||||
headers={"Cache-Control": "public, max-age=31536000, immutable"},
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from server.services.device import DeviceProfile
|
||||
|
||||
router = APIRouter(prefix="/api/device")
|
||||
|
||||
|
||||
@router.get("/defaults")
|
||||
def defaults():
|
||||
return DeviceProfile.builtin().as_dict()
|
||||
@@ -0,0 +1,17 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from server.errors import call
|
||||
from server.schemas import ProxyBody
|
||||
from server.services.http import http
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
@router.post("/proxy")
|
||||
def set_proxy(body: ProxyBody):
|
||||
return call(lambda: _apply(body))
|
||||
|
||||
|
||||
def _apply(body: ProxyBody) -> dict:
|
||||
http.configure_proxy(body.model_dump())
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,30 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from server.errors import call
|
||||
from server.schemas import SessionBody, device_payload
|
||||
from server.services.login import login
|
||||
from server.services.profile_poller import profile_poller
|
||||
from server.services.sessions import sessions
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
@router.post("/me")
|
||||
def me(body: SessionBody):
|
||||
return call(lambda: login.current(body.token, device_payload(body.device)))
|
||||
|
||||
|
||||
@router.get("/profile")
|
||||
def profile():
|
||||
data = sessions.snapshot()
|
||||
if data is None:
|
||||
raise HTTPException(status_code=404, detail="未登录")
|
||||
return data
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(body: SessionBody):
|
||||
token = body.token.strip()
|
||||
profile_poller.stop_if(token)
|
||||
sessions.clear(token)
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,51 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class DeviceBody(BaseModel):
|
||||
brand: str = ""
|
||||
model: str = ""
|
||||
os_version: str = ""
|
||||
device_type: str = ""
|
||||
physical: bool | None = None
|
||||
device_id: str = ""
|
||||
fingerprint: str = ""
|
||||
app_version: str = ""
|
||||
app_build: str = ""
|
||||
language: str = ""
|
||||
|
||||
|
||||
class GuestBody(BaseModel):
|
||||
device: DeviceBody | None = None
|
||||
|
||||
|
||||
class PasswordBody(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
device: DeviceBody | None = None
|
||||
|
||||
|
||||
class AdbBody(BaseModel):
|
||||
serial: str = ""
|
||||
adb: str = ""
|
||||
device_source: str = "device"
|
||||
device: DeviceBody | None = None
|
||||
|
||||
|
||||
class SessionBody(BaseModel):
|
||||
token: str
|
||||
device: DeviceBody | None = None
|
||||
|
||||
|
||||
class ProxyBody(BaseModel):
|
||||
mode: str = "off"
|
||||
scheme: str = "http"
|
||||
host: str = ""
|
||||
port: str = ""
|
||||
username: str = ""
|
||||
password: str = ""
|
||||
|
||||
|
||||
def device_payload(body: DeviceBody | None) -> dict | None:
|
||||
if body is None:
|
||||
return None
|
||||
return body.model_dump()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user