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,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