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:
Roman
2026-09-22 10:05:47 +00:00
commit 41566ab5fb
66 changed files with 8895 additions and 0 deletions
View File
+40
View File
@@ -0,0 +1,40 @@
"""Décompresseur pour le JavaScript packé « p,a,c,k,e,d » (Dean Edwards).
Utilisé par plusieurs hébergeurs vidéo (Uqload, VidMoly…) pour masquer
l'URL du lecteur : le HTML contient `eval(function(p,a,c,k,e,d){...}(...))`.
"""
import re
_DIGITS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
_PACKED_RE = re.compile(
r"\}\('(?P<payload>.*?)',(?P<a>\d+),(?P<c>\d+),'(?P<keys>.*?)'\.split\('\|'\)",
re.DOTALL,
)
def _base_n(number: int, base: int) -> str:
if number == 0:
return "0"
chars = []
while number:
number, rest = divmod(number, base)
chars.append(_DIGITS[rest])
return "".join(reversed(chars))
def unpack_packed_js(text: str) -> str | None:
"""Retourne le JS dépacké si `text` contient un bloc packé, sinon None."""
match = _PACKED_RE.search(text)
if not match:
return None
payload = match.group("payload").replace("\\\\", "\\").replace("\\'", "'")
base, count = int(match.group("a")), int(match.group("c"))
keys = match.group("keys").split("|")
for index in range(count - 1, -1, -1):
token = _base_n(index, base)
if keys[index]:
replacement = keys[index].replace("\\", "\\\\")
payload = re.sub(rf"\b{re.escape(token)}\b", replacement, payload)
return payload
+50
View File
@@ -0,0 +1,50 @@
"""Extracteur Luluvid/Luluvdo (luluvdo.com, luluvid.com, lulustream.com) — embed → m3u8.
Famille StreamSB : la page embed configure jwplayer dans du JS packé
(p,a,c,k,e,d) avec `sources:[{file:"https://.../master.m3u8?t=<token>"}]`.
Le token CDN (tnmr.org…) est lié à l'IP **et à l'User-Agent** de la requête
d'embed : la VideoLink doit donc renvoyer le User-Agent du socle pour que le
téléchargement passe — sans lui, le CDN répond 403.
"""
import logging
import re
from urllib.parse import urlparse
from app.config import get_settings
from app.scrapers.base import HosterExtractor, ScrapeError, VideoLink, register_hoster
from app.scrapers.hosters._packer import unpack_packed_js
from app.scrapers.http import fetch
logger = logging.getLogger(__name__)
_PATTERNS = (
re.compile(r'sources\s*:\s*\[\s*\{[^}]*?file\s*:\s*["\'](?P<url>[^"\']+\.m3u8[^"\']*)["\']'),
re.compile(r'file\s*:\s*["\'](?P<url>https?://[^"\']+?\.m3u8[^"\']*)["\']'),
re.compile(r'["\'](?P<url>https?://[^"\']*?\.m3u8(?:\?[^"\']*)?)["\']'),
)
@register_hoster
class LuluvdoExtractor(HosterExtractor):
name = "luluvdo"
domains = ("luluvdo.com", "luluvid.com", "lulustream.com")
async def extract(self, embed_url: str) -> VideoLink:
html = await fetch(embed_url)
for content in (html, unpack_packed_js(html) or ""):
for pattern in _PATTERNS:
for match in pattern.finditer(content):
url = match.group("url")
if url.startswith("http"):
return VideoLink(
url=url,
hoster=self.name,
headers={
"Referer": f"https://{urlparse(embed_url).hostname}/",
"User-Agent": get_settings().user_agent,
"Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8",
},
is_hls=".m3u8" in url,
)
raise ScrapeError(f"luluvdo : URL vidéo introuvable dans {embed_url}")
+51
View File
@@ -0,0 +1,51 @@
"""Extracteur SendVid (sendvid.com) — page embed → mp4 direct.
La page embed expose soit une balise `<source src="...">` ( lecteur HTML5),
soit une variable JS `video_source`. Extraction par regex, sans dépendance JS.
"""
import logging
import re
from app.scrapers.base import HosterExtractor, ScrapeError, VideoLink, register_hoster
from app.scrapers.hosters._packer import unpack_packed_js
from app.scrapers.http import fetch
logger = logging.getLogger(__name__)
_PATTERNS = (
re.compile(r'<source[^>]+src=["\'](?P<url>[^"\']+)["\']'),
re.compile(r'video_source\s*[:=]\s*["\'](?P<url>[^"\']+)["\']'),
re.compile(r'["\'](?P<url>https?://[^"\']*?\.(?:mp4|m3u8)(?:\?[^"\']*)?)["\']'),
)
@register_hoster
class SendvidExtractor(HosterExtractor):
name = "sendvid"
domains = ("sendvid.com",)
async def extract(self, embed_url: str) -> VideoLink:
html = await fetch(embed_url)
candidates: list[str] = []
for content in (html, unpack_packed_js(html) or ""):
for pattern in _PATTERNS:
for match in pattern.finditer(content):
candidates.append(match.group("url"))
url = self._pick(candidates)
if not url:
raise ScrapeError(f"sendvid : URL vidéo introuvable dans {embed_url}")
return VideoLink(
url=url,
hoster=self.name,
headers={"Referer": embed_url},
is_hls=".m3u8" in url,
)
@staticmethod
def _pick(candidates: list[str]) -> str | None:
video = [u for u in candidates if re.search(r"\.(mp4|m3u8)(\?|$)", u)]
for url in video:
if ".mp4" in url:
return url
return video[0] if video else None
+69
View File
@@ -0,0 +1,69 @@
"""Extracteur Sibnet (video.sibnet.ru) — page shell/watch → mp4 direct.
La page embed `shell.php?videoid=N` (ou la page publique `/videoN`) contient
une config jwplayer du type `player.src([{src: "/v/<hash>/<id>.mp4"}])`.
`shell.php` répond 403 depuis certains réseaux : on retombe alors sur la page
publique de la vidéo, qui expose la même config.
"""
import logging
import re
from urllib.parse import urljoin
from app.scrapers.base import HosterExtractor, ScrapeError, VideoLink, register_hoster
from app.scrapers.http import fetch
logger = logging.getLogger(__name__)
_BASE = "https://video.sibnet.ru"
_VIDEO_ID_RE = re.compile(r"(?:videoid=|/video)(\d+)")
_MP4_PATTERNS = (
re.compile(r'player\.src\(\[\{src:\s*"(?P<url>/v/[^"]+?\.mp4)"'),
re.compile(r'["\'](?P<url>/v/[0-9a-f]{32}/\d+\.mp4)["\']'),
re.compile(r'["\'](?P<url>https?://[^"\']*?/v/[^"\']+?\.mp4)["\']'),
)
@register_hoster
class SibnetExtractor(HosterExtractor):
name = "sibnet"
domains = ("video.sibnet.ru",)
async def extract(self, embed_url: str) -> VideoLink:
match = _VIDEO_ID_RE.search(embed_url)
if not match:
raise ScrapeError(f"sibnet : identifiant vidéo introuvable dans {embed_url}")
video_id = match.group(1)
watch_url = f"{_BASE}/video{video_id}"
pages: list[tuple[str, str]] = []
try:
pages.append(("embed", await fetch(embed_url, retries=0)))
except ScrapeError as exc:
logger.info(
"sibnet : page embed %s inaccessible (%s), essai page publique", embed_url, exc
)
try:
pages.append(("watch", await fetch(watch_url, retries=1)))
except ScrapeError as exc:
logger.error("sibnet : page publique %s inaccessible : %s", watch_url, exc)
for source, html in pages:
url = self._find_mp4(html, source)
if url:
return VideoLink(
url=url,
hoster=self.name,
headers={"Referer": watch_url},
is_hls=False,
)
raise ScrapeError(f"sibnet : URL mp4 introuvable pour la vidéo {video_id}")
def _find_mp4(self, html: str, source: str) -> str | None:
for pattern in _MP4_PATTERNS:
match = pattern.search(html)
if match:
url = urljoin(_BASE + "/", match.group("url"))
logger.debug("sibnet : mp4 trouvé via %s → %s", source, url)
return url
return None
+45
View File
@@ -0,0 +1,45 @@
"""Extracteur Uqload (uqload.to/.co/.com/.io) — page embed → mp4/m3u8.
La page embed contient un jwplayer configuré dans du JS packé
(p,a,c,k,e,d) : `sources:[{file:"https://.../master.m3u8?..."}]`.
On dépacke puis on extrait par regex.
"""
import logging
import re
from app.scrapers.base import HosterExtractor, ScrapeError, VideoLink, register_hoster
from app.scrapers.hosters._packer import unpack_packed_js
from app.scrapers.http import fetch
logger = logging.getLogger(__name__)
_PATTERNS = (
re.compile(r'sources\s*:\s*\[\s*\{[^}]*?(?:file|src)\s*:\s*["\'](?P<url>[^"\']+)["\']'),
re.compile(r'(?:file|src)\s*:\s*["\'](?P<url>https?://[^"\']+?\.(?:m3u8|mp4)[^"\']*)["\']'),
re.compile(r'["\'](?P<url>https?://[^"\']*?\.(?:m3u8|mp4)(?:\?[^"\']*)?)["\']'),
)
@register_hoster
class UqloadExtractor(HosterExtractor):
name = "uqload"
domains = ("uqload.to", "uqload.co", "uqload.com", "uqload.io")
async def extract(self, embed_url: str) -> VideoLink:
html = await fetch(embed_url)
candidates: list[str] = []
for content in (html, unpack_packed_js(html) or ""):
for pattern in _PATTERNS:
for match in pattern.finditer(content):
candidates.append(match.group("url"))
video = [u for u in candidates if re.search(r"\.(mp4|m3u8)(\?|$)", u)]
url = next((u for u in video if ".mp4" in u), None) or (video[0] if video else None)
if not url:
raise ScrapeError(f"uqload : URL vidéo introuvable dans {embed_url}")
return VideoLink(
url=url,
hoster=self.name,
headers={"Referer": embed_url},
is_hls=".m3u8" in url,
)
+44
View File
@@ -0,0 +1,44 @@
"""Extracteur VidMoly (vidmoly.to / vidmoly.me) — page embed → mp4/m3u8.
Le lecteur jwplayer est configuré dans du JS parfois packé (p,a,c,k,e,d) :
`sources: [{file: "...m3u8"}]`. On dépacke si besoin puis on extrait par regex.
"""
import logging
import re
from app.scrapers.base import HosterExtractor, ScrapeError, VideoLink, register_hoster
from app.scrapers.hosters._packer import unpack_packed_js
from app.scrapers.http import fetch
logger = logging.getLogger(__name__)
_PATTERNS = (
re.compile(r'sources\s*:\s*\[\s*\{[^}]*?file\s*:\s*["\'](?P<url>[^"\']+)["\']'),
re.compile(r'file\s*:\s*["\'](?P<url>https?://[^"\']+?\.(?:m3u8|mp4)[^"\']*)["\']'),
re.compile(r'["\'](?P<url>https?://[^"\']*?\.(?:m3u8|mp4)(?:\?[^"\']*)?)["\']'),
)
@register_hoster
class VidmolyExtractor(HosterExtractor):
name = "vidmoly"
domains = ("vidmoly.to", "vidmoly.me", "vidmoly.biz", "vidmoly.com")
async def extract(self, embed_url: str) -> VideoLink:
html = await fetch(embed_url)
candidates: list[str] = []
for content in (html, unpack_packed_js(html) or ""):
for pattern in _PATTERNS:
for match in pattern.finditer(content):
candidates.append(match.group("url"))
video = [u for u in candidates if re.search(r"\.(mp4|m3u8)(\?|$)", u)]
url = next((u for u in video if ".mp4" in u), None) or (video[0] if video else None)
if not url:
raise ScrapeError(f"vidmoly : URL vidéo introuvable dans {embed_url}")
return VideoLink(
url=url,
hoster=self.name,
headers={"Referer": embed_url},
is_hls=".m3u8" in url,
)
+78
View File
@@ -0,0 +1,78 @@
"""Extracteur Vidzy (vidzy.org) — page embed videojs → m3u8.
La page embed ne contient pas l'URL vidéo en clair : le script videojs appelle
une fonction de décodage inline `atob(s)` + reverse + XOR, avec une graine
dérivée du hostname (`somme des codes des caractères & 0xFF`). Si le décodage
échoue, la page sert un leurre `https://s1.fsvid.lol/troll/master.m3u8`
(même valeur pour tous les épisodes) — on le rejette explicitement.
Algorithme (reproduit fidèlement depuis le JS de la page) :
b = base64decode(s) ; a = b[::-1]
r[i] = chr(a[i] ^ ((0x3D + i*89 + H) & 0xFF)) avec H = sum(ord(hostname)) & 0xFF
"""
import base64
import logging
import re
from urllib.parse import urlparse
from app.config import get_settings
from app.scrapers.base import HosterExtractor, ScrapeError, VideoLink, register_hoster
from app.scrapers.http import fetch
logger = logging.getLogger(__name__)
_B64_RE = re.compile(
r"atob\(s\)(?s:.){0,800}?\}\)\(\"(?P<b64>[A-Za-z0-9+/=]{50,})\"",
)
_CLEAR_PATTERNS = (
re.compile(r'sources\s*:\s*\[\s*\{\s*src\s*:\s*["\'](?P<url>https?://[^"\']+?\.m3u8[^"\']*)["\']'),
re.compile(r'_fsvHls\s*=\s*"(?P<url>https?://[^"]+?\.m3u8[^"]*)"'),
)
_TROLL = "/troll/"
def _decode(b64: str, hostname: str) -> str | None:
try:
data = base64.b64decode(b64)
except (ValueError, TypeError):
return None
seed = sum(ord(c) for c in hostname) & 0xFF
decoded = "".join(chr(b ^ ((0x3D + i * 89 + seed) & 0xFF)) for i, b in enumerate(data[::-1]))
return decoded if decoded.startswith(("http://", "https://")) else None
@register_hoster
class VidzyExtractor(HosterExtractor):
name = "vidzy"
domains = ("vidzy.org",)
async def extract(self, embed_url: str) -> VideoLink:
html = await fetch(embed_url)
hostname = urlparse(embed_url).hostname or ""
for match in _B64_RE.finditer(html):
url = _decode(match.group("b64"), hostname)
if url and _TROLL not in url:
return self._video_link(url, embed_url)
logger.warning("vidzy : décodage atob+XOR sans résultat pour %s", embed_url)
for pattern in _CLEAR_PATTERNS:
for match in pattern.finditer(html):
url = match.group("url")
if _TROLL not in url:
return self._video_link(url, embed_url)
raise ScrapeError(
f"vidzy : URL vidéo introuvable dans {embed_url} (leurre anti-bot ou page modifiée)"
)
def _video_link(self, url: str, embed_url: str) -> VideoLink:
return VideoLink(
url=url,
hoster=self.name,
headers={
"Referer": f"https://{urlparse(embed_url).hostname}/",
"User-Agent": get_settings().user_agent,
},
is_hls=".m3u8" in url,
)