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 @@
|
||||
"""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
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user