Skip to content
This repository was archived by the owner on Aug 26, 2026. It is now read-only.

Commit 04562fa

Browse files
committed
Fix open provider and following issues
1 parent 6cc3538 commit 04562fa

13 files changed

Lines changed: 737 additions & 27 deletions

Dockerfile

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,14 @@ COPY app ./app
2929
COPY static ./static
3030
COPY README.md ./
3131
COPY docker/entrypoint.sh /usr/local/bin/pstreamrec-entrypoint
32-
RUN chmod +x /usr/local/bin/pstreamrec-entrypoint
32+
RUN chmod +x /usr/local/bin/pstreamrec-entrypoint && \
33+
test -x /usr/local/bin/pstreamrec-entrypoint && \
34+
PSTREAMREC_ENTRYPOINT_TESTING=1 /usr/local/bin/pstreamrec-entrypoint
3335

3436
# Create data volume for recordings
3537
VOLUME ["/data"]
3638

3739
EXPOSE 8080
3840

39-
ENTRYPOINT ["pstreamrec-entrypoint"]
41+
ENTRYPOINT ["/usr/local/bin/pstreamrec-entrypoint"]
4042
CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT} --proxy-headers"]

app/main.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import mimetypes
33
from pathlib import Path
44
from typing import Any, Optional, Tuple
5-
from urllib.parse import parse_qsl, quote, urlencode, urljoin, urlparse, urlunparse
5+
from urllib.parse import parse_qsl, quote, unquote, urlencode, urljoin, urlparse, urlunparse
66
import os
77
import asyncio
88
import aiohttp
@@ -805,6 +805,11 @@ def _canonical_stream_url(source_type: str, channel_username: str, channel_url:
805805
return f"https://www.cam4.com/{channel}"
806806
if normalized_source == "chaturbate":
807807
return f"https://chaturbate.com/{channel}/"
808+
try:
809+
if provider_registry.has(normalized_source):
810+
return provider_registry.get(normalized_source).canonical_url(channel_username)
811+
except Exception:
812+
pass
808813
return raw_url
809814

810815

@@ -5428,12 +5433,18 @@ async def update_model_volume(username: str, body: ModelVolumeBody):
54285433
@app.post("/api/models")
54295434
async def add_model(model: dict):
54305435
"""Ajoute un modèle dans SQLite"""
5431-
username = model.get('username')
5436+
raw_username = str(model.get('username') or "").strip()
5437+
source_from_url = _source_type_from_url(raw_username)
5438+
username = (
5439+
_normalize_live_channel_username(raw_username, raw_username)
5440+
if raw_username.startswith(("http://", "https://"))
5441+
else raw_username
5442+
)
54325443
if not username:
54335444
raise HTTPException(status_code=400, detail="Username requis")
54345445

54355446
requested_source = _normalize_source_type(
5436-
model.get("sourceType") or model.get("source_type")
5447+
model.get("sourceType") or model.get("source_type") or source_from_url
54375448
)
54385449
source_type = requested_source or await _infer_source_type(username)
54395450
if source_type not in _available_source_types():

app/providers/browser.py

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1955,7 +1955,6 @@ async def _resolve_stripchat_public_hls(
19551955
target: str,
19561956
max_height: Optional[int] = None,
19571957
) -> Optional[ResolvedStream]:
1958-
del max_height
19591958
username = self._stripchat_username_from_target(target)
19601959
if not username:
19611960
return None
@@ -1983,10 +1982,11 @@ async def _resolve_stripchat_public_hls(
19831982
f"https://edge-hls.{host}/hls/{model_id}/master/{model_id}_auto.m3u8"
19841983
f"{self._stripchat_master_playlist_query()}"
19851984
)
1986-
if await self._stripchat_probe_hls_playlist(playlist_url, headers):
1985+
stream_url = await self._stripchat_validated_hls_url(playlist_url, headers, max_height)
1986+
if stream_url:
19871987
item = self._stripchat_model_item(model) or {}
19881988
return ResolvedStream(
1989-
url=playlist_url,
1989+
url=stream_url,
19901990
headers=headers,
19911991
source_type=self.source_type,
19921992
is_live=True,
@@ -1999,6 +1999,58 @@ async def _resolve_stripchat_public_hls(
19991999

20002000
raise ProviderOfflineError(f"Aucun HLS Stripchat public valide pour {username}")
20012001

2002+
async def _stripchat_validated_hls_url(
2003+
self,
2004+
playlist_url: str,
2005+
headers: dict[str, str],
2006+
max_height: Optional[int],
2007+
) -> Optional[str]:
2008+
if not max_height or max_height <= 0:
2009+
return playlist_url if await self._stripchat_probe_hls_playlist(playlist_url, headers) else None
2010+
2011+
playlist_text = await self._stripchat_fetch_hls_playlist(playlist_url, headers)
2012+
if not playlist_text:
2013+
return None
2014+
return self._stripchat_variant_url_for_height(playlist_url, playlist_text, max_height) or playlist_url
2015+
2016+
@staticmethod
2017+
def _stripchat_variant_url_for_height(
2018+
playlist_url: str,
2019+
playlist_text: str,
2020+
max_height: Optional[int],
2021+
) -> Optional[str]:
2022+
if not max_height or max_height <= 0:
2023+
return None
2024+
2025+
variants: list[dict[str, object]] = []
2026+
pending_height = 0
2027+
for raw_line in (playlist_text or "").splitlines():
2028+
line = raw_line.strip()
2029+
if not line:
2030+
continue
2031+
if line.startswith("#EXT-X-STREAM-INF"):
2032+
match = re.search(r"RESOLUTION=\d+x(\d+)", line, re.IGNORECASE)
2033+
pending_height = int(match.group(1)) if match else 0
2034+
continue
2035+
if line.startswith("#"):
2036+
continue
2037+
if pending_height:
2038+
variants.append({
2039+
"height": pending_height,
2040+
"url": urljoin(playlist_url, line),
2041+
})
2042+
pending_height = 0
2043+
2044+
if not variants:
2045+
return None
2046+
2047+
eligible = [item for item in variants if int(item["height"]) <= max_height]
2048+
if eligible:
2049+
selected = max(eligible, key=lambda item: int(item["height"]))
2050+
else:
2051+
selected = min(variants, key=lambda item: int(item["height"]))
2052+
return str(selected["url"])
2053+
20022054
def _stripchat_master_playlist_query(self) -> str:
20032055
params = {
20042056
"minHeight": os.getenv("PSTREAMREC_STRIPCHAT_MIN_HEIGHT", "240"),
@@ -2089,7 +2141,7 @@ def walk(value: object) -> None:
20892141
hosts.append(host)
20902142
return hosts
20912143

2092-
async def _stripchat_probe_hls_playlist(self, playlist_url: str, headers: dict[str, str]) -> bool:
2144+
async def _stripchat_fetch_hls_playlist(self, playlist_url: str, headers: dict[str, str]) -> Optional[str]:
20932145
try:
20942146
timeout = aiohttp.ClientTimeout(total=int(os.getenv("PSTREAMREC_STRIPCHAT_HLS_PROBE_TIMEOUT", "12") or "12"))
20952147
async with aiohttp_client_session(timeout=timeout) as session:
@@ -2102,14 +2154,17 @@ async def _stripchat_probe_hls_playlist(self, playlist_url: str, headers: dict[s
21022154
if resp.status in (401, 403):
21032155
raise ProviderAuthError("Flux Stripchat refuse ou session requise")
21042156
if resp.status >= 400:
2105-
return False
2106-
head = await resp.content.read(512)
2157+
return None
2158+
text = await resp.text(errors="ignore")
21072159
except ProviderError:
21082160
raise
21092161
except Exception as exc:
21102162
logger.debug("Stripchat HLS probe failed", url=playlist_url, error=str(exc))
2111-
return False
2112-
return head.lstrip().startswith(b"#EXTM3U")
2163+
return None
2164+
return text if text.lstrip().startswith("#EXTM3U") else None
2165+
2166+
async def _stripchat_probe_hls_playlist(self, playlist_url: str, headers: dict[str, str]) -> bool:
2167+
return bool(await self._stripchat_fetch_hls_playlist(playlist_url, headers))
21132168

21142169
async def _stripchat_user_by_username(self, username: str) -> dict[str, object]:
21152170
payload = await self._stripchat_api_json(

app/services/chaturbate_api.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"""
55

66
import asyncio
7+
import json
78
import re
89
import time
910
from html import unescape
@@ -642,8 +643,126 @@ def _parse_followed_html(cls, html: str) -> List[Dict[str, Any]]:
642643
"gender": "",
643644
"num_followers": 0,
644645
})
646+
models.extend(cls._parse_followed_embedded_json(html))
647+
return cls._dedupe_followed_items(models)
648+
649+
@classmethod
650+
def _parse_followed_embedded_json(cls, html: str) -> List[Dict[str, Any]]:
651+
models: List[Dict[str, Any]] = []
652+
scripts = re.findall(r"<script\b[^>]*>(.*?)</script>", html or "", re.IGNORECASE | re.DOTALL)
653+
for script in scripts:
654+
text = unescape(script or "").strip()
655+
if not text or len(text) > 2_000_000:
656+
continue
657+
json_values = cls._extract_json_values(text)
658+
for value in json_values:
659+
cls._collect_followed_json_models(value, models)
645660
return models
646661

662+
@classmethod
663+
def _extract_json_values(cls, text: str) -> List[Any]:
664+
values: List[Any] = []
665+
decoder = json.JSONDecoder()
666+
candidates = []
667+
stripped = text.strip()
668+
if stripped.startswith(("{", "[")):
669+
candidates.append(stripped)
670+
for match in re.finditer(r"=\s*({.*?});", text, re.DOTALL):
671+
candidates.append(match.group(1))
672+
673+
for candidate in candidates:
674+
try:
675+
value, _ = decoder.raw_decode(candidate)
676+
except Exception:
677+
continue
678+
values.append(value)
679+
return values
680+
681+
@classmethod
682+
def _collect_followed_json_models(cls, value: Any, models: List[Dict[str, Any]]) -> None:
683+
if isinstance(value, list):
684+
for item in value:
685+
cls._collect_followed_json_models(item, models)
686+
return
687+
if not isinstance(value, dict):
688+
return
689+
690+
item = cls._followed_json_item(value)
691+
if item:
692+
models.append(item)
693+
694+
for child in value.values():
695+
if isinstance(child, (dict, list)):
696+
cls._collect_followed_json_models(child, models)
697+
698+
@classmethod
699+
def _followed_json_item(cls, item: Dict[str, Any]) -> Optional[Dict[str, Any]]:
700+
username = str(
701+
item.get("username")
702+
or item.get("room")
703+
or item.get("room_slug")
704+
or item.get("slug")
705+
or ""
706+
).strip().strip("/")
707+
if not username or not re.match(r"^[A-Za-z0-9_]+$", username):
708+
return None
709+
710+
has_followed_shape = any(
711+
key in item
712+
for key in (
713+
"is_online",
714+
"isOnline",
715+
"current_show",
716+
"room_status",
717+
"num_users",
718+
"viewers",
719+
"thumbnail",
720+
"thumbnail_url",
721+
"img",
722+
)
723+
)
724+
if not has_followed_shape:
725+
return None
726+
727+
room_status = str(
728+
item.get("room_status")
729+
or item.get("roomStatus")
730+
or item.get("current_show")
731+
or item.get("status")
732+
or ""
733+
).strip().lower()
734+
is_private = room_status in {"private", "group", "ticket", "hidden"}
735+
is_online = bool(item.get("is_online", item.get("isOnline", room_status == "public"))) and not is_private
736+
thumbnail = cls._normalize_thumbnail(
737+
item.get("thumbnail_url") or item.get("thumbnail") or item.get("img"),
738+
username,
739+
)
740+
return {
741+
"username": username,
742+
"display_name": str(item.get("display_name") or item.get("displayName") or username),
743+
"is_online": is_online,
744+
"viewers": cls._as_int(item.get("viewers", item.get("num_users", 0))) if is_online else 0,
745+
"thumbnail_url": thumbnail,
746+
"room_status": room_status or ("public" if is_online else "offline"),
747+
"tags": cls._normalize_tags(item.get("tags", [])),
748+
"subject": str(item.get("subject") or item.get("room_subject") or ""),
749+
"gender": str(item.get("gender") or ""),
750+
"num_followers": cls._as_int(item.get("num_followers"), 0),
751+
}
752+
753+
@staticmethod
754+
def _dedupe_followed_items(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
755+
deduped: List[Dict[str, Any]] = []
756+
seen = set()
757+
for item in items:
758+
username = str(item.get("username") or "").strip()
759+
key = username.lower()
760+
if not username or key in seen:
761+
continue
762+
seen.add(key)
763+
deduped.append(item)
764+
return deduped
765+
647766
@staticmethod
648767
def _parse_room_item(item: Dict[str, Any], is_online: bool = True) -> Dict[str, Any]:
649768
"""Parse a room item from the roomlist API into our model format."""

0 commit comments

Comments
 (0)