6 Commits
Author SHA1 Message Date
Roman 6d15fce516 v0.3.0 — Bibliothèque organisée : sections Animés/Séries, groupes repliables par série et dossier
- /api/library enrichi : media_type (domaine source), série/saison/épisode (parsing du titre), dossier
- Filtrage selon la préférence de contenu utilisateur (anime / serie / both)
- Page bibliothèque : sections Animés / Séries & Films, groupes repliables (nb épisodes, dossier, progression, taille)
- neighbors() réutilise le parseur commun (comparaison série + saison)
2026-09-26 14:52:09 +00:00
Roman f79c0cc86e Distribution publique GHCR (miroir, aucun compte requis) + avertissement légal dans le README et le label OCI 2026-09-25 16:36:03 +00:00
Roman 00aae109b0 Déploiement Podman : variante compose (socket podman pour Watchtower) + doc 2026-09-25 16:01:03 +00:00
Roman c7e178eecf v0.2.0 2026-09-25 15:39:35 +00:00
Roman 12bdc8a7ef release.sh : tag annoté avec patchnote (affiché par le panneau MAJ) 2026-09-25 15:39:07 +00:00
Roman eae97f9129 Panneau MAJ simplifié : dépôt public, plus de configuration Gitea — patchnote du dernier tag 2026-09-25 15:39:00 +00:00
17 changed files with 366 additions and 198 deletions
+2
View File
@@ -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 \
+54 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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}
-20
View File
@@ -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
View File
@@ -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,
} }
+29
View File
@@ -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
View File
@@ -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 &amp;&amp; docker compose up -d</code> sinon). (<code>docker compose pull &amp;&amp; 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() {
+73 -7
View File
@@ -4,20 +4,42 @@
{% 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>
<h2 class="section-title">
<span x-text="section.label"></span>
<span class="badge badge-type" x-text="section.count + ' fichier' + (section.count > 1 ? 's' : '')"></span>
</h2>
<div class="lib-groups">
<template x-for="g in section.groups" :key="section.key + '|' + g.series + '|' + g.folder">
<div class="lib-group">
<button type="button" class="lib-group-head" @click="g.open = !g.open">
<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"> <a class="episode-row" :href="'/watch/' + f.id">
<span style="font-size:1.3rem">🎬</span> <span class="episode-num"
<span class="episode-name" style="font-weight:600" x-text="f.title"></span> 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 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" <span x-show="f.position_seconds > 0" class="badge badge-type"
x-text="'⏵ ' + fmtTime(f.position_seconds)"></span> x-text="'⏵ ' + fmtTime(f.position_seconds)"></span>
@@ -26,18 +48,62 @@
</template> </template>
</div> </div>
</div> </div>
</template>
</div>
</section>
</template>
</div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<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;
+51
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
Generated
+1 -1
View File
@@ -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" },