Goon — self-hosted aggregator for adult-content scene metadata. Indexes scenes from TPDB, StashDB, and 30+ public adult tube sites. Cross-source deduplication via perceptual hash + Levenshtein distance. FastAPI backend + APScheduler worker + React Native (Expo) mobile client. FOSS, ad-free, donation-funded. See README for details.
206 lines
6.5 KiB
Python
206 lines
6.5 KiB
Python
"""htmx + Jinja2 admin UI dla MergeCandidate triage.
|
|
|
|
Endpointy:
|
|
GET /ui/ — lista pending (filter status)
|
|
GET /ui/candidate/{id} — side-by-side scen
|
|
POST /ui/candidate/{id}/resolve — htmx form submit (action=merge_keep_left|merge_keep_right|reject)
|
|
zwraca fragment HTML z potwierdzeniem
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from pathlib import Path
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request
|
|
from fastapi.responses import HTMLResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.templating import Jinja2Templates
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.api.scenes import _build_scene_out
|
|
from app.auth import require_api_key
|
|
from app.db import get_session
|
|
from app.models.merge_candidate import MergeCandidate, MergeKind, MergeStatus
|
|
from app.models.scene import Scene
|
|
from app.resolve.scene_merge import MergeError, resolve_candidate
|
|
|
|
_TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates"
|
|
_STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
|
|
|
|
templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
|
|
|
|
|
|
def _score_class(score: float) -> str:
|
|
if score >= 0.92:
|
|
return "high"
|
|
if score >= 0.75:
|
|
return "mid"
|
|
return "low"
|
|
|
|
|
|
templates.env.globals["score_class"] = _score_class
|
|
|
|
|
|
router = APIRouter(
|
|
prefix="/ui",
|
|
tags=["ui"],
|
|
dependencies=[Depends(require_api_key)],
|
|
)
|
|
|
|
|
|
@router.get("/", response_class=HTMLResponse)
|
|
def list_view(
|
|
request: Request,
|
|
session: Annotated[Session, Depends(get_session)],
|
|
status: Annotated[str, Query(pattern="^(pending|auto_merged|merged|rejected|all)$")] = "pending",
|
|
page: Annotated[int, Query(ge=1)] = 1,
|
|
) -> HTMLResponse:
|
|
per_page = 50
|
|
|
|
base = select(MergeCandidate).where(MergeCandidate.kind == MergeKind.scene)
|
|
if status != "all":
|
|
base = base.where(MergeCandidate.status == MergeStatus(status))
|
|
|
|
total = session.execute(select(func.count()).select_from(base.subquery())).scalar_one()
|
|
rows = (
|
|
session.execute(
|
|
base.order_by(MergeCandidate.score.desc(), MergeCandidate.created_at.desc())
|
|
.offset((page - 1) * per_page)
|
|
.limit(per_page)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
|
|
titles: dict[uuid.UUID, str] = {}
|
|
scene_ids = {r.left_id for r in rows} | {r.right_id for r in rows}
|
|
if scene_ids:
|
|
for sid, title in session.execute(
|
|
select(Scene.id, Scene.title).where(Scene.id.in_(scene_ids))
|
|
):
|
|
titles[sid] = title
|
|
|
|
items = [
|
|
{
|
|
"id": r.id,
|
|
"kind": r.kind.value,
|
|
"left_id": r.left_id,
|
|
"right_id": r.right_id,
|
|
"score": r.score,
|
|
"status": r.status.value,
|
|
"left_title": titles.get(r.left_id),
|
|
"right_title": titles.get(r.right_id),
|
|
}
|
|
for r in rows
|
|
]
|
|
|
|
label_map = {
|
|
"pending": "Pending",
|
|
"auto_merged": "Auto-merged",
|
|
"merged": "Merged",
|
|
"rejected": "Rejected",
|
|
"all": "All",
|
|
}
|
|
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"candidates_list.html",
|
|
{
|
|
"items": items,
|
|
"total": total,
|
|
"page": page,
|
|
"per_page": per_page,
|
|
"status": status,
|
|
"status_label": label_map[status],
|
|
},
|
|
)
|
|
|
|
|
|
@router.get("/candidate/{candidate_id}", response_class=HTMLResponse)
|
|
def detail_view(
|
|
candidate_id: uuid.UUID,
|
|
request: Request,
|
|
session: Annotated[Session, Depends(get_session)],
|
|
) -> HTMLResponse:
|
|
cand = session.get(MergeCandidate, candidate_id)
|
|
if cand is None:
|
|
raise HTTPException(status_code=404, detail="merge candidate not found")
|
|
|
|
left_out = right_out = None
|
|
if cand.kind == MergeKind.scene:
|
|
left_scene = session.get(Scene, cand.left_id)
|
|
right_scene = session.get(Scene, cand.right_id)
|
|
if left_scene is not None:
|
|
left_out = _build_scene_out(session, left_scene)
|
|
if right_scene is not None and right_scene.id != cand.left_id:
|
|
right_out = _build_scene_out(session, right_scene)
|
|
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"candidate_detail.html",
|
|
{
|
|
"cand": {
|
|
"id": cand.id,
|
|
"kind": cand.kind.value,
|
|
"score": cand.score,
|
|
"status": cand.status.value,
|
|
"reasons": cand.reasons or {},
|
|
"left": left_out,
|
|
"right": right_out,
|
|
"left_id": cand.left_id,
|
|
"right_id": cand.right_id,
|
|
},
|
|
},
|
|
)
|
|
|
|
|
|
@router.post("/candidate/{candidate_id}/resolve", response_class=HTMLResponse)
|
|
def resolve_form(
|
|
candidate_id: uuid.UUID,
|
|
session: Annotated[Session, Depends(get_session)],
|
|
action: Annotated[str, Form()],
|
|
) -> HTMLResponse:
|
|
if action not in {"merge_keep_left", "merge_keep_right", "reject"}:
|
|
raise HTTPException(status_code=400, detail=f"invalid action: {action}")
|
|
|
|
api_action = "reject" if action == "reject" else "merge"
|
|
keep_left = action != "merge_keep_right"
|
|
|
|
try:
|
|
resolve_candidate(
|
|
session,
|
|
candidate_id=candidate_id,
|
|
action=api_action,
|
|
keep_left=keep_left,
|
|
resolved_by="ui",
|
|
)
|
|
except MergeError as exc:
|
|
return HTMLResponse(
|
|
f'<div class="card" id="actions" style="border-color: var(--bad);">'
|
|
f"<strong>error:</strong> {exc}</div>",
|
|
status_code=400,
|
|
)
|
|
|
|
label = {
|
|
"merge_keep_left": "Merged into LEFT",
|
|
"merge_keep_right": "Merged into RIGHT",
|
|
"reject": "Rejected (kept both)",
|
|
}[action]
|
|
|
|
return HTMLResponse(
|
|
f'<div class="card" id="actions" style="border-color: var(--good);">'
|
|
f"<strong>{label}.</strong> "
|
|
f'<a href="/ui/">← back to list</a></div>'
|
|
)
|
|
|
|
|
|
def mount_static(app) -> None: # pragma: no cover - dev convenience
|
|
# APK MIME type — bez tego Android Browser nie traktuje pliku jako instalable APK
|
|
# (text/plain → "Plik został pobrany" zamiast prompta install). Rejestracja jest
|
|
# idempotentna na poziomie procesu — bezpiecznie wywoływać przy każdym startup.
|
|
import mimetypes
|
|
mimetypes.add_type("application/vnd.android.package-archive", ".apk")
|
|
if _STATIC_DIR.exists():
|
|
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
|