ci: 引入 ruff、pre-commit、前端单测与 GitHub Actions
- CI:后端 ruff + 单测 + dry-run,前端 typecheck/eslint/vitest/build, 并构建 Docker 镜像做冒烟 - ruff(E4/E7/E9/F/B)全量通过:补全异常链、拆分分号语句 - 修复统计接口 counts 恒为空、已完成体积估算错误;统计与高级检索 复用应用 Store 连接,不再按环境变量另开且不关闭的连接 - Vitest:API 错误处理、前缀拼接,以及与后端 TaskState 的契约测试 - npm 固定官方源(项目级 .npmrc),lockfile 统一为 registry.npmjs.org; Dockerfile 支持 NPM_REGISTRY/PIP_INDEX_URL 构建参数并复制 LICENSE - pyproject 补充项目元数据、dev 依赖与 ruff 配置;MCP 默认 WARNING 日志 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
864ab2a7c1
commit
bdd32a6d21
19 files changed
+953
-383
No files matched your search
@@ -0,0 +1,68 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
cache-dependency-path: pyproject.toml
|
||||
- name: Install
|
||||
run: pip install -e ".[all,dev]"
|
||||
- name: Lint
|
||||
run: ruff check .
|
||||
- name: Unit tests
|
||||
run: python -m unittest discover -s tests -v
|
||||
- name: Dry-run smoke
|
||||
run: python -m reelay.main --dry-run
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: web
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- run: npm ci
|
||||
- run: npm run typecheck
|
||||
- run: npm run lint:eslint
|
||||
- run: npm test
|
||||
- run: npm run build
|
||||
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [backend, frontend]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- name: Build image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
load: true
|
||||
tags: reelay:ci
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
- name: Smoke test image
|
||||
run: docker run --rm reelay:ci python -m reelay.main --dry-run
|
||||
@@ -0,0 +1,22 @@
|
||||
# pip install -e ".[dev]" && pre-commit install
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.17.1
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
- id: check-yaml
|
||||
- id: check-added-large-files
|
||||
- id: detect-private-key
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: web-lint
|
||||
name: web typecheck + eslint
|
||||
entry: bash -c 'cd web && npm run -s typecheck && npm run -s lint:eslint'
|
||||
language: system
|
||||
files: ^web/
|
||||
pass_filenames: false
|
||||
+10
-3
@@ -1,16 +1,23 @@
|
||||
# 国内网络可加:--build-arg NPM_REGISTRY=https://registry.npmmirror.com/ \
|
||||
# --build-arg PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
FROM node:24-bookworm-slim AS frontend
|
||||
ARG NPM_REGISTRY=https://registry.npmjs.org/
|
||||
WORKDIR /build/web
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
RUN npm ci
|
||||
RUN npm ci --registry="$NPM_REGISTRY"
|
||||
COPY web/ ./
|
||||
RUN npm run typecheck && npm run build
|
||||
|
||||
FROM python:3.12-slim-bookworm
|
||||
ARG PIP_INDEX_URL=https://pypi.org/simple
|
||||
LABEL org.opencontainers.image.source="https://github.com/Derrors/reelay" \
|
||||
org.opencontainers.image.licenses="MIT" \
|
||||
org.opencontainers.image.description="Self-hosted Telegram → 115 auto transfer with AI parsing"
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml README.md ./
|
||||
COPY pyproject.toml README.md LICENSE ./
|
||||
COPY reelay/ ./reelay/
|
||||
RUN pip install --no-cache-dir ".[all]" \
|
||||
RUN pip install --no-cache-dir --index-url "$PIP_INDEX_URL" ".[all]" \
|
||||
&& useradd --create-home --uid 10001 reelay \
|
||||
&& mkdir -p /app/data /app/config /app/plugins /app/web/dist \
|
||||
&& chown -R reelay:reelay /app/data /app/config
|
||||
|
||||
+36
-5
@@ -1,14 +1,26 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61"]
|
||||
requires = ["setuptools>=77"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "reelay"
|
||||
version = "0.1.0"
|
||||
description = "监听 Telegram 资源 Bot,AI 识别影视资源与 115 链接,审批后转存并整理入库。可扩展内核骨架。"
|
||||
description = "自托管的 Telegram → 115 网盘自动转存:AI 识别分享链接,审批后转存并按媒体库规范整理入库。"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = { text = "MIT" }
|
||||
license = "MIT"
|
||||
license-files = ["LICENSE"]
|
||||
authors = [{ name = "Derrors" }]
|
||||
keywords = ["telegram", "115", "media-library", "jellyfin", "emby", "llm", "self-hosted"]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Environment :: Web Environment",
|
||||
"Framework :: AsyncIO",
|
||||
"Operating System :: MacOS",
|
||||
"Operating System :: POSIX :: Linux",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Multimedia :: Video",
|
||||
]
|
||||
dependencies = [
|
||||
"Jinja2>=3.1,<4",
|
||||
]
|
||||
@@ -25,7 +37,6 @@ full = [
|
||||
web = [
|
||||
"fastapi>=0.115",
|
||||
"uvicorn[standard]>=0.30",
|
||||
"httpx2>=0.1",
|
||||
"PyYAML>=6.0",
|
||||
]
|
||||
agent = ["mcp>=1.2"]
|
||||
@@ -37,9 +48,19 @@ all = [
|
||||
"PyYAML>=6.0",
|
||||
"fastapi>=0.115",
|
||||
"uvicorn[standard]>=0.30",
|
||||
"httpx2>=0.1",
|
||||
"mcp>=1.2",
|
||||
]
|
||||
# 开发与 CI:pip install -e ".[all,dev]"
|
||||
dev = [
|
||||
"ruff>=0.15",
|
||||
"pre-commit>=4",
|
||||
"httpx2>=2", # starlette TestClient
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/Derrors/reelay"
|
||||
Issues = "https://github.com/Derrors/reelay/issues"
|
||||
Changelog = "https://github.com/Derrors/reelay/blob/main/CHANGELOG.md"
|
||||
|
||||
[project.scripts]
|
||||
reelay = "reelay.main:main"
|
||||
@@ -48,3 +69,13 @@ reelay-mcp = "reelay.mcp_server:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["reelay*"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py312"
|
||||
extend-exclude = ["web"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E4", "E7", "E9", "F", "B"]
|
||||
# FastAPI 的 Query()/Depends() 作为参数默认值是框架惯用法
|
||||
ignore = ["B008"]
|
||||
@@ -27,8 +27,10 @@ def _environment() -> ImmutableSandboxedEnvironment:
|
||||
loader=None, enable_async=False)
|
||||
safe = {name: env.filters[name] for name in ALLOWED_FILTERS - {"pad2"} if name in env.filters}
|
||||
safe["pad2"] = lambda value: f"{int(value):02d}"
|
||||
env.filters.clear(); env.filters.update(safe)
|
||||
env.tests.clear(); env.globals.clear()
|
||||
env.filters.clear()
|
||||
env.filters.update(safe)
|
||||
env.tests.clear()
|
||||
env.globals.clear()
|
||||
return env
|
||||
|
||||
|
||||
|
||||
@@ -198,7 +198,10 @@ def _server_class():
|
||||
|
||||
def build_server(tools: ReelayTools, *, readonly: bool):
|
||||
"""注册工具;全部声明为 async,SQLite 访问留在事件循环线程,不进线程池。"""
|
||||
server = _server_class()("reelay", instructions=_INSTRUCTIONS)
|
||||
# SDK 构造时会按 log_level 配置根日志;默认 WARNING,避免 stderr 被 INFO 刷屏。
|
||||
level = os.getenv("REELAY_LOG_LEVEL", "WARNING").upper()
|
||||
level = level if level in {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"} else "WARNING"
|
||||
server = _server_class()("reelay", instructions=_INSTRUCTIONS, log_level=level)
|
||||
|
||||
async def status() -> dict[str, Any]:
|
||||
"""Reelay 运行概况:各状态任务数、待处理/执行中/失败/完成数、运行模式、通知投递与 worker/消息源/115 健康状态。
|
||||
|
||||
+13
-13
@@ -189,14 +189,14 @@ def create_app(
|
||||
try:
|
||||
return {"items": service.list_tasks(state=state, source=source, query=query, limit=limit)}
|
||||
except Exception as exc:
|
||||
raise _error(exc)
|
||||
raise _error(exc) from exc
|
||||
|
||||
@app.get("/api/deliveries")
|
||||
async def deliveries(status: str = "failed", limit: int = Query(default=100, ge=1, le=500)):
|
||||
try:
|
||||
return {"items": store.list_deliveries(status=status, limit=limit)}
|
||||
except ValueError as exc:
|
||||
raise _error(exc)
|
||||
raise _error(exc) from exc
|
||||
|
||||
@app.post("/api/deliveries/{delivery_id}/retry")
|
||||
async def retry_delivery(delivery_id: int):
|
||||
@@ -209,7 +209,7 @@ def create_app(
|
||||
try:
|
||||
return service.get_task(task_id)
|
||||
except Exception as exc:
|
||||
raise _error(exc)
|
||||
raise _error(exc) from exc
|
||||
|
||||
@app.post("/api/tasks/{task_id}/approve")
|
||||
async def approve(task_id: str, payload: ActionPayload | None = None):
|
||||
@@ -220,7 +220,7 @@ def create_app(
|
||||
)
|
||||
return {"ok": True}
|
||||
except Exception as exc:
|
||||
raise _error(exc)
|
||||
raise _error(exc) from exc
|
||||
|
||||
@app.post("/api/tasks/{task_id}/reject")
|
||||
async def reject(task_id: str):
|
||||
@@ -228,7 +228,7 @@ def create_app(
|
||||
await service.reject(task_id)
|
||||
return {"ok": True}
|
||||
except Exception as exc:
|
||||
raise _error(exc)
|
||||
raise _error(exc) from exc
|
||||
|
||||
@app.post("/api/tasks/{task_id}/retry")
|
||||
async def retry(task_id: str):
|
||||
@@ -236,7 +236,7 @@ def create_app(
|
||||
await service.retry(task_id)
|
||||
return {"ok": True}
|
||||
except Exception as exc:
|
||||
raise _error(exc)
|
||||
raise _error(exc) from exc
|
||||
|
||||
@app.post("/api/tasks/{task_id}/reparse")
|
||||
async def reparse(task_id: str):
|
||||
@@ -244,7 +244,7 @@ def create_app(
|
||||
await service.reparse(task_id)
|
||||
return {"ok": True}
|
||||
except Exception as exc:
|
||||
raise _error(exc)
|
||||
raise _error(exc) from exc
|
||||
|
||||
@app.post("/api/tasks/{task_id}/reorganize")
|
||||
async def reorganize(task_id: str, payload: ActionPayload):
|
||||
@@ -252,7 +252,7 @@ def create_app(
|
||||
await service.requeue_organize(task_id, save_dir_alias=payload.save_dir_alias)
|
||||
return {"ok": True}
|
||||
except Exception as exc:
|
||||
raise _error(exc)
|
||||
raise _error(exc) from exc
|
||||
|
||||
@app.post("/api/tasks/{task_id}/reconcile")
|
||||
async def reconcile(task_id: str):
|
||||
@@ -260,21 +260,21 @@ def create_app(
|
||||
await service.reconcile_transfer(task_id)
|
||||
return {"ok": True}
|
||||
except Exception as exc:
|
||||
raise _error(exc)
|
||||
raise _error(exc) from exc
|
||||
|
||||
@app.patch("/api/tasks/{task_id}/parsed")
|
||||
async def patch_parsed(task_id: str, payload: ParsePatchPayload):
|
||||
try:
|
||||
return service.patch_parsed(task_id, payload.values)
|
||||
except Exception as exc:
|
||||
raise _error(exc)
|
||||
raise _error(exc) from exc
|
||||
|
||||
@app.post("/api/organize/preview")
|
||||
async def organize_preview(payload: OrganizePreviewPayload):
|
||||
try:
|
||||
return {"items": service.organize_preview(parsed=payload.parsed, landed=payload.landed)}
|
||||
except Exception as exc:
|
||||
raise _error(exc)
|
||||
raise _error(exc) from exc
|
||||
|
||||
@app.get("/api/config")
|
||||
async def get_config():
|
||||
@@ -292,7 +292,7 @@ def create_app(
|
||||
try:
|
||||
return {"config": await service.save_config(payload.config)}
|
||||
except Exception as exc:
|
||||
raise _error(exc)
|
||||
raise _error(exc) from exc
|
||||
|
||||
@app.put("/api/config/text")
|
||||
async def put_config_text(payload: ConfigTextPayload):
|
||||
@@ -300,7 +300,7 @@ def create_app(
|
||||
config = await service.save_config_text(payload.text)
|
||||
return {"config": config, "text": service.get_config_text()}
|
||||
except Exception as exc:
|
||||
raise _error(exc)
|
||||
raise _error(exc) from exc
|
||||
|
||||
frontend = Path(frontend_dir or PROJECT_ROOT / "web" / "dist").resolve()
|
||||
frontend_assets = frontend / "assets"
|
||||
|
||||
+32
-36
@@ -38,7 +38,8 @@ def _parse_size_gb(value: Any) -> float:
|
||||
m = re.match(r"\s*([0-9.]+)\s*([KMGT]?B|GB|MB|KB|TB)", str(value), re.I)
|
||||
if not m:
|
||||
return 0.0
|
||||
num = float(m.group(1)); unit = m.group(2).upper()
|
||||
num = float(m.group(1))
|
||||
unit = m.group(2).upper()
|
||||
return num * {"B": 1 / 1024 ** 3, "KB": 1 / 1024 ** 2,
|
||||
"MB": 1 / 1024, "GB": 1, "TB": 1024}.get(unit, 0.0)
|
||||
|
||||
@@ -154,7 +155,7 @@ def register_extras(app, *, project_root: Path, store, service, registry) -> Non
|
||||
registry.validate_config(cfg)
|
||||
return {'ok': True}
|
||||
except (ValueError, TypeError, AttributeError) as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
@app.post('/api/extensions/organizer/preview')
|
||||
async def organizer_template_preview(request: Request):
|
||||
@@ -170,7 +171,7 @@ def register_extras(app, *, project_root: Path, store, service, registry) -> Non
|
||||
"original_name": "sample.S01E02.mkv"}
|
||||
return {'path': render_path(template, values, label=f'{kind}命名模板')}
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
@app.post('/api/extensions/dir-router/preview')
|
||||
async def dir_router_preview(request: Request):
|
||||
@@ -183,7 +184,7 @@ def register_extras(app, *, project_root: Path, store, service, registry) -> Non
|
||||
payload.get('origin_country', []))
|
||||
return {'path': path}
|
||||
except (ValueError, TypeError, AttributeError) as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
|
||||
@app.patch('/api/config/sections')
|
||||
async def save_config_sections(request: Request):
|
||||
@@ -209,7 +210,7 @@ def register_extras(app, *, project_root: Path, store, service, registry) -> Non
|
||||
try:
|
||||
saved = await service.save_config(updated)
|
||||
except (ValueError, KeyError) as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
return {'config': saved, 'revision': config_revision(saved)}
|
||||
|
||||
import asyncio
|
||||
@@ -258,16 +259,16 @@ def register_extras(app, *, project_root: Path, store, service, registry) -> Non
|
||||
try:
|
||||
return await asyncio.to_thread(get_store().cookie_qr_start)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=502, detail=f"申请 Cookie 登录二维码失败:{exc}")
|
||||
raise HTTPException(status_code=502, detail=f"申请 Cookie 登录二维码失败:{exc}") from exc
|
||||
|
||||
@app.get("/api/auth115/cookie/qr")
|
||||
async def auth115_cookie_qr_status(uid: str = Query(...)):
|
||||
try:
|
||||
return await asyncio.to_thread(get_store().cookie_qr_status, uid)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc))
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=502, detail=f"查询 Cookie 扫码状态失败:{exc}")
|
||||
raise HTTPException(status_code=502, detail=f"查询 Cookie 扫码状态失败:{exc}") from exc
|
||||
|
||||
@app.get("/api/auth115/cookie/qr/image")
|
||||
async def auth115_cookie_qr_image(uid: str = Query(...)):
|
||||
@@ -288,9 +289,9 @@ def register_extras(app, *, project_root: Path, store, service, registry) -> Non
|
||||
try:
|
||||
png = await asyncio.to_thread(_fetch)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc))
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=502, detail=f"获取 Cookie 二维码图片失败:{exc}")
|
||||
raise HTTPException(status_code=502, detail=f"获取 Cookie 二维码图片失败:{exc}") from exc
|
||||
return Response(content=png, media_type="image/svg+xml",
|
||||
headers={"Cache-Control": "no-store"})
|
||||
|
||||
@@ -300,7 +301,7 @@ def register_extras(app, *, project_root: Path, store, service, registry) -> Non
|
||||
try:
|
||||
return await __import__("asyncio").to_thread(store.qr_start)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=502, detail=f"申请授权二维码失败:{exc}")
|
||||
raise HTTPException(status_code=502, detail=f"申请授权二维码失败:{exc}") from exc
|
||||
|
||||
@app.get("/api/auth115/oauth/qr")
|
||||
async def auth115_qr_status(uid: str = Query(...)):
|
||||
@@ -308,9 +309,9 @@ def register_extras(app, *, project_root: Path, store, service, registry) -> Non
|
||||
try:
|
||||
return await __import__("asyncio").to_thread(store.qr_status, uid)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc))
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=502, detail=f"查询扫码状态失败:{exc}")
|
||||
raise HTTPException(status_code=502, detail=f"查询扫码状态失败:{exc}") from exc
|
||||
|
||||
@app.get("/api/auth115/oauth/qr/image")
|
||||
async def auth115_qr_image(uid: str = Query(...)):
|
||||
@@ -330,9 +331,9 @@ def register_extras(app, *, project_root: Path, store, service, registry) -> Non
|
||||
try:
|
||||
png = await asyncio.to_thread(_fetch)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc))
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=502, detail=f"获取二维码图片失败:{exc}")
|
||||
raise HTTPException(status_code=502, detail=f"获取二维码图片失败:{exc}") from exc
|
||||
return Response(content=png, media_type="image/png",
|
||||
headers={"Cache-Control": "no-store"})
|
||||
|
||||
@@ -417,21 +418,22 @@ def register_extras(app, *, project_root: Path, store, service, registry) -> Non
|
||||
@app.get("/api/tasks/advanced")
|
||||
async def advanced_tasks(q: str | None = None, media_type: str | None = None,
|
||||
limit: int = Query(default=200, ge=1, le=1000)):
|
||||
import sqlite3
|
||||
db = os.getenv("REELAY_DB_PATH", str(project_root / "data" / "reelay.db"))
|
||||
conn = sqlite3.connect(db); conn.row_factory = sqlite3.Row
|
||||
conn = store._conn # 与应用同一个 Store;不要按环境变量另开连接
|
||||
where, args = ["state='done'"], []
|
||||
if q:
|
||||
where.append("title LIKE ?"); args.append(f"%{q}%")
|
||||
where.append("title LIKE ?")
|
||||
args.append(f"%{q}%")
|
||||
if media_type:
|
||||
where.append("json_extract(snapshot,'$.parsed.media_type')=?"); args.append(media_type)
|
||||
where.append("json_extract(snapshot,'$.parsed.media_type')=?")
|
||||
args.append(media_type)
|
||||
rows = conn.execute(
|
||||
f"SELECT task_id,title,state,snapshot,updated_at FROM tasks "
|
||||
f"WHERE {' AND '.join(where)} ORDER BY updated_at DESC LIMIT ?",
|
||||
(*args, limit)).fetchall()
|
||||
groups: dict[str, dict] = {}
|
||||
for r in rows:
|
||||
snap = json.loads(r["snapshot"]); p = snap.get("parsed") or {}
|
||||
snap = json.loads(r["snapshot"])
|
||||
p = snap.get("parsed") or {}
|
||||
key = p.get("title") or "(未识别)"
|
||||
g = groups.setdefault(key, {"title": key, "media_type": p.get("media_type"),
|
||||
"year": p.get("year"), "episodes": [], "tasks": 0,
|
||||
@@ -460,7 +462,7 @@ def register_extras(app, *, project_root: Path, store, service, registry) -> Non
|
||||
try:
|
||||
attr = fs.get_attr(int(fid), refresh=True)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=404, detail=f"未找到文件:{exc}")
|
||||
raise HTTPException(status_code=404, detail=f"未找到文件:{exc}") from exc
|
||||
return {k: attr.get(k) for k in
|
||||
("id", "name", "size", "sha1", "parent_id", "is_dir", "pickcode",
|
||||
"ctime", "mtime")}
|
||||
@@ -495,13 +497,8 @@ def register_extras(app, *, project_root: Path, store, service, registry) -> Non
|
||||
# ------------------------------------------------------------------ #
|
||||
@app.get("/api/stats")
|
||||
async def stats(days: int = Query(default=30, ge=1, le=365)):
|
||||
rows = store.dashboard() if hasattr(store, "dashboard") else {}
|
||||
# 任务按天/状态聚合由 SQL 直接统计
|
||||
import sqlite3
|
||||
db = store.db_path if hasattr(store, "db_path") else os.getenv(
|
||||
"REELAY_DB_PATH", str(project_root / "data" / "reelay.db"))
|
||||
conn = sqlite3.connect(db)
|
||||
conn.row_factory = sqlite3.Row
|
||||
# 任务按天/状态聚合由 SQL 直接统计;复用应用的 Store 连接,不为每个请求新开连接。
|
||||
conn = store._conn
|
||||
since = time.time() - days * 86400
|
||||
daily = conn.execute(
|
||||
"SELECT substr(datetime(updated_at,'unixepoch','localtime'),1,10) d, state, "
|
||||
@@ -510,14 +507,13 @@ def register_extras(app, *, project_root: Path, store, service, registry) -> Non
|
||||
by_title = conn.execute(
|
||||
"SELECT title, state, count(*) n FROM tasks WHERE title IS NOT NULL "
|
||||
"GROUP BY title ORDER BY n DESC LIMIT 15").fetchall()
|
||||
total_bytes = conn.execute(
|
||||
"SELECT COALESCE(SUM(CAST(json_extract(snapshot,'$.parsed.size') AS REAL)),0) "
|
||||
"FROM tasks WHERE state='done' AND json_extract(snapshot,'$.parsed.size') IS NOT NULL"
|
||||
).fetchone()[0]
|
||||
conn.close()
|
||||
# 解析出的 size 是 "15.12 GB" 这类文本,按单位换算后再求和。
|
||||
sizes = conn.execute(
|
||||
"SELECT json_extract(snapshot,'$.parsed.size') FROM tasks "
|
||||
"WHERE state='done' AND json_extract(snapshot,'$.parsed.size') IS NOT NULL").fetchall()
|
||||
return {
|
||||
"counts": rows.get("counts", {}),
|
||||
"counts": store.counts_by_state(),
|
||||
"daily": [dict(r) for r in daily],
|
||||
"top_titles": [dict(r) for r in by_title],
|
||||
"estimated_done_gb": round(float(total_bytes or 0) / (1024 ** 3), 2),
|
||||
"estimated_done_gb": round(sum(_parse_size_gb(row[0]) for row in sizes), 2),
|
||||
}
|
||||
@@ -124,12 +124,15 @@ class TestDirectoryReads(unittest.TestCase):
|
||||
self.assertEqual(result['via'],'oauth')
|
||||
|
||||
def test_expired_credentials_and_missing_directory(self):
|
||||
client=Mock(); credentials=SimpleNamespace(has_oauth=False,cookie_client=lambda:client)
|
||||
client=Mock()
|
||||
credentials=SimpleNamespace(has_oauth=False,cookie_client=lambda:client)
|
||||
client.fs_files.return_value={'state':False,'errno':99,'error':'请重新登录'}
|
||||
with self.assertRaises(StorageBrowseError) as caught: browse_dirs(credentials,'0')
|
||||
with self.assertRaises(StorageBrowseError) as caught:
|
||||
browse_dirs(credentials,'0')
|
||||
self.assertEqual(caught.exception.code,'credentials_expired')
|
||||
client.fs_files.return_value={'state':True,'data':[],'path':[{'cid':'0'}]}
|
||||
with self.assertRaises(StorageBrowseError) as caught: browse_dirs(credentials,'12')
|
||||
with self.assertRaises(StorageBrowseError) as caught:
|
||||
browse_dirs(credentials,'12')
|
||||
self.assertEqual(caught.exception.code,'directory_missing')
|
||||
|
||||
def test_invalid_templates_rejected(self):
|
||||
@@ -138,4 +141,5 @@ class TestDirectoryReads(unittest.TestCase):
|
||||
'{{ title }}/{{ season|pad2 }}.{{ missing }}', '/{{ title }}.{{ ext }}',
|
||||
'{missing}/{ext}',
|
||||
]:
|
||||
with self.assertRaises(ValueError): normalize_config({'organize':{'naming':{'tv':template}}})
|
||||
with self.assertRaises(ValueError):
|
||||
normalize_config({'organize':{'naming':{'tv':template}}})
|
||||
@@ -594,6 +594,20 @@ class TestWebAuthGuard(unittest.TestCase):
|
||||
self.assertEqual(body["decision"], "ask") # 未配 LLM:降级结果只能人工确认,而不是被当作“非影视”跳过
|
||||
self.assertLess(body["confidence"]["score"], 0.5)
|
||||
|
||||
def test_stats_counts_and_size_use_app_store(self):
|
||||
self.client.post("/api/auth/login", json={"password": "pw"})
|
||||
store = self.app.state.store
|
||||
for size in ("1.5 GB", "512 MB"):
|
||||
ctx = message(msg_id=size)
|
||||
ctx.parsed = ParsedResource(is_media=True, title="片", size=size)
|
||||
ctx.state = TaskState.DONE
|
||||
store.save_task(ctx)
|
||||
body = self.client.get("/api/stats").json()
|
||||
self.assertEqual(body["counts"], {"done": 2})
|
||||
self.assertEqual(body["estimated_done_gb"], 2.0)
|
||||
self.assertEqual(self.client.get("/api/tasks/advanced").status_code, 200)
|
||||
self.assertEqual(self.client.get("/api/stats").status_code, 200) # 共享连接未被关闭
|
||||
|
||||
def test_health_hides_runtime_details_without_login(self):
|
||||
self.assertEqual(self.client.get("/api/health").json(), {"ok": True, "service": "reelay"})
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import time
|
||||
import types
|
||||
@@ -409,7 +410,7 @@ class RuntimeTest(unittest.IsolatedAsyncioTestCase):
|
||||
await pipeline.accept(context())
|
||||
await pipeline.dispatch_deliveries()
|
||||
self.assertEqual(store.delivery_counts(), {"done": 1, "pending": 1})
|
||||
for attempt in range(7):
|
||||
for _ in range(7):
|
||||
store._conn.execute("UPDATE outbox SET next_attempt=0 WHERE status='pending'")
|
||||
store._conn.commit()
|
||||
await pipeline.dispatch_deliveries()
|
||||
@@ -432,7 +433,7 @@ class RuntimeTest(unittest.IsolatedAsyncioTestCase):
|
||||
store = self.store()
|
||||
store._conn.execute("CREATE TRIGGER fail_event BEFORE INSERT ON outbox BEGIN SELECT RAISE(ABORT, 'disk failure'); END")
|
||||
pipeline = Pipeline(reg, store, {})
|
||||
with self.assertRaises(Exception):
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
await pipeline.accept(context())
|
||||
self.assertEqual(store.counts_by_state(), {})
|
||||
self.assertEqual(store.delivery_counts(), {})
|
||||
|
||||
+3
-1
@@ -1 +1,3 @@
|
||||
registry=https://registry.npmmirror.com/
|
||||
# 固定官方源,避免本机全局镜像写进 lockfile。国内网络可临时覆盖:
|
||||
# npm_config_registry=https://registry.npmmirror.com/ npm ci
|
||||
registry=https://registry.npmjs.org/
|
||||
Generated
+674
-312
File diff suppressed because it is too large.
Load diff
+4
-2
@@ -8,7 +8,8 @@
|
||||
"build": "vite build",
|
||||
"typecheck": "tsc -p tsconfig.app.json",
|
||||
"lint": "concurrently npm:typecheck npm:lint:eslint",
|
||||
"lint:eslint": "eslint src"
|
||||
"lint:eslint": "eslint src",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
@@ -45,6 +46,7 @@
|
||||
"globals": "^17",
|
||||
"typescript": "~5.9",
|
||||
"typescript-eslint": "^8",
|
||||
"vite": "^8"
|
||||
"vite": "^8",
|
||||
"vitest": "^4.1.12"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { api, apiUrl, ApiError } from '@/api/client';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('apiUrl', () => {
|
||||
it('拼接 BASE_URL 与接口路径,并去掉多余的前导斜杠', () => {
|
||||
expect(apiUrl('dashboard')).toBe('/api/dashboard');
|
||||
expect(apiUrl('/events')).toBe('/api/events');
|
||||
});
|
||||
});
|
||||
|
||||
describe('request 错误处理', () => {
|
||||
const respond = (status: number, body: unknown) =>
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify(body), { status })));
|
||||
|
||||
it('使用后端 detail 作为错误文案并保留状态码', async () => {
|
||||
respond(409, { detail: '任务不是待审批状态' });
|
||||
await expect(api.dashboard()).rejects.toMatchObject({ status: 409, message: '任务不是待审批状态' });
|
||||
});
|
||||
|
||||
it('把 FastAPI 校验错误数组拼成可读文案', async () => {
|
||||
respond(422, { detail: [{ loc: ['body', 'password'], msg: 'Field required' }] });
|
||||
const error = await api.dashboard().catch((err: unknown) => err);
|
||||
expect(error).toBeInstanceOf(ApiError);
|
||||
expect((error as ApiError).message).toBe('body.password:Field required');
|
||||
});
|
||||
|
||||
it('非 JSON 错误响应回退到通用文案', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('<html>bad gateway</html>', { status: 502 })));
|
||||
await expect(api.dashboard()).rejects.toMatchObject({ status: 502, message: '请求失败(502)' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { STATE_META } from '@/lib/status';
|
||||
|
||||
describe('任务状态文案与后端状态机保持一致', () => {
|
||||
it('后端 TaskState 的每个取值都有界面文案', () => {
|
||||
// 直接读取后端契约,后端新增状态而前端遗漏时这里会失败。
|
||||
const contracts = readFileSync(resolve(import.meta.dirname, '../../../reelay/core/contracts.py'), 'utf8');
|
||||
const block = contracts.slice(contracts.indexOf('class TaskState'), contracts.indexOf('class ApprovalMode'));
|
||||
const backendStates = [...block.matchAll(/^\s+[A-Z_]+ = "([a-z_]+)"/gm)].map((match) => match[1]);
|
||||
expect(backendStates.length).toBeGreaterThan(10);
|
||||
expect(Object.keys(STATE_META).sort()).toEqual(backendStates.sort());
|
||||
});
|
||||
});
|
||||
@@ -79,4 +79,3 @@ CardFooter.displayName = "CardFooter"
|
||||
export {
|
||||
Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle
|
||||
};
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "./tailwind-theme.css";
|
||||
@import "./typography.css";
|
||||
@import "./typography.css";
|
||||
@@ -0,0 +1,7 @@
|
||||
import path from 'node:path';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
resolve: { alias: { '@': path.resolve(import.meta.dirname, 'src') } },
|
||||
test: { environment: 'node', include: ['src/**/*.test.ts'] },
|
||||
});
|
||||
Reference in new issue
Block a user