Compare commits
2
Commits
v0.1.0
...
affc97c527
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
affc97c527 | ||
|
|
9148b5fb6a |
@@ -0,0 +1,20 @@
|
||||
# Contexte de build minimal : ni secrets, ni données, ni caches
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.env.example
|
||||
.venv
|
||||
.plasma
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
data/
|
||||
downloads/
|
||||
tests/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
docker-compose.yml.example
|
||||
README.md
|
||||
Projet_descriptions.md
|
||||
scripts/
|
||||
+10
-1
@@ -1,4 +1,4 @@
|
||||
# Copier en .env et adapter. Toutes les variables sont préfixées OHM_.
|
||||
# Copier en .env et adapter. Toutes les variables applicatives sont préfixées OHM_.
|
||||
|
||||
# OBLIGATOIRE en production : clé de signature des tokens (32+ caractères)
|
||||
OHM_SECRET_KEY=change-me-in-production
|
||||
@@ -23,3 +23,12 @@ OHM_SECRET_KEY=change-me-in-production
|
||||
# OHM_REFRESH_TOKEN_TTL_DAYS=30
|
||||
|
||||
# OHM_DEBUG=false
|
||||
|
||||
# ── Déploiement Docker (docker-compose.yml) ────────────────────────────────
|
||||
# Secret partagé entre OhmStreaming et Watchtower pour déclencher les mises
|
||||
# à jour depuis la page Admin. OBLIGATOIRE en Docker.
|
||||
# Générer : openssl rand -hex 24
|
||||
WATCHTOWER_TOKEN=change-me-watchtower
|
||||
|
||||
# Port hôte exposé par docker compose (défaut 8777)
|
||||
# OHM_PORT=8777
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# Étape 1 — dépendances Python via uv (cache couche par couche)
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder
|
||||
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
||||
WORKDIR /opt/ohm
|
||||
|
||||
# D'abord les métadonnées seules : couche réutilisable tant que uv.lock ne bouge pas
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN uv sync --frozen --no-dev --no-install-project --no-cache
|
||||
|
||||
# Puis le code
|
||||
COPY app ./app
|
||||
RUN uv sync --frozen --no-dev --no-cache
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Étape 2 — image d'exécution minimale
|
||||
# ---------------------------------------------------------------------------
|
||||
FROM python:3.13-slim-bookworm
|
||||
|
||||
# ffmpeg (téléchargements HLS), ca-certificates (scraping HTTPS),
|
||||
# gosu (bascule utilisateur non-root dans l'entrypoint)
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg ca-certificates gosu \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Utilisateur non-root
|
||||
RUN useradd --create-home --uid 1000 ohm
|
||||
|
||||
WORKDIR /opt/ohm
|
||||
COPY --from=builder --chown=ohm:ohm /opt/ohm/.venv ./.venv
|
||||
COPY --chown=ohm:ohm app ./app
|
||||
COPY --chown=ohm:ohm docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
ENV PATH="/opt/ohm/.venv/bin:$PATH" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
# Chemins montés en volumes par docker-compose
|
||||
OHM_DATA_DIR=/data \
|
||||
OHM_DOWNLOAD_DIR=/downloads \
|
||||
OHM_DATABASE_PATH=/data/ohm.db
|
||||
|
||||
# Version cuite dans l'image par scripts/release.sh (build-arg VERSION)
|
||||
ARG VERSION=dev
|
||||
ENV OHM_VERSION=${VERSION}
|
||||
|
||||
RUN mkdir -p /data /downloads && chown -R ohm:ohm /opt/ohm /data /downloads
|
||||
# Root par défaut : l'entrypoint chown les volumes puis passe en « ohm »
|
||||
|
||||
EXPOSE 8777
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8777/health', timeout=4)"]
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8777"]
|
||||
@@ -3,12 +3,51 @@
|
||||
Application web **auto-hébergée** (homelab) : centre de contrôle unique pour découvrir,
|
||||
regarder et télécharger des animes et séries VOSTFR/VF.
|
||||
|
||||
> MVP — Phase 1 : recherche multi-sources, fiches enrichies, streaming, gestionnaire
|
||||
> de téléchargements temps réel, bibliothèque locale, favoris, administration.
|
||||
> Phase 2 : intégration Sonarr/Prowlarr — OhmStreaming est un **indexeur Torznab**
|
||||
> et personnalise « Pour toi » avec vos téléchargements Sonarr.
|
||||
## Déploiement (Docker) — recommandé
|
||||
|
||||
## Démarrage rapide
|
||||
Le déploiement officiel passe par Docker Compose : l'image (ffmpeg inclus) est
|
||||
hébergée sur le **registre privé du Gitea** — rien n'est publié publiquement.
|
||||
|
||||
# 1. Récupérer le projet puis se connecter au registre privé (compte Gitea avec accès lecture)
|
||||
git clone https://git.lanro.eu/Roman/ohm_streaming.git && cd ohm_streaming
|
||||
docker login git.lanro.eu
|
||||
|
||||
# 2. Configuration locale
|
||||
cp .env.example .env
|
||||
# → OHM_SECRET_KEY (openssl rand -hex 32) et WATCHTOWER_TOKEN (openssl rand -hex 24) obligatoires
|
||||
|
||||
# 3. Démarrage
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Puis ouvrir http://localhost:8777 — le **premier compte créé est administrateur**.
|
||||
Les données (`data/`, `downloads/`) sont montées en volumes : elles survivent aux
|
||||
mises à jour. Port hôte modifiable via `OHM_PORT` dans `.env`.
|
||||
|
||||
### Mettre à jour
|
||||
|
||||
**Depuis l'interface** (déploiement Docker) : page **Admin → Mise à jour** —
|
||||
configurer une fois le dépôt Gitea + un jeton d'accès (droit lecture), puis
|
||||
« Vérifier » et « ⬆ Mettre à jour maintenant ». Watchtower tire la nouvelle
|
||||
image et recrée le conteneur : quelques secondes d'indisponibilité, les pages
|
||||
ouvertes se reconnectent et rechargent automatiquement.
|
||||
|
||||
**En ligne de commande** (toujours possible) :
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
### Publier une version (mainteneur)
|
||||
|
||||
```bash
|
||||
./scripts/release.sh 0.2.0
|
||||
```
|
||||
|
||||
Bump de version, commit + tag git, build de l'image et push vers
|
||||
`git.lanro.eu/roman/ohm_streaming` (tags `0.2.0` et `latest`).
|
||||
|
||||
## Démarrage rapide (développement)
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
@@ -16,7 +55,8 @@ uv sync
|
||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8777
|
||||
```
|
||||
|
||||
Serveur persistant : `tmux new-session -d -s ohm 'cd ~/Développement/ohm_streaming && uv run uvicorn app.main:app --host 0.0.0.0 --port 8777'`
|
||||
Serveur persistant en dev : préférer Docker (voir plus haut) ; sinon
|
||||
`tmux new-session -d -s ohm 'uv run uvicorn app.main:app --host 0.0.0.0 --port 8777'`.
|
||||
|
||||
Puis ouvrir http://localhost:8777 — le **premier compte créé est administrateur**.
|
||||
|
||||
@@ -30,11 +70,15 @@ Variables d'environnement (préfixe `OHM_`, voir `.env.example`) :
|
||||
| `OHM_DOWNLOAD_DIR` | `./downloads` | Dossier des fichiers téléchargés |
|
||||
| `OHM_DATABASE_PATH` | `./data/ohm.db` | Base SQLite |
|
||||
| `OHM_MAX_PARALLEL_DOWNLOADS` | `3` | Téléchargements simultanés |
|
||||
| `OHM_WATCHTOWER_URL` | *(vide)* | URL Watchtower pour la mise à jour (réglé par docker-compose) |
|
||||
| `OHM_WATCHTOWER_TOKEN` | *(vide)* | Jeton partagé Watchtower (réglé par docker-compose) |
|
||||
| `OHM_VERSION` | *(pyproject)* | Version affichée — cuite dans l'image Docker au build |
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
- **Recherche unifiée** sur plusieurs sources (Vostfree, French-Manga) — chaque source
|
||||
est un module interchangeable activable/désactivable à chaud (page Admin).
|
||||
est un module interchangeable activable/désactivable à chaud (page Admin), dont l'URL
|
||||
est modifiable à la volée (utile si un site change de domaine).
|
||||
- **Extraction en 2 niveaux** : page d'épisode → lecteurs embarqués → URL directe
|
||||
(Sibnet, SendVid, VidMoly, Uqload, Vidzy, Luluvdo).
|
||||
- **Proxy vidéo intégré** (`/api/proxy`) : contourne les protections (tokens liés à l'IP, Referer/UA obligatoires), réécrit les playlists HLS.
|
||||
|
||||
@@ -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()}
|
||||
@@ -38,7 +38,7 @@ class Episode:
|
||||
title: str | None
|
||||
url: str # page de l'épisode chez la source
|
||||
season: int = 1
|
||||
|
||||
version: str | None = None # langue ("vf" / "vostfr") quand la source la distingue
|
||||
|
||||
@dataclass
|
||||
class TitleDetails:
|
||||
|
||||
@@ -259,27 +259,26 @@ class FrenchMangaScraper(SourceScraper):
|
||||
if season_match:
|
||||
season = int(season_match.group(1))
|
||||
|
||||
numbers: set[float] = set()
|
||||
info = data.get("info") or {}
|
||||
episodes: list[Episode] = []
|
||||
for version in versions:
|
||||
numbers: set[float] = set()
|
||||
for number_text in data.get(version) or {}:
|
||||
try:
|
||||
numbers.add(float(number_text))
|
||||
except ValueError:
|
||||
logger.warning("french_manga : numéro d'épisode invalide %r", number_text)
|
||||
|
||||
info = data.get("info") or {}
|
||||
episodes: list[Episode] = []
|
||||
for number in sorted(numbers):
|
||||
number_text = str(int(number)) if number.is_integer() else str(number)
|
||||
episode_info = info.get(number_text) or info.get(str(int(number)))
|
||||
label = episode_info.get("title") if isinstance(episode_info, dict) else None
|
||||
version = next((v for v in versions if number_text in (data.get(v) or {})), versions[0])
|
||||
episodes.append(
|
||||
Episode(
|
||||
number=number,
|
||||
title=label,
|
||||
url=f"{page_url}#{config['episodes']['fragment_prefix']}={version}-{number_text}",
|
||||
season=season,
|
||||
version=version,
|
||||
)
|
||||
)
|
||||
if not episodes:
|
||||
|
||||
@@ -85,6 +85,7 @@ _SLUG_RE = re.compile(r"([^/]+)\.html?$")
|
||||
_NUMBER_RE = re.compile(r"(\d+(?:[.,]\d+)?)")
|
||||
_SEASON_RE = re.compile(r"Saison\s*:?\s*(\d+)", re.IGNORECASE)
|
||||
_SAFE_FRAGMENT_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||
_VERSION_RE = re.compile(r"\b(vostfr|vf)\b", re.IGNORECASE)
|
||||
|
||||
|
||||
def _merged_config() -> dict:
|
||||
@@ -246,8 +247,18 @@ class VostfreeScraper(SourceScraper):
|
||||
return int(season_match.group(1))
|
||||
return 1
|
||||
|
||||
def _detect_version(self, soup: BeautifulSoup, page_url: str) -> str | None:
|
||||
config = _merged_config()
|
||||
title_el = soup.select_one(config["details"]["title"])
|
||||
for text in (self._text(title_el), page_url):
|
||||
match = _VERSION_RE.search(text)
|
||||
if match:
|
||||
return match.group(1).lower()
|
||||
return None
|
||||
|
||||
def _parse_episodes(self, soup: BeautifulSoup, page_url: str, season: int) -> list[Episode]:
|
||||
config = _merged_config()
|
||||
version = self._detect_version(soup, page_url)
|
||||
options = soup.select(config["episodes"]["option"])
|
||||
entries: list[tuple[str, str]] = []
|
||||
seen_ids: set[str] = set()
|
||||
@@ -274,6 +285,7 @@ class VostfreeScraper(SourceScraper):
|
||||
title=label or f"Episode {index}",
|
||||
url=f"{page_url}#{button_id}" if button_id else page_url,
|
||||
season=season,
|
||||
version=version,
|
||||
)
|
||||
)
|
||||
return episodes
|
||||
|
||||
@@ -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,53 @@ 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); } }
|
||||
|
||||
/* ------------------------------------------------------------ filtre VF/VOSTFR */
|
||||
|
||||
.version-filter {
|
||||
display: inline-flex;
|
||||
gap: 0.2rem;
|
||||
background: var(--surface-2);
|
||||
padding: 0.2rem;
|
||||
border-radius: 99px;
|
||||
margin-left: 0.8rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.version-tab {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.28rem 0.75rem;
|
||||
border-radius: 99px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.version-tab:hover { color: var(--text); }
|
||||
.version-tab.active { background: var(--accent); color: #fff; }
|
||||
.badge-version { background: rgba(90, 200, 250, 0.14); color: #7ec8f5; }
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
+139
-11
@@ -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) {
|
||||
s._checking = true;
|
||||
const res = await fetch(`/api/admin/sources/${s.name}/health`, { method: 'POST' });
|
||||
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.health = data.healthy; s.healthDetail = data.detail;
|
||||
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;
|
||||
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;
|
||||
toast(data.healthy ? `✔ ${s.label} : ${data.detail}` : `✖ ${s.label} : ${data.detail}`);
|
||||
}
|
||||
},
|
||||
|
||||
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>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<div class="title-stats">
|
||||
<span x-show="details.rating"><span class="rating-star">★</span> <strong x-text="details.rating"></strong>/10</span>
|
||||
<span x-show="details.year">Année : <strong x-text="details.year"></strong></span>
|
||||
<span><strong x-text="details.episodes.length"></strong> épisode(s)</span>
|
||||
<span><strong x-text="uniqueEpisodeCount"></strong> épisode(s)</span>
|
||||
</div>
|
||||
<div class="title-actions">
|
||||
<button class="btn" @click="downloadSeason" :disabled="seasonJob.running">
|
||||
@@ -43,11 +43,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-title">Épisodes</h2>
|
||||
<h2 class="section-title">Épisodes
|
||||
<span class="version-filter" x-show="epVersions.length" x-cloak>
|
||||
<template x-for="v in epVersions" :key="v">
|
||||
<button class="version-tab" :class="{ active: versionFilter === v }"
|
||||
@click="versionFilter = v" x-text="v.toUpperCase()"></button>
|
||||
</template>
|
||||
<button class="version-tab" :class="{ active: versionFilter === 'all' }"
|
||||
@click="versionFilter = 'all'">Tout</button>
|
||||
</span>
|
||||
</h2>
|
||||
<div class="episode-list">
|
||||
<template x-for="ep in details.episodes" :key="ep.number">
|
||||
<template x-for="ep in filteredEpisodes" :key="ep.number + '-' + (ep.version || '')">
|
||||
<div class="episode-row">
|
||||
<span class="episode-num" x-text="'E' + ep.number"></span>
|
||||
<span class="badge badge-version" x-show="ep.version" x-text="ep.version.toUpperCase()"></span>
|
||||
<span class="episode-name" x-text="ep.title || `Épisode ${ep.number}`"></span>
|
||||
<button class="btn btn-sm btn-ghost" @click="streamEpisode(ep)" :disabled="ep._busy">▶ Stream</button>
|
||||
<button class="btn btn-sm" @click="downloadEpisode(ep)" :disabled="ep._busy">⬇</button>
|
||||
@@ -76,6 +86,23 @@ function titlePage() {
|
||||
details: null, loading: true, error: null, favorite: false,
|
||||
player: { active: false, hls: null },
|
||||
seasonJob: { running: false, done: 0, total: 0 },
|
||||
versionFilter: 'all',
|
||||
|
||||
get epVersions() {
|
||||
if (!this.details) return [];
|
||||
return [...new Set(this.details.episodes.map(e => e.version).filter(Boolean))];
|
||||
},
|
||||
|
||||
get filteredEpisodes() {
|
||||
if (!this.details) return [];
|
||||
if (this.versionFilter === 'all') return this.details.episodes;
|
||||
return this.details.episodes.filter(e => e.version === this.versionFilter);
|
||||
},
|
||||
|
||||
get uniqueEpisodeCount() {
|
||||
if (!this.details) return 0;
|
||||
return new Set(this.details.episodes.map(e => e.number)).size;
|
||||
},
|
||||
|
||||
async load(source, sourceId) {
|
||||
this.source = source; this.sourceId = sourceId;
|
||||
@@ -84,6 +111,7 @@ function titlePage() {
|
||||
if (!res.ok) throw new Error((await res.json()).detail || 'Erreur ' + res.status);
|
||||
this.details = await res.json();
|
||||
this.details.episodes.forEach(e => e._busy = false);
|
||||
this.versionFilter = this.epVersions.includes('vostfr') ? 'vostfr' : 'all';
|
||||
document.title = this.details.title + ' — Ohm Stream';
|
||||
} catch (e) { this.error = e.message; }
|
||||
finally { this.loading = false; }
|
||||
@@ -98,7 +126,10 @@ function titlePage() {
|
||||
return data.links[0];
|
||||
},
|
||||
|
||||
epLabel(ep) { return `${this.details.title} - E${ep.number}`; },
|
||||
epLabel(ep) {
|
||||
const version = ep.version ? ` (${ep.version.toUpperCase()})` : '';
|
||||
return `${this.details.title} - E${ep.number}${version}`;
|
||||
},
|
||||
|
||||
// Lecture via le proxy serveur (tokens liés à l'IP, Referer obligatoire)
|
||||
openPlayer(link) {
|
||||
@@ -148,8 +179,8 @@ function titlePage() {
|
||||
},
|
||||
|
||||
async downloadSeason() {
|
||||
this.seasonJob = { running: true, done: 0, total: this.details.episodes.length };
|
||||
for (const ep of this.details.episodes) {
|
||||
this.seasonJob = { running: true, done: 0, total: this.filteredEpisodes.length };
|
||||
for (const ep of this.filteredEpisodes) {
|
||||
await this.downloadEpisode(ep);
|
||||
this.seasonJob.done++;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,53 @@
|
||||
# Déploiement OhmStreaming — https://git.lanro.eu/Roman/ohm_streaming
|
||||
#
|
||||
# 1. docker login git.lanro.eu (compte Gitea avec accès lecture au repo)
|
||||
# 2. cp .env.example .env && $EDITOR .env (OHM_SECRET_KEY + WATCHTOWER_TOKEN obligatoires)
|
||||
# 3. docker compose up -d
|
||||
#
|
||||
# Mise à jour : page Admin → « Mise à jour », ou manuellement :
|
||||
# docker compose pull && docker compose up -d
|
||||
|
||||
services:
|
||||
ohm:
|
||||
image: git.lanro.eu/roman/ohm_streaming:latest
|
||||
# Build local (dev) : décommenter ces lignes et commenter « image: » ci-dessus
|
||||
# build:
|
||||
# context: .
|
||||
# args:
|
||||
# VERSION: dev
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${OHM_PORT:-8777}:8777"
|
||||
env_file: .env
|
||||
environment:
|
||||
# Chemins internes (volumes ci-dessous) — ne pas changer
|
||||
OHM_DATA_DIR: /data
|
||||
OHM_DOWNLOAD_DIR: /downloads
|
||||
OHM_DATABASE_PATH: /data/ohm.db
|
||||
# Watchtower compagnon sur le réseau interne compose
|
||||
OHM_WATCHTOWER_URL: http://watchtower:8080
|
||||
OHM_WATCHTOWER_TOKEN: ${WATCHTOWER_TOKEN:?WATCHTOWER_TOKEN manquant — voir .env.example}
|
||||
volumes:
|
||||
- ./data:/data # base SQLite, persiste entre les mises à jour
|
||||
- ./downloads:/downloads # bibliothèque téléchargée
|
||||
labels:
|
||||
# Watchtower ne touche que les conteneurs portant ce label
|
||||
- com.centurylinklabs.watchtower.enable=true
|
||||
|
||||
# Compagnon de mise à jour : recrée le conteneur ohm quand l'admin le demande
|
||||
# (API HTTP interne uniquement, aucun port publié sur l'hôte)
|
||||
watchtower:
|
||||
image: containrrr/watchtower
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
# Pas de vérification périodique : uniquement sur appel de l'admin
|
||||
WATCHTOWER_HTTP_API_UPDATE: "true"
|
||||
WATCHTOWER_HTTP_API_TOKEN: ${WATCHTOWER_TOKEN:?WATCHTOWER_TOKEN manquant — voir .env.example}
|
||||
# Ne mettre à jour que les conteneurs étiquetés (ohm), pas watchtower lui-même
|
||||
WATCHTOWER_LABEL_ENABLE: "true"
|
||||
# Compatibilité Docker récent (l'image watchtower négocie une API trop ancienne)
|
||||
DOCKER_API_VERSION: "1.44"
|
||||
# Supprimer les anciennes images après mise à jour
|
||||
WATCHTOWER_CLEANUP: "true"
|
||||
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
# Corrige les permissions des volumes (créés par Docker en root au premier up)
|
||||
# puis bascule sur l'utilisateur non-root « ohm ».
|
||||
set -e
|
||||
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
chown -R ohm:ohm /data /downloads 2>/dev/null || true
|
||||
exec gosu ohm:ohm "$@"
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
# Publie une version d'OhmStreaming :
|
||||
# bump de version → commit + tag git → build de l'image Docker → push registre Gitea.
|
||||
#
|
||||
# Usage : ./scripts/release.sh 0.2.0
|
||||
# Option : OHM_REGISTRY=git.lanro.eu/roman/ohm_streaming (défaut) pour viser un autre registre.
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:?Usage : ./scripts/release.sh <version> (ex. 0.2.0)}"
|
||||
REGISTRY="${OHM_REGISTRY:-git.lanro.eu/roman/ohm_streaming}"
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "✖ « $VERSION » n'est pas un semver X.Y.Z"; exit 1; }
|
||||
|
||||
if [[ -n "$(git status --porcelain)" ]]; then
|
||||
echo "✖ Dépôt sale — committez ou rangez vos modifications avant de publier :"
|
||||
git status --short
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TAG="v$VERSION"
|
||||
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
|
||||
echo "✖ Le tag $TAG existe déjà"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "▶ 1/3 Version $VERSION dans pyproject.toml"
|
||||
uv version "$VERSION"
|
||||
git add pyproject.toml uv.lock
|
||||
git commit -m "v$VERSION"
|
||||
|
||||
echo "▶ 2/3 Tag et push git"
|
||||
git tag "$TAG"
|
||||
git push origin HEAD
|
||||
git push origin "$TAG"
|
||||
|
||||
echo "▶ 3/3 Image Docker → $REGISTRY"
|
||||
docker build --build-arg VERSION="$VERSION" -t "$REGISTRY:$VERSION" -t "$REGISTRY:latest" .
|
||||
docker push "$REGISTRY:$VERSION"
|
||||
docker push "$REGISTRY:latest"
|
||||
|
||||
echo
|
||||
echo "✔ v$VERSION publiée ($REGISTRY:$VERSION et :latest)"
|
||||
echo " Les instances la détecteront via Admin → Mise à jour → « Vérifier »."
|
||||
@@ -38,3 +38,25 @@ async def database() -> AsyncIterator[None]:
|
||||
await db.execute(f"DELETE FROM {table}")
|
||||
yield
|
||||
await db.close()
|
||||
|
||||
# ---------------------------------------------------------------- client HTTP partagé
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client() -> AsyncClient:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_cookies(client: AsyncClient):
|
||||
"""Premier compte créé → administrateur, cookies de session."""
|
||||
r = await client.post("/auth/register", data={"username": "admin", "password": "secret123"})
|
||||
assert r.status_code == 303
|
||||
return r.cookies
|
||||
|
||||
+83
-18
@@ -1,25 +1,8 @@
|
||||
"""Tests d'intégration API (auth, downloads, favoris, streaming, admin)."""
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db import db
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client() -> AsyncClient:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_cookies(client: AsyncClient):
|
||||
r = await client.post("/auth/register", data={"username": "admin", "password": "secret123"})
|
||||
assert r.status_code == 303
|
||||
return r.cookies
|
||||
from app.scrapers.base import ScrapeError, SearchResult
|
||||
|
||||
|
||||
async def test_health(client):
|
||||
@@ -121,3 +104,85 @@ async def test_source_toggle(client, admin_cookies):
|
||||
states = {s["name"]: s["enabled"] for s in r.json()}
|
||||
assert states["vostfree"] is False
|
||||
assert states["french_manga"] is True
|
||||
|
||||
|
||||
async def test_source_health(client, admin_cookies, monkeypatch):
|
||||
# source inconnue → 404 (pas de 500)
|
||||
r = await client.post("/api/admin/sources/inconnue/health", cookies=admin_cookies)
|
||||
assert r.status_code == 404
|
||||
|
||||
# état initial : aucune santé connue
|
||||
r = await client.get("/api/admin/sources", cookies=admin_cookies)
|
||||
assert all(s["health"] is None for s in r.json())
|
||||
|
||||
async def ok_search(self, query: str) -> list[SearchResult]:
|
||||
return [SearchResult(source=self.name, source_id="x", title="X", url="http://x")]
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.vostfree.VostfreeScraper.search", ok_search)
|
||||
r = await client.post("/api/admin/sources/vostfree/health", cookies=admin_cookies)
|
||||
data = r.json()
|
||||
assert data["healthy"] is True and data["detail"] == "1 résultats" and data["checked_at"]
|
||||
|
||||
# résultat persisté et exposé par GET /sources
|
||||
r = await client.get("/api/admin/sources", cookies=admin_cookies)
|
||||
health = {s["name"]: s["health"] for s in r.json()}
|
||||
assert health["vostfree"]["healthy"] is True
|
||||
assert health["french_manga"] is None
|
||||
|
||||
async def failing_search(self, query: str) -> list[SearchResult]:
|
||||
raise ScrapeError("site indisponible")
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.vostfree.VostfreeScraper.search", failing_search)
|
||||
r = await client.post("/api/admin/sources/vostfree/health", cookies=admin_cookies)
|
||||
data = r.json()
|
||||
assert r.status_code == 200 and data["healthy"] is False and data["detail"] == "site indisponible"
|
||||
|
||||
async def broken_search(self, query: str) -> list[SearchResult]:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.vostfree.VostfreeScraper.search", broken_search)
|
||||
r = await client.post("/api/admin/sources/vostfree/health", cookies=admin_cookies)
|
||||
data = r.json()
|
||||
assert r.status_code == 200 and data["healthy"] is False and "boom" in data["detail"]
|
||||
|
||||
|
||||
async def test_source_toggle_unknown(client, admin_cookies):
|
||||
r = await client.post(
|
||||
"/api/admin/sources/inconnue/toggle", json={"enabled": True}, cookies=admin_cookies
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
async def test_source_url_override(client, admin_cookies):
|
||||
from app.scrapers.base import get_source
|
||||
|
||||
r = await client.get("/api/admin/sources", cookies=admin_cookies)
|
||||
src = next(s for s in r.json() if s["name"] == "vostfree")
|
||||
default = src["default_base_url"]
|
||||
assert src["base_url"] == default and src["overridden"] is False
|
||||
|
||||
# source inconnue → 404
|
||||
r = await client.put("/api/admin/sources/inconnue/url", json={"url": "https://x.org"}, cookies=admin_cookies)
|
||||
assert r.status_code == 404
|
||||
|
||||
# URL invalide → 422
|
||||
r = await client.put("/api/admin/sources/vostfree/url", json={"url": "pas-une-url"}, cookies=admin_cookies)
|
||||
assert r.status_code == 422
|
||||
|
||||
# changement de domaine (slash final toléré) → appliqué à l'instance + persisté
|
||||
r = await client.put("/api/admin/sources/vostfree/url", json={"url": "https://exemple.org/"},
|
||||
cookies=admin_cookies)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["base_url"] == "https://exemple.org" and data["overridden"] is True
|
||||
assert get_source("vostfree").base_url == "https://exemple.org"
|
||||
|
||||
r = await client.get("/api/admin/sources", cookies=admin_cookies)
|
||||
src = next(s for s in r.json() if s["name"] == "vostfree")
|
||||
assert src["base_url"] == "https://exemple.org" and src["overridden"] is True
|
||||
|
||||
# URL vide → retour au défaut du code
|
||||
r = await client.put("/api/admin/sources/vostfree/url", json={"url": ""}, cookies=admin_cookies)
|
||||
data = r.json()
|
||||
assert data["base_url"] == default and data["overridden"] is False
|
||||
assert get_source("vostfree").base_url == default
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Mises à jour : semver, détection Gitea (API tags), déclenchement Watchtower, endpoints."""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.services import update as update_service
|
||||
from app.services.settings import get_update_config
|
||||
from app.version import get_version
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_update_cache():
|
||||
update_service.invalidate_cache()
|
||||
yield
|
||||
update_service.invalidate_cache()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- semver / version
|
||||
|
||||
|
||||
def test_parse_tag():
|
||||
assert update_service.parse_tag("v0.2.1") == (0, 2, 1)
|
||||
assert update_service.parse_tag("0.10.3") == (0, 10, 3)
|
||||
assert update_service.parse_tag("v0.2") is None
|
||||
assert update_service.parse_tag("v0.2.1-beta") is None
|
||||
assert update_service.parse_tag("nimporte") is None
|
||||
|
||||
|
||||
def test_is_newer():
|
||||
assert update_service.is_newer("v0.2.0", "0.1.9")
|
||||
assert update_service.is_newer("v1.0.0", "v0.99.99")
|
||||
assert not update_service.is_newer("v0.1.0", "0.1.0")
|
||||
assert not update_service.is_newer("v0.1.0", "dev") # courant non semver → jamais forcé
|
||||
|
||||
|
||||
def test_get_version_env_override(monkeypatch):
|
||||
monkeypatch.setenv("OHM_VERSION", "9.9.9")
|
||||
assert get_version() == "9.9.9"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- détection Gitea
|
||||
|
||||
|
||||
async def test_fetch_latest_without_repo():
|
||||
from app.services.settings import set_update_config
|
||||
|
||||
await set_update_config("https://git.example", "", "tok")
|
||||
assert await update_service.fetch_latest_version(force=True) is None
|
||||
|
||||
|
||||
async def test_fetch_latest_public_repo_no_token(monkeypatch):
|
||||
"""Dépôt public : pas de jeton → appel sans en-tête Authorization."""
|
||||
from app.services.settings import set_update_config
|
||||
|
||||
await set_update_config("https://git.example", "roman/ohm", "")
|
||||
|
||||
class FakeResponse:
|
||||
def raise_for_status(self) -> None:
|
||||
pass
|
||||
|
||||
def json(self) -> list[dict]:
|
||||
return [{"name": "v0.3.0"}]
|
||||
|
||||
calls: list[tuple[str, dict]] = []
|
||||
|
||||
async def fake_get(self: httpx.AsyncClient, url: str, headers: dict | None = None) -> FakeResponse:
|
||||
calls.append((url, headers or {}))
|
||||
return FakeResponse()
|
||||
|
||||
monkeypatch.setattr(httpx.AsyncClient, "get", fake_get)
|
||||
assert await update_service.fetch_latest_version(force=True) == "v0.3.0"
|
||||
assert calls[0][1] == {} # aucun en-tête d'auth
|
||||
|
||||
|
||||
async def test_fetch_latest_picks_highest_semver(monkeypatch):
|
||||
"""Avec jeton : en-tête Authorization + plus haut semver retenu, puis cache."""
|
||||
from app.services.settings import set_update_config
|
||||
|
||||
await set_update_config("https://git.example", "roman/ohm", "tok")
|
||||
|
||||
class FakeResponse:
|
||||
def raise_for_status(self) -> None:
|
||||
pass
|
||||
|
||||
def json(self) -> list[dict]:
|
||||
return [{"name": "v0.1.0"}, {"name": "v0.2.3"}, {"name": "v1.0.0-rc"}, {"name": "divers"}]
|
||||
|
||||
calls: list[tuple[str, dict]] = []
|
||||
|
||||
async def fake_get(self: httpx.AsyncClient, url: str, headers: dict | None = None) -> FakeResponse:
|
||||
calls.append((url, headers or {}))
|
||||
return FakeResponse()
|
||||
|
||||
monkeypatch.setattr(httpx.AsyncClient, "get", fake_get)
|
||||
latest = await update_service.fetch_latest_version(force=True)
|
||||
|
||||
assert latest == "v0.2.3"
|
||||
assert calls == [
|
||||
("https://git.example/api/v1/repos/roman/ohm/tags?limit=20", {"Authorization": "token tok"})
|
||||
]
|
||||
# puis servi par le cache (plus d'appel réseau)
|
||||
assert await update_service.fetch_latest_version() == "v0.2.3"
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
async def test_fetch_latest_network_error_degrades(monkeypatch):
|
||||
from app.services.settings import set_update_config
|
||||
|
||||
await set_update_config("https://git.example", "roman/ohm", "tok")
|
||||
|
||||
async def fake_get(self: httpx.AsyncClient, url: str, headers: dict | None = None):
|
||||
raise httpx.ConnectError("injoignable")
|
||||
|
||||
monkeypatch.setattr(httpx.AsyncClient, "get", fake_get)
|
||||
assert await update_service.fetch_latest_version(force=True) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- déclenchement
|
||||
|
||||
|
||||
async def test_trigger_update_requires_watchtower():
|
||||
with pytest.raises(update_service.UpdateError, match="Watchtower"):
|
||||
await update_service.trigger_update()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- endpoints
|
||||
|
||||
|
||||
async def test_version_endpoint_public(client):
|
||||
r = await client.get("/api/version")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["version"] == get_version() and data["name"]
|
||||
|
||||
|
||||
async def test_update_requires_admin(client):
|
||||
r = await client.get("/api/admin/update", follow_redirects=False)
|
||||
assert r.status_code == 303 and r.headers["location"] == "/login"
|
||||
|
||||
|
||||
async def test_update_status_defaults(client, admin_cookies, monkeypatch):
|
||||
# Aucun appel réseau : la détection renvoie None (repo sans tag)
|
||||
async def fake_fetch(*, force: bool = False):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(update_service, "fetch_latest_version", fake_fetch)
|
||||
|
||||
r = await client.get("/api/admin/update", cookies=admin_cookies)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["configured"] is True and data["docker"] is False # repo par défaut présent
|
||||
assert data["latest"] is None and data["update_available"] is False
|
||||
assert data["config"]["gitea_url"] == "https://git.lanro.eu"
|
||||
assert data["config"]["repo"] == "Roman/ohm_streaming"
|
||||
|
||||
|
||||
async def test_update_save_config_normalizes(client, admin_cookies):
|
||||
r = await client.put(
|
||||
"/api/admin/update",
|
||||
json={"gitea_url": "https://git.example/", "repo": "/roman/ohm/", "token": " tok "},
|
||||
cookies=admin_cookies,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert await get_update_config() == {
|
||||
"gitea_url": "https://git.example",
|
||||
"repo": "roman/ohm",
|
||||
"token": "tok",
|
||||
}
|
||||
|
||||
|
||||
async def test_update_check_degrades_gracefully(client, admin_cookies):
|
||||
# Gitea configuré mais injoignable : le check répond quand même (latest=None)
|
||||
await client.put(
|
||||
"/api/admin/update",
|
||||
json={"gitea_url": "https://git.inexistant", "repo": "roman/ohm", "token": "tok"},
|
||||
cookies=admin_cookies,
|
||||
)
|
||||
r = await client.post("/api/admin/update/check", cookies=admin_cookies)
|
||||
assert r.status_code == 200 and r.json()["latest"] is None
|
||||
|
||||
|
||||
async def test_update_apply_without_docker_is_502(client, admin_cookies):
|
||||
r = await client.post("/api/admin/update/apply", cookies=admin_cookies)
|
||||
assert r.status_code == 502
|
||||
Reference in New Issue
Block a user