Compare commits
3
Commits
fed61adc56
..
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7e178eecf | ||
|
|
12bdc8a7ef | ||
|
|
eae97f9129 |
+4
-1
@@ -34,7 +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)
|
||||
# Mise à jour (dépôt public — lecture anonyme de l'API Gitea)
|
||||
gitea_url: str = "https://git.lanro.eu"
|
||||
gitea_repo: str = "Roman/ohm_streaming"
|
||||
# Déploiement Docker — Watchtower compagnon
|
||||
watchtower_url: str = ""
|
||||
watchtower_token: str = ""
|
||||
|
||||
|
||||
+1
-13
@@ -28,7 +28,6 @@ from app.services.settings import (
|
||||
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
|
||||
@@ -222,23 +221,12 @@ async def test_sonarr(admin: AdminUser) -> dict:
|
||||
# ---------------------------------------------------------------- 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."""
|
||||
"""Version courante, dernière version disponible et patchnote."""
|
||||
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)."""
|
||||
|
||||
@@ -114,23 +114,3 @@ async def set_sonarr_config(url: str, apikey: str) -> None:
|
||||
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)
|
||||
|
||||
+18
-21
@@ -1,6 +1,7 @@
|
||||
"""Mises à jour logicielles.
|
||||
|
||||
- Détection : dernier tag semver du dépôt Gitea via son API (jeton requis, repo privé).
|
||||
- Détection : dernier tag semver du dépôt Gitea public via son API (lecture anonyme),
|
||||
avec le message du tag comme patchnote.
|
||||
- Application : POST à Watchtower (compagnon docker-compose) qui tire la nouvelle
|
||||
image et recrée le conteneur — quelques secondes d'indisponibilité.
|
||||
"""
|
||||
@@ -12,7 +13,6 @@ 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__)
|
||||
@@ -20,11 +20,11 @@ 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}
|
||||
_cache: dict[str, object] = {"checked_at": 0.0, "latest": None, "notes": None}
|
||||
|
||||
|
||||
class UpdateError(Exception):
|
||||
"""Erreur de mise à jour (config absente, Gitea injoignable, Watchtower KO)."""
|
||||
"""Erreur de mise à jour (Gitea injoignable, Watchtower KO)."""
|
||||
|
||||
|
||||
def parse_tag(tag: str) -> tuple[int, int, int] | None:
|
||||
@@ -41,51 +41,48 @@ def is_newer(latest: str, current: str) -> bool:
|
||||
|
||||
|
||||
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."""
|
||||
"""Dernier tag semver du dépôt + patchnote (cache 5 min). None si 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 {}
|
||||
settings = get_settings()
|
||||
url = f"{settings.gitea_url}/api/v1/repos/{settings.gitea_repo}/tags?limit=20"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client:
|
||||
resp = await client.get(url, headers=headers)
|
||||
resp = await client.get(url)
|
||||
resp.raise_for_status()
|
||||
tags = [t["name"] for t in resp.json() if parse_tag(t.get("name", ""))]
|
||||
tags = [
|
||||
(t["name"], (t.get("message") or "").strip())
|
||||
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)
|
||||
latest, notes = max(tags, key=lambda t: parse_tag(t[0])) if tags else (None, None)
|
||||
_cache.update(checked_at=now, latest=latest, notes=notes or None)
|
||||
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)
|
||||
_cache.update(checked_at=0.0, latest=None, notes=None)
|
||||
|
||||
|
||||
async def status() -> dict[str, object]:
|
||||
"""État complet : version courante, dernière dispo, config."""
|
||||
config = await get_update_config()
|
||||
"""État complet : version courante, dernière dispo, patchnote."""
|
||||
current = get_version()
|
||||
latest = await fetch_latest_version()
|
||||
return {
|
||||
"current": current,
|
||||
"latest": latest,
|
||||
"notes": _cache["notes"],
|
||||
"update_available": bool(latest and is_newer(latest, current)),
|
||||
"configured": bool(config["repo"]),
|
||||
"docker": bool(get_settings().watchtower_url),
|
||||
"config": config,
|
||||
}
|
||||
|
||||
|
||||
|
||||
+11
-26
@@ -111,28 +111,23 @@
|
||||
</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)">
|
||||
<details x-show="upd.notes" style="margin:0.6rem 0 0">
|
||||
<summary style="cursor:pointer;font-size:0.85rem;color:var(--text-dim)">
|
||||
Patchnote <span x-text="upd.latest"></span>
|
||||
</summary>
|
||||
<pre style="white-space:pre-wrap;font-size:0.82rem;margin:0.5rem 0 0;color:var(--text-dim)"
|
||||
x-text="upd.notes"></pre>
|
||||
</details>
|
||||
<p x-show="!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>
|
||||
@@ -171,9 +166,8 @@ 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,
|
||||
current: '', latest: null, notes: null, update_available: false, docker: false,
|
||||
_checking: false, _applying: false,
|
||||
},
|
||||
integrations: {
|
||||
torznab: { apikey: '', endpoint: '' }, sonarr: { url: '', apikey: '' },
|
||||
@@ -287,18 +281,9 @@ function adminPage() {
|
||||
applyUpdateData(data) {
|
||||
this.upd.current = data.current;
|
||||
this.upd.latest = data.latest;
|
||||
this.upd.notes = data.notes;
|
||||
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() {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "ohm-stream"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
description = "Ohm Stream Downloader — centre de contrôle auto-hébergé pour animes et séries VOSTFR"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
|
||||
Executable → Regular
+3
-3
@@ -2,11 +2,11 @@
|
||||
# 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
|
||||
# Usage : ./scripts/release.sh 0.2.0 ["patchnote multi-lignes"]
|
||||
# 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)}"
|
||||
VERSION="${1:?Usage : ./scripts/release.sh <version> [patchnote] (ex : 0.2.0)}"
|
||||
REGISTRY="${OHM_REGISTRY:-git.lanro.eu/roman/ohm_streaming}"
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
@@ -30,7 +30,7 @@ git add pyproject.toml uv.lock
|
||||
git commit -m "v$VERSION"
|
||||
|
||||
echo "▶ 2/3 Tag et push git"
|
||||
git tag "$TAG"
|
||||
git tag -a "$TAG" -m "${2:-$TAG}"
|
||||
git push origin HEAD
|
||||
git push origin "$TAG"
|
||||
|
||||
|
||||
+27
-50
@@ -3,8 +3,8 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.config import get_settings
|
||||
from app.services import update as update_service
|
||||
from app.services.settings import get_update_config
|
||||
from app.version import get_version
|
||||
|
||||
|
||||
@@ -15,6 +15,12 @@ def reset_update_cache():
|
||||
update_service.invalidate_cache()
|
||||
|
||||
|
||||
def _patch_gitea(monkeypatch, url: str = "https://git.example", repo: str = "roman/ohm") -> None:
|
||||
settings = get_settings()
|
||||
monkeypatch.setattr(settings, "gitea_url", url)
|
||||
monkeypatch.setattr(settings, "gitea_repo", repo)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- semver / version
|
||||
|
||||
|
||||
@@ -41,18 +47,9 @@ def test_get_version_env_override(monkeypatch):
|
||||
# ---------------------------------------------------------------- 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", "")
|
||||
async def test_fetch_latest_no_auth_header(monkeypatch):
|
||||
"""Dépôt public : appel sans en-tête Authorization."""
|
||||
_patch_gitea(monkeypatch)
|
||||
|
||||
class FakeResponse:
|
||||
def raise_for_status(self) -> None:
|
||||
@@ -73,17 +70,20 @@ async def test_fetch_latest_public_repo_no_token(monkeypatch):
|
||||
|
||||
|
||||
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")
|
||||
"""Plus haut semver retenu + patchnote du tag, puis cache."""
|
||||
_patch_gitea(monkeypatch)
|
||||
|
||||
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"}]
|
||||
return [
|
||||
{"name": "v0.1.0", "message": "ancien"},
|
||||
{"name": "v0.2.3", "message": "correctifs\n"},
|
||||
{"name": "v1.0.0-rc"},
|
||||
{"name": "divers"},
|
||||
]
|
||||
|
||||
calls: list[tuple[str, dict]] = []
|
||||
|
||||
@@ -92,21 +92,18 @@ async def test_fetch_latest_picks_highest_semver(monkeypatch):
|
||||
return FakeResponse()
|
||||
|
||||
monkeypatch.setattr(httpx.AsyncClient, "get", fake_get)
|
||||
latest = await update_service.fetch_latest_version(force=True)
|
||||
status = await update_service.status()
|
||||
|
||||
assert latest == "v0.2.3"
|
||||
assert calls == [
|
||||
("https://git.example/api/v1/repos/roman/ohm/tags?limit=20", {"Authorization": "token tok"})
|
||||
]
|
||||
assert status["latest"] == "v0.2.3"
|
||||
assert status["notes"] == "correctifs"
|
||||
assert calls == [("https://git.example/api/v1/repos/roman/ohm/tags?limit=20", {})]
|
||||
# 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")
|
||||
_patch_gitea(monkeypatch)
|
||||
|
||||
async def fake_get(self: httpx.AsyncClient, url: str, headers: dict | None = None):
|
||||
raise httpx.ConnectError("injoignable")
|
||||
@@ -148,33 +145,13 @@ async def test_update_status_defaults(client, admin_cookies, monkeypatch):
|
||||
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"
|
||||
assert data["docker"] is False and data["notes"] is None
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
async def test_update_check_degrades_gracefully(client, admin_cookies, monkeypatch):
|
||||
# Gitea injoignable : le check répond quand même (latest=None)
|
||||
_patch_gitea(monkeypatch, url="https://git.inexistant", repo="roman/ohm")
|
||||
r = await client.post("/api/admin/update/check", cookies=admin_cookies)
|
||||
assert r.status_code == 200 and r.json()["latest"] is None
|
||||
|
||||
|
||||
Reference in New Issue
Block a user