mirror of
https://github.com/vatax3/ranger.git
synced 2026-08-09 00:27:36 +00:00
- Score qualité global (résolution/source/HDR/codec/langue/seeders) +
critère de tri "quality" (⭐), sélectionnable et par défaut en 2e position.
Tri par défaut élargi à tous les critères (dont priorité tracker).
- Cache court des liens débrideur résolus (TTL 15 min, clé service+hash+SE+ip)
-> seek instantané, ~5x moins d'appels débrideur/StremThru. Table dédiée,
visible et purgeable dans l'admin.
- Mode d'affichage : "detailed" (actuel) ou "simple" (famille) = 1 lien par
résolution, meilleur choix auto (cache puis score qualité).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
571 lines
21 KiB
Python
571 lines
21 KiB
Python
"""
|
|
Ranger — Stremio addon multi-trackers / multi-débrideurs.
|
|
|
|
v1 : films / séries / anime, contenu français comme étranger.
|
|
- Multi-débrideurs avec priorité (AllDebrid, Real-Debrid, TorBox, DebridLink)
|
|
- StremThru intégré (contournement blocage IP datacenter)
|
|
- Trackers publics (YGG, TPB, EZTV, Nyaa) + privés/semi (C411, Torr9, Tr4ker,
|
|
NekoBT, UNIT3D) + Torznab générique (Jackett/Prowlarr)
|
|
- TMDB (titres FR, anime, épisode absolu) avec fallback Cinemeta
|
|
- Filtres taille/résolution/codec/langue, tri multi-critères, déduplication
|
|
- Cache SQLite (dispo débrideur + recherches + métadonnées)
|
|
- Compatible AIOStreams (tags [XX+] / statut de cache)
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import time
|
|
|
|
import aiofiles
|
|
import aiohttp
|
|
from aiohttp import web
|
|
|
|
from core import cache
|
|
from core.config import decode_config, DEFAULT_CONFIG
|
|
from core.debrid import build_backends
|
|
from core.metadata import get_media_info, compute_absolute_episode
|
|
from core.search import run_search
|
|
from core import ranking
|
|
from core import formatting
|
|
|
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s:%(message)s")
|
|
|
|
APP_VERSION = "1.0.0"
|
|
ADDON_ID = "community.ranger.addon"
|
|
PORT = int(os.getenv("PORT", "7000"))
|
|
BASE_PATH = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
# Token du panel admin (si non défini, le panel est désactivé)
|
|
ADMIN_TOKEN = (os.getenv("RANGER_ADMIN_TOKEN") or "").strip()
|
|
|
|
# Compteurs runtime (dashboard admin)
|
|
RUNTIME = {
|
|
"started_at": time.time(),
|
|
"stream_requests": 0,
|
|
"streams_served": 0,
|
|
"resolve_requests": 0,
|
|
"resolve_ok": 0,
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# Middleware
|
|
# ============================================================================
|
|
|
|
@web.middleware
|
|
async def cors_middleware(request, handler):
|
|
if request.method == "OPTIONS":
|
|
response = web.Response(status=204)
|
|
else:
|
|
response = await handler(request)
|
|
response.headers["Access-Control-Allow-Origin"] = "*"
|
|
response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
|
|
response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
|
|
return response
|
|
|
|
|
|
def get_client_ip(request):
|
|
"""IP publique du lecteur Stremio, transmise à StremThru pour le débrideur."""
|
|
import ipaddress
|
|
forwarded = request.headers.get("X-Forwarded-For", "")
|
|
ip = forwarded.split(",")[0].strip() if forwarded else request.remote
|
|
try:
|
|
if ip and ipaddress.ip_address(ip).is_global:
|
|
return ip
|
|
except ValueError:
|
|
pass
|
|
return None
|
|
|
|
|
|
# ============================================================================
|
|
# Page de configuration
|
|
# ============================================================================
|
|
|
|
async def handle_configure(request):
|
|
try:
|
|
async with aiofiles.open(os.path.join(BASE_PATH, "templates", "configure.html"),
|
|
mode="r", encoding="utf-8") as f:
|
|
content = await f.read()
|
|
content = content.replace("__APP_VERSION__", APP_VERSION)
|
|
|
|
# Persistance : si une config est présente dans l'URL, on la ré-injecte
|
|
# pour pré-remplir le formulaire (clés API, trackers, filtres, tri...).
|
|
import json as _json
|
|
prefill = "null"
|
|
config_str = request.match_info.get("config", "")
|
|
if config_str:
|
|
decoded = decode_config(config_str)
|
|
if decoded:
|
|
prefill = _json.dumps(decoded)
|
|
content = content.replace("const PREFILL = null;", f"const PREFILL = {prefill};")
|
|
|
|
return web.Response(text=content, content_type="text/html")
|
|
except Exception as e:
|
|
logging.error(f"configure error: {e}")
|
|
return web.Response(text=str(e), status=500)
|
|
|
|
|
|
# ============================================================================
|
|
# Manifest
|
|
# ============================================================================
|
|
|
|
# Logo de l'addon : "R" violet sur fond sombre arrondi (reflète la homepage web)
|
|
LOGO_SVG = """<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
|
|
<rect width="256" height="256" rx="56" fill="#0f1115"/>
|
|
<rect x="6" y="6" width="244" height="244" rx="52" fill="none" stroke="#7c5cff" stroke-width="4" opacity="0.35"/>
|
|
<text x="50%" y="52%" dominant-baseline="central" text-anchor="middle"
|
|
font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif"
|
|
font-size="170" font-weight="800" fill="#7c5cff">R</text>
|
|
</svg>"""
|
|
|
|
|
|
def _logo_url(request):
|
|
return f"{request.scheme}://{request.host}/logo.svg"
|
|
|
|
|
|
def _manifest(configured, request):
|
|
logo = _logo_url(request)
|
|
return {
|
|
"id": ADDON_ID,
|
|
"version": APP_VERSION,
|
|
"name": "Ranger",
|
|
"description": "Addon ultime multi-trackers / multi-débrideurs (FR & international) — films, séries, anime.",
|
|
"logo": logo,
|
|
"icon": logo,
|
|
"types": ["movie", "series"],
|
|
"catalogs": [],
|
|
"resources": ["stream"],
|
|
"idPrefixes": ["tt"],
|
|
"behaviorHints": {
|
|
"configurable": True,
|
|
"configurationRequired": not configured,
|
|
},
|
|
}
|
|
|
|
|
|
async def handle_logo(request):
|
|
return web.Response(body=LOGO_SVG.encode("utf-8"), content_type="image/svg+xml",
|
|
headers={"Cache-Control": "public, max-age=86400"})
|
|
|
|
|
|
async def handle_manifest_no_config(request):
|
|
return web.json_response(_manifest(configured=False, request=request))
|
|
|
|
|
|
async def handle_manifest(request):
|
|
config = decode_config(request.match_info.get("config", ""))
|
|
if not config:
|
|
return web.json_response(_manifest(configured=False, request=request))
|
|
return web.json_response(_manifest(configured=True, request=request))
|
|
|
|
|
|
async def handle_stream_no_config(request):
|
|
host_url = f"{request.scheme}://{request.host}"
|
|
return web.json_response({"streams": [{
|
|
"name": "Ranger",
|
|
"title": "⚙️ Configure Ranger",
|
|
"externalUrl": f"{host_url}/configure",
|
|
}]})
|
|
|
|
|
|
# ============================================================================
|
|
# Stream
|
|
# ============================================================================
|
|
|
|
def _parse_stream_id(stream_id):
|
|
imdb_id, season, episode = stream_id, None, None
|
|
if ":" in stream_id:
|
|
parts = stream_id.split(":")
|
|
imdb_id = parts[0]
|
|
if len(parts) >= 3:
|
|
try:
|
|
season, episode = int(parts[1]), int(parts[2])
|
|
except ValueError:
|
|
pass
|
|
return imdb_id, season, episode
|
|
|
|
|
|
async def handle_stream(request):
|
|
RUNTIME["stream_requests"] += 1
|
|
config = decode_config(request.match_info.get("config", ""))
|
|
if not config:
|
|
return web.json_response({"streams": []})
|
|
|
|
stream_type = request.match_info.get("type")
|
|
stream_id = request.match_info.get("id")
|
|
imdb_id, season, episode = _parse_stream_id(stream_id)
|
|
logging.info(f"Stream {stream_type} {imdb_id} S{season}E{episode}")
|
|
|
|
config_str = request.match_info.get("config", "")
|
|
host_url = f"{request.scheme}://{request.host}"
|
|
filters = config.get("filters") or {}
|
|
|
|
# 1. Métadonnées (TMDB ou Cinemeta)
|
|
media_info = await get_media_info(imdb_id, stream_type, (config.get("tmdb_key") or "").strip())
|
|
if not media_info:
|
|
media_info = {"title": "", "original_title": "", "year": "", "is_anime": False, "seasons": [], "tmdb_id": None}
|
|
tmdb_id = media_info.get("tmdb_id")
|
|
absolute_episode = compute_absolute_episode(media_info, season, episode) if media_info.get("is_anime") else None
|
|
|
|
# 2. Recherche parallèle sur tous les trackers activés
|
|
raw = await run_search(config, media_info, stream_type, imdb_id, tmdb_id, season, episode, absolute_episode)
|
|
if not raw:
|
|
return web.json_response({"streams": []})
|
|
|
|
# 3. Filtrage pertinence + préférences, enrichissement parsing, dédup
|
|
torrents = ranking.enrich(raw)
|
|
torrents = ranking.filter_relevance(
|
|
torrents, stream_type, media_info, season, episode, absolute_episode,
|
|
exclude_packs=filters.get("exclude_season_packs", False),
|
|
)
|
|
torrents = ranking.dedupe(torrents, config.get("providers_order") or [])
|
|
torrents = ranking.filter_preferences(torrents, filters)
|
|
if not torrents:
|
|
return web.json_response({"streams": []})
|
|
logging.info(f"{len(torrents)} torrents pertinents après filtrage")
|
|
|
|
# 4. Disponibilité débrideur (parallèle) + cache SQLite
|
|
backends = build_backends(config, client_ip=get_client_ip(request))
|
|
hashes = [t["info_hash"] for t in torrents if t.get("info_hash")]
|
|
availability = {}
|
|
if backends and hashes:
|
|
results = await asyncio.gather(*[b.check_availability(hashes) for b in backends])
|
|
for backend, avail in zip(backends, results):
|
|
availability[backend.name] = avail
|
|
logging.info(f"{backend.name}: {sum(1 for v in avail.values() if v)}/{len(hashes)} en cache")
|
|
|
|
# 5-7. Construction des streams selon le mode d'affichage
|
|
if config.get("display_mode") == "simple":
|
|
# Mode famille : 1 lien par résolution, meilleur choix automatique
|
|
entries = _build_simple_entries(torrents, backends, availability, config)
|
|
streams = _serialize_streams(entries, config_str, host_url, stream_type, season, episode, simple=True)
|
|
else:
|
|
entries = _build_entries(torrents, backends, availability, config)
|
|
entries = ranking.sort_entries(entries, config)
|
|
entries = ranking.apply_limits(entries, filters)
|
|
streams = _serialize_streams(entries, config_str, host_url, stream_type, season, episode)
|
|
|
|
RUNTIME["streams_served"] += len(streams)
|
|
logging.info(f"{len(streams)} streams renvoyés à Stremio")
|
|
return web.json_response({"streams": streams})
|
|
|
|
|
|
def _build_entries(torrents, backends, availability, config):
|
|
"""
|
|
Une entrée = {torrent, service|None, cached}.
|
|
- debrid_mode 'first' : premier débrideur (par priorité) où c'est caché.
|
|
- debrid_mode 'all' : une entrée par débrideur où c'est caché.
|
|
- non caché : selon show_uncached / cached_only.
|
|
- P2P : selon show_p2p.
|
|
"""
|
|
filters = config.get("filters") or {}
|
|
debrid_mode = config.get("debrid_mode", "first")
|
|
cached_only = filters.get("cached_only", False)
|
|
show_uncached = filters.get("show_uncached", True) and not cached_only
|
|
show_p2p = filters.get("show_p2p", False)
|
|
|
|
entries = []
|
|
for t in torrents:
|
|
ih = t["info_hash"]
|
|
cached_backends = [b for b in backends if availability.get(b.name, {}).get(b.clean_hash(ih), False)]
|
|
|
|
if cached_backends:
|
|
chosen = cached_backends if debrid_mode == "all" else cached_backends[:1]
|
|
for b in chosen:
|
|
entries.append({"torrent": t, "service": b.name, "cached": True})
|
|
elif backends and show_uncached:
|
|
# Non caché : proposé via le débrideur prioritaire (clic = ajout)
|
|
entries.append({"torrent": t, "service": backends[0].name, "cached": False})
|
|
|
|
if show_p2p:
|
|
entries.append({"torrent": t, "service": None, "cached": False})
|
|
|
|
return entries
|
|
|
|
|
|
def _build_simple_entries(torrents, backends, availability, config):
|
|
"""
|
|
Mode simplifié : une seule entrée par résolution, le meilleur torrent
|
|
(cache d'abord, puis score qualité) sur le débrideur prioritaire dispo.
|
|
"""
|
|
filters = config.get("filters") or {}
|
|
cached_only = filters.get("cached_only", False)
|
|
show_uncached = filters.get("show_uncached", True) and not cached_only
|
|
lang_order = config.get("language_order") or []
|
|
res_order = config.get("resolution_order") or ["4K", "1080p", "720p", "SD"]
|
|
|
|
# Candidats : (torrent, service, cached) au meilleur débrideur possible
|
|
candidates = []
|
|
for t in torrents:
|
|
ih = t["info_hash"]
|
|
cached_backends = [b for b in backends if availability.get(b.name, {}).get(b.clean_hash(ih), False)]
|
|
if cached_backends:
|
|
candidates.append({"torrent": t, "service": cached_backends[0].name, "cached": True})
|
|
elif backends and show_uncached:
|
|
candidates.append({"torrent": t, "service": backends[0].name, "cached": False})
|
|
|
|
# Groupement par résolution, meilleur candidat par groupe
|
|
best_by_res = {}
|
|
for c in candidates:
|
|
res = c["torrent"]["_meta"]["resolution"] or "Auto"
|
|
rank = (0 if c["cached"] else 1, -ranking.score_of(c["torrent"], lang_order))
|
|
if res not in best_by_res or rank < best_by_res[res][0]:
|
|
best_by_res[res] = (rank, c)
|
|
|
|
# Ordonné selon la préférence de résolution
|
|
def res_key(res):
|
|
try:
|
|
return res_order.index(res)
|
|
except ValueError:
|
|
return len(res_order)
|
|
|
|
entries = []
|
|
for res in sorted(best_by_res, key=res_key):
|
|
entry = best_by_res[res][1]
|
|
entry["resolution"] = res
|
|
entries.append(entry)
|
|
return entries
|
|
|
|
|
|
def _serialize_streams(entries, config_str, host_url, stream_type, season, episode, simple=False):
|
|
streams = []
|
|
for entry in entries:
|
|
t = entry["torrent"]
|
|
service = entry["service"]
|
|
if service is None:
|
|
streams.append(formatting.build_p2p_stream(t))
|
|
continue
|
|
|
|
resolve_url = f"{host_url}/{config_str}/resolve/{service}/{t['info_hash']}"
|
|
if season is not None and episode is not None:
|
|
resolve_url += f"?season={season}&episode={episode}"
|
|
elif stream_type == "movie":
|
|
resolve_url += "?type=movie"
|
|
|
|
if simple:
|
|
streams.append(formatting.build_simple_stream(
|
|
t, service, entry["cached"], resolve_url, entry.get("resolution", "")))
|
|
else:
|
|
streams.append(formatting.build_debrid_stream(t, service, entry["cached"], resolve_url))
|
|
return streams
|
|
|
|
|
|
# ============================================================================
|
|
# Resolve (au moment de la lecture)
|
|
# ============================================================================
|
|
|
|
async def handle_resolve(request):
|
|
RUNTIME["resolve_requests"] += 1
|
|
config = decode_config(request.match_info.get("config", ""))
|
|
if not config:
|
|
return web.Response(status=400, text="Config invalide")
|
|
|
|
service_name = request.match_info.get("service")
|
|
info_hash = request.match_info.get("hash")
|
|
season = request.query.get("season")
|
|
episode = request.query.get("episode")
|
|
media_type = request.query.get("type")
|
|
client_ip = get_client_ip(request)
|
|
|
|
# Cache court des liens résolus : évite de re-débrider à chaque seek
|
|
# (libmpv rappelle /resolve à chaque Range request). Clé liée à l'IP client
|
|
# car certains liens débrideur sont générés pour une IP donnée.
|
|
link_key = f"{service_name}|{info_hash}|{season or ''}|{episode or ''}|{client_ip or ''}"
|
|
cached_url = cache.get_link(link_key)
|
|
if cached_url:
|
|
RUNTIME["resolve_ok"] += 1
|
|
logging.info(f"⚡ Lien résolu servi depuis le cache ({service_name})")
|
|
raise web.HTTPFound(cached_url)
|
|
|
|
backends = build_backends(config, client_ip=client_ip)
|
|
backend = next((b for b in backends if b.name == service_name), None)
|
|
if not backend:
|
|
return web.Response(status=400, text=f"Débrideur non configuré : {service_name}")
|
|
|
|
url = await backend.resolve(
|
|
info_hash,
|
|
season=int(season) if season else None,
|
|
episode=int(episode) if episode else None,
|
|
media_type=media_type,
|
|
)
|
|
if url:
|
|
RUNTIME["resolve_ok"] += 1
|
|
cache.set_link(link_key, url)
|
|
raise web.HTTPFound(url)
|
|
return web.Response(status=404, text="Impossible de résoudre le stream")
|
|
|
|
|
|
# ============================================================================
|
|
# Panel admin
|
|
# ============================================================================
|
|
|
|
def _admin_authorized(request):
|
|
"""Vrai si le token admin est configuré et correspond."""
|
|
if not ADMIN_TOKEN:
|
|
return None # panel désactivé
|
|
token = request.headers.get("X-Admin-Token") or request.query.get("token") or ""
|
|
return token == ADMIN_TOKEN
|
|
|
|
|
|
def _require_admin(request):
|
|
"""Retourne une web.Response d'erreur si non autorisé, sinon None."""
|
|
auth = _admin_authorized(request)
|
|
if auth is None:
|
|
return web.json_response({"error": "Panel admin désactivé (RANGER_ADMIN_TOKEN non défini)"}, status=503)
|
|
if not auth:
|
|
return web.json_response({"error": "Token admin invalide"}, status=401)
|
|
return None
|
|
|
|
|
|
async def handle_admin_page(request):
|
|
try:
|
|
async with aiofiles.open(os.path.join(BASE_PATH, "templates", "admin.html"),
|
|
mode="r", encoding="utf-8") as f:
|
|
content = await f.read()
|
|
content = content.replace("__APP_VERSION__", APP_VERSION)
|
|
content = content.replace("__ADMIN_ENABLED__", "true" if ADMIN_TOKEN else "false")
|
|
return web.Response(text=content, content_type="text/html")
|
|
except Exception as e:
|
|
return web.Response(text=str(e), status=500)
|
|
|
|
|
|
async def handle_admin_stats(request):
|
|
err = _require_admin(request)
|
|
if err is not None:
|
|
return err
|
|
uptime = int(time.time() - RUNTIME["started_at"])
|
|
return web.json_response({
|
|
"version": APP_VERSION,
|
|
"uptime_seconds": uptime,
|
|
"runtime": {k: v for k, v in RUNTIME.items() if k != "started_at"},
|
|
"cache": cache.stats(),
|
|
})
|
|
|
|
|
|
async def handle_admin_searches(request):
|
|
err = _require_admin(request)
|
|
if err is not None:
|
|
return err
|
|
limit = int(request.query.get("limit", "300"))
|
|
return web.json_response({"searches": cache.list_searches(limit)})
|
|
|
|
|
|
async def handle_admin_meta(request):
|
|
err = _require_admin(request)
|
|
if err is not None:
|
|
return err
|
|
limit = int(request.query.get("limit", "300"))
|
|
return web.json_response({"meta": cache.list_meta(limit)})
|
|
|
|
|
|
async def handle_admin_refresh(request):
|
|
err = _require_admin(request)
|
|
if err is not None:
|
|
return err
|
|
body = await request.json()
|
|
imdb = (body.get("imdb") or "").strip()
|
|
if not imdb:
|
|
return web.json_response({"error": "imdb requis"}, status=400)
|
|
deleted = cache.refresh_media(imdb)
|
|
logging.info(f"Admin: refresh {imdb} -> {deleted}")
|
|
return web.json_response({"deleted": deleted})
|
|
|
|
|
|
async def handle_admin_delete(request):
|
|
err = _require_admin(request)
|
|
if err is not None:
|
|
return err
|
|
body = await request.json()
|
|
table = body.get("table")
|
|
key = body.get("key")
|
|
if not table or not key:
|
|
return web.json_response({"error": "table et key requis"}, status=400)
|
|
n = cache.delete_key(table, key)
|
|
return web.json_response({"deleted": n})
|
|
|
|
|
|
async def handle_admin_flush(request):
|
|
err = _require_admin(request)
|
|
if err is not None:
|
|
return err
|
|
body = await request.json()
|
|
table = body.get("table", "")
|
|
if table not in ("searches", "availability", "meta", "all"):
|
|
return web.json_response({"error": "table invalide"}, status=400)
|
|
deleted = cache.flush(table)
|
|
logging.info(f"Admin: flush {table} -> {deleted}")
|
|
return web.json_response({"deleted": deleted})
|
|
|
|
|
|
async def handle_admin_cleanup(request):
|
|
err = _require_admin(request)
|
|
if err is not None:
|
|
return err
|
|
cache.cleanup()
|
|
return web.json_response({"status": "ok"})
|
|
|
|
|
|
# ============================================================================
|
|
# Divers
|
|
# ============================================================================
|
|
|
|
async def handle_health(request):
|
|
return web.json_response({"status": "ok", "version": APP_VERSION})
|
|
|
|
|
|
async def _cache_cleanup_loop(app):
|
|
"""Tâche de fond : purge périodique du cache SQLite."""
|
|
try:
|
|
while True:
|
|
await asyncio.sleep(3600)
|
|
try:
|
|
cache.cleanup()
|
|
logging.info("Cache SQLite purgé")
|
|
except Exception as e:
|
|
logging.error(f"Cache cleanup error: {e}")
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
|
|
async def _on_startup(app):
|
|
app["cleanup_task"] = asyncio.create_task(_cache_cleanup_loop(app))
|
|
|
|
|
|
async def _on_cleanup(app):
|
|
task = app.get("cleanup_task")
|
|
if task:
|
|
task.cancel()
|
|
|
|
|
|
def get_app():
|
|
app = web.Application(middlewares=[cors_middleware])
|
|
app.router.add_get("/", handle_configure)
|
|
app.router.add_get("/configure", handle_configure)
|
|
app.router.add_get("/logo.svg", handle_logo)
|
|
app.router.add_get("/health", handle_health)
|
|
app.router.add_get("/manifest.json", handle_manifest_no_config)
|
|
app.router.add_get("/stream/{type}/{id}.json", handle_stream_no_config)
|
|
|
|
# Panel admin
|
|
app.router.add_get("/admin", handle_admin_page)
|
|
app.router.add_get("/admin/api/stats", handle_admin_stats)
|
|
app.router.add_get("/admin/api/searches", handle_admin_searches)
|
|
app.router.add_get("/admin/api/meta", handle_admin_meta)
|
|
app.router.add_post("/admin/api/refresh", handle_admin_refresh)
|
|
app.router.add_post("/admin/api/delete", handle_admin_delete)
|
|
app.router.add_post("/admin/api/flush", handle_admin_flush)
|
|
app.router.add_post("/admin/api/cleanup", handle_admin_cleanup)
|
|
|
|
app.router.add_get("/{config}/configure", handle_configure)
|
|
app.router.add_get("/{config}/manifest.json", handle_manifest)
|
|
app.router.add_get("/{config}/stream/{type}/{id}.json", handle_stream)
|
|
app.router.add_get("/{config}/resolve/{service}/{hash}", handle_resolve)
|
|
|
|
app.on_startup.append(_on_startup)
|
|
app.on_cleanup.append(_on_cleanup)
|
|
return app
|
|
|
|
|
|
if __name__ == "__main__":
|
|
web.run_app(get_app(), host="0.0.0.0", port=PORT)
|