v0.1.0 — Réécriture complète de OhmStreaming
Nouvelle version réécrite de zéro : recherche multi-sources (Vostfree, French-Manga), extraction 2 niveaux, proxy vidéo intégré, streaming/téléchargement HLS, métadonnées Kitsu, bibliothèque locale, comptes JWT + administration, découverte fusionnée, indexeur Torznab (Sonarr/Prowlarr).
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import os
|
||||
import tempfile
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Environnement de test AVANT tout import de l'app
|
||||
_tmp = Path(tempfile.mkdtemp(prefix="ohm-test-"))
|
||||
os.environ["OHM_DATA_DIR"] = str(_tmp)
|
||||
os.environ["OHM_DOWNLOAD_DIR"] = str(_tmp / "downloads")
|
||||
os.environ["OHM_DATABASE_PATH"] = str(_tmp / "test.db")
|
||||
os.environ["OHM_SECRET_KEY"] = "test-secret-key-with-32-bytes-minimum!"
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db import db
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def anyio_backend() -> str:
|
||||
return "asyncio"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def database() -> AsyncIterator[None]:
|
||||
"""DB fraîche par test."""
|
||||
get_settings.cache_clear()
|
||||
await db.connect()
|
||||
for table in (
|
||||
"users",
|
||||
"refresh_tokens",
|
||||
"downloads",
|
||||
"metadata_cache",
|
||||
"settings",
|
||||
"favorites",
|
||||
"watch_progress",
|
||||
):
|
||||
await db.execute(f"DELETE FROM {table}")
|
||||
yield
|
||||
await db.close()
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Tests d'intégration API (auth, downloads, favoris, streaming, admin)."""
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db import db
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client() -> AsyncClient:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_cookies(client: AsyncClient):
|
||||
r = await client.post("/auth/register", data={"username": "admin", "password": "secret123"})
|
||||
assert r.status_code == 303
|
||||
return r.cookies
|
||||
|
||||
|
||||
async def test_health(client):
|
||||
r = await client.get("/health")
|
||||
assert r.status_code == 200 and r.json() == {"status": "ok"}
|
||||
|
||||
|
||||
async def test_pages_require_auth(client):
|
||||
r = await client.get("/", follow_redirects=False)
|
||||
assert r.status_code == 303 and r.headers["location"] == "/login"
|
||||
r = await client.get("/api/downloads", follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
|
||||
|
||||
async def test_register_login_me(client, admin_cookies):
|
||||
r = await client.get("/auth/me", cookies=admin_cookies)
|
||||
data = r.json()
|
||||
assert data["username"] == "admin" and data["is_admin"] is True
|
||||
|
||||
|
||||
async def test_duplicate_username(client):
|
||||
await client.post("/auth/register", data={"username": "toto", "password": "secret123"})
|
||||
r = await client.post("/auth/register", data={"username": "toto", "password": "secret123"})
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
async def test_enqueue_bad_internal_url(client, admin_cookies):
|
||||
r = await client.post(
|
||||
"/api/downloads", json={"internal_url": "invalide"}, cookies=admin_cookies
|
||||
)
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
async def test_favorites_flow(client, admin_cookies):
|
||||
fav = {"source": "vostfree", "source_id": "1-x", "title": "Frieren"}
|
||||
r = await client.post("/api/favorites", json=fav, cookies=admin_cookies)
|
||||
assert r.status_code == 201
|
||||
r = await client.post("/api/favorites", json=fav, cookies=admin_cookies)
|
||||
assert r.status_code == 409
|
||||
r = await client.get("/api/favorites", cookies=admin_cookies)
|
||||
assert r.json()["total"] == 1
|
||||
fav_id = r.json()["items"][0]["id"]
|
||||
r = await client.delete(f"/api/favorites/{fav_id}", cookies=admin_cookies)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
async def test_stream_range(client, admin_cookies):
|
||||
payload = b"video-bytes" * 1000
|
||||
path = get_settings().download_dir / "ep1.mp4"
|
||||
path.write_bytes(payload)
|
||||
await db.execute(
|
||||
"INSERT INTO downloads (source_key, video_url, page_url, title, file_path, status, "
|
||||
"total_bytes, downloaded_bytes) VALUES ('k', 'u', 'p', 'Ep 1', 'ep1.mp4', 'done', ?, ?)",
|
||||
(len(payload), len(payload)),
|
||||
)
|
||||
r = await client.get("/api/stream/1", cookies=admin_cookies)
|
||||
assert r.status_code == 200 and r.content == payload
|
||||
r = await client.get("/api/stream/1", headers={"Range": "bytes=10-19"}, cookies=admin_cookies)
|
||||
assert r.status_code == 206
|
||||
assert r.content == payload[10:20]
|
||||
assert r.headers["content-range"] == f"bytes 10-19/{len(payload)}"
|
||||
r = await client.get(
|
||||
"/api/stream/1", headers={"Range": "bytes=999999999-"}, cookies=admin_cookies
|
||||
)
|
||||
assert r.status_code == 416
|
||||
|
||||
|
||||
async def test_admin_required(client):
|
||||
await client.post("/auth/register", data={"username": "admin2", "password": "secret123"})
|
||||
r = await client.post("/auth/register", data={"username": "user", "password": "secret123"})
|
||||
user_cookies = r.cookies
|
||||
r = await client.get("/api/admin/users", cookies=user_cookies)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
async def test_admin_user_management(client, admin_cookies):
|
||||
await client.post("/auth/register", data={"username": "user", "password": "secret123"})
|
||||
r = await client.get("/api/admin/users", cookies=admin_cookies)
|
||||
users = {u["username"]: u["id"] for u in r.json()}
|
||||
assert set(users) == {"admin", "user"}
|
||||
admin_id, user_id = users["admin"], users["user"]
|
||||
r = await client.post(f"/api/admin/users/{user_id}/toggle-active", cookies=admin_cookies)
|
||||
assert r.json() == {"is_active": False}
|
||||
r = await client.post(f"/api/admin/users/{user_id}/toggle-admin", cookies=admin_cookies)
|
||||
assert r.json() == {"is_admin": True}
|
||||
# pas d'auto-modification
|
||||
r = await client.post(f"/api/admin/users/{admin_id}/toggle-admin", cookies=admin_cookies)
|
||||
assert r.status_code == 400
|
||||
r = await client.delete(f"/api/admin/users/{user_id}", cookies=admin_cookies)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
async def test_source_toggle(client, admin_cookies):
|
||||
r = await client.post(
|
||||
"/api/admin/sources/vostfree/toggle", json={"enabled": False}, cookies=admin_cookies
|
||||
)
|
||||
assert r.json()["enabled"] is False
|
||||
r = await client.get("/api/sources", cookies=admin_cookies)
|
||||
states = {s["name"]: s["enabled"] for s in r.json()}
|
||||
assert states["vostfree"] is False
|
||||
assert states["french_manga"] is True
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Tests unitaires : format interne, sanitisation, auth, registres scrapers."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app import auth
|
||||
from app.scrapers.base import (
|
||||
decode_internal_url,
|
||||
encode_internal_url,
|
||||
get_source,
|
||||
import_all_scrapers,
|
||||
resolve_hoster,
|
||||
)
|
||||
from app.services.downloads import sanitize_filename
|
||||
|
||||
# ------------------------------------------------------------ format interne
|
||||
|
||||
|
||||
def test_internal_url_roundtrip():
|
||||
value = encode_internal_url("https://cdn/x.mp4", "https://site/ep1", "Mon Titre")
|
||||
assert decode_internal_url(value) == ("https://cdn/x.mp4", "https://site/ep1", "Mon Titre")
|
||||
|
||||
|
||||
def test_internal_url_rejects_separator():
|
||||
with pytest.raises(ValueError):
|
||||
encode_internal_url("https://a|b", "p", "t")
|
||||
|
||||
|
||||
def test_internal_url_decode_invalid():
|
||||
with pytest.raises(ValueError):
|
||||
decode_internal_url("un|deux")
|
||||
|
||||
|
||||
# ------------------------------------------------------------ sanitisation
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[
|
||||
("Naruto: Ép 01 <VOSTFR> / test", "Naruto Ép 01 VOSTFR test"),
|
||||
("..", "video"),
|
||||
("", "video"),
|
||||
("titre/norm\\al", "titre norm al"),
|
||||
(" espaces multiples ", "espaces multiples"),
|
||||
],
|
||||
)
|
||||
def test_sanitize_filename(raw, expected):
|
||||
assert sanitize_filename(raw) == expected
|
||||
|
||||
|
||||
def test_sanitize_no_traversal():
|
||||
assert "/" not in sanitize_filename("../../etc/passwd")
|
||||
assert sanitize_filename("../../etc/passwd") == "etc passwd"
|
||||
|
||||
|
||||
# ------------------------------------------------------------ auth
|
||||
|
||||
|
||||
async def test_first_user_is_admin():
|
||||
user = await auth.create_user("alice", "password1")
|
||||
assert user.is_admin is True
|
||||
second = await auth.create_user("bob", "password1")
|
||||
assert second.is_admin is False
|
||||
|
||||
|
||||
async def test_authenticate():
|
||||
await auth.create_user("carol", "password1")
|
||||
assert await auth.authenticate("carol", "password1") is not None
|
||||
assert await auth.authenticate("carol", "wrong") is None
|
||||
assert await auth.authenticate("nobody", "password1") is None
|
||||
|
||||
|
||||
async def test_access_token_roundtrip():
|
||||
user = await auth.create_user("dave", "password1")
|
||||
token = auth.create_access_token(user)
|
||||
payload = auth.decode_access_token(token)
|
||||
assert payload["username"] == "dave"
|
||||
assert auth.decode_access_token("garbage") is None
|
||||
|
||||
|
||||
async def test_refresh_token_rotation():
|
||||
user = await auth.create_user("erin", "password1")
|
||||
token = await auth.create_refresh_token(user.id)
|
||||
assert (await auth.use_refresh_token(token)).username == "erin"
|
||||
# Un refresh token est à usage unique (rotation)
|
||||
assert await auth.use_refresh_token(token) is None
|
||||
|
||||
|
||||
async def test_disabled_account_cannot_login():
|
||||
user = await auth.create_user("frank", "password1")
|
||||
await auth.db.execute("UPDATE users SET is_active = 0 WHERE id = ?", (user.id,))
|
||||
assert await auth.authenticate("frank", "password1") is None
|
||||
|
||||
|
||||
# ------------------------------------------------------------ registres scrapers
|
||||
|
||||
|
||||
def test_sources_registered():
|
||||
import_all_scrapers()
|
||||
assert get_source("vostfree").name == "vostfree"
|
||||
assert get_source("french_manga").name == "french_manga"
|
||||
|
||||
|
||||
def test_hoster_resolution():
|
||||
import_all_scrapers()
|
||||
assert resolve_hoster("https://video.sibnet.ru/shell.php?videoid=1").name == "sibnet"
|
||||
assert resolve_hoster("https://uqload.com/embed-x.html").name == "uqload"
|
||||
assert resolve_hoster("https://vidmoly.to/embed-x.html").name == "vidmoly"
|
||||
assert resolve_hoster("https://sendvid.com/embed/x").name == "sendvid"
|
||||
assert resolve_hoster("https://inconnu.example.com/v/1") is None
|
||||
@@ -0,0 +1,367 @@
|
||||
"""Tests découverte : parsing latest(), service for_you et endpoint /api/discover."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.db import db
|
||||
from app.main import app
|
||||
from app.scrapers.base import ScrapeError, SearchResult
|
||||
from app.services.discover import DiscoverService, category_slug, discover
|
||||
|
||||
# --------------------------------------------------------------- parsing latest
|
||||
|
||||
VOSTFREE_LATEST_HTML = """
|
||||
<html><body>
|
||||
<div class="movie-poster">
|
||||
<div class="play"><a class="fa fa-play link"
|
||||
href="https://ipv4.vostfree.ws/1404-helck-vostfr-ddl-streaming-1fichier-uptobox.html"
|
||||
alt="Helck VOSTFR"><span>Helck VOSTFR</span></a></div>
|
||||
<div class="quality">VOSTFR</div>
|
||||
<span class="image"><img src="https://vostfree.ws/uploads/posts/helck.jpg" alt="Helck VOSTFR"/></span>
|
||||
</div>
|
||||
<div class="movie-poster">
|
||||
<div class="play"></div>
|
||||
</div>
|
||||
<div class="movie-poster">
|
||||
<div class="play"><a class="fa fa-play link" href="/sans-slug-" alt="Bizarre"></a></div>
|
||||
</div>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
FRENCH_MANGA_LATEST_HTML = """
|
||||
<html><body>
|
||||
<div class="short"><div class="short-in nl">
|
||||
<a class="short-poster img-box with-mask"
|
||||
href="https://w16.french-manga.net/index.php?newsid=1498905" alt="Draw This, Then Die! - Saison 1">
|
||||
<img src="https://image.tmdb.org/t/p/w500/dZp.jpg" width="160" height="240" alt="affiche"/>
|
||||
</a>
|
||||
<span class="mli-eps">11 / 12</span>
|
||||
<div class="short-title">Draw This, Then Die! - Saison 1 (2024)</div>
|
||||
</div></div>
|
||||
<div class="short"><div class="short-in nl">
|
||||
<a class="short-poster img-box with-mask" href="/relative-no-id.html"></a>
|
||||
</div></div>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
|
||||
class _FakeSource:
|
||||
"""Source factice injectée dans le registre du service discover."""
|
||||
|
||||
name = "fake"
|
||||
label = "Fake"
|
||||
|
||||
def __init__(self, results: list[SearchResult] | None = None, error: bool = False):
|
||||
self._results = results or []
|
||||
self._error = error
|
||||
|
||||
async def latest(self) -> list[SearchResult]:
|
||||
if self._error:
|
||||
raise ScrapeError("source indisponible")
|
||||
return self._results
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client() -> AsyncClient:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_cookies(client: AsyncClient):
|
||||
r = await client.post("/auth/register", data={"username": "admin", "password": "secret123"})
|
||||
assert r.status_code == 303
|
||||
return r.cookies
|
||||
|
||||
|
||||
def test_category_slug():
|
||||
assert category_slug("Action") == "action"
|
||||
assert category_slug("Slice of Life") == "slice-of-life"
|
||||
assert category_slug("Comédie") == "comedie" # accents retirés
|
||||
assert category_slug(" Super Power! ") == "super-power"
|
||||
|
||||
|
||||
async def test_latest_vostfree_parsing(monkeypatch):
|
||||
|
||||
async def fake_fetch_soup(url, **kwargs):
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
return BeautifulSoup(VOSTFREE_LATEST_HTML, "lxml")
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.vostfree.fetch_soup", fake_fetch_soup)
|
||||
results = await asyncio.shield(_latest_of("vostfree"))
|
||||
assert len(results) == 1 # les blocs sans lien/slug valide sont ignorés
|
||||
item = results[0]
|
||||
assert item.source == "vostfree"
|
||||
assert item.source_id == "1404-helck-vostfr-ddl-streaming-1fichier-uptobox.html".removesuffix(
|
||||
".html"
|
||||
)
|
||||
assert item.title == "Helck VOSTFR" # depuis l'attribut alt
|
||||
assert item.image_url == "https://vostfree.ws/uploads/posts/helck.jpg"
|
||||
|
||||
|
||||
async def _latest_of(name: str) -> list[SearchResult]:
|
||||
from app.scrapers.base import get_source
|
||||
|
||||
return await get_source(name).latest()
|
||||
|
||||
|
||||
async def test_latest_french_manga_parsing(monkeypatch):
|
||||
async def fake_fetch_soup(url, **kwargs):
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
return BeautifulSoup(FRENCH_MANGA_LATEST_HTML, "lxml")
|
||||
|
||||
monkeypatch.setattr("app.scrapers.sources.french_manga.fetch_soup", fake_fetch_soup)
|
||||
results = await _latest_of("french_manga")
|
||||
assert len(results) == 1
|
||||
item = results[0]
|
||||
assert item.source == "french_manga"
|
||||
assert item.source_id == "1498905"
|
||||
assert item.title == "Draw This, Then Die! - Saison 1" # année en queue retirée
|
||||
assert item.image_url == "https://image.tmdb.org/t/p/w500/dZp.jpg"
|
||||
assert item.url.endswith("newsid=1498905")
|
||||
|
||||
|
||||
async def test_latest_merges_and_sorts_by_release_date(monkeypatch):
|
||||
"""Sources fusionnées, doublons retirés, tri du plus récent au plus ancien."""
|
||||
import app.services.discover as discover_module
|
||||
|
||||
src_a = _FakeSource(
|
||||
[
|
||||
SearchResult(source="a", source_id="1", title="Frieren VOSTFR", url="https://x/1"),
|
||||
SearchResult(source="a", source_id="2", title="Helck VOSTFR", url="https://x/2"),
|
||||
]
|
||||
)
|
||||
src_b = _FakeSource(
|
||||
[
|
||||
SearchResult(source="b", source_id="9", title="Frieren - Saison 1", url="https://y/9"),
|
||||
SearchResult(source="b", source_id="8", title="Old Anime", url="https://y/8"),
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(discover_module, "all_sources", lambda: [src_a, src_b])
|
||||
|
||||
async def enabled(name: str) -> bool:
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(discover_module, "is_source_enabled", enabled)
|
||||
|
||||
async def fake_match(title: str):
|
||||
return None # aucun match → items sans date, triés en fin de liste
|
||||
|
||||
service = DiscoverService()
|
||||
monkeypatch.setattr(service, "_kitsu_match_for_title", fake_match)
|
||||
result = await service.latest(limit=10)
|
||||
|
||||
titles = [item["title"] for item in result]
|
||||
assert titles.count("Frieren VOSTFR") + titles.count("Frieren - Saison 1") == 1 # dédoublonné
|
||||
assert "Helck VOSTFR" in titles and "Old Anime" in titles
|
||||
assert all(item["start_date"] is None for item in result)
|
||||
|
||||
|
||||
async def test_latest_orders_by_kitsu_start_date(monkeypatch):
|
||||
"""Les dates de sortie Kitsu pilotent l'ordre (plus récent d'abord)."""
|
||||
import app.services.discover as discover_module
|
||||
|
||||
src = _FakeSource(
|
||||
[
|
||||
SearchResult(source="a", source_id=str(i), title=f"Anime {i}", url=f"https://x/{i}")
|
||||
for i in range(3)
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(discover_module, "all_sources", lambda: [src])
|
||||
|
||||
async def enabled(name: str) -> bool:
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(discover_module, "is_source_enabled", enabled)
|
||||
|
||||
async def fake_match(title: str):
|
||||
dates = {"Anime 0": "2021-01-01", "Anime 1": "2026-01-01", "Anime 2": None}
|
||||
start = dates[title]
|
||||
return {"id": "1", "attributes": {"startDate": start}, "genres": []}
|
||||
|
||||
service = DiscoverService()
|
||||
monkeypatch.setattr(service, "_kitsu_match_for_title", fake_match)
|
||||
result = await service.latest(limit=10)
|
||||
assert [item["title"] for item in result] == ["Anime 1", "Anime 0", "Anime 2"]
|
||||
assert result[0]["rating"] is None
|
||||
|
||||
|
||||
async def test_latest_skips_broken_source_and_uses_cache(monkeypatch):
|
||||
"""Une source en échec disparaît sans erreur, et le TTL évite les re-scrapes."""
|
||||
import app.services.discover as discover_module
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
def make_source():
|
||||
async def latest():
|
||||
calls["n"] += 1
|
||||
return []
|
||||
|
||||
return type("S", (), {"name": "s", "label": "S", "latest": staticmethod(latest)})()
|
||||
|
||||
monkeypatch.setattr(
|
||||
discover_module, "all_sources", lambda: [_FakeSource(error=True), make_source()]
|
||||
)
|
||||
|
||||
async def enabled(name: str) -> bool:
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(discover_module, "is_source_enabled", enabled)
|
||||
|
||||
service = DiscoverService()
|
||||
assert await service.latest() == []
|
||||
assert await service.latest() == []
|
||||
assert calls["n"] == 1 # deuxième appel servi depuis le cache TTL
|
||||
|
||||
|
||||
# --------------------------------------------------------------- pour toi
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def history(monkeypatch):
|
||||
"""Téléchargements + favoris en base, Kitsu mocké."""
|
||||
cursor = await db.execute(
|
||||
"INSERT INTO users (username, password_hash) VALUES (?, ?)",
|
||||
("tester", "x" * 64),
|
||||
)
|
||||
user_id = cursor.lastrowid
|
||||
await db.execute(
|
||||
"INSERT INTO downloads (source_key, video_url, title, status) VALUES (?,?,?,?)",
|
||||
("k1", "https://v/1", "Frieren S1 - E1", "done"),
|
||||
)
|
||||
await db.execute(
|
||||
"INSERT INTO downloads (source_key, video_url, title, status) VALUES (?,?,?,?)",
|
||||
("k2", "https://v/2", "Helck VOSTFR", "done"),
|
||||
)
|
||||
await db.execute(
|
||||
"INSERT INTO favorites (user_id, source, source_id, title, image_url, payload) "
|
||||
"VALUES (?,?,?,?,?,?)",
|
||||
(user_id, "vostfree", "x", "Favori", None, '{"genres": ["Comedy", "Action"]}'),
|
||||
)
|
||||
|
||||
async def fake_search_anime(title: str):
|
||||
lowered = title.lower()
|
||||
if "frieren" in lowered:
|
||||
return {"id": "46474", "attributes": {}, "genres": []} # genres → via catégories
|
||||
if "helck" in lowered:
|
||||
return {"id": "999", "attributes": {}, "genres": ["Action"]}
|
||||
return None
|
||||
|
||||
async def fake_categories(anime_id: object) -> list[str]:
|
||||
return ["Fantasy", "Adventure"] if anime_id == "46474" else []
|
||||
|
||||
async def fake_kitsu_anime(params: dict) -> list[dict]:
|
||||
slugs = frozenset(params["filter[categories]"].split(","))
|
||||
assert params["sort"] == "-userCount"
|
||||
catalog = {
|
||||
frozenset(["fantasy", "adventure", "action", "comedy"]): [
|
||||
{"title": "Helck", "kitsu_id": "999"},
|
||||
{"title": "Sousou no Frieren", "kitsu_id": "46474"},
|
||||
{"title": "Konosuba", "kitsu_id": "1"},
|
||||
],
|
||||
}
|
||||
return catalog.get(slugs, [])
|
||||
|
||||
service = DiscoverService()
|
||||
service.test_user_id = user_id # pour les assertions du test
|
||||
monkeypatch.setattr(service._kitsu, "search_anime", fake_search_anime)
|
||||
monkeypatch.setattr(service, "_kitsu_categories", fake_categories)
|
||||
monkeypatch.setattr(service, "_kitsu_anime", fake_kitsu_anime)
|
||||
return service
|
||||
|
||||
|
||||
async def test_for_you_aggregates_genres_and_excludes_owned(history):
|
||||
result = await history.for_you(user_id=history.test_user_id, limit=10)
|
||||
assert set(result["based_on"]) == {"Action", "Fantasy", "Adventure", "Comedy"}
|
||||
assert result["based_on"][0] == "Action" # 2 occurrences (téléchargement + favori)
|
||||
titles = [item["title"] for item in result["items"]]
|
||||
assert "Helck" not in titles # déjà possédé (« Helck VOSTFR » → « helck ») → exclu
|
||||
assert "Sousou no Frieren" in titles
|
||||
assert "Konosuba" in titles
|
||||
|
||||
|
||||
async def test_for_you_empty_without_history(monkeypatch):
|
||||
service = DiscoverService()
|
||||
result = await service.for_you(user_id=42)
|
||||
assert result == {"based_on": [], "items": []}
|
||||
|
||||
|
||||
|
||||
class _FakeSonarr:
|
||||
"""Profil Sonarr factice : titres possédés + genres."""
|
||||
|
||||
def __init__(self, owned: set[str], genres: dict[str, int]) -> None:
|
||||
self._profile = (owned, genres)
|
||||
|
||||
async def profile(self) -> tuple[set[str], dict[str, int]]:
|
||||
return self._profile
|
||||
|
||||
|
||||
async def test_for_you_merges_sonarr_genres_and_owned(monkeypatch, history):
|
||||
monkeypatch.setattr(
|
||||
"app.services.discover.sonarr",
|
||||
_FakeSonarr(
|
||||
owned={"mob psycho 100"}, # déjà possédé sur Sonarr → exclu
|
||||
genres={"Fantasy": 5, "Action": 5}, # Action cumule avec l'historique local
|
||||
),
|
||||
)
|
||||
async def fake_kitsu_anime(params: dict) -> list[dict]:
|
||||
return [
|
||||
{"title": "Konosuba", "kitsu_id": "1"},
|
||||
{"title": "Mob Psycho 100", "kitsu_id": "2"},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(history, "_kitsu_anime", fake_kitsu_anime)
|
||||
result = await history.for_you(user_id=history.test_user_id, limit=10)
|
||||
# genres locaux (Action×2, Fantasy, Adventure, Comedy) + Sonarr (Fantasy+5, Action+5)
|
||||
counts = {"Action": 7, "Fantasy": 6, "Adventure": 1, "Comedy": 1}
|
||||
assert sorted(result["based_on"]) == sorted(
|
||||
sorted(counts, key=counts.get, reverse=True)[:4]
|
||||
)
|
||||
titles = [item["title"] for item in result["items"]]
|
||||
assert "Mob Psycho 100" not in titles # possédé sur Sonarr → exclu
|
||||
assert "Konosuba" in titles
|
||||
|
||||
# --------------------------------------------------------------- endpoint
|
||||
|
||||
|
||||
async def test_api_discover_requires_auth(client):
|
||||
r = await client.get("/api/discover", follow_redirects=False)
|
||||
assert r.status_code == 303
|
||||
|
||||
|
||||
async def test_api_discover_sections(client, admin_cookies, monkeypatch):
|
||||
async def fake_latest(limit: int = 24):
|
||||
return [
|
||||
{"source": "vostfree", "label": "Vostfree", "source_id": "a", "title": "T",
|
||||
"start_date": "2026-01-01", "status": "current", "rating": 8.1},
|
||||
]
|
||||
|
||||
async def fake_must_watch(limit: int = 20):
|
||||
return [{"kitsu_id": "1", "title": "Attack on Titan", "rating": 8.5}]
|
||||
|
||||
async def fake_for_you(user_id: int, limit: int = 20):
|
||||
return {"based_on": ["Action"], "items": [{"kitsu_id": "2", "title": "X"}]}
|
||||
|
||||
monkeypatch.setattr(discover, "latest", fake_latest)
|
||||
monkeypatch.setattr(discover, "must_watch", fake_must_watch)
|
||||
monkeypatch.setattr(discover, "for_you", fake_for_you)
|
||||
|
||||
r = await client.get("/api/discover", cookies=admin_cookies)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["latest"][0]["label"] == "Vostfree"
|
||||
assert data["latest"][0]["start_date"] == "2026-01-01"
|
||||
assert data["must_watch"][0]["title"] == "Attack on Titan"
|
||||
assert data["for_you"]["based_on"] == ["Action"]
|
||||
|
||||
|
||||
async def test_discover_page_renders(client, admin_cookies):
|
||||
r = await client.get("/discover", cookies=admin_cookies)
|
||||
assert r.status_code == 200
|
||||
assert "Découvrir" in r.text
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Tests du gestionnaire de téléchargements (serveur HTTP local avec Range)."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
|
||||
from app.config import get_settings
|
||||
from app.services.downloads import download_manager as dm
|
||||
|
||||
PAYLOAD = b"x" * 500_000
|
||||
|
||||
|
||||
async def _start_file_server() -> tuple[web.AppRunner, int]:
|
||||
"""Mini serveur HTTP supportant les requêtes Range."""
|
||||
|
||||
async def handle(request: web.Request) -> web.StreamResponse:
|
||||
range_header = request.headers.get("Range")
|
||||
if range_header:
|
||||
start = int(range_header.removeprefix("bytes=").split("-")[0])
|
||||
return web.Response(
|
||||
body=PAYLOAD[start:],
|
||||
status=206,
|
||||
headers={"Content-Range": f"bytes {start}-{len(PAYLOAD) - 1}/{len(PAYLOAD)}"},
|
||||
)
|
||||
return web.Response(body=PAYLOAD, headers={"Content-Length": str(len(PAYLOAD))})
|
||||
|
||||
app = web.Application()
|
||||
app.router.add_get("/video.mp4", handle)
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, "127.0.0.1", 0)
|
||||
await site.start()
|
||||
port = site._server.sockets[0].getsockname()[1]
|
||||
return runner, port
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def file_server():
|
||||
runner, port = await _start_file_server()
|
||||
yield f"http://127.0.0.1:{port}"
|
||||
await runner.cleanup()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def manager():
|
||||
await dm.start()
|
||||
yield dm
|
||||
await dm.stop()
|
||||
|
||||
|
||||
async def _wait_status(download_id: int, wanted: set[str], timeout: float = 10) -> dict:
|
||||
for _ in range(int(timeout * 10)):
|
||||
data = await dm.get(download_id)
|
||||
if data["status"] in wanted:
|
||||
return data
|
||||
await asyncio.sleep(0.1)
|
||||
raise AssertionError(f"statut {wanted} non atteint (actuel: {data['status']})")
|
||||
|
||||
|
||||
async def test_full_download(file_server, manager):
|
||||
d = await dm.enqueue(f"{file_server}/video.mp4", "http://page", "Episode Test 1")
|
||||
data = await _wait_status(d["id"], {"done", "failed"})
|
||||
assert data["status"] == "done", data.get("error")
|
||||
assert data["percent"] == 100.0
|
||||
path = get_settings().download_dir / data["file_path"]
|
||||
assert path.read_bytes() == PAYLOAD
|
||||
|
||||
|
||||
async def test_anti_duplicate(file_server, manager):
|
||||
d1 = await dm.enqueue(f"{file_server}/video.mp4", "http://page", "Ep")
|
||||
d2 = await dm.enqueue(f"{file_server}/video.mp4", "http://page", "Ep")
|
||||
assert d1["id"] == d2["id"]
|
||||
assert d2["duplicate"] is True
|
||||
await _wait_status(d1["id"], {"done", "failed"})
|
||||
|
||||
|
||||
async def test_pause_resume(file_server, manager):
|
||||
d = await dm.enqueue(f"{file_server}/video.mp4", "http://page", "Ep Pause")
|
||||
await asyncio.sleep(0.2)
|
||||
await dm.pause(d["id"])
|
||||
data = await _wait_status(d["id"], {"paused", "done"})
|
||||
if data["status"] == "paused": # assez rapide pour être pausé
|
||||
assert await dm.resume(d["id"]) is True
|
||||
data = await _wait_status(d["id"], {"done", "failed"})
|
||||
assert data["status"] == "done", data.get("error")
|
||||
path = get_settings().download_dir / data["file_path"]
|
||||
assert path.read_bytes() == PAYLOAD
|
||||
|
||||
|
||||
async def test_failed_download_and_retry(manager):
|
||||
d = await dm.enqueue("http://127.0.0.1:1/nope.mp4", "http://page", "Ep KO")
|
||||
data = await _wait_status(d["id"], {"failed"})
|
||||
assert data["error"]
|
||||
assert await dm.retry(d["id"]) is True
|
||||
data = await _wait_status(d["id"], {"failed"})
|
||||
assert await dm.cancel(d["id"]) is True
|
||||
|
||||
|
||||
async def test_cancel(file_server, manager):
|
||||
d = await dm.enqueue(f"{file_server}/video.mp4", "http://page", "Ep Cancel")
|
||||
await dm.cancel(d["id"])
|
||||
data = await dm.get(d["id"])
|
||||
assert data["status"] in ("cancelled", "done") # course possible si déjà fini
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Tests : réécriture de playlists HLS (proxy) et sélection de variante."""
|
||||
|
||||
from app.routers.proxy import _rewrite_playlist
|
||||
from app.services.downloads import _best_variant, _parse_ffmpeg_time
|
||||
|
||||
MASTER = """#EXTM3U
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=400000,RESOLUTION=640x360
|
||||
low/index.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=900000,RESOLUTION=1280x720
|
||||
https://cdn.example.com/high/index.m3u8
|
||||
"""
|
||||
|
||||
MEDIA = """#EXTM3U
|
||||
#EXT-X-KEY:METHOD=AES-128,URI="https://cdn.example.com/key.bin",IV=0xabc
|
||||
#EXTINF:6.0,
|
||||
seg-1.ts
|
||||
#EXTINF:6.0,
|
||||
https://cdn.example.com/seg-2.ts
|
||||
"""
|
||||
|
||||
|
||||
def test_best_variant_picks_highest_bandwidth():
|
||||
url = _best_variant(MASTER, "https://site.example.com/master.m3u8")
|
||||
assert url == "https://cdn.example.com/high/index.m3u8"
|
||||
|
||||
|
||||
def test_best_variant_none_for_media_playlist():
|
||||
assert _best_variant(MEDIA, "https://x/") is None
|
||||
|
||||
|
||||
def test_parse_ffmpeg_time():
|
||||
assert _parse_ffmpeg_time("00:01:30.50") == 90.5
|
||||
assert _parse_ffmpeg_time("01:00:00.00") == 3600.0
|
||||
|
||||
|
||||
def test_rewrite_playlist_routes_everything_through_proxy():
|
||||
out = _rewrite_playlist(MEDIA, "https://cdn.example.com/hls/master.m3u8", "https://ref/")
|
||||
assert "/api/proxy?url=" in out
|
||||
# URI relative des segments résolue contre l'URL de base
|
||||
assert "seg-1.ts" in out and "https%3A%2F%2Fcdn.example.com%2Fhls%2Fseg-1.ts" in out
|
||||
# URI absolue conservée mais proxiée
|
||||
assert "seg-2.ts" in out
|
||||
# La clé AES dans l'attribut URI="..." est aussi réécrite
|
||||
assert 'URI="/api/proxy?url=' in out
|
||||
# Le referer est propagé aux URLs proxiées
|
||||
assert "ref=https%3A%2F%2Fref%2F" in out
|
||||
|
||||
|
||||
def test_rewrite_playlist_without_referer():
|
||||
out = _rewrite_playlist("#EXTM3U\nseg.ts\n", "https://cdn.example.com/m.m3u8", None)
|
||||
assert "ref=" not in out
|
||||
assert out.startswith("#EXTM3U")
|
||||
@@ -0,0 +1,237 @@
|
||||
"""Tests de l'indexeur Torznab (compatibilité Sonarr / Prowlarr)."""
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.db import db
|
||||
from app.main import app
|
||||
from app.scrapers.base import Episode, SearchResult, VideoLink
|
||||
from app.services.settings import get_torznab_apikey
|
||||
from app.services.torznab import torznab as service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client() -> AsyncClient:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def apikey() -> str:
|
||||
return await get_torznab_apikey()
|
||||
|
||||
|
||||
class FakeSource:
|
||||
name = "fake"
|
||||
label = "Fake"
|
||||
base_url = "https://fake.example"
|
||||
|
||||
async def search(self, query: str) -> list[SearchResult]:
|
||||
return [
|
||||
SearchResult(
|
||||
source="fake",
|
||||
source_id="frieren-1",
|
||||
title="Frieren",
|
||||
url="https://fake.example/frieren",
|
||||
)
|
||||
]
|
||||
|
||||
async def list_episodes(self, source_id: str) -> list[Episode]:
|
||||
return [
|
||||
Episode(number=1, title="Épisode 1", url="https://fake.example/ep1", season=1),
|
||||
Episode(number=2, title="Épisode 2", url="https://fake.example/ep2", season=1),
|
||||
Episode(number=2.5, title="OAV", url="https://fake.example/oav", season=1),
|
||||
Episode(number=1, title="Épisode 1", url="https://fake.example/s2ep1", season=2),
|
||||
]
|
||||
|
||||
async def extract_embed_links(self, episode_url: str) -> list[str]:
|
||||
return ["https://embed.example/player"]
|
||||
|
||||
async def get_details(self, source_id): ...
|
||||
async def latest(self): return []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_source(monkeypatch):
|
||||
source = FakeSource()
|
||||
|
||||
async def _sources():
|
||||
return [source]
|
||||
|
||||
monkeypatch.setattr(service, "_enabled_sources", _sources)
|
||||
return source
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- auth + caps
|
||||
|
||||
|
||||
async def test_torznab_rejects_missing_apikey(client):
|
||||
r = await client.get("/torznab/api", params={"t": "caps"})
|
||||
assert r.status_code == 401
|
||||
assert 'code="100"' in r.text
|
||||
|
||||
|
||||
async def test_torznab_rejects_wrong_apikey(client):
|
||||
r = await client.get("/torznab/api", params={"t": "caps", "apikey": "mauvaise"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
async def test_torznab_caps(client, apikey):
|
||||
r = await client.get("/torznab/api", params={"t": "caps", "apikey": apikey})
|
||||
assert r.status_code == 200
|
||||
root = ET.fromstring(r.text)
|
||||
assert root.tag == "caps"
|
||||
tv = root.find(".//tv-search")
|
||||
assert tv is not None and tv.get("available") == "yes"
|
||||
assert "q,season,ep" in (tv.get("supportedParams") or "")
|
||||
assert any(c.get("id") == "5070" for c in root.findall(".//subcat"))
|
||||
|
||||
|
||||
async def test_torznab_apikey_via_header(client, apikey):
|
||||
r = await client.get("/torznab/api", params={"t": "caps"}, headers={"X-Api-Key": apikey})
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
async def test_torznab_unknown_function(client, apikey):
|
||||
r = await client.get("/torznab/api", params={"t": "music", "apikey": apikey})
|
||||
assert r.status_code == 400
|
||||
assert 'code="203"' in r.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- recherche
|
||||
|
||||
|
||||
async def test_torznab_tvsearch_filters_season_ep(client, apikey, fake_source):
|
||||
r = await client.get(
|
||||
"/torznab/api",
|
||||
params={"t": "tvsearch", "q": "Frieren", "season": 1, "ep": 2, "apikey": apikey},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
root = ET.fromstring(r.text)
|
||||
items = root.findall(".//item")
|
||||
assert len(items) == 1
|
||||
assert items[0].find("title").text == "Frieren S01E02 VOSTFR WEB-DL"
|
||||
link = items[0].find("link").text
|
||||
assert "/torznab/download" in link and "season=1" in link and "ep=2" in link
|
||||
|
||||
|
||||
async def test_torznab_tvsearch_all_episodes(client, apikey, fake_source):
|
||||
r = await client.get(
|
||||
"/torznab/api", params={"t": "tvsearch", "q": "Frieren", "apikey": apikey}
|
||||
)
|
||||
root = ET.fromstring(r.text)
|
||||
titles = [i.find("title").text for i in root.findall(".//item")]
|
||||
assert len(titles) == 3 # OAV 2.5 exclue : S01E01, S01E02, S02E01
|
||||
assert "Frieren S02E01 VOSTFR WEB-DL" in titles
|
||||
|
||||
|
||||
async def test_torznab_search_requires_query(client, apikey):
|
||||
r = await client.get("/torznab/api", params={"t": "search", "apikey": apikey})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- grab
|
||||
|
||||
|
||||
class _FakeManager:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, str, str]] = []
|
||||
|
||||
async def enqueue(self, video_url: str, page_url: str, title: str) -> dict:
|
||||
self.calls.append((video_url, page_url, title))
|
||||
return {"id": 7, "title": title, "status": "pending"}
|
||||
|
||||
|
||||
class _FakeExtractor:
|
||||
name = "fakehost"
|
||||
|
||||
async def extract(self, embed_url: str) -> VideoLink:
|
||||
return VideoLink(url="https://cdn.example/video.mp4", hoster="fakehost")
|
||||
|
||||
|
||||
async def test_torznab_download_enqueues_and_returns_torrent(client, apikey, fake_source, monkeypatch):
|
||||
manager = _FakeManager()
|
||||
monkeypatch.setattr("app.scrapers.base.get_source", lambda name: fake_source)
|
||||
monkeypatch.setattr("app.services.torznab.resolve_hoster", lambda url: _FakeExtractor())
|
||||
monkeypatch.setattr("app.services.torznab.download_manager", manager)
|
||||
|
||||
r = await client.get(
|
||||
"/torznab/download",
|
||||
params={
|
||||
"apikey": apikey,
|
||||
"source": "fake",
|
||||
"sid": "frieren-1",
|
||||
"season": 1,
|
||||
"ep": 2,
|
||||
"series": "Frieren",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"].startswith("application/x-bittorrent")
|
||||
assert r.content.startswith(b"d") and b"OhmStreaming" in r.content # bencode valide
|
||||
assert manager.calls == [
|
||||
("https://cdn.example/video.mp4", "https://fake.example/ep2", "Frieren S01E02")
|
||||
]
|
||||
|
||||
|
||||
async def test_torznab_download_rejects_bad_key(client):
|
||||
r = await client.get("/torznab/download", params={"apikey": "mauvaise"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
async def test_torznab_download_unknown_episode(client, apikey, fake_source, monkeypatch):
|
||||
monkeypatch.setattr("app.scrapers.base.get_source", lambda name: fake_source)
|
||||
r = await client.get(
|
||||
"/torznab/download",
|
||||
params={
|
||||
"apikey": apikey,
|
||||
"source": "fake",
|
||||
"sid": "frieren-1",
|
||||
"season": 9,
|
||||
"ep": 9,
|
||||
"series": "Frieren",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 502
|
||||
assert 'code="300"' in r.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- admin
|
||||
|
||||
|
||||
async def _admin(client: AsyncClient):
|
||||
r = await client.post("/auth/register", data={"username": "admin", "password": "secret123"})
|
||||
assert r.status_code == 303
|
||||
return r.cookies
|
||||
|
||||
|
||||
async def test_admin_integrations(client, apikey):
|
||||
cookies = await _admin(client)
|
||||
r = await client.get("/api/admin/integrations", cookies=cookies)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["torznab"]["apikey"] == apikey
|
||||
assert data["torznab"]["endpoint"].endswith("/torznab/api")
|
||||
assert data["sonarr"] == {"url": "", "apikey": ""}
|
||||
|
||||
r = await client.put(
|
||||
"/api/admin/integrations/sonarr",
|
||||
json={"url": "http://sonarr:8989/", "apikey": "abc"},
|
||||
cookies=cookies,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
row = await db.fetchone("SELECT value FROM settings WHERE key = 'sonarr:url'")
|
||||
assert row["value"] == '"http://sonarr:8989"' # slash final retiré
|
||||
|
||||
|
||||
async def test_admin_regenerate_torznab_key(client, apikey):
|
||||
cookies = await _admin(client)
|
||||
r = await client.post("/api/admin/integrations/torznab/regenerate", cookies=cookies)
|
||||
assert r.status_code == 200
|
||||
new_key = r.json()["apikey"]
|
||||
assert new_key != apikey and len(new_key) == 32
|
||||
r = await client.get("/torznab/api", params={"t": "caps", "apikey": apikey})
|
||||
assert r.status_code == 401 # l'ancienne clé est révoquée
|
||||
Reference in New Issue
Block a user