Mise à jour automatique (Gitea + Watchtower) et conteneurisation Docker
- Détection de version via l'API Gitea, application via Watchtower - Router système, UI admin (intégrations), bandeau de mise à jour - Dockerfile multi-étapes, docker-compose, scripts/release.sh - Version centralisée dans app/version.py (pyproject ou OHM_VERSION au build)
This commit is contained in:
@@ -34,6 +34,10 @@ class Settings(BaseSettings):
|
||||
kitsu_base_url: str = "https://kitsu.io/api/edge"
|
||||
metadata_cache_ttl_hours: int = 72
|
||||
|
||||
# Mise à jour (déploiement Docker — Watchtower compagnon)
|
||||
watchtower_url: str = ""
|
||||
watchtower_token: str = ""
|
||||
|
||||
def ensure_dirs(self) -> None:
|
||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.download_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
+15
-1
@@ -9,10 +9,22 @@ from fastapi.staticfiles import StaticFiles
|
||||
from app.config import BASE_DIR, get_settings
|
||||
from app.db import db
|
||||
from app.logging_config import setup_logging
|
||||
from app.routers import admin, auth, discover, downloads, library, pages, proxy, search, torznab
|
||||
from app.routers import (
|
||||
admin,
|
||||
auth,
|
||||
discover,
|
||||
downloads,
|
||||
library,
|
||||
pages,
|
||||
proxy,
|
||||
search,
|
||||
system,
|
||||
torznab,
|
||||
)
|
||||
from app.scrapers.http import close_client
|
||||
from app.services.discover import discover as discover_service
|
||||
from app.services.downloads import download_manager
|
||||
from app.services.settings import apply_source_base_urls
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -30,6 +42,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
settings = get_settings()
|
||||
setup_logging(settings.debug)
|
||||
await db.connect()
|
||||
await apply_source_base_urls()
|
||||
await download_manager.start()
|
||||
warmup = asyncio.create_task(discover_service.latest())
|
||||
warmup.add_done_callback(_log_task_error)
|
||||
@@ -46,6 +59,7 @@ def create_app() -> FastAPI:
|
||||
app = FastAPI(title=settings.app_name, lifespan=lifespan)
|
||||
app.include_router(torznab.router)
|
||||
|
||||
app.include_router(system.router)
|
||||
app.include_router(pages.router)
|
||||
app.include_router(pages.protected)
|
||||
|
||||
|
||||
+96
-9
@@ -1,7 +1,8 @@
|
||||
"""Administration : utilisateurs, activation des sources, santé."""
|
||||
"""Administration : utilisateurs, activation des sources, santé, mises à jour."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
@@ -9,17 +10,29 @@ from pydantic import BaseModel
|
||||
from app import auth
|
||||
from app.db import db
|
||||
from app.routers.auth import AdminUser
|
||||
from app.scrapers.base import ScrapeError, all_sources, get_source, import_all_scrapers
|
||||
from app.scrapers.base import (
|
||||
ScrapeError,
|
||||
SourceScraper,
|
||||
all_sources,
|
||||
get_source,
|
||||
import_all_scrapers,
|
||||
)
|
||||
from app.services.discover import discover
|
||||
from app.services.settings import (
|
||||
get_sonarr_config,
|
||||
get_source_health,
|
||||
get_torznab_apikey,
|
||||
is_source_enabled,
|
||||
reset_torznab_apikey,
|
||||
set_sonarr_config,
|
||||
set_source_base_url,
|
||||
set_source_enabled,
|
||||
set_source_health,
|
||||
set_update_config,
|
||||
)
|
||||
from app.services.sonarr import sonarr
|
||||
from app.services.update import UpdateError, fetch_latest_version, trigger_update
|
||||
from app.services.update import status as update_status
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -92,26 +105,62 @@ class SourceToggle(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
def _source_or_404(name: str) -> SourceScraper:
|
||||
try:
|
||||
return get_source(name)
|
||||
except ScrapeError as exc:
|
||||
raise HTTPException(404, str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/sources/{name}/toggle")
|
||||
async def toggle_source(name: str, payload: SourceToggle, admin: AdminUser) -> dict:
|
||||
get_source(name) # 404 implicite si inconnue
|
||||
_source_or_404(name)
|
||||
await set_source_enabled(name, payload.enabled)
|
||||
return {"name": name, "enabled": payload.enabled}
|
||||
|
||||
class SourceUrlUpdate(BaseModel):
|
||||
url: str
|
||||
|
||||
|
||||
@router.put("/sources/{name}/url")
|
||||
async def update_source_url(name: str, payload: SourceUrlUpdate, admin: AdminUser) -> dict:
|
||||
"""Change l'URL d'une source (ex. le site a changé de domaine) ; vide = défaut."""
|
||||
source = _source_or_404(name)
|
||||
default = type(source).base_url
|
||||
url = payload.url.strip().rstrip("/")
|
||||
if url and url != default:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||
raise HTTPException(422, "URL invalide — format attendu : https://domaine.tld")
|
||||
await set_source_base_url(name, url)
|
||||
else:
|
||||
url = default
|
||||
await set_source_base_url(name, None)
|
||||
source.base_url = url
|
||||
logger.info("URL de la source %s : %s", name, url)
|
||||
return {
|
||||
"name": name,
|
||||
"base_url": url,
|
||||
"default_base_url": default,
|
||||
"overridden": url != default,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/sources/{name}/health")
|
||||
async def health_check(name: str, admin: AdminUser) -> dict:
|
||||
"""Test de santé manuel : la source doit répondre à une recherche simple."""
|
||||
source = get_source(name)
|
||||
source = _source_or_404(name)
|
||||
try:
|
||||
results = await asyncio.wait_for(source.search("naruto"), timeout=30)
|
||||
healthy = len(results) > 0
|
||||
detail = f"{len(results)} résultats"
|
||||
healthy, detail = len(results) > 0, f"{len(results)} résultats"
|
||||
except (ScrapeError, TimeoutError) as exc:
|
||||
healthy = False
|
||||
detail = str(exc)[:200]
|
||||
healthy, detail = False, str(exc)[:200]
|
||||
logger.error("Health check %s KO : %s", name, exc)
|
||||
return {"name": name, "healthy": healthy, "detail": detail}
|
||||
except Exception as exc:
|
||||
healthy, detail = False, f"Erreur inattendue : {exc}"[:200]
|
||||
logger.exception("Health check %s : erreur inattendue", name)
|
||||
state = await set_source_health(name, healthy, detail)
|
||||
return {"name": name, **state}
|
||||
|
||||
|
||||
@router.get("/sources")
|
||||
@@ -121,7 +170,10 @@ async def sources_status(admin: AdminUser) -> list[dict]:
|
||||
"name": s.name,
|
||||
"label": s.label,
|
||||
"base_url": s.base_url,
|
||||
"default_base_url": type(s).base_url,
|
||||
"overridden": s.base_url != type(s).base_url,
|
||||
"enabled": await is_source_enabled(s.name),
|
||||
"health": await get_source_health(s.name),
|
||||
}
|
||||
for s in all_sources()
|
||||
]
|
||||
@@ -166,3 +218,38 @@ async def save_sonarr(payload: SonarrConfig, admin: AdminUser) -> dict:
|
||||
@router.post("/integrations/sonarr/test")
|
||||
async def test_sonarr(admin: AdminUser) -> dict:
|
||||
return await sonarr.test_connection()
|
||||
|
||||
# ---------------------------------------------------------------- mise à jour logicielle
|
||||
|
||||
|
||||
class UpdateConfig(BaseModel):
|
||||
gitea_url: str
|
||||
repo: str
|
||||
token: str
|
||||
|
||||
|
||||
@router.get("/update")
|
||||
async def get_update(admin: AdminUser) -> dict:
|
||||
"""Version courante, dernière version disponible et configuration Gitea."""
|
||||
return await update_status()
|
||||
|
||||
|
||||
@router.put("/update")
|
||||
async def save_update(payload: UpdateConfig, admin: AdminUser) -> dict:
|
||||
await set_update_config(payload.gitea_url, payload.repo, payload.token)
|
||||
return await update_status()
|
||||
|
||||
@router.post("/update/check")
|
||||
async def check_update(admin: AdminUser) -> dict:
|
||||
"""Force la re-vérification de la dernière version (ignore le cache)."""
|
||||
await fetch_latest_version(force=True)
|
||||
return await update_status()
|
||||
|
||||
|
||||
@router.post("/update/apply")
|
||||
async def apply_update(admin: AdminUser) -> dict:
|
||||
"""Déclenche la mise à jour via Watchtower (le conteneur est recréé)."""
|
||||
try:
|
||||
return await trigger_update()
|
||||
except UpdateError as exc:
|
||||
raise HTTPException(502, str(exc)) from exc
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Endpoints système publics : version (utilisée par le frontend pour détecter
|
||||
une mise à jour et recharger la page automatiquement)."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.config import get_settings
|
||||
from app.version import get_version
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["system"])
|
||||
|
||||
|
||||
@router.get("/version")
|
||||
async def version() -> dict[str, str]:
|
||||
return {"name": get_settings().app_name, "version": get_version()}
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Paramètres persistés en DB (activation des sources, réglages UI…)."""
|
||||
"""Paramètres persistés en DB (activation des sources, santé, réglages UI…)."""
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.db import db
|
||||
|
||||
@@ -35,6 +36,48 @@ async def set_source_enabled(name: str, enabled: bool) -> None:
|
||||
await set_setting(f"source:{name}:enabled", enabled)
|
||||
logger.info("Source %s %s", name, "activée" if enabled else "désactivée")
|
||||
|
||||
|
||||
async def get_source_health(name: str) -> dict | None:
|
||||
"""Dernier état de santé connu d'une source (None si jamais testée)."""
|
||||
value = await get_setting(f"source:{name}:health")
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
async def set_source_health(name: str, healthy: bool, detail: str) -> dict:
|
||||
state = {
|
||||
"healthy": healthy,
|
||||
"detail": detail,
|
||||
"checked_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
await set_setting(f"source:{name}:health", state)
|
||||
return state
|
||||
|
||||
|
||||
async def get_source_base_url(name: str) -> str | None:
|
||||
"""URL personnalisée d'une source (None = valeur par défaut du code)."""
|
||||
value = await get_setting(f"source:{name}:base_url")
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
|
||||
async def set_source_base_url(name: str, url: str | None) -> None:
|
||||
"""Persiste l'URL personnalisée (None/'' → retour à la valeur par défaut)."""
|
||||
key = f"source:{name}:base_url"
|
||||
if url:
|
||||
await set_setting(key, url)
|
||||
else:
|
||||
await db.execute("DELETE FROM settings WHERE key = ?", (key,))
|
||||
|
||||
|
||||
async def apply_source_base_urls() -> None:
|
||||
"""Applique les URL personnalisées aux instances de sources (au démarrage)."""
|
||||
from app.scrapers.base import all_sources
|
||||
|
||||
for source in all_sources():
|
||||
override = await get_source_base_url(source.name)
|
||||
if override and override != type(source).base_url:
|
||||
source.base_url = override
|
||||
logger.info("URL personnalisée pour %s : %s", source.name, override)
|
||||
|
||||
# ---------------------------------------------------------------- intégrations *arr
|
||||
|
||||
TORZNAB_APIKEY_KEY = "torznab:apikey"
|
||||
@@ -70,3 +113,24 @@ async def set_sonarr_config(url: str, apikey: str) -> None:
|
||||
await set_setting(SONARR_URL_KEY, url.rstrip("/"))
|
||||
await set_setting(SONARR_APIKEY_KEY, apikey.strip())
|
||||
logger.info("Configuration Sonarr enregistrée (%s)", url)
|
||||
|
||||
# ---------------------------------------------------------------- mise à jour logicielle
|
||||
|
||||
UPDATE_GITEA_URL_KEY = "update:gitea_url"
|
||||
UPDATE_REPO_KEY = "update:repo"
|
||||
UPDATE_TOKEN_KEY = "update:token"
|
||||
|
||||
|
||||
async def get_update_config() -> dict[str, str]:
|
||||
return {
|
||||
"gitea_url": await get_setting(UPDATE_GITEA_URL_KEY, "https://git.lanro.eu"),
|
||||
"repo": await get_setting(UPDATE_REPO_KEY, "Roman/ohm_streaming"),
|
||||
"token": await get_setting(UPDATE_TOKEN_KEY, ""),
|
||||
}
|
||||
|
||||
|
||||
async def set_update_config(gitea_url: str, repo: str, token: str) -> None:
|
||||
await set_setting(UPDATE_GITEA_URL_KEY, gitea_url.rstrip("/"))
|
||||
await set_setting(UPDATE_REPO_KEY, repo.strip().strip("/"))
|
||||
await set_setting(UPDATE_TOKEN_KEY, token.strip())
|
||||
logger.info("Configuration de mise à jour enregistrée (%s)", repo)
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Mises à jour logicielles.
|
||||
|
||||
- Détection : dernier tag semver du dépôt Gitea via son API (jeton requis, repo privé).
|
||||
- Application : POST à Watchtower (compagnon docker-compose) qui tire la nouvelle
|
||||
image et recrée le conteneur — quelques secondes d'indisponibilité.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import get_settings
|
||||
from app.services.settings import get_update_config
|
||||
from app.version import get_version
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TAG_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$")
|
||||
_CACHE_TTL = 300.0 # secondes
|
||||
|
||||
_cache: dict[str, object] = {"checked_at": 0.0, "latest": None}
|
||||
|
||||
|
||||
class UpdateError(Exception):
|
||||
"""Erreur de mise à jour (config absente, Gitea injoignable, Watchtower KO)."""
|
||||
|
||||
|
||||
def parse_tag(tag: str) -> tuple[int, int, int] | None:
|
||||
"""'v0.2.1' → (0, 2, 1) ; None si le tag n'est pas un semver strict."""
|
||||
m = _TAG_RE.match(tag.strip())
|
||||
return tuple(int(g) for g in m.groups()) if m else None # type: ignore[return-value]
|
||||
|
||||
|
||||
def is_newer(latest: str, current: str) -> bool:
|
||||
a, b = parse_tag(latest), parse_tag(current)
|
||||
if a is None or b is None:
|
||||
return False
|
||||
return a > b
|
||||
|
||||
|
||||
async def fetch_latest_version(*, force: bool = False) -> str | None:
|
||||
"""Dernier tag semver du dépôt (cache 5 min). None si non configuré ou erreur."""
|
||||
now = time.monotonic()
|
||||
latest_cache = _cache["latest"]
|
||||
if not force and latest_cache and now - float(_cache["checked_at"]) < _CACHE_TTL:
|
||||
return str(latest_cache) # type: ignore[arg-type]
|
||||
|
||||
config = await get_update_config()
|
||||
if not config["repo"]:
|
||||
return None
|
||||
|
||||
url = f"{config['gitea_url']}/api/v1/repos/{config['repo']}/tags?limit=20"
|
||||
# Jeton requis uniquement pour un dépôt privé (public en lecture : inutile)
|
||||
headers = {"Authorization": f"token {config['token']}"} if config["token"] else {}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client:
|
||||
resp = await client.get(url, headers=headers)
|
||||
resp.raise_for_status()
|
||||
tags = [t["name"] for t in resp.json() if parse_tag(t.get("name", ""))]
|
||||
except (httpx.HTTPError, ValueError, KeyError) as exc:
|
||||
logger.warning("Vérification de mise à jour impossible : %s", exc)
|
||||
return None
|
||||
|
||||
latest = max(tags, key=parse_tag) if tags else None # type: ignore[arg-type]
|
||||
_cache.update(checked_at=now, latest=latest)
|
||||
if latest and is_newer(latest, get_version()):
|
||||
logger.info("Nouvelle version disponible : %s (courante %s)", latest, get_version())
|
||||
return latest
|
||||
|
||||
|
||||
def invalidate_cache() -> None:
|
||||
_cache.update(checked_at=0.0, latest=None)
|
||||
|
||||
|
||||
async def status() -> dict[str, object]:
|
||||
"""État complet : version courante, dernière dispo, config."""
|
||||
config = await get_update_config()
|
||||
current = get_version()
|
||||
latest = await fetch_latest_version()
|
||||
return {
|
||||
"current": current,
|
||||
"latest": latest,
|
||||
"update_available": bool(latest and is_newer(latest, current)),
|
||||
"configured": bool(config["repo"]),
|
||||
"docker": bool(get_settings().watchtower_url),
|
||||
"config": config,
|
||||
}
|
||||
|
||||
|
||||
async def trigger_update() -> dict[str, str]:
|
||||
"""Demande à Watchtower de recréer le conteneur avec la dernière image.
|
||||
|
||||
Le conteneur courant (donc cette requête) disparaît quelques secondes après :
|
||||
la réponse est renvoyée immédiatement, le frontend gère la reconnexion.
|
||||
"""
|
||||
settings = get_settings()
|
||||
if not settings.watchtower_url:
|
||||
raise UpdateError(
|
||||
"Watchtower non configuré — mise à jour disponible uniquement en déploiement Docker"
|
||||
)
|
||||
headers = {}
|
||||
if settings.watchtower_token:
|
||||
headers["Authorization"] = f"Bearer {settings.watchtower_token}"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.post(f"{settings.watchtower_url}/v1/update", headers=headers)
|
||||
resp.raise_for_status()
|
||||
except httpx.HTTPError as exc:
|
||||
raise UpdateError(f"Watchtower injoignable : {exc}") from exc
|
||||
logger.info("Mise à jour déclenchée via Watchtower (version courante %s)", get_version())
|
||||
return {"status": "started"}
|
||||
@@ -147,6 +147,7 @@ button { font-family: inherit; }
|
||||
.btn-sm { padding: 0.4rem 0.85rem; font-size: 0.82rem; }
|
||||
.btn-danger { background: #3d1114; color: var(--danger); border: 1px solid #5c1a1e; }
|
||||
.btn-danger:hover { background: #4d1518; filter: none; }
|
||||
.btn-accent { background: var(--accent); color: #fff; border: none; font-weight: 600; }
|
||||
|
||||
/* ------------------------------------------------------------ grille de posters */
|
||||
|
||||
@@ -652,3 +653,26 @@ button { font-family: inherit; }
|
||||
.search-bar { flex-direction: column; }
|
||||
.search-bar .btn { justify-content: center; }
|
||||
}
|
||||
|
||||
/* ---------------------------------------------- bandeau mise à jour (update-watcher.js) */
|
||||
.update-banner {
|
||||
position: fixed;
|
||||
inset: auto 0 1.2rem 0;
|
||||
margin: 0 auto;
|
||||
width: max-content;
|
||||
max-width: 90vw;
|
||||
padding: 0.7rem 1.2rem;
|
||||
border-radius: 10px;
|
||||
background: rgba(20, 20, 24, 0.96);
|
||||
border: 1px solid var(--accent);
|
||||
color: #fff;
|
||||
font-size: 0.9rem;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.update-banner .spin { display: inline-block; animation: update-spin 1s linear infinite; }
|
||||
@keyframes update-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/* Surveille la disponibilité du serveur et détecte les changements de version.
|
||||
* Pendant une mise à jour Docker (quelques secondes d'indisponibilité), affiche
|
||||
* un bandeau « Mise à jour en cours » puis recharge la page automatiquement
|
||||
* quand le service revient avec une nouvelle version.
|
||||
* Un serveur qui RÉPOND est considéré comme disponible (même 404 : instance
|
||||
* antérieure à l'endpoint /api/version) — seul un échec réseau (connexion
|
||||
* refusée, timeout) signifie « en cours de redémarrage ». */
|
||||
(() => {
|
||||
const POLL_MS = 8000;
|
||||
const KEY_UPDATING = 'ohm-updating';
|
||||
|
||||
let initialVersion = null;
|
||||
let failures = 0;
|
||||
let banner = null;
|
||||
let done = false;
|
||||
|
||||
const isUpdating = () => sessionStorage.getItem(KEY_UPDATING) === '1';
|
||||
|
||||
function showBanner() {
|
||||
if (banner) return;
|
||||
banner = document.createElement('div');
|
||||
banner.className = 'update-banner';
|
||||
banner.innerHTML = '<span class="spin">⟳</span> Mise à jour en cours — reconnexion automatique…';
|
||||
document.body.appendChild(banner);
|
||||
}
|
||||
|
||||
function hideBanner() {
|
||||
if (banner) banner.remove();
|
||||
banner = null;
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
if (document.hidden || done) return;
|
||||
let version = null;
|
||||
let up = false;
|
||||
try {
|
||||
const r = await fetch('/api/version', { cache: 'no-store' });
|
||||
up = true; // le serveur a répondu : il est vivant
|
||||
if (r.ok) version = (await r.json()).version ?? null;
|
||||
} catch {
|
||||
up = false; // connexion refusée / réseau coupé : serveur injoignable
|
||||
}
|
||||
|
||||
if (!up) {
|
||||
failures += 1;
|
||||
if (isUpdating() || failures >= 2) showBanner();
|
||||
return;
|
||||
}
|
||||
|
||||
failures = 0;
|
||||
hideBanner();
|
||||
if (version !== null && initialVersion === null) initialVersion = version;
|
||||
if (isUpdating() || (version !== null && version !== initialVersion)) {
|
||||
done = true;
|
||||
sessionStorage.removeItem(KEY_UPDATING);
|
||||
setTimeout(() => location.reload(), 1000); // laisser le serveur finir de démarrer
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) poll();
|
||||
});
|
||||
|
||||
if (isUpdating()) showBanner();
|
||||
poll();
|
||||
setInterval(poll, POLL_MS);
|
||||
})();
|
||||
+140
-12
@@ -26,12 +26,23 @@
|
||||
<template x-for="s in sources" :key="s.name">
|
||||
<tr>
|
||||
<td><strong x-text="s.label"></strong> <span style="color:var(--text-dim);font-size:0.8rem" x-text="'(' + s.name + ')'"></span></td>
|
||||
<td style="font-size:0.82rem;color:var(--text-dim)" x-text="s.base_url"></td>
|
||||
<td>
|
||||
<div style="display:flex;gap:0.35rem;align-items:center;flex-wrap:wrap">
|
||||
<input class="input" x-model="s._url" :placeholder="s.default_base_url"
|
||||
style="min-width:230px;font-size:0.8rem;padding:0.3rem 0.5rem"
|
||||
@change="s._urlDirty = s._url.trim() !== s.base_url">
|
||||
<button class="btn btn-sm btn-ghost" @click="saveUrl(s)" :disabled="!s._urlDirty || s._saving"
|
||||
x-text="s._saving ? '…' : 'Enregistrer'"></button>
|
||||
<button class="btn btn-sm btn-ghost" x-show="s.overridden" @click="resetUrl(s)"
|
||||
:disabled="s._saving" title="Restaurer l'URL par défaut">↺</button>
|
||||
</div>
|
||||
</td>
|
||||
<td><div class="toggle" :class="s.enabled && 'on'" @click="toggle(s)"></div></td>
|
||||
<td>
|
||||
<span x-show="s.health == null" style="color:var(--text-dim)">—</span>
|
||||
<span x-show="s.health === true" style="color:var(--success)">✔ OK</span>
|
||||
<span x-show="s.health === false" style="color:var(--danger)" :title="s.healthDetail">✖ KO</span>
|
||||
<span x-show="!s.health" style="color:var(--text-dim)">—</span>
|
||||
<span x-show="s.health" :style="s.health?.healthy ? 'color:var(--success)' : 'color:var(--danger)'"
|
||||
:title="s.health ? s.health.detail + (s.health.checked_at ? ' — vérifié le ' + new Date(s.health.checked_at).toLocaleString('fr-FR') : '') : ''"
|
||||
x-text="s.health?.healthy ? '✔ OK' : '✖ KO'"></span>
|
||||
</td>
|
||||
<td><button class="btn btn-sm btn-ghost" @click="healthCheck(s)" :disabled="s._checking" x-text="s._checking ? '…' : 'Tester'"></button></td>
|
||||
</tr>
|
||||
@@ -84,6 +95,38 @@
|
||||
:style="integrations.testResult?.ok ? 'color:var(--success)' : 'color:var(--danger)'"
|
||||
x-text="integrations.testResult?.detail"></p>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h2>🔄 Mise à jour</h2>
|
||||
<p style="color:var(--text-dim);font-size:0.85rem;margin:0 0 0.6rem">
|
||||
Version installée : <strong x-text="upd.current || '—'"></strong>
|
||||
<span x-show="upd.update_available" class="badge" style="background:var(--accent);color:#fff"
|
||||
x-text="(upd.latest || '?') + ' disponible'"></span>
|
||||
<span x-show="upd.latest && !upd.update_available" style="color:var(--success)">✔ à jour</span>
|
||||
</p>
|
||||
|
||||
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center">
|
||||
<input class="input" x-model="upd.config.gitea_url" placeholder="https://git.lanro.eu"
|
||||
style="flex:2;min-width:220px" @change="upd._dirty = true">
|
||||
<input class="input" x-model="upd.config.repo" placeholder="Roman/ohm_streaming"
|
||||
style="flex:1;min-width:180px" @change="upd._dirty = true">
|
||||
<input class="input" x-model="upd.config.token" type="password" placeholder="Jeton d'accès Gitea"
|
||||
style="flex:1;min-width:180px" @change="upd._dirty = true">
|
||||
<button class="btn btn-sm" @click="saveUpdate()" :disabled="!upd._dirty">Enregistrer</button>
|
||||
<button class="btn btn-sm btn-ghost" @click="checkUpdate()" :disabled="upd._checking"
|
||||
x-text="upd._checking ? '…' : 'Vérifier'"></button>
|
||||
<button class="btn btn-sm btn-accent" x-show="upd.update_available" @click="applyUpdate()"
|
||||
:disabled="upd._applying" x-text="upd._applying ? 'Mise à jour…' : '⬆ Mettre à jour maintenant'"></button>
|
||||
</div>
|
||||
<p x-show="!upd.configured" style="margin:0.5rem 0 0;font-size:0.85rem;color:var(--text-dim)">
|
||||
Renseignez le dépôt Gitea pour activer la détection des mises à jour.
|
||||
Jeton d'accès (droit « lecture ») nécessaire uniquement si le dépôt est privé.
|
||||
</p>
|
||||
<p x-show="upd.configured && !upd.docker" style="margin:0.5rem 0 0;font-size:0.85rem;color:var(--text-dim)">
|
||||
⚠ Mise à jour automatique disponible uniquement en déploiement Docker
|
||||
(<code>docker compose pull && docker compose up -d</code> sinon).
|
||||
</p>
|
||||
</div>
|
||||
+
|
||||
|
||||
<div class="panel">
|
||||
<h2>👥 Utilisateurs</h2>
|
||||
@@ -121,6 +164,11 @@
|
||||
function adminPage() {
|
||||
return {
|
||||
users: [], sources: [], stats: {}, forbidden: false,
|
||||
upd: {
|
||||
current: '', latest: null, update_available: false, configured: false, docker: false,
|
||||
config: { gitea_url: '', repo: '', token: '' },
|
||||
_dirty: false, _checking: false, _applying: false,
|
||||
},
|
||||
integrations: {
|
||||
torznab: { apikey: '', endpoint: '' }, sonarr: { url: '', apikey: '' },
|
||||
_regen: false, _testing: false, testResult: null,
|
||||
@@ -132,7 +180,7 @@ function adminPage() {
|
||||
]);
|
||||
if (u.status === 403) { this.forbidden = true; return; }
|
||||
this.users = await u.json();
|
||||
this.sources = await s.json();
|
||||
this.sources = (await s.json()).map((x) => ({ ...x, _url: x.base_url, _urlDirty: false, _saving: false }));
|
||||
this.stats = await st.json();
|
||||
const itg = await fetch('/api/admin/integrations');
|
||||
if (itg.ok) {
|
||||
@@ -140,8 +188,10 @@ function adminPage() {
|
||||
this.integrations.torznab = data.torznab;
|
||||
this.integrations.sonarr = { ...data.sonarr, _dirty: false };
|
||||
}
|
||||
const upd = await fetch('/api/admin/update');
|
||||
if (upd.ok) this.applyUpdateData(await upd.json());
|
||||
this.checkAllSources();
|
||||
},
|
||||
|
||||
copy(value) {
|
||||
navigator.clipboard.writeText(value).then(() => toast('✔ Copié'));
|
||||
},
|
||||
@@ -170,13 +220,53 @@ function adminPage() {
|
||||
if (res.ok) s.enabled = !s.enabled;
|
||||
},
|
||||
|
||||
async healthCheck(s) {
|
||||
async saveUrl(s) {
|
||||
s._saving = true;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/sources/${s.name}/url`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: s._url }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
s.base_url = data.base_url;
|
||||
s.overridden = data.overridden;
|
||||
s._url = data.base_url;
|
||||
s._urlDirty = false;
|
||||
toast('✔ URL enregistrée — test de la source…');
|
||||
this.healthCheck(s);
|
||||
} else {
|
||||
toast('✖ ' + ((await res.json()).detail || 'Erreur'));
|
||||
}
|
||||
} catch {
|
||||
toast('✖ Erreur réseau');
|
||||
} finally {
|
||||
s._saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
async resetUrl(s) {
|
||||
s._url = '';
|
||||
await this.saveUrl(s);
|
||||
},
|
||||
|
||||
async healthCheck(s, notify = true) {
|
||||
s._checking = true;
|
||||
const res = await fetch(`/api/admin/sources/${s.name}/health`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
s.health = data.healthy; s.healthDetail = data.detail;
|
||||
s._checking = false;
|
||||
toast(data.healthy ? `✔ ${s.label} : ${data.detail}` : `✖ ${s.label} : ${data.detail}`);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/sources/${s.name}/health`, { method: 'POST' });
|
||||
const data = res.ok ? await res.json() : { healthy: false, detail: `Erreur serveur (${res.status})` };
|
||||
s.health = { healthy: data.healthy, detail: data.detail, checked_at: data.checked_at };
|
||||
if (notify) toast(`${data.healthy ? '✔' : '✖'} ${s.label} : ${data.detail}`);
|
||||
} catch {
|
||||
s.health = { healthy: false, detail: 'Erreur réseau', checked_at: null };
|
||||
if (notify) toast(`✖ ${s.label} : erreur réseau`);
|
||||
} finally {
|
||||
s._checking = false;
|
||||
}
|
||||
},
|
||||
|
||||
checkAllSources() {
|
||||
return Promise.allSettled(this.sources.filter(s => s.enabled).map(s => this.healthCheck(s, false)));
|
||||
},
|
||||
|
||||
async post(url) { await fetch(url, { method: 'POST' }); await this.load(); },
|
||||
@@ -188,6 +278,44 @@ function adminPage() {
|
||||
this.integrations._testing = false;
|
||||
},
|
||||
|
||||
applyUpdateData(data) {
|
||||
this.upd.current = data.current;
|
||||
this.upd.latest = data.latest;
|
||||
this.upd.update_available = data.update_available;
|
||||
this.upd.configured = data.configured;
|
||||
this.upd.docker = data.docker;
|
||||
this.upd.config = { ...data.config, _dirty: false };
|
||||
},
|
||||
|
||||
async saveUpdate() {
|
||||
const res = await fetch('/api/admin/update', {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(this.upd.config),
|
||||
});
|
||||
if (res.ok) { this.applyUpdateData(await res.json()); toast('✔ Configuration enregistrée'); }
|
||||
},
|
||||
|
||||
async checkUpdate() {
|
||||
this.upd._checking = true;
|
||||
const res = await fetch('/api/admin/update/check', { method: 'POST' });
|
||||
if (res.ok) this.applyUpdateData(await res.json());
|
||||
this.upd._checking = false;
|
||||
if (this.upd.update_available) toast('⬆ ' + this.upd.latest + ' disponible');
|
||||
},
|
||||
|
||||
async applyUpdate() {
|
||||
if (!confirm('Mettre à jour maintenant ? Le service sera indisponible quelques secondes.')) return;
|
||||
this.upd._applying = true;
|
||||
sessionStorage.setItem('ohm-updating', '1');
|
||||
const res = await fetch('/api/admin/update/apply', { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
sessionStorage.removeItem('ohm-updating');
|
||||
const detail = (await res.json()).detail || 'Erreur';
|
||||
toast('✖ ' + detail); this.upd._applying = false;
|
||||
}
|
||||
// sinon : le conteneur est recréé, update-watcher.js affiche le bandeau et recharge la page
|
||||
},
|
||||
|
||||
async del(u) {
|
||||
if (!confirm(`Supprimer le compte « ${u.username} » ?`)) return;
|
||||
await fetch('/api/admin/users/' + u.id, { method: 'DELETE' });
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<script src="/static/js/app.js"></script>
|
||||
<script src="/static/js/update-watcher.js"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Version de l'application.
|
||||
|
||||
Source de vérité : `version` de pyproject.toml (lue via les métadonnées du paquet
|
||||
installé par uv). Dans l'image Docker, `OHM_VERSION` est cuit au build par
|
||||
scripts/release.sh et prime sur tout.
|
||||
"""
|
||||
|
||||
import importlib.metadata
|
||||
import os
|
||||
|
||||
|
||||
def get_version() -> str:
|
||||
v = os.environ.get("OHM_VERSION")
|
||||
if v:
|
||||
return v
|
||||
try:
|
||||
return importlib.metadata.version("ohm-stream")
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
return "dev"
|
||||
Reference in New Issue
Block a user