from __future__ import annotations import hashlib from datetime import UTC, datetime from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.config import Settings, get_settings from app.db import get_session from app.db.models import Tracker from app.db.queries import get_tracker, trackers_query from app.services.scoring import load_metrics from app.services.smart_list import ( SmartListFilters, build_live_list, build_smart_list, format_smart_list_json, format_smart_list_txt, ) router = APIRouter() def _iso(dt: datetime | None) -> str | None: if dt is None: return None if dt.tzinfo is None: dt = dt.replace(tzinfo=UTC) return dt.isoformat().replace("+00:00", "Z") @router.get("/meta") async def meta(settings: Settings = Depends(get_settings)) -> dict[str, Any]: return { "name": settings.app_name, "description": settings.app_description, "version": settings.app_version, "public_url": settings.public_url, "repository_url": settings.repository_url or None, "timezone": "UTC", "generated_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"), } @router.get("/trackers") async def list_trackers( session: AsyncSession = Depends(get_session), q: str | None = None, scheme: str | None = None, status: str | None = None, source: str | None = None, sort: str = "checked", order: str = "desc", page: int = Query(1, ge=1), per_page: int = Query(50, ge=1, le=200), ) -> dict[str, Any]: stmt = trackers_query(q=q, scheme=scheme, status=status, source=source, sort=sort, order=order) count_stmt = select(func.count()).select_from(stmt.order_by(None).subquery()) total = int(await session.scalar(count_stmt) or 0) rows = await session.execute(stmt.offset((page - 1) * per_page).limit(per_page)) trackers = list(rows.scalars().unique().all()) items = [] for t in trackers: metrics = await load_metrics(session, t) items.append(_tracker_dict(t, metrics)) return { "total": total, "page": page, "per_page": per_page, "items": items, } @router.get("/trackers/{tracker_id}") async def tracker_detail( tracker_id: int, session: AsyncSession = Depends(get_session), ) -> dict[str, Any]: tracker = await get_tracker(session, tracker_id) if tracker is None: raise HTTPException(status_code=404, detail="not found") metrics = await load_metrics(session, tracker) data = _tracker_dict(tracker, metrics) data["sources"] = [ { "name": s.source_name, "is_best_at_source": s.is_best_at_source, "is_stable_at_source": s.is_stable_at_source, "is_live_at_source": s.is_live_at_source, "last_seen_at": _iso(s.last_seen_at), } for s in tracker.sources ] data["dns_records"] = [ {"type": d.record_type, "value": d.value, "last_seen_at": _iso(d.last_seen_at)} for d in tracker.dns_records ] return data def _tracker_dict(t: Tracker, metrics) -> dict[str, Any]: return { "id": t.id, "url": t.canonical_url, "scheme": t.scheme, "hostname": t.hostname, "port": t.port, "status": t.current_status, "score": metrics.score, "provisional": metrics.provisional, "uptime_24h": metrics.uptime_24h, "uptime_7d": metrics.uptime_7d, "uptime_30d": metrics.uptime_30d, "latency_median_7d": metrics.latency_median_7d, "latency_p95_7d": metrics.latency_p95_7d, "valid_rate_7d": metrics.valid_rate_7d, "measurement_count": metrics.measurement_count, "supports_ipv4": t.supports_ipv4, "supports_ipv6": t.supports_ipv6, "asn": t.asn, "network_name": t.network_name, "country_code": t.country_code, "infrastructure_fingerprint": t.infrastructure_fingerprint, "terminal_cname": t.terminal_cname, "last_checked_at": _iso(t.last_checked_at), "first_seen_at": _iso(t.first_seen_at), "sources": [s.source_name for s in (t.sources or [])], } def _etag_for(content: str | bytes) -> str: if isinstance(content, str): content = content.encode("utf-8") return '"' + hashlib.sha256(content).hexdigest()[:32] + '"' @router.get("/lists/smart.txt") async def smart_list_txt( request: Request, session: AsyncSession = Depends(get_session), protocol: str | None = None, min_uptime: float = 0.95, max_latency: float | None = None, ip: str = "any", limit: int = Query(20, ge=1, le=50), min_age_days: float = 0.0, diversity: bool = True, ) -> Response: filters = SmartListFilters( protocol=protocol, min_uptime=min_uptime, max_latency_ms=max_latency, ip_version=ip, limit=limit, min_age_days=min_age_days, diversity=diversity, ) entries = await build_smart_list(session, filters) body = format_smart_list_txt(entries) etag = _etag_for(body) if request.headers.get("if-none-match") == etag: return Response( status_code=304, headers={"ETag": etag, "Cache-Control": "public, max-age=60"} ) return Response( content=body, media_type="text/plain; charset=utf-8", headers={"ETag": etag, "Cache-Control": "public, max-age=60"}, ) @router.get("/lists/smart.json") async def smart_list_json( request: Request, session: AsyncSession = Depends(get_session), protocol: str | None = None, min_uptime: float = 0.95, max_latency: float | None = None, ip: str = "any", limit: int = Query(20, ge=1, le=50), min_age_days: float = 0.0, diversity: bool = True, ) -> Response: filters = SmartListFilters( protocol=protocol, min_uptime=min_uptime, max_latency_ms=max_latency, ip_version=ip, limit=limit, min_age_days=min_age_days, diversity=diversity, ) entries = await build_smart_list(session, filters) payload = format_smart_list_json(entries) import orjson body = orjson.dumps(payload) etag = _etag_for(body) if request.headers.get("if-none-match") == etag: return Response( status_code=304, headers={"ETag": etag, "Cache-Control": "public, max-age=60"} ) return Response( content=body, media_type="application/json", headers={"ETag": etag, "Cache-Control": "public, max-age=60"}, ) @router.get("/lists/live.txt") async def live_list_txt( request: Request, session: AsyncSession = Depends(get_session), ) -> Response: urls = await build_live_list(session) body = ("\n\n".join(urls) + "\n") if urls else "" etag = _etag_for(body) if request.headers.get("if-none-match") == etag: return Response( status_code=304, headers={"ETag": etag, "Cache-Control": "public, max-age=30"} ) return Response( content=body, media_type="text/plain; charset=utf-8", headers={"ETag": etag, "Cache-Control": "public, max-age=30"}, )