mirror of
https://github.com/HyPnoTiiK/stream-fusion.git
synced 2026-07-28 23:12:46 +00:00
2177 lines
83 KiB
Python
2177 lines
83 KiB
Python
import time
|
|
from fastapi import APIRouter, Request, Depends, Form
|
|
from fastapi.templating import Jinja2Templates
|
|
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
|
from fastapi.security import APIKeyHeader
|
|
from sqlalchemy import text
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from starlette.status import HTTP_303_SEE_OTHER
|
|
import secrets
|
|
import uuid
|
|
from datetime import timedelta
|
|
|
|
from stream_fusion.services.postgresql.dependencies import get_db_session
|
|
from stream_fusion.services.postgresql.schemas.apikey_schemas import APIKeyCreate, APIKeyUpdate
|
|
from stream_fusion.utils.security.security_secret import SecretManager
|
|
from stream_fusion.services.postgresql.dao.apikey_dao import APIKeyDAO
|
|
from stream_fusion.services.postgresql.dao.peerkey_dao import PeerKeyDAO
|
|
from stream_fusion.services.postgresql.dao.debridcache_dao import DebridCacheDAO
|
|
from stream_fusion.services.postgresql.dao.torrentgroup_dao import TorrentGroupDAO
|
|
from stream_fusion.web.api.auth.schemas import UsageLogs, UsageLog
|
|
from stream_fusion.logging_config import logger
|
|
from stream_fusion.settings import settings
|
|
from stream_fusion.services.redis.redis_config import get_redis_dependency
|
|
from stream_fusion.utils.string_encoding import generate_csrf_token, verify_csrf_token
|
|
from stream_fusion.web.api.utils import ensure_uuid
|
|
|
|
router = APIRouter()
|
|
|
|
templates = Jinja2Templates(directory=settings.admin_template_dir)
|
|
# Expose the CSRF token generator as a Jinja2 global —
|
|
# base.html calls {% set csrf_token = csrf_token_gen() %} once per render.
|
|
templates.env.globals["csrf_token_gen"] = generate_csrf_token
|
|
|
|
from stream_fusion.version import get_version
|
|
try:
|
|
templates.env.globals["app_version"] = get_version()
|
|
except Exception:
|
|
templates.env.globals["app_version"] = "?"
|
|
|
|
_DEFAULT_SESSION_KEY = "331cbfe48117fcba53d09572b10d2fc293d86131dc51be46d8aa9843c2e9f48d"
|
|
|
|
|
|
def _masked(value) -> bool:
|
|
return bool(value and str(value).strip())
|
|
|
|
|
|
def _build_config_view(s) -> dict:
|
|
def proxy_host_only(url) -> str | None:
|
|
if not url:
|
|
return None
|
|
try:
|
|
from yarl import URL as YURL
|
|
u = YURL(str(url))
|
|
return f"{u.scheme}://{u.host}:{u.port}"
|
|
except Exception:
|
|
return str(url)[:80]
|
|
|
|
return {
|
|
"session_key_is_default": s.session_key == _DEFAULT_SESSION_KEY,
|
|
"general": {
|
|
"host": s.host,
|
|
"port": s.port,
|
|
"workers_count": s.workers_count,
|
|
"use_https": s.use_https,
|
|
"log_level": s.log_level.value,
|
|
"debug": s.debug,
|
|
"allow_debrid_download": s.allow_debrid_download,
|
|
"download_service": s.download_service.value if s.download_service else None,
|
|
},
|
|
"security": {
|
|
"session_key_is_default": s.session_key == _DEFAULT_SESSION_KEY,
|
|
"security_hide_docs": s.security_hide_docs,
|
|
"secret_api_key_set": _masked(s.secret_api_key),
|
|
"config_secret_key_set": _masked(s.config_secret_key),
|
|
},
|
|
"debrid": [
|
|
{"name": "Real-Debrid", "token_set": _masked(s.rd_token), "unique": s.rd_unique_account},
|
|
{"name": "AllDebrid", "token_set": _masked(s.ad_token), "unique": s.ad_unique_account},
|
|
{"name": "TorBox", "token_set": _masked(s.tb_token), "unique": s.tb_unique_account},
|
|
{"name": "Premiumize", "token_set": _masked(s.pm_token), "unique": s.pm_unique_account},
|
|
{"name": "Debrid-Link", "token_set": _masked(s.dl_token), "unique": s.dl_unique_account},
|
|
{"name": "EasyDebrid", "token_set": _masked(s.ed_token), "unique": s.ed_unique_account},
|
|
{"name": "Offcloud", "token_set": _masked(s.oc_credentials), "unique": s.oc_unique_account},
|
|
{"name": "PikPak", "token_set": _masked(s.pp_credentials), "unique": s.pp_unique_account},
|
|
],
|
|
"proxy": {
|
|
"proxy_url": proxy_host_only(s.proxy_url),
|
|
"playback_proxy": s.playback_proxy,
|
|
"proxy_buffer_size_kb": s.proxy_buffer_size // 1024,
|
|
"playback_limit_requests": s.playback_limit_requests,
|
|
"playback_limit_seconds": s.playback_limit_seconds,
|
|
},
|
|
"peer": {
|
|
"url": s.peer_streamfusion_url or None,
|
|
"key_id_set": _masked(s.peer_streamfusion_key_id),
|
|
"secret_set": _masked(s.peer_streamfusion_secret),
|
|
},
|
|
"indexers": [
|
|
{"name": "Jackett", "enabled": s.jackett_enable, "url": f"{s.jackett_schema}://{s.jackett_host}:{s.jackett_port}", "key_set": _masked(s.jackett_api_key)},
|
|
{"name": "YGG / YGGFlix", "enabled": _masked(s.ygg_passkey), "url": s.yggflix_url, "key_set": _masked(s.ygg_passkey)},
|
|
{"name": "C411", "enabled": s.c411_enable, "url": s.c411_url, "key_set": _masked(s.c411_api_key) or _masked(s.c411_passkey)},
|
|
{"name": "Torr9", "enabled": s.torr9_enable, "url": s.torr9_url, "key_set": _masked(s.torr9_api_key)},
|
|
{"name": "LaCale", "enabled": s.lacale_enable, "url": s.lacale_url, "key_set": _masked(s.lacale_api_key)},
|
|
{"name": "GénérationFree", "enabled": s.generationfree_enable, "url": s.generationfree_url, "key_set": _masked(s.generationfree_api_key)},
|
|
{"name": "ABN", "enabled": s.abn_enable, "url": s.abn_url, "key_set": _masked(s.abn_api_key)},
|
|
{"name": "G3MINI", "enabled": s.g3mini_enable, "url": s.g3mini_url, "key_set": _masked(s.g3mini_api_key)},
|
|
{"name": "TheOldSchool", "enabled": s.theoldschool_enable, "url": s.theoldschool_url, "key_set": _masked(s.theoldschool_api_key)},
|
|
{"name": "Zilean", "enabled": True, "url": f"{s.zilean_schema}://{s.zilean_host}", "key_set": None},
|
|
],
|
|
"tmdb": {
|
|
"language": s.tmdb_language,
|
|
"api_key_set": _masked(s.tmdb_api_key),
|
|
},
|
|
"redis": {
|
|
"host": s.redis_host,
|
|
"port": s.redis_port,
|
|
"db": s.redis_db,
|
|
"expiration_days": round(s.redis_expiration / 86400, 1),
|
|
"password_set": _masked(s.redis_password),
|
|
},
|
|
"postgresql": {
|
|
"host": s.pg_host,
|
|
"port": s.pg_port,
|
|
"database": s.pg_base,
|
|
"user": s.pg_user,
|
|
"pool_size": s.pg_pool_size,
|
|
"max_overflow": s.pg_max_overflow,
|
|
},
|
|
}
|
|
|
|
SECRET_KEY_NAME = "secret-key"
|
|
secret_header = APIKeyHeader(name=SECRET_KEY_NAME, auto_error=False)
|
|
|
|
secret = SecretManager()
|
|
|
|
|
|
def custom_url_for(name: str, **path_params: any) -> str:
|
|
def wrapper(request: Request):
|
|
url = request.url_for(name, **path_params)
|
|
if settings.use_https:
|
|
return str(url.replace(scheme="https"))
|
|
return str(url)
|
|
|
|
return wrapper
|
|
|
|
|
|
templates.env.globals["url_for"] = custom_url_for
|
|
|
|
|
|
def _fmt_date(value) -> str:
|
|
"""Format a Unix timestamp or datetime to 'DD MMM. YYYY HH:MM'."""
|
|
from datetime import datetime as _dt
|
|
if value is None or value in ('', 'None', '—'):
|
|
return '—'
|
|
if value == 'Unlimited':
|
|
return 'Unlimited'
|
|
if isinstance(value, (int, float)) and value > 0:
|
|
return _dt.fromtimestamp(int(value)).strftime('%d %b. %Y %H:%M')
|
|
if hasattr(value, 'strftime'):
|
|
return value.strftime('%d %b. %Y %H:%M')
|
|
return str(value)
|
|
|
|
|
|
def _fmt_relative(value) -> str:
|
|
"""Show relative time (il y a Xj) for recent dates, absolute for older ones."""
|
|
import time as _t
|
|
from datetime import datetime as _dt
|
|
if value is None or value in ('', 'None', '—'):
|
|
return '—'
|
|
ts = None
|
|
if isinstance(value, (int, float)) and value > 0:
|
|
ts = int(value)
|
|
elif hasattr(value, 'timestamp'):
|
|
ts = int(value.timestamp())
|
|
if ts is None:
|
|
return '—'
|
|
diff = int(_t.time()) - ts
|
|
if diff < 60:
|
|
return "À l'instant"
|
|
if diff < 3600:
|
|
return f"il y a {diff // 60} min"
|
|
if diff < 86400:
|
|
return f"il y a {diff // 3600}h"
|
|
if diff < 7 * 86400:
|
|
return f"il y a {diff // 86400}j"
|
|
return _dt.fromtimestamp(ts).strftime('%d %b. %Y')
|
|
|
|
|
|
def _fmt_size(value) -> str:
|
|
try:
|
|
b = int(value)
|
|
except (TypeError, ValueError):
|
|
return '—'
|
|
if b >= 1024 ** 3:
|
|
return f"{b / 1024 ** 3:.2f} Go"
|
|
if b >= 1024 ** 2:
|
|
return f"{b / 1024 ** 2:.1f} Mo"
|
|
if b >= 1024:
|
|
return f"{b / 1024:.0f} Ko"
|
|
return f"{b} o"
|
|
|
|
|
|
templates.env.filters['fmt_date'] = _fmt_date
|
|
templates.env.filters['fmt_relative'] = _fmt_relative
|
|
templates.env.filters['fmt_size'] = _fmt_size
|
|
|
|
|
|
def redirect_to_login(request: Request):
|
|
return RedirectResponse(
|
|
url=custom_url_for("login_page")(request), status_code=HTTP_303_SEE_OTHER
|
|
)
|
|
|
|
|
|
async def get_session_id_from_request(request: Request):
|
|
session_id = request.session.get("session_id")
|
|
if not session_id:
|
|
return None
|
|
return session_id
|
|
|
|
|
|
async def session_based_security(
|
|
request: Request,
|
|
session_id: str = Depends(get_session_id_from_request),
|
|
redis_client=get_redis_dependency(),
|
|
):
|
|
if not session_id:
|
|
return redirect_to_login(request)
|
|
|
|
secret_key = redis_client.get(session_id)
|
|
if not secret_key:
|
|
request.session.clear()
|
|
return redirect_to_login(request)
|
|
|
|
if not secrets.compare_digest(secret_key.decode(), secret.value):
|
|
redis_client.delete(session_id)
|
|
request.session.clear()
|
|
return redirect_to_login(request)
|
|
|
|
redis_client.expire(session_id, timedelta(hours=2))
|
|
return True
|
|
|
|
|
|
def admin_context(request: Request, **kwargs) -> dict:
|
|
"""Base context for all admin templates — injects the CSRF token."""
|
|
return {"request": request, "csrf_token": generate_csrf_token(), **kwargs}
|
|
|
|
|
|
async def require_csrf(request: Request):
|
|
"""
|
|
Validates the CSRF token on admin POST routes.
|
|
Reads the token from the form field 'csrf_token' or the 'X-CSRF-Token' header.
|
|
"""
|
|
token = ""
|
|
try:
|
|
form = await request.form()
|
|
token = form.get("csrf_token", "")
|
|
except Exception:
|
|
pass
|
|
if not token:
|
|
token = request.headers.get("X-CSRF-Token", "")
|
|
if not verify_csrf_token(token):
|
|
from fastapi import HTTPException
|
|
raise HTTPException(status_code=403, detail="Invalid or expired CSRF token.")
|
|
|
|
|
|
# ── Auth ───────────────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/", response_class=HTMLResponse)
|
|
async def login_page(request: Request):
|
|
return templates.TemplateResponse("login.html", {"request": request})
|
|
|
|
|
|
@router.post("/login")
|
|
async def login(
|
|
request: Request,
|
|
secret_key: str = Form(...),
|
|
redis_client=get_redis_dependency(),
|
|
):
|
|
if secrets.compare_digest(secret_key, secret.value):
|
|
session_id = str(uuid.uuid4())
|
|
redis_client.setex(session_id, timedelta(hours=2), secret_key)
|
|
request.session["session_id"] = session_id
|
|
logger.info("Admin login successful")
|
|
return RedirectResponse(
|
|
url=custom_url_for("dashboard")(request), status_code=HTTP_303_SEE_OTHER
|
|
)
|
|
logger.warning("Admin login attempt with invalid secret key")
|
|
return templates.TemplateResponse(
|
|
"login.html", {"request": request, "error": "Clé secrète invalide"}
|
|
)
|
|
|
|
|
|
@router.get("/logout")
|
|
async def logout(request: Request, redis_client=get_redis_dependency()):
|
|
session_id = request.session.get("session_id")
|
|
if session_id:
|
|
redis_client.delete(session_id)
|
|
request.session.clear()
|
|
return RedirectResponse(
|
|
url=custom_url_for("login_page")(request), status_code=HTTP_303_SEE_OTHER
|
|
)
|
|
|
|
|
|
# ── Dashboard ─────────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/dashboard", response_class=HTMLResponse)
|
|
async def dashboard(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
apikey_dao: APIKeyDAO = Depends(),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
redis_client=get_redis_dependency(),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return authenticated
|
|
|
|
# API keys stats
|
|
usage_stats = await apikey_dao.get_usage_stats()
|
|
total_keys = len(usage_stats)
|
|
active_keys = sum(1 for k in usage_stats if k.is_active)
|
|
|
|
# Peer keys count — direct SQL to avoid session.begin() conflict
|
|
try:
|
|
r = await db.execute(text("SELECT COUNT(*) FROM peer_keys WHERE is_active = true"))
|
|
active_peers = r.scalar() or 0
|
|
except Exception:
|
|
active_peers = 0
|
|
|
|
# Debrid cache count
|
|
try:
|
|
result = await db.execute(text("SELECT COUNT(*) FROM debrid_cache"))
|
|
debrid_cache_count = result.scalar() or 0
|
|
except Exception:
|
|
debrid_cache_count = 0
|
|
|
|
# Torrent items count
|
|
try:
|
|
result = await db.execute(text("SELECT COUNT(*) FROM torrent_items"))
|
|
torrent_count = result.scalar() or 0
|
|
except Exception:
|
|
torrent_count = 0
|
|
|
|
# Matched/orphan torrents
|
|
try:
|
|
result = await db.execute(text("SELECT COUNT(*) FROM torrent_items WHERE tmdb_id IS NOT NULL"))
|
|
torrent_matched_count = result.scalar() or 0
|
|
except Exception:
|
|
torrent_matched_count = 0
|
|
torrent_orphan_count = torrent_count - torrent_matched_count
|
|
|
|
# Redis status
|
|
try:
|
|
redis_ok = bool(redis_client.ping())
|
|
except Exception:
|
|
redis_ok = False
|
|
|
|
return templates.TemplateResponse("dashboard.html", {
|
|
"request": request,
|
|
"total_keys": total_keys,
|
|
"active_keys": active_keys,
|
|
"active_peers": active_peers,
|
|
"debrid_cache_count": debrid_cache_count,
|
|
"torrent_count": torrent_count,
|
|
"torrent_matched_count": torrent_matched_count,
|
|
"torrent_orphan_count": torrent_orphan_count,
|
|
"redis_ok": redis_ok,
|
|
})
|
|
|
|
|
|
# ── API Keys ──────────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/api-keys", response_class=HTMLResponse)
|
|
async def list_api_keys(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
apikey_dao: APIKeyDAO = Depends(),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return authenticated
|
|
|
|
usage_stats = await apikey_dao.get_usage_stats()
|
|
usage_logs = UsageLogs(
|
|
logs=[
|
|
UsageLog(
|
|
api_key=key.api_key,
|
|
is_active=key.is_active,
|
|
never_expire=key.never_expire,
|
|
expiration_date=(key.expiration_date if key.expiration_date else "Unlimited"),
|
|
latest_query_date=(key.latest_query_date if key.latest_query_date else "None"),
|
|
total_queries=key.total_queries,
|
|
name=key.name if key.name else "JohnDoe",
|
|
proxied_links=key.proxied_links,
|
|
)
|
|
for key in usage_stats
|
|
]
|
|
)
|
|
return templates.TemplateResponse(
|
|
"api_keys.html", {"request": request, "logs": usage_logs.logs}
|
|
)
|
|
|
|
|
|
@router.get("/create-api-key", response_class=HTMLResponse)
|
|
async def create_api_key_page(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return authenticated
|
|
return templates.TemplateResponse("create_api_key.html", {"request": request})
|
|
|
|
|
|
@router.post("/create-api-key")
|
|
async def create_api_key(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
name: str = Form(None),
|
|
never_expires: bool = Form(False),
|
|
proxied_links: bool = Form(False),
|
|
apikey_dao: APIKeyDAO = Depends(),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return authenticated
|
|
|
|
key = APIKeyCreate(name=name, never_expire=never_expires, proxied_links=proxied_links)
|
|
await apikey_dao.create_key(key)
|
|
logger.info(f"Admin: API key created (name={name})")
|
|
return RedirectResponse(
|
|
url=custom_url_for("list_api_keys")(request), status_code=HTTP_303_SEE_OTHER
|
|
)
|
|
|
|
|
|
@router.post("/revoke-api-key")
|
|
async def revoke_api_key(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
api_key: str = Form(...),
|
|
apikey_dao: APIKeyDAO = Depends(),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return authenticated
|
|
|
|
api_key_uuid = ensure_uuid(api_key)
|
|
await apikey_dao.revoke_key(api_key_uuid)
|
|
return RedirectResponse(
|
|
url=custom_url_for("list_api_keys")(request), status_code=HTTP_303_SEE_OTHER
|
|
)
|
|
|
|
|
|
@router.post("/renew-api-key")
|
|
async def renew_api_key(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
api_key: str = Form(...),
|
|
apikey_dao: APIKeyDAO = Depends(),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return authenticated
|
|
|
|
api_key_uuid = ensure_uuid(api_key)
|
|
try:
|
|
await apikey_dao.renew_key(api_key_uuid)
|
|
except Exception as e:
|
|
logger.error(f"Admin: failed to renew API key: {e}")
|
|
return RedirectResponse(
|
|
url=custom_url_for("list_api_keys")(request), status_code=HTTP_303_SEE_OTHER
|
|
)
|
|
|
|
|
|
@router.post("/delete-api-key")
|
|
async def delete_api_key(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
api_key: str = Form(...),
|
|
apikey_dao: APIKeyDAO = Depends(),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return authenticated
|
|
|
|
api_key_uuid = ensure_uuid(api_key)
|
|
try:
|
|
await apikey_dao.delete_key(api_key_uuid)
|
|
except Exception as e:
|
|
logger.error(f"Admin: failed to delete API key: {e}")
|
|
return RedirectResponse(
|
|
url=custom_url_for("list_api_keys")(request), status_code=HTTP_303_SEE_OTHER
|
|
)
|
|
|
|
|
|
@router.post("/toggle-proxied-links")
|
|
async def toggle_proxied_links(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
api_key: str = Form(...),
|
|
apikey_dao: APIKeyDAO = Depends(),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return authenticated
|
|
|
|
api_key_uuid = ensure_uuid(api_key)
|
|
try:
|
|
key_info = await apikey_dao.get_key_by_uuid(api_key_uuid)
|
|
if key_info:
|
|
current_value = getattr(key_info, "proxied_links", False)
|
|
await apikey_dao.update_key(api_key_uuid, APIKeyUpdate(proxied_links=not current_value))
|
|
except Exception as e:
|
|
logger.error(f"Admin: failed to toggle proxied links: {e}")
|
|
return RedirectResponse(
|
|
url=custom_url_for("list_api_keys")(request), status_code=HTTP_303_SEE_OTHER
|
|
)
|
|
|
|
|
|
# ── Peer Keys ──────────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/peer-keys", response_class=HTMLResponse)
|
|
async def list_peer_keys(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return authenticated
|
|
|
|
dao = PeerKeyDAO(db)
|
|
keys = await dao.list_keys()
|
|
new_key = request.session.pop("new_peer_key", None)
|
|
return templates.TemplateResponse(
|
|
"peer_keys.html", {"request": request, "keys": keys, "new_key": new_key}
|
|
)
|
|
|
|
|
|
@router.get("/create-peer-key", response_class=HTMLResponse)
|
|
async def create_peer_key_page(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return authenticated
|
|
return templates.TemplateResponse("create_peer_key.html", {"request": request})
|
|
|
|
|
|
@router.post("/create-peer-key")
|
|
async def create_peer_key(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
name: str = Form(...),
|
|
rate_limit: int = Form(60),
|
|
rate_window: int = Form(60),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return authenticated
|
|
|
|
dao = PeerKeyDAO(db)
|
|
result = await dao.create_key(name=name, rate_limit=rate_limit, rate_window=rate_window)
|
|
logger.success(f"Admin: peer key created for '{name}' ({result['key_id'][:8]}…)")
|
|
request.session["new_peer_key"] = result
|
|
return RedirectResponse(
|
|
url=custom_url_for("list_peer_keys")(request), status_code=HTTP_303_SEE_OTHER
|
|
)
|
|
|
|
|
|
@router.post("/revoke-peer-key")
|
|
async def revoke_peer_key(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
key_id: str = Form(...),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return authenticated
|
|
|
|
dao = PeerKeyDAO(db)
|
|
await dao.revoke_key(key_id)
|
|
return RedirectResponse(
|
|
url=custom_url_for("list_peer_keys")(request), status_code=HTTP_303_SEE_OTHER
|
|
)
|
|
|
|
|
|
@router.post("/delete-peer-key")
|
|
async def delete_peer_key(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
key_id: str = Form(...),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return authenticated
|
|
|
|
dao = PeerKeyDAO(db)
|
|
await dao.delete_key(key_id)
|
|
return RedirectResponse(
|
|
url=custom_url_for("list_peer_keys")(request), status_code=HTTP_303_SEE_OTHER
|
|
)
|
|
|
|
|
|
# ── Maintenance ────────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/maintenance", response_class=HTMLResponse)
|
|
async def maintenance_page(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return authenticated
|
|
return templates.TemplateResponse("maintenance.html", {"request": request})
|
|
|
|
|
|
@router.post("/maintenance/clean-expired-api-keys")
|
|
async def clean_expired_api_keys(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
days: int = Form(30),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
|
|
|
try:
|
|
result = await db.execute(
|
|
text("""
|
|
DELETE FROM api_keys
|
|
WHERE NOT never_expire
|
|
AND (latest_query_date IS NULL OR latest_query_date < EXTRACT(EPOCH FROM NOW())::BIGINT - :seconds)
|
|
AND expiration_date < EXTRACT(EPOCH FROM NOW())::BIGINT
|
|
"""),
|
|
{"seconds": days * 86400},
|
|
)
|
|
await db.commit()
|
|
count = result.rowcount
|
|
logger.warning(f"Admin maintenance: deleted {count} expired API keys (inactive > {days} days)")
|
|
return JSONResponse({"success": True, "deleted": count, "message": f"{count} clé(s) API supprimée(s)"})
|
|
except Exception as e:
|
|
await db.rollback()
|
|
logger.error(f"Admin maintenance: clean-expired-api-keys failed: {e}")
|
|
return JSONResponse({"success": False, "message": str(e)}, status_code=500)
|
|
|
|
|
|
@router.post("/maintenance/clean-expired-debrid-cache")
|
|
async def clean_expired_debrid_cache(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
|
|
|
try:
|
|
dao = DebridCacheDAO(db)
|
|
count = await dao.delete_expired()
|
|
logger.warning(f"Admin maintenance: deleted {count} expired debrid cache entries")
|
|
return JSONResponse({"success": True, "deleted": count, "message": f"{count} entrée(s) expirée(s) supprimée(s)"})
|
|
except Exception as e:
|
|
logger.error(f"Admin maintenance: clean-expired-debrid-cache failed: {e}")
|
|
return JSONResponse({"success": False, "message": str(e)}, status_code=500)
|
|
|
|
|
|
@router.post("/maintenance/purge-debrid-cache")
|
|
async def purge_debrid_cache(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
|
|
|
try:
|
|
result = await db.execute(text("DELETE FROM debrid_cache"))
|
|
await db.commit()
|
|
count = result.rowcount
|
|
logger.warning(f"Admin maintenance: purged entire debrid cache ({count} rows)")
|
|
return JSONResponse({"success": True, "deleted": count, "message": f"Cache debrid vidé ({count} entrées)"})
|
|
except Exception as e:
|
|
await db.rollback()
|
|
logger.error(f"Admin maintenance: purge-debrid-cache failed: {e}")
|
|
return JSONResponse({"success": False, "message": str(e)}, status_code=500)
|
|
|
|
|
|
@router.post("/maintenance/clean-old-torrents")
|
|
async def clean_old_torrents(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
days: int = Form(90),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
|
|
|
try:
|
|
cutoff = int(time.time()) - (days * 86400)
|
|
result = await db.execute(
|
|
text("DELETE FROM torrent_items WHERE updated_at < :cutoff").bindparams(cutoff=cutoff)
|
|
)
|
|
await db.commit()
|
|
count = result.rowcount
|
|
logger.warning(f"Admin maintenance: deleted {count} torrent items not updated in {days} days")
|
|
return JSONResponse({"success": True, "deleted": count, "message": f"{count} torrent(s) non mis à jour depuis {days} jours supprimé(s)"})
|
|
except Exception as e:
|
|
await db.rollback()
|
|
logger.error(f"Admin maintenance: clean-old-torrents failed: {e}")
|
|
return JSONResponse({"success": False, "message": str(e)}, status_code=500)
|
|
|
|
|
|
@router.post("/maintenance/flush-redis-all")
|
|
async def flush_redis_all(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
redis_client=get_redis_dependency(),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
|
|
|
try:
|
|
redis_client.flushdb()
|
|
logger.warning("Admin maintenance: Redis FLUSHDB executed")
|
|
return JSONResponse({"success": True, "message": "Cache Redis entièrement vidé"})
|
|
except Exception as e:
|
|
logger.error(f"Admin maintenance: flush-redis-all failed: {e}")
|
|
return JSONResponse({"success": False, "message": str(e)}, status_code=500)
|
|
|
|
|
|
@router.post("/maintenance/flush-redis-pattern")
|
|
async def flush_redis_pattern(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
pattern: str = Form(...),
|
|
redis_client=get_redis_dependency(),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
|
|
|
# Whitelist des patterns autorisés pour éviter toute suppression accidentelle de session
|
|
allowed_patterns = {
|
|
"searchcache": ["search:*"],
|
|
"title_matching": ["title_matching:*"],
|
|
"rd_tokens": ["rd_access_token:*"],
|
|
"debrid_avail": ["debrid_avail:*"],
|
|
"debrid_avail_rd": ["debrid_avail:realdebrid:*"],
|
|
"debrid_avail_ad": ["debrid_avail:alldebrid:*"],
|
|
"debrid_avail_tb": ["debrid_avail:torbox:*"],
|
|
"debrid_avail_pm": ["debrid_avail:premiumize:*"],
|
|
"ratelimit_peer": ["ratelimit:peer:*"],
|
|
"bg_refresh": ["bg_refresh:*"],
|
|
}
|
|
|
|
if pattern not in allowed_patterns:
|
|
return JSONResponse({"success": False, "message": "Pattern non autorisé"}, status_code=400)
|
|
|
|
try:
|
|
total_deleted = 0
|
|
for p in allowed_patterns[pattern]:
|
|
keys = redis_client.keys(p)
|
|
if keys:
|
|
total_deleted += redis_client.delete(*keys)
|
|
logger.warning(f"Admin maintenance: flushed Redis pattern '{pattern}' ({total_deleted} keys deleted)")
|
|
return JSONResponse({"success": True, "deleted": total_deleted, "message": f"{total_deleted} clé(s) Redis supprimée(s) (pattern: {pattern})"})
|
|
except Exception as e:
|
|
logger.error(f"Admin maintenance: flush-redis-pattern failed: {e}")
|
|
return JSONResponse({"success": False, "message": str(e)}, status_code=500)
|
|
|
|
|
|
@router.post("/maintenance/reset-api-key-counters")
|
|
async def reset_api_key_counters(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
|
try:
|
|
result = await db.execute(text("UPDATE api_keys SET total_queries = 0, latest_query_date = NULL"))
|
|
await db.commit()
|
|
count = result.rowcount
|
|
logger.warning(f"Admin maintenance: reset query counters for {count} API keys")
|
|
return JSONResponse({"success": True, "message": f"Compteurs réinitialisés pour {count} clé(s) API"})
|
|
except Exception as e:
|
|
await db.rollback()
|
|
logger.error(f"Admin maintenance: reset-api-key-counters failed: {e}")
|
|
return JSONResponse({"success": False, "message": str(e)}, status_code=500)
|
|
|
|
|
|
@router.post("/maintenance/reset-peer-key-counters")
|
|
async def reset_peer_key_counters(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
|
try:
|
|
result = await db.execute(text("UPDATE peer_keys SET total_queries = 0, last_used_at = NULL"))
|
|
await db.commit()
|
|
count = result.rowcount
|
|
logger.warning(f"Admin maintenance: reset query counters for {count} peer keys")
|
|
return JSONResponse({"success": True, "message": f"Compteurs réinitialisés pour {count} peer key(s)"})
|
|
except Exception as e:
|
|
await db.rollback()
|
|
logger.error(f"Admin maintenance: reset-peer-key-counters failed: {e}")
|
|
return JSONResponse({"success": False, "message": str(e)}, status_code=500)
|
|
|
|
|
|
@router.post("/maintenance/disable-expired-api-keys")
|
|
async def disable_expired_api_keys(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
|
try:
|
|
result = await db.execute(
|
|
text("""
|
|
UPDATE api_keys SET is_active = false
|
|
WHERE never_expire = false
|
|
AND expiration_date < EXTRACT(EPOCH FROM NOW())::BIGINT
|
|
AND is_active = true
|
|
""")
|
|
)
|
|
await db.commit()
|
|
count = result.rowcount
|
|
logger.warning(f"Admin maintenance: disabled {count} expired API keys")
|
|
return JSONResponse({"success": True, "updated": count, "message": f"{count} clé(s) API expirée(s) désactivée(s)"})
|
|
except Exception as e:
|
|
await db.rollback()
|
|
logger.error(f"Admin maintenance: disable-expired-api-keys failed: {e}")
|
|
return JSONResponse({"success": False, "message": str(e)}, status_code=500)
|
|
|
|
|
|
@router.post("/maintenance/disable-expired-peer-keys")
|
|
async def disable_expired_peer_keys(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
|
try:
|
|
result = await db.execute(
|
|
text("""
|
|
UPDATE peer_keys SET is_active = false
|
|
WHERE expires_at IS NOT NULL
|
|
AND expires_at < EXTRACT(EPOCH FROM NOW())::BIGINT
|
|
AND is_active = true
|
|
""")
|
|
)
|
|
await db.commit()
|
|
count = result.rowcount
|
|
logger.warning(f"Admin maintenance: disabled {count} expired peer keys")
|
|
return JSONResponse({"success": True, "updated": count, "message": f"{count} peer key(s) expirée(s) désactivée(s)"})
|
|
except Exception as e:
|
|
await db.rollback()
|
|
logger.error(f"Admin maintenance: disable-expired-peer-keys failed: {e}")
|
|
return JSONResponse({"success": False, "message": str(e)}, status_code=500)
|
|
|
|
|
|
@router.post("/maintenance/clean-orphan-torrents")
|
|
async def clean_orphan_torrents(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
days: int = Form(30),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
|
try:
|
|
cutoff = int(time.time()) - (days * 86400)
|
|
result = await db.execute(
|
|
text("DELETE FROM torrent_items WHERE tmdb_id IS NULL AND created_at < :cutoff").bindparams(cutoff=cutoff)
|
|
)
|
|
await db.commit()
|
|
count = result.rowcount
|
|
logger.warning(f"Admin maintenance: deleted {count} orphan torrent items (tmdb_id IS NULL, older than {days} days)")
|
|
return JSONResponse({"success": True, "deleted": count, "message": f"{count} torrent(s) orphelin(s) supprimé(s)"})
|
|
except Exception as e:
|
|
await db.rollback()
|
|
logger.error(f"Admin maintenance: clean-orphan-torrents failed: {e}")
|
|
return JSONResponse({"success": False, "message": str(e)}, status_code=500)
|
|
|
|
|
|
@router.post("/maintenance/group-by-infohash")
|
|
async def group_torrents_by_infohash(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
"""Create/extend groups for all torrent_items that share the same info_hash."""
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
|
try:
|
|
group_dao = TorrentGroupDAO(db)
|
|
result = await group_dao.batch_group_by_info_hash()
|
|
await db.commit()
|
|
msg = (
|
|
f"{result['groups_created']} groupe(s) créé(s), "
|
|
f"{result['items_grouped']} torrent(s) groupé(s) par info_hash"
|
|
)
|
|
logger.info(f"Admin maintenance: group-by-infohash — {msg}")
|
|
return JSONResponse({"success": True, **result, "message": msg})
|
|
except Exception as e:
|
|
await db.rollback()
|
|
logger.error(f"Admin maintenance: group-by-infohash failed: {e}")
|
|
return JSONResponse({"success": False, "message": str(e)}, status_code=500)
|
|
|
|
|
|
@router.post("/maintenance/group-by-title-size")
|
|
async def group_torrents_by_title_size(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
"""Create/extend groups for torrents with matching normalized title and similar size."""
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
|
try:
|
|
group_dao = TorrentGroupDAO(db)
|
|
result = await group_dao.batch_group_by_title_size()
|
|
await db.commit()
|
|
msg = (
|
|
f"{result['groups_created']} groupe(s) créé(s), "
|
|
f"{result['items_grouped']} torrent(s) groupé(s) par titre+taille"
|
|
)
|
|
logger.info(f"Admin maintenance: group-by-title-size — {msg}")
|
|
return JSONResponse({"success": True, **result, "message": msg})
|
|
except Exception as e:
|
|
await db.rollback()
|
|
logger.error(f"Admin maintenance: group-by-title-size failed: {e}")
|
|
return JSONResponse({"success": False, "message": str(e)}, status_code=500)
|
|
|
|
|
|
@router.post("/maintenance/propagate-tmdb-groups")
|
|
async def propagate_tmdb_to_groups(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
"""Propagate TMDB IDs to all members of every existing group."""
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
|
try:
|
|
group_dao = TorrentGroupDAO(db)
|
|
result = await group_dao.propagate_tmdb_all_groups()
|
|
await db.commit()
|
|
msg = (
|
|
f"{result['groups_updated']} groupe(s) mis à jour, "
|
|
f"{result['items_updated']} torrent(s) avec TMDB ID propagé"
|
|
)
|
|
logger.info(f"Admin maintenance: propagate-tmdb-groups — {msg}")
|
|
return JSONResponse({"success": True, **result, "message": msg})
|
|
except Exception as e:
|
|
await db.rollback()
|
|
logger.error(f"Admin maintenance: propagate-tmdb-groups failed: {e}")
|
|
return JSONResponse({"success": False, "message": str(e)}, status_code=500)
|
|
|
|
|
|
@router.post("/maintenance/cleanup-empty-groups")
|
|
async def cleanup_empty_groups(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
"""Delete torrent groups that have no members."""
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
|
try:
|
|
group_dao = TorrentGroupDAO(db)
|
|
count = await group_dao.delete_empty_groups()
|
|
await db.commit()
|
|
msg = f"{count} groupe(s) vide(s) supprimé(s)"
|
|
logger.info(f"Admin maintenance: cleanup-empty-groups — {msg}")
|
|
return JSONResponse({"success": True, "deleted": count, "message": msg})
|
|
except Exception as e:
|
|
await db.rollback()
|
|
logger.error(f"Admin maintenance: cleanup-empty-groups failed: {e}")
|
|
return JSONResponse({"success": False, "message": str(e)}, status_code=500)
|
|
|
|
|
|
@router.get("/maintenance/stats")
|
|
async def maintenance_stats(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
redis_client=get_redis_dependency(),
|
|
):
|
|
"""Return table row counts and Redis key counts as JSON for the maintenance stats panel."""
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
|
|
|
pg_stats = {}
|
|
|
|
try:
|
|
r = await db.execute(text("""
|
|
SELECT COUNT(*),
|
|
COUNT(*) FILTER (WHERE is_active),
|
|
COUNT(*) FILTER (WHERE NOT never_expire AND expiration_date < EXTRACT(EPOCH FROM NOW())::BIGINT AND is_active)
|
|
FROM api_keys
|
|
"""))
|
|
row = r.fetchone()
|
|
pg_stats["api_keys"] = {"total": row[0] or 0, "active": row[1] or 0, "expired_active": row[2] or 0}
|
|
except Exception:
|
|
pg_stats["api_keys"] = {"total": "?", "active": "?", "expired_active": "?"}
|
|
|
|
try:
|
|
r = await db.execute(text("""
|
|
SELECT COUNT(*),
|
|
COUNT(*) FILTER (WHERE is_active),
|
|
COUNT(*) FILTER (WHERE expires_at IS NOT NULL AND expires_at < EXTRACT(EPOCH FROM NOW())::BIGINT AND is_active)
|
|
FROM peer_keys
|
|
"""))
|
|
row = r.fetchone()
|
|
pg_stats["peer_keys"] = {"total": row[0] or 0, "active": row[1] or 0, "expired_active": row[2] or 0}
|
|
except Exception:
|
|
pg_stats["peer_keys"] = {"total": "?", "active": "?", "expired_active": "?"}
|
|
|
|
try:
|
|
r = await db.execute(text("""
|
|
SELECT COUNT(*),
|
|
COUNT(*) FILTER (WHERE expires_at < EXTRACT(EPOCH FROM NOW())::BIGINT)
|
|
FROM debrid_cache
|
|
"""))
|
|
row = r.fetchone()
|
|
pg_stats["debrid_cache"] = {"total": row[0] or 0, "expired": row[1] or 0}
|
|
except Exception:
|
|
pg_stats["debrid_cache"] = {"total": "?", "expired": "?"}
|
|
|
|
try:
|
|
cutoff_90d = int(time.time()) - 7776000
|
|
r = await db.execute(
|
|
text("""
|
|
SELECT COUNT(*),
|
|
COUNT(*) FILTER (WHERE tmdb_id IS NULL),
|
|
COUNT(*) FILTER (WHERE updated_at < :cutoff)
|
|
FROM torrent_items
|
|
"""),
|
|
{"cutoff": cutoff_90d},
|
|
)
|
|
row = r.fetchone()
|
|
pg_stats["torrent_items"] = {"total": row[0] or 0, "orphans": row[1] or 0, "old_90d": row[2] or 0}
|
|
except Exception:
|
|
pg_stats["torrent_items"] = {"total": "?", "orphans": "?", "old_90d": "?"}
|
|
|
|
try:
|
|
r = await db.execute(text(
|
|
"SELECT indexer, COUNT(*) AS cnt FROM torrent_items GROUP BY indexer ORDER BY cnt DESC LIMIT 5"
|
|
))
|
|
pg_stats["top_indexers"] = [{"indexer": row[0], "count": row[1]} for row in r.fetchall()]
|
|
except Exception:
|
|
pg_stats["top_indexers"] = []
|
|
|
|
try:
|
|
sizes = {}
|
|
for table in ["torrent_items", "debrid_cache"]:
|
|
r = await db.execute(text(f"SELECT pg_size_pretty(pg_total_relation_size('{table}'))"))
|
|
sizes[table] = r.scalar() or "?"
|
|
pg_stats["table_sizes"] = sizes
|
|
except Exception:
|
|
pg_stats["table_sizes"] = {}
|
|
|
|
try:
|
|
r = await db.execute(text("SELECT COUNT(*) FROM metadata_mappings"))
|
|
pg_stats["metadata_mappings"] = r.scalar() or 0
|
|
except Exception:
|
|
pg_stats["metadata_mappings"] = "?"
|
|
|
|
try:
|
|
r = await db.execute(text("""
|
|
SELECT
|
|
(SELECT COUNT(*) FROM torrent_groups) AS total_groups,
|
|
(SELECT COUNT(*) FROM torrent_items WHERE group_id IS NOT NULL) AS grouped_items,
|
|
(SELECT COUNT(*) FROM torrent_items WHERE group_id IS NULL) AS ungrouped_items,
|
|
(SELECT COUNT(*) FROM torrent_groups WHERE tmdb_id IS NOT NULL) AS groups_with_tmdb
|
|
"""))
|
|
row = r.fetchone()
|
|
pg_stats["torrent_groups"] = {
|
|
"total_groups": row[0] or 0,
|
|
"grouped_items": row[1] or 0,
|
|
"ungrouped_items": row[2] or 0,
|
|
"groups_with_tmdb": row[3] or 0,
|
|
}
|
|
except Exception:
|
|
pg_stats["torrent_groups"] = {
|
|
"total_groups": "?",
|
|
"grouped_items": "?",
|
|
"ungrouped_items": "?",
|
|
"groups_with_tmdb": "?",
|
|
}
|
|
|
|
redis_stats = {}
|
|
try:
|
|
info = redis_client.info("memory")
|
|
redis_stats["used_memory_human"] = info.get("used_memory_human", "?")
|
|
redis_stats["total_keys"] = redis_client.dbsize()
|
|
except Exception:
|
|
redis_stats = {"used_memory_human": "?", "total_keys": "?"}
|
|
|
|
redis_patterns = {
|
|
"searchcache": ["search:*"],
|
|
"title_matching": ["title_matching:*"],
|
|
"rd_tokens": ["rd_access_token:*"],
|
|
"debrid_avail": ["debrid_avail:*"],
|
|
"debrid_avail_rd": ["debrid_avail:realdebrid:*"],
|
|
"debrid_avail_ad": ["debrid_avail:alldebrid:*"],
|
|
"debrid_avail_tb": ["debrid_avail:torbox:*"],
|
|
"debrid_avail_pm": ["debrid_avail:premiumize:*"],
|
|
"ratelimit_peer": ["ratelimit:peer:*"],
|
|
"bg_refresh": ["bg_refresh:*"],
|
|
}
|
|
key_counts = {}
|
|
try:
|
|
for name, patterns in redis_patterns.items():
|
|
count = 0
|
|
for pat in patterns:
|
|
count += sum(1 for _ in redis_client.scan_iter(pat, count=500))
|
|
key_counts[name] = count
|
|
redis_stats["key_counts"] = key_counts
|
|
except Exception:
|
|
redis_stats["key_counts"] = {}
|
|
|
|
return JSONResponse({"pg": pg_stats, "redis": redis_stats})
|
|
|
|
|
|
# ── Scheduler ─────────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/scheduler", response_class=HTMLResponse)
|
|
async def scheduler_page(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return authenticated
|
|
return templates.TemplateResponse("scheduled_tasks.html", {"request": request})
|
|
|
|
|
|
@router.get("/scheduler/status")
|
|
async def scheduler_status(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
|
scheduler = getattr(request.app.state, "scheduler", None)
|
|
if scheduler is None:
|
|
return JSONResponse({"error": "Scheduler non initialisé"}, status_code=503)
|
|
return JSONResponse(scheduler.get_status())
|
|
|
|
|
|
@router.post("/scheduler/trigger/{job_id}")
|
|
async def scheduler_trigger(
|
|
request: Request,
|
|
job_id: str,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
|
scheduler = getattr(request.app.state, "scheduler", None)
|
|
if scheduler is None:
|
|
return JSONResponse({"success": False, "message": "Scheduler non initialisé"}, status_code=503)
|
|
triggered = await scheduler.trigger_job(job_id)
|
|
if triggered:
|
|
logger.warning(f"Admin: manual trigger of scheduler job '{job_id}'")
|
|
return JSONResponse({"success": True, "message": f"Job '{job_id}' déclenché manuellement"})
|
|
return JSONResponse({"success": False, "message": f"Job '{job_id}' introuvable"}, status_code=404)
|
|
|
|
|
|
# ── Torrent search ────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/search/hash", response_class=HTMLResponse)
|
|
async def search_hash_page(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
q: str = None,
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return authenticated
|
|
|
|
results = []
|
|
error = None
|
|
if q:
|
|
q = q.strip().lower()
|
|
try:
|
|
rows = await db.execute(
|
|
text("SELECT * FROM torrent_items WHERE info_hash = :h ORDER BY created_at DESC").bindparams(h=q)
|
|
)
|
|
results = [dict(r) for r in rows.mappings().all()]
|
|
except Exception as e:
|
|
error = str(e)
|
|
|
|
return templates.TemplateResponse("search_hash.html", {
|
|
"request": request, "results": results, "query": q or "", "error": error,
|
|
})
|
|
|
|
|
|
@router.get("/search/tmdb", response_class=HTMLResponse)
|
|
async def search_tmdb_page(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
q: str = None,
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return authenticated
|
|
|
|
results = []
|
|
error = None
|
|
if q:
|
|
try:
|
|
tmdb_id = int(q.strip())
|
|
rows = await db.execute(
|
|
text("""
|
|
SELECT raw_title, size, info_hash, trackers, indexer,
|
|
seeders, languages, type, tmdb_id, created_at, updated_at
|
|
FROM torrent_items
|
|
WHERE tmdb_id = :tid
|
|
ORDER BY seeders DESC
|
|
LIMIT 500
|
|
""").bindparams(tid=tmdb_id)
|
|
)
|
|
results = [dict(r) for r in rows.mappings().all()]
|
|
except ValueError:
|
|
error = "Le TMDB ID doit être un entier."
|
|
except Exception as e:
|
|
error = str(e)
|
|
|
|
return templates.TemplateResponse("search_tmdb.html", {
|
|
"request": request, "results": results, "query": q or "", "error": error,
|
|
})
|
|
|
|
|
|
# ── Metadata Mappings ─────────────────────────────────────────────────────────
|
|
|
|
@router.get("/mappings", response_class=HTMLResponse)
|
|
async def list_mappings(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return authenticated
|
|
|
|
from stream_fusion.services.postgresql.dao.metadatamapping_dao import MetadataMappingDAO
|
|
dao = MetadataMappingDAO(db)
|
|
mappings = await dao.list_all()
|
|
return templates.TemplateResponse("mappings.html", {"request": request, "mappings": mappings})
|
|
|
|
|
|
@router.post("/mappings/create")
|
|
async def create_mapping(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
imdb_id: str = Form(...),
|
|
tmdb_id: str = Form(None),
|
|
title_override: str = Form(None),
|
|
media_type: str = Form("series"),
|
|
notes: str = Form(None),
|
|
search_titles: str = Form(None),
|
|
year_override: int = Form(None),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
|
|
|
from stream_fusion.services.postgresql.dao.metadatamapping_dao import MetadataMappingDAO
|
|
dao = MetadataMappingDAO(db)
|
|
parsed_search_titles = [t.strip() for t in search_titles.split(",") if t.strip()] if search_titles else None
|
|
try:
|
|
mapping = await dao.create(
|
|
imdb_id=imdb_id.strip(),
|
|
tmdb_id=tmdb_id.strip() if tmdb_id else None,
|
|
title_override=title_override.strip() if title_override else None,
|
|
media_type=media_type,
|
|
notes=notes.strip() if notes else None,
|
|
search_titles=parsed_search_titles,
|
|
year_override=year_override,
|
|
)
|
|
logger.info(f"Admin: metadata mapping created for {imdb_id}")
|
|
return JSONResponse({"success": True, "id": mapping.id})
|
|
except Exception as e:
|
|
logger.error(f"Admin: failed to create mapping: {e}")
|
|
return JSONResponse({"success": False, "message": str(e)}, status_code=500)
|
|
|
|
|
|
@router.post("/mappings/edit")
|
|
async def edit_mapping(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
mapping_id: int = Form(...),
|
|
imdb_id: str = Form(...),
|
|
tmdb_id: str = Form(None),
|
|
title_override: str = Form(None),
|
|
media_type: str = Form("series"),
|
|
notes: str = Form(None),
|
|
search_titles: str = Form(None),
|
|
year_override: int = Form(None),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
|
|
|
from stream_fusion.services.postgresql.dao.metadatamapping_dao import MetadataMappingDAO
|
|
dao = MetadataMappingDAO(db)
|
|
parsed_search_titles = [t.strip() for t in search_titles.split(",") if t.strip()] if search_titles else None
|
|
try:
|
|
await dao.update(
|
|
mapping_id=mapping_id,
|
|
imdb_id=imdb_id.strip(),
|
|
tmdb_id=tmdb_id.strip() if tmdb_id else None,
|
|
title_override=title_override.strip() if title_override else None,
|
|
media_type=media_type,
|
|
notes=notes.strip() if notes else None,
|
|
search_titles=parsed_search_titles,
|
|
year_override=year_override,
|
|
)
|
|
logger.info(f"Admin: metadata mapping {mapping_id} updated")
|
|
return JSONResponse({"success": True})
|
|
except Exception as e:
|
|
logger.error(f"Admin: failed to update mapping {mapping_id}: {e}")
|
|
return JSONResponse({"success": False, "message": str(e)}, status_code=500)
|
|
|
|
|
|
@router.get("/config", response_class=HTMLResponse)
|
|
async def config_page(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return authenticated
|
|
from stream_fusion.services.settings.settings_registry import REGISTRY_BY_CATEGORY, PAGE_CATEGORIES
|
|
# Build summary: count of settings per sub-page
|
|
page_counts = {
|
|
page: sum(len(REGISTRY_BY_CATEGORY.get(cat, [])) for cat in cats)
|
|
for page, cats in PAGE_CATEGORIES.items()
|
|
}
|
|
return templates.TemplateResponse(
|
|
"config_page.html",
|
|
{"request": request, "page_counts": page_counts},
|
|
)
|
|
|
|
|
|
async def _get_settings_service(request: Request):
|
|
from stream_fusion.services.settings.settings_service import SettingsService
|
|
return request.app.state.settings_service
|
|
|
|
|
|
async def _config_subpage(
|
|
request: Request,
|
|
page_name: str,
|
|
template_name: str,
|
|
authenticated,
|
|
extra_ctx: dict = None,
|
|
):
|
|
"""Shared logic for all config sub-pages (GET)."""
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return authenticated
|
|
from stream_fusion.services.settings.settings_registry import (
|
|
REGISTRY_BY_CATEGORY, PAGE_CATEGORIES, SETTINGS_REGISTRY,
|
|
)
|
|
svc = await _get_settings_service(request)
|
|
effective = await svc.get_all_effective()
|
|
cats = PAGE_CATEGORIES.get(page_name, [])
|
|
registry_sections = {cat: REGISTRY_BY_CATEGORY.get(cat, []) for cat in cats}
|
|
ctx = {
|
|
"request": request,
|
|
"effective": effective,
|
|
"registry_sections": registry_sections,
|
|
"page_name": page_name,
|
|
}
|
|
if extra_ctx:
|
|
ctx.update(extra_ctx)
|
|
return templates.TemplateResponse(template_name, ctx)
|
|
|
|
|
|
async def _config_subpage_post(request: Request, page_name: str, authenticated):
|
|
"""Shared logic for all config sub-pages (POST)."""
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
|
from stream_fusion.services.settings.settings_registry import (
|
|
REGISTRY_BY_KEY, PAGE_CATEGORIES, REGISTRY_BY_CATEGORY,
|
|
)
|
|
svc = await _get_settings_service(request)
|
|
form = await request.form()
|
|
|
|
# Collect all keys that belong to this page's categories
|
|
cats = PAGE_CATEGORIES.get(page_name, [])
|
|
page_keys = {
|
|
defn.key
|
|
for cat in cats
|
|
for defn in REGISTRY_BY_CATEGORY.get(cat, [])
|
|
}
|
|
|
|
updates: dict[str, str] = {}
|
|
validation_errors: list[str] = []
|
|
|
|
for key in page_keys:
|
|
defn = REGISTRY_BY_KEY.get(key)
|
|
if defn is None:
|
|
continue
|
|
raw = form.get(key)
|
|
if raw is None:
|
|
# Unchecked checkboxes don't appear in form data — treat as False
|
|
if defn.type == "bool":
|
|
updates[key] = "false"
|
|
continue
|
|
try:
|
|
svc._coerce(key, str(raw))
|
|
updates[key] = str(raw)
|
|
except (ValueError, TypeError) as exc:
|
|
validation_errors.append(f"{defn.label}: {exc}")
|
|
|
|
if validation_errors:
|
|
return JSONResponse({"success": False, "errors": validation_errors}, status_code=422)
|
|
|
|
requires_restart = await svc.set_many(updates)
|
|
logger.info(
|
|
f"Admin: config/{page_name} — saved {len(updates)} setting(s), "
|
|
f"{len(requires_restart)} require restart"
|
|
)
|
|
return JSONResponse({
|
|
"success": True,
|
|
"saved": len(updates),
|
|
"requires_restart": requires_restart,
|
|
})
|
|
|
|
|
|
# ── Config sub-pages ──────────────────────────────────────────────────────────
|
|
|
|
@router.get("/config/general", response_class=HTMLResponse)
|
|
async def config_general_get(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
):
|
|
return await _config_subpage(request, "general", "config_general.html", authenticated)
|
|
|
|
|
|
@router.post("/config/general")
|
|
async def config_general_post(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
):
|
|
return await _config_subpage_post(request, "general", authenticated)
|
|
|
|
|
|
@router.get("/config/proxy", response_class=HTMLResponse)
|
|
async def config_proxy_get(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
):
|
|
return await _config_subpage(request, "proxy", "config_proxy.html", authenticated)
|
|
|
|
|
|
@router.post("/config/proxy")
|
|
async def config_proxy_post(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
):
|
|
return await _config_subpage_post(request, "proxy", authenticated)
|
|
|
|
|
|
@router.get("/config/cache", response_class=HTMLResponse)
|
|
async def config_cache_get(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
):
|
|
return await _config_subpage(request, "cache", "config_cache.html", authenticated)
|
|
|
|
|
|
@router.post("/config/cache")
|
|
async def config_cache_post(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
):
|
|
return await _config_subpage_post(request, "cache", authenticated)
|
|
|
|
|
|
@router.get("/config/indexers", response_class=HTMLResponse)
|
|
async def config_indexers_get(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
):
|
|
cfg = _build_config_view(settings)
|
|
return await _config_subpage(request, "indexers", "config_indexers.html", authenticated,
|
|
extra_ctx={"cfg_readonly": cfg})
|
|
|
|
|
|
@router.post("/config/indexers")
|
|
async def config_indexers_post(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
):
|
|
return await _config_subpage_post(request, "indexers", authenticated)
|
|
|
|
|
|
@router.get("/config/tmdb", response_class=HTMLResponse)
|
|
async def config_tmdb_get(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
):
|
|
# Page TMDB fusionnée dans Indexeurs & TMDB
|
|
return RedirectResponse(url=str(request.url_for("config_indexers_get")), status_code=301)
|
|
|
|
|
|
@router.post("/config/tmdb")
|
|
async def config_tmdb_post(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
):
|
|
return await _config_subpage_post(request, "tmdb", authenticated)
|
|
|
|
|
|
@router.get("/config/system", response_class=HTMLResponse)
|
|
async def config_system_get(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
):
|
|
cfg = _build_config_view(settings)
|
|
return await _config_subpage(
|
|
request, "system", "config_system.html", authenticated,
|
|
extra_ctx={"cfg_readonly": cfg},
|
|
)
|
|
|
|
|
|
@router.post("/config/system")
|
|
async def config_system_post(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
):
|
|
return await _config_subpage_post(request, "system", authenticated)
|
|
|
|
|
|
# ── Config reset routes ───────────────────────────────────────────────────────
|
|
|
|
async def _config_subpage_reset(request: Request, page_name: str, authenticated):
|
|
"""Restore all settings of a page to their original env-var defaults."""
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
|
from stream_fusion.services.settings.settings_registry import PAGE_CATEGORIES, REGISTRY_BY_CATEGORY
|
|
svc = await _get_settings_service(request)
|
|
cats = PAGE_CATEGORIES.get(page_name, [])
|
|
keys = [
|
|
defn.key
|
|
for cat in cats
|
|
for defn in REGISTRY_BY_CATEGORY.get(cat, [])
|
|
]
|
|
requires_restart = await svc.reset_many(keys)
|
|
logger.info(f"Admin: config/{page_name} — reset {len(keys)} setting(s) to env defaults")
|
|
return JSONResponse({"success": True, "reset": len(keys), "requires_restart": requires_restart})
|
|
|
|
|
|
@router.post("/config/general/reset")
|
|
async def config_general_reset(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
):
|
|
return await _config_subpage_reset(request, "general", authenticated)
|
|
|
|
|
|
@router.post("/config/proxy/reset")
|
|
async def config_proxy_reset(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
):
|
|
return await _config_subpage_reset(request, "proxy", authenticated)
|
|
|
|
|
|
@router.post("/config/cache/reset")
|
|
async def config_cache_reset(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
):
|
|
return await _config_subpage_reset(request, "cache", authenticated)
|
|
|
|
|
|
@router.post("/config/indexers/reset")
|
|
async def config_indexers_reset(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
):
|
|
return await _config_subpage_reset(request, "indexers", authenticated)
|
|
|
|
|
|
@router.post("/config/tmdb/reset")
|
|
async def config_tmdb_reset(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
):
|
|
return await _config_subpage_reset(request, "tmdb", authenticated)
|
|
|
|
|
|
@router.post("/config/system/reset")
|
|
async def config_system_reset(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
):
|
|
return await _config_subpage_reset(request, "system", authenticated)
|
|
|
|
|
|
@router.post("/mappings/delete")
|
|
async def delete_mapping(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
mapping_id: int = Form(...),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
|
|
|
from stream_fusion.services.postgresql.dao.metadatamapping_dao import MetadataMappingDAO
|
|
dao = MetadataMappingDAO(db)
|
|
try:
|
|
await dao.delete(mapping_id)
|
|
logger.info(f"Admin: metadata mapping {mapping_id} deleted")
|
|
return JSONResponse({"success": True})
|
|
except Exception as e:
|
|
logger.error(f"Admin: failed to delete mapping {mapping_id}: {e}")
|
|
return JSONResponse({"success": False, "message": str(e)}, status_code=500)
|
|
|
|
|
|
# ── Matching & Language rules ──────────────────────────────────────────────────
|
|
|
|
@router.get("/matching/title-rules", response_class=HTMLResponse)
|
|
async def matching_title_rules(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return authenticated
|
|
from stream_fusion.services.postgresql.dao.title_normalization_rule_dao import TitleNormalizationRuleDAO
|
|
dao = TitleNormalizationRuleDAO(db)
|
|
rules = await dao.get_all()
|
|
grouped: dict[str, list] = {}
|
|
for r in rules:
|
|
grouped.setdefault(r.rule_type, []).append(r)
|
|
return templates.TemplateResponse(
|
|
"matching_title_rules.html",
|
|
admin_context(request, grouped=grouped, rule_types=["substitution", "release_tag", "article", "ligature"]),
|
|
)
|
|
|
|
|
|
@router.post("/matching/title-rules/create")
|
|
async def matching_title_rules_create(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
rule_type: str = Form(...),
|
|
pattern: str = Form(...),
|
|
replacement: str = Form(""),
|
|
description: str = Form(""),
|
|
is_active: bool = Form(True),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
|
from stream_fusion.services.postgresql.dao.title_normalization_rule_dao import TitleNormalizationRuleDAO
|
|
dao = TitleNormalizationRuleDAO(db)
|
|
try:
|
|
rule = await dao.create(
|
|
rule_type=rule_type.strip(),
|
|
pattern=pattern.strip(),
|
|
replacement=replacement.strip(),
|
|
description=description.strip() or None,
|
|
is_active=is_active,
|
|
)
|
|
return JSONResponse({"success": True, "id": rule.id})
|
|
except Exception as e:
|
|
logger.error(f"Admin: failed to create title rule: {e}")
|
|
return JSONResponse({"success": False, "message": str(e)}, status_code=500)
|
|
|
|
|
|
@router.post("/matching/title-rules/update")
|
|
async def matching_title_rules_update(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
rule_id: int = Form(...),
|
|
rule_type: str = Form(...),
|
|
pattern: str = Form(...),
|
|
replacement: str = Form(""),
|
|
description: str = Form(""),
|
|
is_active: bool = Form(True),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
|
from stream_fusion.services.postgresql.dao.title_normalization_rule_dao import TitleNormalizationRuleDAO
|
|
dao = TitleNormalizationRuleDAO(db)
|
|
try:
|
|
rule = await dao.update(
|
|
rule_id=rule_id,
|
|
rule_type=rule_type.strip(),
|
|
pattern=pattern.strip(),
|
|
replacement=replacement.strip(),
|
|
description=description.strip() or None,
|
|
is_active=is_active,
|
|
)
|
|
if not rule:
|
|
return JSONResponse({"success": False, "message": "Règle introuvable"}, status_code=404)
|
|
return JSONResponse({"success": True})
|
|
except Exception as e:
|
|
logger.error(f"Admin: failed to update title rule {rule_id}: {e}")
|
|
return JSONResponse({"success": False, "message": str(e)}, status_code=500)
|
|
|
|
|
|
@router.post("/matching/title-rules/delete")
|
|
async def matching_title_rules_delete(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
rule_id: int = Form(...),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
|
from stream_fusion.services.postgresql.dao.title_normalization_rule_dao import TitleNormalizationRuleDAO
|
|
dao = TitleNormalizationRuleDAO(db)
|
|
try:
|
|
await dao.delete(rule_id)
|
|
return JSONResponse({"success": True})
|
|
except Exception as e:
|
|
logger.error(f"Admin: failed to delete title rule {rule_id}: {e}")
|
|
return JSONResponse({"success": False, "message": str(e)}, status_code=500)
|
|
|
|
|
|
@router.post("/matching/title-rules/reload")
|
|
async def matching_title_rules_reload(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
|
try:
|
|
from stream_fusion.utils.filter.title_matching import get_normalizer
|
|
await get_normalizer().reload()
|
|
return JSONResponse({"success": True, "message": "Règles de normalisation rechargées"})
|
|
except Exception as e:
|
|
logger.error(f"Admin: failed to reload title rules: {e}")
|
|
return JSONResponse({"success": False, "message": str(e)}, status_code=500)
|
|
|
|
|
|
# ── Language rules ─────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/matching/language-rules", response_class=HTMLResponse)
|
|
async def matching_language_rules(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return authenticated
|
|
from stream_fusion.services.postgresql.dao.language_rule_dao import LanguageRuleDAO
|
|
dao = LanguageRuleDAO(db)
|
|
rules = await dao.get_all()
|
|
grouped: dict[str, list] = {}
|
|
for r in rules:
|
|
grouped.setdefault(r.rule_type, []).append(r)
|
|
return templates.TemplateResponse(
|
|
"matching_language_rules.html",
|
|
admin_context(
|
|
request,
|
|
grouped=grouped,
|
|
rule_types=["french_pattern", "release_group", "code_mapping", "priority_group"],
|
|
),
|
|
)
|
|
|
|
|
|
@router.post("/matching/language-rules/create")
|
|
async def matching_language_rules_create(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
rule_type: str = Form(...),
|
|
key: str = Form(...),
|
|
value: str = Form(...),
|
|
description: str = Form(""),
|
|
is_active: bool = Form(True),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
|
from stream_fusion.services.postgresql.dao.language_rule_dao import LanguageRuleDAO
|
|
dao = LanguageRuleDAO(db)
|
|
try:
|
|
rule = await dao.create(
|
|
rule_type=rule_type.strip(),
|
|
key=key.strip(),
|
|
value=value.strip(),
|
|
description=description.strip() or None,
|
|
is_active=is_active,
|
|
)
|
|
return JSONResponse({"success": True, "id": rule.id})
|
|
except Exception as e:
|
|
logger.error(f"Admin: failed to create language rule: {e}")
|
|
return JSONResponse({"success": False, "message": str(e)}, status_code=500)
|
|
|
|
|
|
@router.post("/matching/language-rules/update")
|
|
async def matching_language_rules_update(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
rule_id: int = Form(...),
|
|
rule_type: str = Form(...),
|
|
key: str = Form(...),
|
|
value: str = Form(...),
|
|
description: str = Form(""),
|
|
is_active: bool = Form(True),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
|
from stream_fusion.services.postgresql.dao.language_rule_dao import LanguageRuleDAO
|
|
dao = LanguageRuleDAO(db)
|
|
try:
|
|
rule = await dao.update(
|
|
rule_id=rule_id,
|
|
rule_type=rule_type.strip(),
|
|
key=key.strip(),
|
|
value=value.strip(),
|
|
description=description.strip() or None,
|
|
is_active=is_active,
|
|
)
|
|
if not rule:
|
|
return JSONResponse({"success": False, "message": "Règle introuvable"}, status_code=404)
|
|
return JSONResponse({"success": True})
|
|
except Exception as e:
|
|
logger.error(f"Admin: failed to update language rule {rule_id}: {e}")
|
|
return JSONResponse({"success": False, "message": str(e)}, status_code=500)
|
|
|
|
|
|
@router.post("/matching/language-rules/delete")
|
|
async def matching_language_rules_delete(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
rule_id: int = Form(...),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
|
from stream_fusion.services.postgresql.dao.language_rule_dao import LanguageRuleDAO
|
|
dao = LanguageRuleDAO(db)
|
|
try:
|
|
await dao.delete(rule_id)
|
|
return JSONResponse({"success": True})
|
|
except Exception as e:
|
|
logger.error(f"Admin: failed to delete language rule {rule_id}: {e}")
|
|
return JSONResponse({"success": False, "message": str(e)}, status_code=500)
|
|
|
|
|
|
@router.post("/matching/language-rules/reload")
|
|
async def matching_language_rules_reload(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
|
try:
|
|
from stream_fusion.utils.filter.title_matching import get_lang_manager
|
|
await get_lang_manager().reload()
|
|
return JSONResponse({"success": True, "message": "Règles de langue rechargées"})
|
|
except Exception as e:
|
|
logger.error(f"Admin: failed to reload language rules: {e}")
|
|
return JSONResponse({"success": False, "message": str(e)}, status_code=500)
|
|
|
|
|
|
# ── Matching test page ─────────────────────────────────────────────────────────
|
|
|
|
@router.get("/matching/test", response_class=HTMLResponse)
|
|
async def matching_test_page(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return authenticated
|
|
return templates.TemplateResponse("matching_test.html", admin_context(request))
|
|
|
|
|
|
@router.post("/matching/test")
|
|
async def matching_test_run(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
raw_title: str = Form(...),
|
|
tmdb_titles: str = Form(...),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
|
try:
|
|
from stream_fusion.utils.filter.title_matching import get_matcher
|
|
matcher = get_matcher()
|
|
titles = [t.strip() for t in tmdb_titles.splitlines() if t.strip()]
|
|
result = matcher.analyze(raw_title.strip(), titles)
|
|
return JSONResponse(result)
|
|
except Exception as e:
|
|
logger.error(f"Admin: matching test failed: {e}")
|
|
return JSONResponse({"error": str(e)}, status_code=500)
|
|
|
|
|
|
# ── TMDB Mismatches ───────────────────────────────────────────────────────────
|
|
|
|
@router.get("/mismatches", response_class=HTMLResponse)
|
|
async def list_mismatches(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return authenticated
|
|
|
|
from stream_fusion.services.postgresql.dao.mismatch_dao import TmdbMismatchDAO
|
|
dao = TmdbMismatchDAO(db)
|
|
mismatches = await dao.list_all()
|
|
return templates.TemplateResponse(
|
|
"mismatches.html", {"request": request, "mismatches": mismatches}
|
|
)
|
|
|
|
|
|
@router.post("/mismatches/create")
|
|
async def create_mismatch(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
info_hash: str = Form(...),
|
|
tmdb_id: int = Form(...),
|
|
raw_title: str = Form(...),
|
|
indexer: str = Form(...),
|
|
notes: str = Form(None),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
|
|
|
from stream_fusion.services.postgresql.dao.mismatch_dao import TmdbMismatchDAO
|
|
dao = TmdbMismatchDAO(db)
|
|
result = await dao.create(
|
|
info_hash=info_hash.strip().lower(),
|
|
tmdb_id=tmdb_id,
|
|
raw_title=raw_title.strip(),
|
|
indexer=indexer.strip(),
|
|
notes=notes.strip() if notes else None,
|
|
)
|
|
if result:
|
|
logger.info(f"Admin: mismatch created for ({info_hash[:8]}…, tmdb_id={tmdb_id})")
|
|
return JSONResponse({"success": True, "id": result.id})
|
|
return JSONResponse({"error": "Déjà signalé ou erreur"}, status_code=409)
|
|
|
|
|
|
@router.post("/mismatches/delete")
|
|
async def delete_mismatch(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
mismatch_id: int = Form(...),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
|
|
|
from stream_fusion.services.postgresql.dao.mismatch_dao import TmdbMismatchDAO
|
|
dao = TmdbMismatchDAO(db)
|
|
deleted = await dao.delete(mismatch_id)
|
|
if deleted:
|
|
return JSONResponse({"success": True})
|
|
return JSONResponse({"error": "Non trouvé"}, status_code=404)
|
|
|
|
|
|
# ── Unmatched Torrents ────────────────────────────────────────────────────────
|
|
|
|
@router.get("/search/unmatched", response_class=HTMLResponse)
|
|
async def search_unmatched_page(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
q: str = None,
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return authenticated
|
|
|
|
from stream_fusion.services.postgresql.dao.torrentitem_dao import TorrentItemDAO
|
|
dao = TorrentItemDAO(db)
|
|
items = await dao.search_unmatched(query=q) if q else []
|
|
return templates.TemplateResponse(
|
|
"search_unmatched.html",
|
|
{"request": request, "items": items, "query": q or ""},
|
|
)
|
|
|
|
|
|
@router.post("/search/assign-tmdb")
|
|
async def assign_tmdb_to_selection(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
|
|
|
form = await request.form()
|
|
tmdb_id_raw = form.get("tmdb_id", "").strip()
|
|
info_hashes = [v.strip().lower() for v in form.getlist("info_hashes") if v.strip()]
|
|
|
|
if not tmdb_id_raw or not tmdb_id_raw.isdigit():
|
|
return JSONResponse({"error": "TMDB ID invalide"}, status_code=400)
|
|
if not info_hashes:
|
|
return JSONResponse({"error": "Aucun torrent sélectionné"}, status_code=400)
|
|
|
|
from stream_fusion.services.postgresql.dao.torrentitem_dao import TorrentItemDAO
|
|
dao = TorrentItemDAO(db)
|
|
updated = await dao.assign_tmdb_by_info_hashes(info_hashes, int(tmdb_id_raw))
|
|
logger.info(f"Admin: assigned tmdb_id={tmdb_id_raw} to {updated} torrents")
|
|
return JSONResponse({"success": True, "updated": updated})
|
|
|
|
|
|
# ── Torrent Groups Browser ────────────────────────────────────────────────────
|
|
|
|
@router.get("/search/groups", response_class=HTMLResponse)
|
|
async def search_groups_page(
|
|
request: Request,
|
|
q: str = "",
|
|
authenticated: bool = Depends(session_based_security),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
):
|
|
"""Admin page to browse torrent groups — search by TMDB ID, info_hash, or title."""
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return authenticated
|
|
|
|
results = []
|
|
error = None
|
|
query = q.strip()
|
|
search_type = None
|
|
|
|
if query:
|
|
try:
|
|
from stream_fusion.services.postgresql.dao.torrentgroup_dao import TorrentGroupDAO
|
|
group_dao = TorrentGroupDAO(db)
|
|
|
|
if query.isdigit():
|
|
# TMDB ID
|
|
search_type = "tmdb"
|
|
results = await group_dao.search_groups_by_tmdb_id(int(query))
|
|
elif len(query) == 40 and all(c in "0123456789abcdefABCDEF" for c in query):
|
|
# info_hash
|
|
search_type = "hash"
|
|
results = await group_dao.search_groups_by_info_hash(query)
|
|
else:
|
|
# Titre libre
|
|
search_type = "title"
|
|
results = await group_dao.search_groups_by_title(query)
|
|
except Exception as e:
|
|
logger.error(f"Admin search_groups_page: {e}")
|
|
error = str(e)
|
|
|
|
return templates.TemplateResponse("search_groups.html", {
|
|
"request": request,
|
|
"query": query,
|
|
"search_type": search_type,
|
|
"results": results,
|
|
"error": error,
|
|
})
|
|
|
|
|
|
# ── TMDB Orphan Matcher ───────────────────────────────────────────────────────
|
|
|
|
@router.get("/tmdb-matcher", response_class=HTMLResponse)
|
|
async def tmdb_matcher_page(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return authenticated
|
|
return templates.TemplateResponse("tmdb_matcher.html", {"request": request})
|
|
|
|
|
|
@router.get("/tmdb-matcher/history")
|
|
async def tmdb_matcher_history(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
run_id: str = None,
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
|
|
|
scheduler = getattr(request.app.state, "scheduler", None)
|
|
tracker = getattr(scheduler, "_history_tracker", None) if scheduler else None
|
|
|
|
if tracker is None:
|
|
# Tracker not yet initialised (no run has occurred); build one on the fly
|
|
from stream_fusion.services.tmdb_matcher.history import MatchHistoryTracker
|
|
redis_pool = getattr(request.app.state, "redis_pool", None)
|
|
if redis_pool is None:
|
|
return JSONResponse({"error": "Redis non disponible"}, status_code=503)
|
|
tracker = MatchHistoryTracker(redis_pool)
|
|
|
|
if run_id:
|
|
detail = tracker.get_run(run_id)
|
|
if detail is None:
|
|
return JSONResponse({"error": "Run introuvable"}, status_code=404)
|
|
from dataclasses import asdict
|
|
return JSONResponse(asdict(detail))
|
|
|
|
from dataclasses import asdict
|
|
summaries = tracker.list_runs()
|
|
return JSONResponse([asdict(s) for s in summaries])
|
|
|
|
|
|
@router.post("/tmdb-matcher/revert")
|
|
async def tmdb_matcher_revert(
|
|
request: Request,
|
|
authenticated: bool = Depends(session_based_security),
|
|
_csrf: None = Depends(require_csrf),
|
|
db: AsyncSession = Depends(get_db_session),
|
|
info_hash: str = Form(...),
|
|
tmdb_id: int = Form(...),
|
|
raw_title: str = Form(""),
|
|
indexer: str = Form(""),
|
|
):
|
|
if isinstance(authenticated, RedirectResponse):
|
|
return JSONResponse({"error": "Non authentifié"}, status_code=401)
|
|
|
|
from stream_fusion.services.postgresql.dao.mismatch_dao import TmdbMismatchDAO
|
|
dao = TmdbMismatchDAO(db)
|
|
mismatch = await dao.create(
|
|
info_hash=info_hash,
|
|
tmdb_id=tmdb_id,
|
|
raw_title=raw_title or info_hash,
|
|
indexer=indexer or "auto_match",
|
|
notes="Revert depuis l'historique TMDB matcher",
|
|
)
|
|
if mismatch:
|
|
logger.warning(
|
|
f"Admin: reverted TMDB match ({info_hash[:8]}…, tmdb_id={tmdb_id}) "
|
|
f"via tmdb_matcher history"
|
|
)
|
|
return JSONResponse({"success": True, "message": "Match revert avec succès"})
|
|
return JSONResponse(
|
|
{"success": False, "message": "Revert échoué (déjà revert ou erreur DB)"},
|
|
status_code=400,
|
|
)
|