Compare commits
6
Commits
fed61adc56
..
v0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d15fce516 | ||
|
|
f79c0cc86e | ||
|
|
00aae109b0 | ||
|
|
c7e178eecf | ||
|
|
12bdc8a7ef | ||
|
|
eae97f9129 |
@@ -18,6 +18,8 @@ RUN uv sync --frozen --no-dev --no-cache
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
FROM python:3.13-slim-bookworm
|
FROM python:3.13-slim-bookworm
|
||||||
|
|
||||||
|
LABEL org.opencontainers.image.source="https://git.lanro.eu/Roman/ohm_streaming" \
|
||||||
|
org.opencontainers.image.description="Ohm Stream Downloader — outil technique d'automatisation, ne fournit ni n'héberge aucun contenu. L'usage relève de la seule responsabilité de l'utilisateur (droit d'auteur)."
|
||||||
# ffmpeg (téléchargements HLS), ca-certificates (scraping HTTPS),
|
# ffmpeg (téléchargements HLS), ca-certificates (scraping HTTPS),
|
||||||
# gosu (bascule utilisateur non-root dans l'entrypoint)
|
# gosu (bascule utilisateur non-root dans l'entrypoint)
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
|
|||||||
@@ -3,10 +3,34 @@
|
|||||||
Application web **auto-hébergée** (homelab) : centre de contrôle unique pour découvrir,
|
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.
|
regarder et télécharger des animes et séries VOSTFR/VF.
|
||||||
|
|
||||||
|
## ⚖️ Avertissement légal
|
||||||
|
|
||||||
|
Ohm Stream Downloader est un **outil technique d'automatisation** : il ne
|
||||||
|
fournit, n'héberge ni ne distribue **aucun contenu**. Il automatise uniquement
|
||||||
|
l'accès à des flux déjà publiquement accessibles sur des sites tiers, dont
|
||||||
|
l'auteur n'est pas responsable et qu'il n'exploite pas.
|
||||||
|
|
||||||
|
L'utilisation de ce logiciel relève de la **seule responsabilité de
|
||||||
|
l'utilisateur**, tenu de respecter la législation de son pays — notamment le
|
||||||
|
droit d'auteur (en France : art. L.122-4 du Code de la propriété
|
||||||
|
intellectuelle). Le téléchargement ou la diffusion d'œuvres protégées sans
|
||||||
|
autorisation est interdit par la loi.
|
||||||
|
|
||||||
|
Projet publié à des fins **éducatives et de recherche**. L'auteur décline
|
||||||
|
toute responsabilité en cas d'usage illégal et n'encourage en aucune façon le
|
||||||
|
piratage.
|
||||||
|
|
||||||
|
> **Disclaimer (EN)**: this project is a technical automation tool. It does
|
||||||
|
> not provide, host or distribute any content — it merely automates access to
|
||||||
|
> streams already publicly available on third-party websites that the author
|
||||||
|
> neither controls nor operates. Users are solely responsible for complying
|
||||||
|
> with their local laws, including copyright. The author assumes no liability
|
||||||
|
> for illegal use and does not endorse piracy.
|
||||||
|
|
||||||
## Déploiement (Docker) — recommandé
|
## Déploiement (Docker) — recommandé
|
||||||
|
|
||||||
Le déploiement officiel passe par Docker Compose : l'image (ffmpeg inclus) est
|
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.
|
hébergée publiquement sur **GHCR** (`ghcr.io/aulyriusohm/ohm-streaming`) — aucun compte requis.
|
||||||
|
|
||||||
### Installation guidée (recommandée)
|
### Installation guidée (recommandée)
|
||||||
|
|
||||||
@@ -18,14 +42,13 @@ git clone https://git.lanro.eu/Roman/ohm_streaming.git && cd ohm_streaming
|
|||||||
Le script vérifie Docker, demande la **destination des épisodes** (dossier dédié
|
Le script vérifie Docker, demande la **destination des épisodes** (dossier dédié
|
||||||
recommandé — voir « Bibliothèque Plex/Sonarr » ci-dessous), le port, génère les
|
recommandé — voir « Bibliothèque Plex/Sonarr » ci-dessous), le port, génère les
|
||||||
secrets, branche le montage et démarre. Non interactif aussi :
|
secrets, branche le montage et démarre. Non interactif aussi :
|
||||||
`./scripts/install.sh --dir /srv/animes --port 8777 --skip-login`.
|
`./scripts/install.sh --dir /srv/animes --port 8777`.
|
||||||
|
|
||||||
### À la main
|
### À la main
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Récupérer le projet puis se connecter au registre privé (compte Gitea avec accès lecture)
|
# 1. Récupérer le projet
|
||||||
git clone https://git.lanro.eu/Roman/ohm_streaming.git && cd ohm_streaming
|
git clone https://git.lanro.eu/Roman/ohm_streaming.git && cd ohm_streaming
|
||||||
docker login git.lanro.eu
|
|
||||||
|
|
||||||
# 2. Configuration locale
|
# 2. Configuration locale
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
@@ -36,7 +59,33 @@ docker compose up -d
|
|||||||
```
|
```
|
||||||
|
|
||||||
Puis ouvrir http://localhost:8777 — le **premier compte créé est administrateur**.
|
Puis ouvrir http://localhost:8777 — le **premier compte créé est administrateur**.
|
||||||
Les données (`data/`, `downloads/`) sont montées en volumes : elles survivent aux
|
Les données (`data/`, `downloads/`) sont montées en volumes : elles survivent aux mises à jour.
|
||||||
|
|
||||||
|
### Variante Podman
|
||||||
|
|
||||||
|
Même déploiement via `docker-compose.podman.yml` (socket Podman pour Watchtower,
|
||||||
|
au lieu du socket Docker) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 0. Socket Podman (une seule fois) — rootless :
|
||||||
|
systemctl --user enable --now podman.socket
|
||||||
|
sudo loginctl enable-linger $USER # persister après déconnexion
|
||||||
|
export PODMAN_SOCKET=/run/user/$(id -u)/podman/podman.sock
|
||||||
|
# rootful : sudo systemctl enable --now podman.socket (aucun export nécessaire)
|
||||||
|
|
||||||
|
# 1. Récupérer le projet
|
||||||
|
git clone https://git.lanro.eu/Roman/ohm_streaming.git && cd ohm_streaming
|
||||||
|
|
||||||
|
# 2. Configuration locale (identique à Docker)
|
||||||
|
cp .env.example .env # → OHM_SECRET_KEY et WATCHTOWER_TOKEN obligatoires
|
||||||
|
|
||||||
|
# 3. Démarrage
|
||||||
|
podman compose -f docker-compose.podman.yml up -d
|
||||||
|
# (« podman compose » délègue à docker-compose ; sinon : podman-compose)
|
||||||
|
```
|
||||||
|
|
||||||
|
La mise à jour depuis la page Admin (bouton Watchtower) fonctionne à l'identique ;
|
||||||
|
à la main : `podman compose -f docker-compose.podman.yml pull && podman compose -f docker-compose.podman.yml up -d`.
|
||||||
|
|
||||||
|
|
||||||
### Bibliothèque Plex / Sonarr existante
|
### Bibliothèque Plex / Sonarr existante
|
||||||
|
|||||||
+4
-1
@@ -34,7 +34,10 @@ class Settings(BaseSettings):
|
|||||||
kitsu_base_url: str = "https://kitsu.io/api/edge"
|
kitsu_base_url: str = "https://kitsu.io/api/edge"
|
||||||
metadata_cache_ttl_hours: int = 72
|
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_url: str = ""
|
||||||
watchtower_token: str = ""
|
watchtower_token: str = ""
|
||||||
|
|
||||||
|
|||||||
+1
-13
@@ -28,7 +28,6 @@ from app.services.settings import (
|
|||||||
set_source_base_url,
|
set_source_base_url,
|
||||||
set_source_enabled,
|
set_source_enabled,
|
||||||
set_source_health,
|
set_source_health,
|
||||||
set_update_config,
|
|
||||||
)
|
)
|
||||||
from app.services.sonarr import sonarr
|
from app.services.sonarr import sonarr
|
||||||
from app.services.update import UpdateError, fetch_latest_version, trigger_update
|
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
|
# ---------------------------------------------------------------- mise à jour logicielle
|
||||||
|
|
||||||
|
|
||||||
class UpdateConfig(BaseModel):
|
|
||||||
gitea_url: str
|
|
||||||
repo: str
|
|
||||||
token: str
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/update")
|
@router.get("/update")
|
||||||
async def get_update(admin: AdminUser) -> dict:
|
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()
|
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")
|
@router.post("/update/check")
|
||||||
async def check_update(admin: AdminUser) -> dict:
|
async def check_update(admin: AdminUser) -> dict:
|
||||||
"""Force la re-vérification de la dernière version (ignore le cache)."""
|
"""Force la re-vérification de la dernière version (ignore le cache)."""
|
||||||
|
|||||||
+66
-14
@@ -4,6 +4,8 @@ import logging
|
|||||||
import mimetypes
|
import mimetypes
|
||||||
import re
|
import re
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
|
from pathlib import PurePosixPath
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
@@ -24,42 +26,92 @@ CHUNK_SIZE = 1 << 20 # 1 Mio
|
|||||||
|
|
||||||
# ---------------------------------------------------------------- bibliothèque
|
# ---------------------------------------------------------------- bibliothèque
|
||||||
|
|
||||||
|
_SEASON_EP_RE = re.compile(r"\bs(\d{1,2})\s*e(\d{1,4}(?:\.\d(?!\d))?)", re.IGNORECASE)
|
||||||
|
_SEASON_RE = re.compile(r"\b(?:saison|season|s)\s*0?(\d+)", re.IGNORECASE)
|
||||||
|
_EPISODE_RE = re.compile(r"\b(?:épisode|episode|ep|e)\s*0?(\d+(?:\.\d(?!\d))?)", re.IGNORECASE)
|
||||||
|
_VIDEO_EXTS = {".mp4", ".mkv", ".avi", ".webm", ".mov", ".m4v", ".ts"}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_title(title: str) -> dict:
|
||||||
|
"""Extrait nom de série, saison et épisode depuis un titre de téléchargement."""
|
||||||
|
stem, dot, ext = title.rpartition(".")
|
||||||
|
if dot and f".{ext.lower()}" in _VIDEO_EXTS:
|
||||||
|
title = stem
|
||||||
|
season = episode = None
|
||||||
|
cut = len(title)
|
||||||
|
if match := _SEASON_EP_RE.search(title): # format compact S02E05
|
||||||
|
season, episode = int(match.group(1)), float(match.group(2))
|
||||||
|
cut = match.start()
|
||||||
|
else:
|
||||||
|
if match := _SEASON_RE.search(title):
|
||||||
|
season = int(match.group(1))
|
||||||
|
cut = min(cut, match.start())
|
||||||
|
if match := _EPISODE_RE.search(title):
|
||||||
|
episode = float(match.group(1))
|
||||||
|
cut = min(cut, match.start())
|
||||||
|
series = re.sub(r"\s+", " ", title[:cut].replace(".", " ").replace("_", " ")).strip(" -–—:")
|
||||||
|
return {"series": series or title, "season": season, "episode": episode}
|
||||||
|
|
||||||
|
|
||||||
|
def classify_media(page_url: str | None, video_url: str | None) -> str:
|
||||||
|
"""'anime' ou 'serie' selon le domaine de la page/URL source (défaut : anime)."""
|
||||||
|
from app.scrapers.base import all_sources
|
||||||
|
|
||||||
|
hosts = [
|
||||||
|
urlparse(u).netloc.lower()
|
||||||
|
for u in (page_url, video_url)
|
||||||
|
if u and urlparse(u).netloc
|
||||||
|
]
|
||||||
|
for src in all_sources():
|
||||||
|
domain = urlparse(src.base_url).netloc.lower()
|
||||||
|
if domain and any(h == domain or h.endswith(f".{domain}") for h in hosts):
|
||||||
|
return "anime" if "anime" in src.media_types else "serie"
|
||||||
|
return "anime"
|
||||||
|
|
||||||
|
|
||||||
@router.get("/library")
|
@router.get("/library")
|
||||||
async def library(user: CurrentUser) -> list[dict]:
|
async def library(user: CurrentUser) -> list[dict]:
|
||||||
"""Fichiers téléchargés, streamables, avec progression de visionnage."""
|
"""Fichiers téléchargés, streamables, enrichis (type, série, dossier) pour le regroupement."""
|
||||||
rows = await db.fetchall(
|
rows = await db.fetchall(
|
||||||
"SELECT d.*, wp.position_seconds FROM downloads d "
|
"SELECT d.*, wp.position_seconds FROM downloads d "
|
||||||
"LEFT JOIN watch_progress wp ON wp.download_id = d.id AND wp.user_id = ? "
|
"LEFT JOIN watch_progress wp ON wp.download_id = d.id AND wp.user_id = ? "
|
||||||
"WHERE d.status = 'done' ORDER BY d.updated_at DESC",
|
"WHERE d.status = 'done' ORDER BY d.updated_at DESC",
|
||||||
(user.id,),
|
(user.id,),
|
||||||
)
|
)
|
||||||
return [dict(row) for row in rows]
|
items = []
|
||||||
|
for row in rows:
|
||||||
|
item = dict(row)
|
||||||
|
item["media_type"] = classify_media(row["page_url"], row["video_url"])
|
||||||
|
item.update(parse_title(row["title"]))
|
||||||
|
folder = str(PurePosixPath(row["file_path"]).parent) if row["file_path"] else ""
|
||||||
|
item["folder"] = "" if folder == "." else folder
|
||||||
|
items.append(item)
|
||||||
|
if user.content_preference in ("anime", "serie"):
|
||||||
|
items = [i for i in items if i["media_type"] == user.content_preference]
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
@router.get("/library/{download_id}/neighbors")
|
@router.get("/library/{download_id}/neighbors")
|
||||||
async def neighbors(download_id: int) -> dict:
|
async def neighbors(download_id: int) -> dict:
|
||||||
"""Épisode précédent/suivant : heuristique sur les titres (même série, N±1)."""
|
"""Épisode précédent/suivant : heuristique sur les titres (même série, N±1)."""
|
||||||
rows = await db.fetchall("SELECT id, title FROM downloads WHERE status = 'done' ORDER BY title")
|
rows = await db.fetchall("SELECT id, title FROM downloads WHERE status = 'done' ORDER BY title")
|
||||||
|
|
||||||
def parse(title: str) -> tuple[str, float | None]:
|
|
||||||
match = re.search(r"(?:épisode|episode|ep|e)\s*(\d+(?:\.\d+)?)", title, re.IGNORECASE)
|
|
||||||
if not match:
|
|
||||||
return title, None
|
|
||||||
return title[: match.start()].strip(), float(match.group(1))
|
|
||||||
|
|
||||||
current = await db.fetchone("SELECT id, title FROM downloads WHERE id = ?", (download_id,))
|
current = await db.fetchone("SELECT id, title FROM downloads WHERE id = ?", (download_id,))
|
||||||
if current is None:
|
if current is None:
|
||||||
raise HTTPException(404, "Fichier introuvable")
|
raise HTTPException(404, "Fichier introuvable")
|
||||||
base, number = parse(current["title"])
|
cur = parse_title(current["title"])
|
||||||
prev_ep = next_ep = None
|
prev_ep = next_ep = None
|
||||||
for row in rows:
|
for row in rows:
|
||||||
other_base, other_num = parse(row["title"])
|
other = parse_title(row["title"])
|
||||||
if other_base != base or other_num is None or row["id"] == download_id:
|
if (
|
||||||
|
other["series"] != cur["series"]
|
||||||
|
or other["season"] != cur["season"]
|
||||||
|
or other["episode"] is None
|
||||||
|
or row["id"] == download_id
|
||||||
|
):
|
||||||
continue
|
continue
|
||||||
if number is not None and other_num == number - 1:
|
if cur["episode"] is not None and other["episode"] == cur["episode"] - 1:
|
||||||
prev_ep = row["id"]
|
prev_ep = row["id"]
|
||||||
if number is not None and other_num == number + 1:
|
if cur["episode"] is not None and other["episode"] == cur["episode"] + 1:
|
||||||
next_ep = row["id"]
|
next_ep = row["id"]
|
||||||
return {"previous": prev_ep, "next": next_ep}
|
return {"previous": prev_ep, "next": next_ep}
|
||||||
|
|
||||||
|
|||||||
@@ -114,23 +114,3 @@ async def set_sonarr_config(url: str, apikey: str) -> None:
|
|||||||
await set_setting(SONARR_APIKEY_KEY, apikey.strip())
|
await set_setting(SONARR_APIKEY_KEY, apikey.strip())
|
||||||
logger.info("Configuration Sonarr enregistrée (%s)", url)
|
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.
|
"""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
|
- Application : POST à Watchtower (compagnon docker-compose) qui tire la nouvelle
|
||||||
image et recrée le conteneur — quelques secondes d'indisponibilité.
|
image et recrée le conteneur — quelques secondes d'indisponibilité.
|
||||||
"""
|
"""
|
||||||
@@ -12,7 +13,6 @@ import time
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.services.settings import get_update_config
|
|
||||||
from app.version import get_version
|
from app.version import get_version
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -20,11 +20,11 @@ logger = logging.getLogger(__name__)
|
|||||||
_TAG_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$")
|
_TAG_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$")
|
||||||
_CACHE_TTL = 300.0 # secondes
|
_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):
|
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:
|
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:
|
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()
|
now = time.monotonic()
|
||||||
latest_cache = _cache["latest"]
|
latest_cache = _cache["latest"]
|
||||||
if not force and latest_cache and now - float(_cache["checked_at"]) < _CACHE_TTL:
|
if not force and latest_cache and now - float(_cache["checked_at"]) < _CACHE_TTL:
|
||||||
return str(latest_cache) # type: ignore[arg-type]
|
return str(latest_cache) # type: ignore[arg-type]
|
||||||
|
|
||||||
config = await get_update_config()
|
settings = get_settings()
|
||||||
if not config["repo"]:
|
url = f"{settings.gitea_url}/api/v1/repos/{settings.gitea_repo}/tags?limit=20"
|
||||||
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:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client:
|
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()
|
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:
|
except (httpx.HTTPError, ValueError, KeyError) as exc:
|
||||||
logger.warning("Vérification de mise à jour impossible : %s", exc)
|
logger.warning("Vérification de mise à jour impossible : %s", exc)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
latest = max(tags, key=parse_tag) if tags else None # type: ignore[arg-type]
|
latest, notes = max(tags, key=lambda t: parse_tag(t[0])) if tags else (None, None)
|
||||||
_cache.update(checked_at=now, latest=latest)
|
_cache.update(checked_at=now, latest=latest, notes=notes or None)
|
||||||
if latest and is_newer(latest, get_version()):
|
if latest and is_newer(latest, get_version()):
|
||||||
logger.info("Nouvelle version disponible : %s (courante %s)", latest, get_version())
|
logger.info("Nouvelle version disponible : %s (courante %s)", latest, get_version())
|
||||||
return latest
|
return latest
|
||||||
|
|
||||||
|
|
||||||
def invalidate_cache() -> None:
|
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]:
|
async def status() -> dict[str, object]:
|
||||||
"""État complet : version courante, dernière dispo, config."""
|
"""État complet : version courante, dernière dispo, patchnote."""
|
||||||
config = await get_update_config()
|
|
||||||
current = get_version()
|
current = get_version()
|
||||||
latest = await fetch_latest_version()
|
latest = await fetch_latest_version()
|
||||||
return {
|
return {
|
||||||
"current": current,
|
"current": current,
|
||||||
"latest": latest,
|
"latest": latest,
|
||||||
|
"notes": _cache["notes"],
|
||||||
"update_available": bool(latest and is_newer(latest, current)),
|
"update_available": bool(latest and is_newer(latest, current)),
|
||||||
"configured": bool(config["repo"]),
|
|
||||||
"docker": bool(get_settings().watchtower_url),
|
"docker": bool(get_settings().watchtower_url),
|
||||||
"config": config,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -314,6 +314,35 @@ button { font-family: inherit; }
|
|||||||
}
|
}
|
||||||
.episode-name { flex: 1; font-size: 0.92rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #d6d6dc; }
|
.episode-name { flex: 1; font-size: 0.92rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #d6d6dc; }
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ bibliothèque */
|
||||||
|
|
||||||
|
.lib-groups { display: flex; flex-direction: column; gap: 0.7rem; }
|
||||||
|
.lib-group { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
||||||
|
.lib-group-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1.1rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
user-select: none;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.lib-group-head:hover { background: var(--surface-2); }
|
||||||
|
.lib-chevron { color: var(--text-dim); font-size: 0.72rem; width: 0.9rem; flex-shrink: 0; }
|
||||||
|
.lib-group-name { flex: 1; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.lib-group-meta { font-size: 0.78rem; color: var(--text-dim); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
|
.lib-group-body { padding: 0.7rem; border-top: 1px solid var(--border); }
|
||||||
|
.lib-group-body .episode-row { background: transparent; }
|
||||||
|
.lib-group-body .episode-row:hover { background: var(--surface-2); }
|
||||||
|
|
||||||
|
.section-title .badge { vertical-align: middle; margin-left: 0.5rem; }
|
||||||
|
|
||||||
/* ------------------------------------------------------------ téléchargements */
|
/* ------------------------------------------------------------ téléchargements */
|
||||||
|
|
||||||
.progress {
|
.progress {
|
||||||
|
|||||||
+11
-26
@@ -111,28 +111,23 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center">
|
<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"
|
<button class="btn btn-sm btn-ghost" @click="checkUpdate()" :disabled="upd._checking"
|
||||||
x-text="upd._checking ? '…' : 'Vérifier'"></button>
|
x-text="upd._checking ? '…' : 'Vérifier'"></button>
|
||||||
<button class="btn btn-sm btn-accent" x-show="upd.update_available" @click="applyUpdate()"
|
<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>
|
:disabled="upd._applying" x-text="upd._applying ? 'Mise à jour…' : '⬆ Mettre à jour maintenant'"></button>
|
||||||
</div>
|
</div>
|
||||||
<p x-show="!upd.configured" 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">
|
||||||
Renseignez le dépôt Gitea pour activer la détection des mises à jour.
|
<summary style="cursor:pointer;font-size:0.85rem;color:var(--text-dim)">
|
||||||
Jeton d'accès (droit « lecture ») nécessaire uniquement si le dépôt est privé.
|
Patchnote <span x-text="upd.latest"></span>
|
||||||
</p>
|
</summary>
|
||||||
<p x-show="upd.configured && !upd.docker" style="margin:0.5rem 0 0;font-size:0.85rem;color:var(--text-dim)">
|
<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
|
⚠ Mise à jour automatique disponible uniquement en déploiement Docker
|
||||||
(<code>docker compose pull && docker compose up -d</code> sinon).
|
(<code>docker compose pull && docker compose up -d</code> sinon).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
+
|
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h2>👥 Utilisateurs</h2>
|
<h2>👥 Utilisateurs</h2>
|
||||||
@@ -171,9 +166,8 @@ function adminPage() {
|
|||||||
return {
|
return {
|
||||||
users: [], sources: [], stats: {}, forbidden: false,
|
users: [], sources: [], stats: {}, forbidden: false,
|
||||||
upd: {
|
upd: {
|
||||||
current: '', latest: null, update_available: false, configured: false, docker: false,
|
current: '', latest: null, notes: null, update_available: false, docker: false,
|
||||||
config: { gitea_url: '', repo: '', token: '' },
|
_checking: false, _applying: false,
|
||||||
_dirty: false, _checking: false, _applying: false,
|
|
||||||
},
|
},
|
||||||
integrations: {
|
integrations: {
|
||||||
torznab: { apikey: '', endpoint: '' }, sonarr: { url: '', apikey: '' },
|
torznab: { apikey: '', endpoint: '' }, sonarr: { url: '', apikey: '' },
|
||||||
@@ -287,18 +281,9 @@ function adminPage() {
|
|||||||
applyUpdateData(data) {
|
applyUpdateData(data) {
|
||||||
this.upd.current = data.current;
|
this.upd.current = data.current;
|
||||||
this.upd.latest = data.latest;
|
this.upd.latest = data.latest;
|
||||||
|
this.upd.notes = data.notes;
|
||||||
this.upd.update_available = data.update_available;
|
this.upd.update_available = data.update_available;
|
||||||
this.upd.configured = data.configured;
|
|
||||||
this.upd.docker = data.docker;
|
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() {
|
async checkUpdate() {
|
||||||
|
|||||||
+81
-15
@@ -4,27 +4,54 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1 class="page-title">Bibliothèque</h1>
|
<h1 class="page-title">Bibliothèque</h1>
|
||||||
<p class="page-sub">Tes fichiers téléchargés, prêts à être regardés.</p>
|
<p class="page-sub">Tes fichiers téléchargés, regroupés par série et par dossier.</p>
|
||||||
|
|
||||||
<div x-data="libraryPage()" x-init="load()">
|
<div x-data="libraryPage()" x-init="load()">
|
||||||
<div class="spinner" x-show="loading"></div>
|
<div class="spinner" x-show="loading"></div>
|
||||||
|
|
||||||
<template x-if="!loading && items.length === 0">
|
<template x-if="!loading && sections.length === 0">
|
||||||
<div class="empty-state"><div class="big">🎞️</div>Rien ici pour l'instant — télécharge des épisodes !</div>
|
<div class="empty-state"><div class="big">🎞️</div>Rien ici pour l'instant — télécharge des épisodes !</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="episode-list">
|
<template x-for="section in sections" :key="section.key">
|
||||||
<template x-for="f in items" :key="f.id">
|
<section>
|
||||||
<a class="episode-row" :href="'/watch/' + f.id">
|
<h2 class="section-title">
|
||||||
<span style="font-size:1.3rem">🎬</span>
|
<span x-text="section.label"></span>
|
||||||
<span class="episode-name" style="font-weight:600" x-text="f.title"></span>
|
<span class="badge badge-type" x-text="section.count + ' fichier' + (section.count > 1 ? 's' : '')"></span>
|
||||||
<span style="font-size:0.8rem;color:var(--text-dim)" x-text="fmtBytes(f.total_bytes)"></span>
|
</h2>
|
||||||
<span x-show="f.position_seconds > 0" class="badge badge-type"
|
|
||||||
x-text="'⏵ ' + fmtTime(f.position_seconds)"></span>
|
<div class="lib-groups">
|
||||||
<span class="btn btn-sm">▶</span>
|
<template x-for="g in section.groups" :key="section.key + '|' + g.series + '|' + g.folder">
|
||||||
</a>
|
<div class="lib-group">
|
||||||
</template>
|
<button type="button" class="lib-group-head" @click="g.open = !g.open">
|
||||||
</div>
|
<span class="lib-chevron" x-text="g.open ? '▾' : '▸'"></span>
|
||||||
|
<span class="lib-group-name" x-text="g.series"></span>
|
||||||
|
<span class="badge badge-type" x-text="g.items.length + ' ép.'"></span>
|
||||||
|
<span x-show="g.folder" class="badge badge-version" :title="g.folder"
|
||||||
|
x-text="'📁 ' + g.folder"></span>
|
||||||
|
<span x-show="g.watched > 0" class="lib-group-meta"
|
||||||
|
x-text="'👁 ' + g.watched + '/' + g.items.length"></span>
|
||||||
|
<span class="lib-group-meta" x-text="fmtBytes(g.total)"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="episode-list lib-group-body" x-show="g.open" x-transition>
|
||||||
|
<template x-for="f in g.items" :key="f.id">
|
||||||
|
<a class="episode-row" :href="'/watch/' + f.id">
|
||||||
|
<span class="episode-num"
|
||||||
|
x-text="f.episode != null ? 'E' + f.episode : '🎬'"></span>
|
||||||
|
<span class="episode-name" style="font-weight:600" x-text="shortTitle(g, f)"></span>
|
||||||
|
<span style="font-size:0.8rem;color:var(--text-dim)" x-text="fmtBytes(f.total_bytes)"></span>
|
||||||
|
<span x-show="f.position_seconds > 0" class="badge badge-type"
|
||||||
|
x-text="'⏵ ' + fmtTime(f.position_seconds)"></span>
|
||||||
|
<span class="btn btn-sm">▶</span>
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -32,12 +59,51 @@
|
|||||||
<script>
|
<script>
|
||||||
function libraryPage() {
|
function libraryPage() {
|
||||||
return {
|
return {
|
||||||
items: [], loading: true,
|
items: [], sections: [], loading: true,
|
||||||
|
|
||||||
async load() {
|
async load() {
|
||||||
const res = await fetch('/api/library');
|
const res = await fetch('/api/library');
|
||||||
this.items = await res.json();
|
this.items = await res.json();
|
||||||
|
this.sections = [
|
||||||
|
{ key: 'anime', label: '⛩ Animés' },
|
||||||
|
{ key: 'serie', label: '📺 Séries & Films' },
|
||||||
|
]
|
||||||
|
.map(s => {
|
||||||
|
const groups = this.buildGroups(this.items.filter(i => i.media_type === s.key));
|
||||||
|
return { ...s, groups, count: groups.reduce((n, g) => n + g.items.length, 0) };
|
||||||
|
})
|
||||||
|
.filter(s => s.groups.length > 0);
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
buildGroups(items) {
|
||||||
|
const map = new Map();
|
||||||
|
for (const f of items) {
|
||||||
|
const key = (f.series || f.title) + '|' + (f.folder || '');
|
||||||
|
if (!map.has(key)) map.set(key, { series: f.series || f.title, folder: f.folder || '', items: [], open: false });
|
||||||
|
map.get(key).items.push(f);
|
||||||
|
}
|
||||||
|
const groups = [...map.values()].sort((a, b) => a.series.localeCompare(b.series, 'fr'));
|
||||||
|
for (const g of groups) {
|
||||||
|
g.items.sort((a, b) =>
|
||||||
|
(a.season ?? 0) - (b.season ?? 0) ||
|
||||||
|
(a.episode ?? Infinity) - (b.episode ?? Infinity) ||
|
||||||
|
a.title.localeCompare(b.title, 'fr'));
|
||||||
|
g.total = g.items.reduce((n, i) => n + (i.total_bytes || 0), 0);
|
||||||
|
g.watched = g.items.filter(i => i.position_seconds > 0).length;
|
||||||
|
}
|
||||||
|
if (groups.length === 1) groups[0].open = true;
|
||||||
|
return groups;
|
||||||
|
},
|
||||||
|
|
||||||
|
shortTitle(g, f) {
|
||||||
|
let t = f.title;
|
||||||
|
if (t.toLowerCase().startsWith(g.series.toLowerCase())) {
|
||||||
|
t = t.slice(g.series.length).replace(/^[\s\-–—_.:]+/, '');
|
||||||
|
}
|
||||||
|
return t || f.title;
|
||||||
|
},
|
||||||
|
|
||||||
fmtBytes(n) {
|
fmtBytes(n) {
|
||||||
if (n == null) return '';
|
if (n == null) return '';
|
||||||
const units = ['o', 'Ko', 'Mo', 'Go']; let i = 0;
|
const units = ['o', 'Ko', 'Mo', 'Go']; let i = 0;
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Déploiement OhmStreaming sous Podman — variante de docker-compose.yml
|
||||||
|
#
|
||||||
|
# 1. Activer le socket Podman (une seule fois) :
|
||||||
|
# rootless : systemctl --user enable --now podman.socket
|
||||||
|
# sudo loginctl enable-linger $USER # persister après déconnexion
|
||||||
|
# export PODMAN_SOCKET=/run/user/$(id -u)/podman/podman.sock
|
||||||
|
# rootful : sudo systemctl enable --now podman.socket
|
||||||
|
# 2. cp .env.example .env && $EDITOR .env (OHM_SECRET_KEY + WATCHTOWER_TOKEN obligatoires)
|
||||||
|
# 3. podman compose -f docker-compose.podman.yml up -d
|
||||||
|
# (ou podman-compose -f docker-compose.podman.yml up -d)
|
||||||
|
#
|
||||||
|
# Mise à jour : page Admin → « Mise à jour », ou manuellement :
|
||||||
|
# podman compose -f docker-compose.podman.yml pull && podman compose -f docker-compose.podman.yml up -d
|
||||||
|
|
||||||
|
services:
|
||||||
|
ohm:
|
||||||
|
image: ${OHM_IMAGE:-ghcr.io/aulyriusohm/ohm-streaming:latest}
|
||||||
|
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:
|
||||||
|
# Fedora/SELinux : suffixer de « :z » en cas d'erreur « Permission denied »
|
||||||
|
- ./data:/data
|
||||||
|
- ./downloads:/downloads
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
# Socket Podman (compatible API Docker) au lieu de /var/run/docker.sock
|
||||||
|
- ${PODMAN_SOCKET:-/run/podman/podman.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"
|
||||||
|
# Pas de DOCKER_API_VERSION : Podman annonce lui-même la version d'API
|
||||||
|
# Supprimer les anciennes images après mise à jour
|
||||||
|
WATCHTOWER_CLEANUP: "true"
|
||||||
+6
-4
@@ -2,16 +2,18 @@
|
|||||||
#
|
#
|
||||||
# Installation guidée (recommandée) : ./scripts/install.sh
|
# Installation guidée (recommandée) : ./scripts/install.sh
|
||||||
# Ou à la main :
|
# Ou à la main :
|
||||||
# 1. docker login git.lanro.eu (compte Gitea avec accès lecture au repo)
|
# 1. cp .env.example .env && $EDITOR .env (OHM_SECRET_KEY + WATCHTOWER_TOKEN obligatoires)
|
||||||
# 2. cp .env.example .env && $EDITOR .env (OHM_SECRET_KEY + WATCHTOWER_TOKEN obligatoires)
|
# 2. docker compose up -d
|
||||||
# 3. docker compose up -d
|
#
|
||||||
|
# Image par défaut : GHCR (publique). Pour utiliser le registre Gitea à la place :
|
||||||
|
# OHM_IMAGE=git.lanro.eu/roman/ohm_streaming:latest dans .env (+ docker login git.lanro.eu)
|
||||||
#
|
#
|
||||||
# Mise à jour : page Admin → « Mise à jour », ou manuellement :
|
# Mise à jour : page Admin → « Mise à jour », ou manuellement :
|
||||||
# docker compose pull && docker compose up -d
|
# docker compose pull && docker compose up -d
|
||||||
|
|
||||||
services:
|
services:
|
||||||
ohm:
|
ohm:
|
||||||
image: git.lanro.eu/roman/ohm_streaming:latest
|
image: ${OHM_IMAGE:-ghcr.io/aulyriusohm/ohm-streaming:latest}
|
||||||
# Build local (dev) : décommenter ces lignes et commenter « image: » ci-dessus
|
# Build local (dev) : décommenter ces lignes et commenter « image: » ci-dessus
|
||||||
# build:
|
# build:
|
||||||
# context: .
|
# context: .
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "ohm-stream"
|
name = "ohm-stream"
|
||||||
version = "0.1.0"
|
version = "0.3.0"
|
||||||
description = "Ohm Stream Downloader — centre de contrôle auto-hébergé pour animes et séries VOSTFR"
|
description = "Ohm Stream Downloader — centre de contrôle auto-hébergé pour animes et séries VOSTFR"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
+2
-18
@@ -5,7 +5,7 @@
|
|||||||
# bash scripts/install.sh (depuis un clone du dépôt)
|
# bash scripts/install.sh (depuis un clone du dépôt)
|
||||||
# curl -fsSL <gitea>/raw/branch/main/scripts/install.sh | bash
|
# curl -fsSL <gitea>/raw/branch/main/scripts/install.sh | bash
|
||||||
#
|
#
|
||||||
# Options non interactives : --dir <chemin> --port <n> --skip-login
|
# Options non interactives : --dir <chemin> --port <n>
|
||||||
# (les valeurs passées en option court-circuitent les questions)
|
# (les valeurs passées en option court-circuitent les questions)
|
||||||
#
|
#
|
||||||
# Pose les questions (destination des animés, port), génère les secrets,
|
# Pose les questions (destination des animés, port), génère les secrets,
|
||||||
@@ -23,12 +23,11 @@ ok() { printf " ${GREEN}✔${RESET} %s\n" "$1"; }
|
|||||||
warn() { printf " ${YELLOW}⚠${RESET} %s\n" "$1"; }
|
warn() { printf " ${YELLOW}⚠${RESET} %s\n" "$1"; }
|
||||||
die() { printf " ${RED}✖ %s${RESET}\n" "$1" >&2; exit 1; }
|
die() { printf " ${RED}✖ %s${RESET}\n" "$1" >&2; exit 1; }
|
||||||
|
|
||||||
ARG_DIR="" ARG_PORT="" ARG_SKIP_LOGIN=0
|
ARG_DIR="" ARG_PORT=""
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--dir) ARG_DIR="$2"; shift 2;;
|
--dir) ARG_DIR="$2"; shift 2;;
|
||||||
--port) ARG_PORT="$2"; shift 2;;
|
--port) ARG_PORT="$2"; shift 2;;
|
||||||
--skip-login) ARG_SKIP_LOGIN=1; shift;;
|
|
||||||
*) shift;;
|
*) shift;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
@@ -74,21 +73,6 @@ if [ ! -f "$COMPOSE_FILE" ]; then
|
|||||||
fi
|
fi
|
||||||
ok "Projet prêt dans $(pwd)"
|
ok "Projet prêt dans $(pwd)"
|
||||||
|
|
||||||
# ---------------------------------------------------------------- registre privé
|
|
||||||
step "Accès au registre privé ($REGISTRY_HOST)"
|
|
||||||
if [ "$ARG_SKIP_LOGIN" = "1" ]; then
|
|
||||||
warn "--skip-login : connexion au registre ignorée"
|
|
||||||
elif docker login "$REGISTRY_HOST" 2>/dev/null; then
|
|
||||||
ok "Connecté au registre"
|
|
||||||
else
|
|
||||||
warn "Pas encore connecté — un compte Gitea avec accès lecture au dépôt est requis."
|
|
||||||
if [ "$(ask "Se connecter maintenant (docker login) ?" "o")" = "o" ]; then
|
|
||||||
docker login "$REGISTRY_HOST" || die "docker login a échoué — relance le script après t'être connecté."
|
|
||||||
ok "Connecté au registre"
|
|
||||||
else
|
|
||||||
die "Sans docker login, l'image ne pourra pas être tirée."
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- destination des épisodes
|
# ---------------------------------------------------------------- destination des épisodes
|
||||||
step "Destination des épisodes téléchargés"
|
step "Destination des épisodes téléchargés"
|
||||||
|
|||||||
Executable → Regular
+12
-9
@@ -1,13 +1,14 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Publie une version d'OhmStreaming :
|
# Publie une version d'OhmStreaming :
|
||||||
# bump de version → commit + tag git → build de l'image Docker → push registre Gitea.
|
# bump de version → commit + tag git → build de l'image Docker → push GHCR (public) + 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.
|
# Option : OHM_REGISTRY=git.lanro.eu/roman/ohm_streaming (défaut) pour viser un autre registre.
|
||||||
set -euo pipefail
|
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}"
|
REGISTRY="${OHM_REGISTRY:-git.lanro.eu/roman/ohm_streaming}"
|
||||||
|
GHCR="${OHM_GHCR:-ghcr.io/aulyriusohm/ohm-streaming}"
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "✖ « $VERSION » n'est pas un semver X.Y.Z"; exit 1; }
|
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "✖ « $VERSION » n'est pas un semver X.Y.Z"; exit 1; }
|
||||||
@@ -30,15 +31,17 @@ git add pyproject.toml uv.lock
|
|||||||
git commit -m "v$VERSION"
|
git commit -m "v$VERSION"
|
||||||
|
|
||||||
echo "▶ 2/3 Tag et push git"
|
echo "▶ 2/3 Tag et push git"
|
||||||
git tag "$TAG"
|
git tag -a "$TAG" -m "${2:-$TAG}"
|
||||||
git push origin HEAD
|
git push origin HEAD
|
||||||
git push origin "$TAG"
|
git push origin "$TAG"
|
||||||
|
|
||||||
echo "▶ 3/3 Image Docker → $REGISTRY"
|
echo "▶ 3/3 Image Docker → $GHCR (public) + $REGISTRY (Gitea)"
|
||||||
docker build --build-arg VERSION="$VERSION" -t "$REGISTRY:$VERSION" -t "$REGISTRY:latest" .
|
docker build --build-arg VERSION="$VERSION" -t "$GHCR:$VERSION" -t "$GHCR:latest" .
|
||||||
docker push "$REGISTRY:$VERSION"
|
docker push "$GHCR:$VERSION" && docker push "$GHCR:latest"
|
||||||
docker push "$REGISTRY:latest"
|
docker tag "$GHCR:$VERSION" "$REGISTRY:$VERSION"
|
||||||
|
docker tag "$GHCR:latest" "$REGISTRY:latest"
|
||||||
|
docker push "$REGISTRY:$VERSION" && docker push "$REGISTRY:latest"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "✔ v$VERSION publiée ($REGISTRY:$VERSION et :latest)"
|
echo "✔ v$VERSION publiée ($GHCR:$VERSION et :latest — public, miroir Gitea $REGISTRY)"
|
||||||
echo " Les instances la détecteront via Admin → Mise à jour → « Vérifier »."
|
echo " Les instances la détecteront via Admin → Mise à jour → « Vérifier »."
|
||||||
|
|||||||
+27
-50
@@ -3,8 +3,8 @@
|
|||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
from app.services import update as update_service
|
from app.services import update as update_service
|
||||||
from app.services.settings import get_update_config
|
|
||||||
from app.version import get_version
|
from app.version import get_version
|
||||||
|
|
||||||
|
|
||||||
@@ -15,6 +15,12 @@ def reset_update_cache():
|
|||||||
update_service.invalidate_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
|
# ---------------------------------------------------------------- semver / version
|
||||||
|
|
||||||
|
|
||||||
@@ -41,18 +47,9 @@ def test_get_version_env_override(monkeypatch):
|
|||||||
# ---------------------------------------------------------------- détection Gitea
|
# ---------------------------------------------------------------- détection Gitea
|
||||||
|
|
||||||
|
|
||||||
async def test_fetch_latest_without_repo():
|
async def test_fetch_latest_no_auth_header(monkeypatch):
|
||||||
from app.services.settings import set_update_config
|
"""Dépôt public : appel sans en-tête Authorization."""
|
||||||
|
_patch_gitea(monkeypatch)
|
||||||
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:
|
class FakeResponse:
|
||||||
def raise_for_status(self) -> None:
|
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):
|
async def test_fetch_latest_picks_highest_semver(monkeypatch):
|
||||||
"""Avec jeton : en-tête Authorization + plus haut semver retenu, puis cache."""
|
"""Plus haut semver retenu + patchnote du tag, puis cache."""
|
||||||
from app.services.settings import set_update_config
|
_patch_gitea(monkeypatch)
|
||||||
|
|
||||||
await set_update_config("https://git.example", "roman/ohm", "tok")
|
|
||||||
|
|
||||||
class FakeResponse:
|
class FakeResponse:
|
||||||
def raise_for_status(self) -> None:
|
def raise_for_status(self) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def json(self) -> list[dict]:
|
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]] = []
|
calls: list[tuple[str, dict]] = []
|
||||||
|
|
||||||
@@ -92,21 +92,18 @@ async def test_fetch_latest_picks_highest_semver(monkeypatch):
|
|||||||
return FakeResponse()
|
return FakeResponse()
|
||||||
|
|
||||||
monkeypatch.setattr(httpx.AsyncClient, "get", fake_get)
|
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 status["latest"] == "v0.2.3"
|
||||||
assert calls == [
|
assert status["notes"] == "correctifs"
|
||||||
("https://git.example/api/v1/repos/roman/ohm/tags?limit=20", {"Authorization": "token tok"})
|
assert calls == [("https://git.example/api/v1/repos/roman/ohm/tags?limit=20", {})]
|
||||||
]
|
|
||||||
# puis servi par le cache (plus d'appel réseau)
|
# puis servi par le cache (plus d'appel réseau)
|
||||||
assert await update_service.fetch_latest_version() == "v0.2.3"
|
assert await update_service.fetch_latest_version() == "v0.2.3"
|
||||||
assert len(calls) == 1
|
assert len(calls) == 1
|
||||||
|
|
||||||
|
|
||||||
async def test_fetch_latest_network_error_degrades(monkeypatch):
|
async def test_fetch_latest_network_error_degrades(monkeypatch):
|
||||||
from app.services.settings import set_update_config
|
_patch_gitea(monkeypatch)
|
||||||
|
|
||||||
await set_update_config("https://git.example", "roman/ohm", "tok")
|
|
||||||
|
|
||||||
async def fake_get(self: httpx.AsyncClient, url: str, headers: dict | None = None):
|
async def fake_get(self: httpx.AsyncClient, url: str, headers: dict | None = None):
|
||||||
raise httpx.ConnectError("injoignable")
|
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)
|
r = await client.get("/api/admin/update", cookies=admin_cookies)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
data = r.json()
|
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["latest"] is None and data["update_available"] is False
|
||||||
assert data["config"]["gitea_url"] == "https://git.lanro.eu"
|
assert data["docker"] is False and data["notes"] is None
|
||||||
assert data["config"]["repo"] == "Roman/ohm_streaming"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_update_save_config_normalizes(client, admin_cookies):
|
async def test_update_check_degrades_gracefully(client, admin_cookies, monkeypatch):
|
||||||
r = await client.put(
|
# Gitea injoignable : le check répond quand même (latest=None)
|
||||||
"/api/admin/update",
|
_patch_gitea(monkeypatch, url="https://git.inexistant", repo="roman/ohm")
|
||||||
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)
|
r = await client.post("/api/admin/update/check", cookies=admin_cookies)
|
||||||
assert r.status_code == 200 and r.json()["latest"] is None
|
assert r.status_code == 200 and r.json()["latest"] is None
|
||||||
|
|
||||||
|
|||||||
@@ -774,7 +774,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ohm-stream"
|
name = "ohm-stream"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiosqlite" },
|
{ name = "aiosqlite" },
|
||||||
|
|||||||
Reference in New Issue
Block a user