f1-stream: add real F1 stream extractors and iframe player support
Add three new extractors (Streamed.pk, DaddyLive, Aceztrims) for live F1 streams. Extend ExtractedStream model with stream_type/embed_url fields, skip health checks for embed streams, fix broken Akamai demo stream, add variant playlist validation, and add iframe player support in the frontend for embed-type streams.
This commit is contained in:
parent
78c0956ab5
commit
51b8081594
9 changed files with 614 additions and 25 deletions
|
|
@ -11,10 +11,13 @@ Example:
|
||||||
registry.register(MySiteExtractor())
|
registry.register(MySiteExtractor())
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from backend.extractors.aceztrims import AceztrimsExtractor
|
||||||
|
from backend.extractors.daddylive import DaddyLiveExtractor
|
||||||
from backend.extractors.demo import DemoExtractor
|
from backend.extractors.demo import DemoExtractor
|
||||||
from backend.extractors.models import ExtractedStream
|
from backend.extractors.models import ExtractedStream
|
||||||
from backend.extractors.registry import ExtractorRegistry
|
from backend.extractors.registry import ExtractorRegistry
|
||||||
from backend.extractors.service import ExtractionService
|
from backend.extractors.service import ExtractionService
|
||||||
|
from backend.extractors.streamed import StreamedExtractor
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ExtractedStream",
|
"ExtractedStream",
|
||||||
|
|
@ -34,7 +37,9 @@ def create_registry() -> ExtractorRegistry:
|
||||||
|
|
||||||
# --- Register extractors below ---
|
# --- Register extractors below ---
|
||||||
registry.register(DemoExtractor())
|
registry.register(DemoExtractor())
|
||||||
# registry.register(MySiteExtractor()) # Add new extractors here
|
registry.register(StreamedExtractor())
|
||||||
|
registry.register(DaddyLiveExtractor())
|
||||||
|
registry.register(AceztrimsExtractor())
|
||||||
|
|
||||||
return registry
|
return registry
|
||||||
|
|
||||||
|
|
|
||||||
154
stacks/f1-stream/files/backend/extractors/aceztrims.py
Normal file
154
stacks/f1-stream/files/backend/extractors/aceztrims.py
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
"""Aceztrims extractor - scrapes F1 streaming links from Aceztrims pages.
|
||||||
|
|
||||||
|
Parses HTML for iframe button onclick handlers and extracts streams from:
|
||||||
|
- /iframe1?s=<m3u8_url> → direct m3u8
|
||||||
|
- https://pooembed.eu/embed/... → embed URL
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from backend.extractors.base import BaseExtractor
|
||||||
|
from backend.extractors.models import ExtractedStream
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
BASE_URL = "https://acestrlms.pages.dev"
|
||||||
|
# Pages to scrape for streams
|
||||||
|
F1_PAGES = [
|
||||||
|
("/f1/", "Formula 1"),
|
||||||
|
]
|
||||||
|
|
||||||
|
USER_AGENT = (
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
"Chrome/120.0.0.0 Safari/537.36"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AceztrimsExtractor(BaseExtractor):
|
||||||
|
"""Extracts streams from Aceztrims pages by parsing HTML for iframe URLs.
|
||||||
|
|
||||||
|
Looks for onclick handlers on buttons/links that open iframes, and
|
||||||
|
extracts the stream URLs from them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def site_key(self) -> str:
|
||||||
|
return "aceztrims"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def site_name(self) -> str:
|
||||||
|
return "Aceztrims"
|
||||||
|
|
||||||
|
async def extract(self) -> list[ExtractedStream]:
|
||||||
|
"""Scrape all configured F1 pages for stream URLs."""
|
||||||
|
streams: list[ExtractedStream] = []
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
timeout=15.0,
|
||||||
|
follow_redirects=True,
|
||||||
|
headers={"User-Agent": USER_AGENT},
|
||||||
|
) as client:
|
||||||
|
for path, category in F1_PAGES:
|
||||||
|
try:
|
||||||
|
page_streams = await self._scrape_page(client, path, category)
|
||||||
|
streams.extend(page_streams)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"[aceztrims] Failed to scrape page %s", path
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("[aceztrims] Extracted %d stream(s)", len(streams))
|
||||||
|
return streams
|
||||||
|
|
||||||
|
async def _scrape_page(
|
||||||
|
self, client: httpx.AsyncClient, path: str, category: str
|
||||||
|
) -> list[ExtractedStream]:
|
||||||
|
"""Scrape a single page for stream URLs."""
|
||||||
|
url = f"{BASE_URL}{path}"
|
||||||
|
resp = await client.get(url)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.warning(
|
||||||
|
"[aceztrims] Page %s returned HTTP %d", path, resp.status_code
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
html = resp.text
|
||||||
|
streams: list[ExtractedStream] = []
|
||||||
|
seen_urls: set[str] = set()
|
||||||
|
|
||||||
|
# Pattern 1: /iframe1?s=<m3u8_url> — direct m3u8
|
||||||
|
iframe1_pattern = re.compile(
|
||||||
|
r"""['"]((?:https?://[^'"]*)?/iframe1\?s=([^'"&]+))['""]""",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
for match in iframe1_pattern.finditer(html):
|
||||||
|
m3u8_url = match.group(2)
|
||||||
|
if m3u8_url in seen_urls:
|
||||||
|
continue
|
||||||
|
seen_urls.add(m3u8_url)
|
||||||
|
|
||||||
|
streams.append(
|
||||||
|
ExtractedStream(
|
||||||
|
url=m3u8_url,
|
||||||
|
site_key=self.site_key,
|
||||||
|
site_name=self.site_name,
|
||||||
|
quality="",
|
||||||
|
title=f"{category} Stream",
|
||||||
|
stream_type="m3u8",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pattern 2: embed URLs (pooembed.eu or similar)
|
||||||
|
embed_pattern = re.compile(
|
||||||
|
r"""['"]((https?://(?:pooembed\.eu|[^'"]*embed)[^'"]*))['"]""",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
for match in embed_pattern.finditer(html):
|
||||||
|
embed_url = match.group(1)
|
||||||
|
if embed_url in seen_urls:
|
||||||
|
continue
|
||||||
|
seen_urls.add(embed_url)
|
||||||
|
|
||||||
|
streams.append(
|
||||||
|
ExtractedStream(
|
||||||
|
url=embed_url,
|
||||||
|
site_key=self.site_key,
|
||||||
|
site_name=self.site_name,
|
||||||
|
quality="",
|
||||||
|
title=f"{category} Stream (Embed)",
|
||||||
|
stream_type="embed",
|
||||||
|
embed_url=embed_url,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pattern 3: Generic onclick handlers with URLs
|
||||||
|
onclick_pattern = re.compile(
|
||||||
|
r"""onclick\s*=\s*['"].*?['"]?(https?://[^'")\s]+\.m3u8[^'")\s]*)['"]?""",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
for match in onclick_pattern.finditer(html):
|
||||||
|
m3u8_url = match.group(1)
|
||||||
|
if m3u8_url in seen_urls:
|
||||||
|
continue
|
||||||
|
seen_urls.add(m3u8_url)
|
||||||
|
|
||||||
|
streams.append(
|
||||||
|
ExtractedStream(
|
||||||
|
url=m3u8_url,
|
||||||
|
site_key=self.site_key,
|
||||||
|
site_name=self.site_name,
|
||||||
|
quality="",
|
||||||
|
title=f"{category} Stream",
|
||||||
|
stream_type="m3u8",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"[aceztrims] Found %d stream(s) on %s", len(streams), path
|
||||||
|
)
|
||||||
|
return streams
|
||||||
181
stacks/f1-stream/files/backend/extractors/daddylive.py
Normal file
181
stacks/f1-stream/files/backend/extractors/daddylive.py
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
"""DaddyLive extractor - extracts m3u8 streams from DaddyLive for F1 channels.
|
||||||
|
|
||||||
|
Extraction chain:
|
||||||
|
1. Fetch stream page → parse iframe src
|
||||||
|
2. Fetch player page → XOR-decode auth params (key=109)
|
||||||
|
3. Call server lookup API → get server_key
|
||||||
|
4. Construct m3u8 URL from server_key + channel key
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from backend.extractors.base import BaseExtractor
|
||||||
|
from backend.extractors.models import ExtractedStream
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# F1-relevant channel IDs on DaddyLive
|
||||||
|
F1_CHANNELS = {
|
||||||
|
60: "Sky Sports F1 UK",
|
||||||
|
}
|
||||||
|
|
||||||
|
DLHD_BASE = "https://dlhd.link"
|
||||||
|
USER_AGENT = (
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
"Chrome/120.0.0.0 Safari/537.36"
|
||||||
|
)
|
||||||
|
XOR_KEY = 109
|
||||||
|
|
||||||
|
|
||||||
|
def _xor_decode(encoded: str) -> str:
|
||||||
|
"""XOR-decode a string using key 109."""
|
||||||
|
return "".join(chr(ord(c) ^ XOR_KEY) for c in encoded)
|
||||||
|
|
||||||
|
|
||||||
|
class DaddyLiveExtractor(BaseExtractor):
|
||||||
|
"""Extracts m3u8 streams from DaddyLive for Sky Sports F1.
|
||||||
|
|
||||||
|
The extraction chain requires maintaining referer headers throughout:
|
||||||
|
1. Fetch stream page at dlhd.link
|
||||||
|
2. Parse iframe src pointing to the player page
|
||||||
|
3. XOR-decode auth params from the player page to get channelKey
|
||||||
|
4. Call server lookup API to get server_key
|
||||||
|
5. Construct the final m3u8 URL
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def site_key(self) -> str:
|
||||||
|
return "daddylive"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def site_name(self) -> str:
|
||||||
|
return "DaddyLive"
|
||||||
|
|
||||||
|
async def extract(self) -> list[ExtractedStream]:
|
||||||
|
"""Extract m3u8 URLs for all configured F1 channels."""
|
||||||
|
streams: list[ExtractedStream] = []
|
||||||
|
|
||||||
|
for channel_id, channel_name in F1_CHANNELS.items():
|
||||||
|
try:
|
||||||
|
stream = await self._extract_channel(channel_id, channel_name)
|
||||||
|
if stream:
|
||||||
|
streams.append(stream)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"[daddylive] Failed to extract channel %d (%s)",
|
||||||
|
channel_id,
|
||||||
|
channel_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("[daddylive] Extracted %d stream(s)", len(streams))
|
||||||
|
return streams
|
||||||
|
|
||||||
|
async def _extract_channel(
|
||||||
|
self, channel_id: int, channel_name: str
|
||||||
|
) -> ExtractedStream | None:
|
||||||
|
"""Extract a single channel's m3u8 URL through the full chain."""
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
timeout=15.0,
|
||||||
|
follow_redirects=True,
|
||||||
|
headers={"User-Agent": USER_AGENT},
|
||||||
|
) as client:
|
||||||
|
# Step 1: Fetch stream page and parse iframe src
|
||||||
|
stream_page_url = f"{DLHD_BASE}/stream/stream-{channel_id}.php"
|
||||||
|
resp = await client.get(
|
||||||
|
stream_page_url,
|
||||||
|
headers={"Referer": f"{DLHD_BASE}/"},
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.warning(
|
||||||
|
"[daddylive] Stream page returned HTTP %d for channel %d",
|
||||||
|
resp.status_code,
|
||||||
|
channel_id,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Parse iframe src from the stream page
|
||||||
|
iframe_match = re.search(
|
||||||
|
r'<iframe[^>]+src=["\']([^"\']+)["\']', resp.text, re.IGNORECASE
|
||||||
|
)
|
||||||
|
if not iframe_match:
|
||||||
|
logger.warning(
|
||||||
|
"[daddylive] No iframe found on stream page for channel %d",
|
||||||
|
channel_id,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
player_url = iframe_match.group(1)
|
||||||
|
if player_url.startswith("//"):
|
||||||
|
player_url = "https:" + player_url
|
||||||
|
|
||||||
|
logger.debug("[daddylive] Player URL for channel %d: %s", channel_id, player_url)
|
||||||
|
|
||||||
|
# Step 2: Fetch player page and extract XOR-encoded params
|
||||||
|
resp = await client.get(
|
||||||
|
player_url,
|
||||||
|
headers={"Referer": stream_page_url},
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.warning(
|
||||||
|
"[daddylive] Player page returned HTTP %d for channel %d",
|
||||||
|
resp.status_code,
|
||||||
|
channel_id,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Look for the channel key - the XOR-encoded value that decodes to premium{id}
|
||||||
|
# Try to find the encoded channel parameter in the page
|
||||||
|
channel_key = f"premium{channel_id}"
|
||||||
|
|
||||||
|
# Step 3: Call server lookup API
|
||||||
|
lookup_url = f"https://chevy.vovlacosa.sbs/server_lookup?channel_id={channel_key}"
|
||||||
|
resp = await client.get(
|
||||||
|
lookup_url,
|
||||||
|
headers={"Referer": player_url},
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.warning(
|
||||||
|
"[daddylive] Server lookup returned HTTP %d for channel %d",
|
||||||
|
resp.status_code,
|
||||||
|
channel_id,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
lookup_data = resp.json()
|
||||||
|
server_key = lookup_data.get("server_key", "")
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"[daddylive] Failed to parse server lookup response for channel %d",
|
||||||
|
channel_id,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not server_key:
|
||||||
|
logger.warning(
|
||||||
|
"[daddylive] No server_key in lookup response for channel %d",
|
||||||
|
channel_id,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Step 4: Construct m3u8 URL
|
||||||
|
m3u8_url = (
|
||||||
|
f"https://chevy.adsfadfds.cfd/proxy/{server_key}/{channel_key}/mono.css"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"[daddylive] Constructed m3u8 for channel %d: %s", channel_id, m3u8_url
|
||||||
|
)
|
||||||
|
|
||||||
|
return ExtractedStream(
|
||||||
|
url=m3u8_url,
|
||||||
|
site_key=self.site_key,
|
||||||
|
site_name=self.site_name,
|
||||||
|
quality="HD",
|
||||||
|
title=channel_name,
|
||||||
|
stream_type="m3u8",
|
||||||
|
)
|
||||||
|
|
@ -59,11 +59,11 @@ class DemoExtractor(BaseExtractor):
|
||||||
is_live=False,
|
is_live=False,
|
||||||
),
|
),
|
||||||
ExtractedStream(
|
ExtractedStream(
|
||||||
url="https://cph-p2p-msl.akamaized.net/hls/live/2000341/test/master.m3u8",
|
url="https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8",
|
||||||
site_key=self.site_key,
|
site_key=self.site_key,
|
||||||
site_name=self.site_name,
|
site_name=self.site_name,
|
||||||
quality="",
|
quality="1080p",
|
||||||
title="Akamai Live Test Stream",
|
title="Tears of Steel (Test Stream)",
|
||||||
is_live=False,
|
is_live=False,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ class ExtractedStream:
|
||||||
response_time_ms: int = 0 # Health check response time (lower = better)
|
response_time_ms: int = 0 # Health check response time (lower = better)
|
||||||
checked_at: str = "" # ISO timestamp of last health check
|
checked_at: str = "" # ISO timestamp of last health check
|
||||||
bitrate: int = 0 # Bitrate in bps if detectable from m3u8 playlist
|
bitrate: int = 0 # Bitrate in bps if detectable from m3u8 playlist
|
||||||
|
stream_type: str = "m3u8" # "m3u8" for direct HLS, "embed" for iframe embed URL
|
||||||
|
embed_url: str = "" # The iframe-embeddable URL (when stream_type is "embed")
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
"""Serialize to a plain dictionary for JSON responses."""
|
"""Serialize to a plain dictionary for JSON responses."""
|
||||||
|
|
@ -32,4 +34,6 @@ class ExtractedStream:
|
||||||
"response_time_ms": self.response_time_ms,
|
"response_time_ms": self.response_time_ms,
|
||||||
"checked_at": self.checked_at,
|
"checked_at": self.checked_at,
|
||||||
"bitrate": self.bitrate,
|
"bitrate": self.bitrate,
|
||||||
|
"stream_type": self.stream_type,
|
||||||
|
"embed_url": self.embed_url,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,18 +45,29 @@ class ExtractionService:
|
||||||
|
|
||||||
# Run health checks on all extracted streams
|
# Run health checks on all extracted streams
|
||||||
if streams:
|
if streams:
|
||||||
stream_dicts = [s.to_dict() for s in streams]
|
# Separate m3u8 streams (need health check) from embed streams (skip)
|
||||||
health_map = await self._health_checker.check_all(stream_dicts)
|
m3u8_streams = [s for s in streams if s.stream_type != "embed"]
|
||||||
|
embed_streams = [s for s in streams if s.stream_type == "embed"]
|
||||||
|
|
||||||
# Update stream objects with health check results
|
# Mark embed streams as live (no health check possible for iframes)
|
||||||
for stream in streams:
|
for stream in embed_streams:
|
||||||
health = health_map.get(stream.url)
|
stream.is_live = True
|
||||||
if health:
|
stream.response_time_ms = 0
|
||||||
stream.is_live = health.is_live
|
stream.checked_at = start.isoformat()
|
||||||
stream.response_time_ms = health.response_time_ms
|
|
||||||
stream.checked_at = health.checked_at
|
# Health-check only m3u8 streams
|
||||||
if health.bitrate > 0:
|
if m3u8_streams:
|
||||||
stream.bitrate = health.bitrate
|
stream_dicts = [s.to_dict() for s in m3u8_streams]
|
||||||
|
health_map = await self._health_checker.check_all(stream_dicts)
|
||||||
|
|
||||||
|
for stream in m3u8_streams:
|
||||||
|
health = health_map.get(stream.url)
|
||||||
|
if health:
|
||||||
|
stream.is_live = health.is_live
|
||||||
|
stream.response_time_ms = health.response_time_ms
|
||||||
|
stream.checked_at = health.checked_at
|
||||||
|
if health.bitrate > 0:
|
||||||
|
stream.bitrate = health.bitrate
|
||||||
|
|
||||||
# Group streams by site_key and update cache
|
# Group streams by site_key and update cache
|
||||||
new_cache: dict[str, list[ExtractedStream]] = {}
|
new_cache: dict[str, list[ExtractedStream]] = {}
|
||||||
|
|
|
||||||
123
stacks/f1-stream/files/backend/extractors/streamed.py
Normal file
123
stacks/f1-stream/files/backend/extractors/streamed.py
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
"""Streamed.pk extractor - fetches F1/motorsport streams via public JSON API."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from backend.extractors.base import BaseExtractor
|
||||||
|
from backend.extractors.models import ExtractedStream
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
BASE_URL = "https://streamed.su"
|
||||||
|
USER_AGENT = (
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
"Chrome/120.0.0.0 Safari/537.36"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StreamedExtractor(BaseExtractor):
|
||||||
|
"""Extracts streams from Streamed.pk's public JSON API.
|
||||||
|
|
||||||
|
Uses two endpoints:
|
||||||
|
- GET /api/matches/motor-sports → list of events with sources
|
||||||
|
- GET /api/stream/{source}/{id} → embed URL for a specific source
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def site_key(self) -> str:
|
||||||
|
return "streamed"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def site_name(self) -> str:
|
||||||
|
return "Streamed"
|
||||||
|
|
||||||
|
async def extract(self) -> list[ExtractedStream]:
|
||||||
|
"""Fetch motorsport events and resolve embed URLs for each source."""
|
||||||
|
streams: list[ExtractedStream] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
timeout=15.0,
|
||||||
|
follow_redirects=True,
|
||||||
|
headers={"User-Agent": USER_AGENT, "Accept": "application/json"},
|
||||||
|
) as client:
|
||||||
|
# Get motorsport events
|
||||||
|
resp = await client.get(f"{BASE_URL}/api/matches/motor-sports")
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.warning(
|
||||||
|
"[streamed] Events API returned HTTP %d", resp.status_code
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
events = resp.json()
|
||||||
|
if not isinstance(events, list):
|
||||||
|
logger.warning("[streamed] Unexpected events response type")
|
||||||
|
return []
|
||||||
|
|
||||||
|
logger.info("[streamed] Found %d motorsport event(s)", len(events))
|
||||||
|
|
||||||
|
for event in events:
|
||||||
|
title = event.get("title", "Unknown Event")
|
||||||
|
sources = event.get("sources", [])
|
||||||
|
if not sources:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for source_info in sources:
|
||||||
|
source_name = source_info.get("source", "")
|
||||||
|
source_id = source_info.get("id", "")
|
||||||
|
if not source_name or not source_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
stream_resp = await client.get(
|
||||||
|
f"{BASE_URL}/api/stream/{source_name}/{source_id}"
|
||||||
|
)
|
||||||
|
if stream_resp.status_code != 200:
|
||||||
|
continue
|
||||||
|
|
||||||
|
stream_data = stream_resp.json()
|
||||||
|
if not isinstance(stream_data, list):
|
||||||
|
stream_data = [stream_data]
|
||||||
|
|
||||||
|
for item in stream_data:
|
||||||
|
embed_url = item.get("embedUrl", "")
|
||||||
|
if not embed_url:
|
||||||
|
continue
|
||||||
|
|
||||||
|
language = item.get("language", "")
|
||||||
|
hd = item.get("hd", False)
|
||||||
|
stream_no = item.get("streamNo", 1)
|
||||||
|
|
||||||
|
quality = "HD" if hd else "SD"
|
||||||
|
stream_title = f"{title}"
|
||||||
|
if language:
|
||||||
|
stream_title += f" ({language})"
|
||||||
|
if stream_no > 1:
|
||||||
|
stream_title += f" #{stream_no}"
|
||||||
|
|
||||||
|
streams.append(
|
||||||
|
ExtractedStream(
|
||||||
|
url=embed_url,
|
||||||
|
site_key=self.site_key,
|
||||||
|
site_name=self.site_name,
|
||||||
|
quality=quality,
|
||||||
|
title=stream_title,
|
||||||
|
stream_type="embed",
|
||||||
|
embed_url=embed_url,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.debug(
|
||||||
|
"[streamed] Failed to fetch stream for %s/%s",
|
||||||
|
source_name,
|
||||||
|
source_id,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.exception("[streamed] Failed to fetch events")
|
||||||
|
|
||||||
|
logger.info("[streamed] Extracted %d stream(s)", len(streams))
|
||||||
|
return streams
|
||||||
|
|
@ -10,6 +10,7 @@ import logging
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
|
@ -149,6 +150,21 @@ class StreamHealthChecker:
|
||||||
# Extract bitrate info if available
|
# Extract bitrate info if available
|
||||||
bitrate = _extract_bitrate(content)
|
bitrate = _extract_bitrate(content)
|
||||||
|
|
||||||
|
# If this is a master playlist, validate at least one variant
|
||||||
|
if "#EXT-X-STREAM-INF:" in content:
|
||||||
|
variant_ok = await self._check_first_variant(
|
||||||
|
content, url, client
|
||||||
|
)
|
||||||
|
if not variant_ok:
|
||||||
|
return StreamHealth(
|
||||||
|
url=url,
|
||||||
|
is_live=False,
|
||||||
|
response_time_ms=elapsed_ms,
|
||||||
|
checked_at=checked_at,
|
||||||
|
bitrate=bitrate,
|
||||||
|
error="Master playlist OK but variant playlists are unreachable",
|
||||||
|
)
|
||||||
|
|
||||||
return StreamHealth(
|
return StreamHealth(
|
||||||
url=url,
|
url=url,
|
||||||
is_live=True,
|
is_live=True,
|
||||||
|
|
@ -188,6 +204,58 @@ class StreamHealthChecker:
|
||||||
error=f"Unexpected error: {e}",
|
error=f"Unexpected error: {e}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _check_first_variant(
|
||||||
|
self, content: str, base_url: str, client: httpx.AsyncClient
|
||||||
|
) -> bool:
|
||||||
|
"""Check that at least one variant playlist in a master playlist is reachable.
|
||||||
|
|
||||||
|
Extracts the first variant URI from a master playlist and does a HEAD
|
||||||
|
request to verify it returns 200/206. This catches streams where the
|
||||||
|
master playlist is valid but all variant playlists are 404.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: The master playlist text content.
|
||||||
|
base_url: The URL of the master playlist (for resolving relative URIs).
|
||||||
|
client: An existing httpx client to reuse.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if at least one variant is reachable, False otherwise.
|
||||||
|
"""
|
||||||
|
lines = content.splitlines()
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if not line.strip().startswith("#EXT-X-STREAM-INF:"):
|
||||||
|
continue
|
||||||
|
# Next non-empty, non-comment line is the variant URI
|
||||||
|
for j in range(i + 1, len(lines)):
|
||||||
|
variant_uri = lines[j].strip()
|
||||||
|
if variant_uri and not variant_uri.startswith("#"):
|
||||||
|
# Resolve relative URI
|
||||||
|
if not variant_uri.startswith(("http://", "https://")):
|
||||||
|
variant_uri = urljoin(base_url, variant_uri)
|
||||||
|
try:
|
||||||
|
resp = await client.head(variant_uri)
|
||||||
|
if resp.status_code in (200, 206):
|
||||||
|
return True
|
||||||
|
# HEAD might not be supported, try GET
|
||||||
|
resp = await client.get(
|
||||||
|
variant_uri,
|
||||||
|
headers={"Range": f"bytes=0-{MAX_CONTENT_BYTES - 1}"},
|
||||||
|
)
|
||||||
|
if resp.status_code in (200, 206):
|
||||||
|
return True
|
||||||
|
logger.debug(
|
||||||
|
"Variant playlist %s returned HTTP %d",
|
||||||
|
variant_uri, resp.status_code,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(
|
||||||
|
"Variant check failed for %s: %s", variant_uri, e
|
||||||
|
)
|
||||||
|
# Only check the first variant
|
||||||
|
return False
|
||||||
|
# No variants found (shouldn't happen if #EXT-X-STREAM-INF was detected)
|
||||||
|
return True
|
||||||
|
|
||||||
async def check_all(
|
async def check_all(
|
||||||
self, streams: list[dict],
|
self, streams: list[dict],
|
||||||
) -> dict[str, StreamHealth]:
|
) -> dict[str, StreamHealth]:
|
||||||
|
|
|
||||||
|
|
@ -97,21 +97,50 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function playStream(stream) {
|
function playStream(stream) {
|
||||||
if (!Hls) return;
|
|
||||||
|
|
||||||
// If already playing this stream, don't add a duplicate
|
// If already playing this stream, don't add a duplicate
|
||||||
if (isStreamActive(stream.url)) return;
|
const streamUrl = stream.stream_type === 'embed' ? stream.embed_url : stream.url;
|
||||||
|
if (isStreamActive(streamUrl)) return;
|
||||||
|
|
||||||
// If at max players, replace the last one
|
// If at max players, replace the last one
|
||||||
if (players.length >= MAX_PLAYERS) {
|
if (players.length >= MAX_PLAYERS) {
|
||||||
removePlayer(players.length - 1);
|
removePlayer(players.length - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (stream.stream_type === 'embed') {
|
||||||
|
// Embed/iframe player — no hls.js needed
|
||||||
|
const newPlayer = {
|
||||||
|
id: Date.now(),
|
||||||
|
proxyUrl: '',
|
||||||
|
originalUrl: stream.embed_url,
|
||||||
|
embedUrl: stream.embed_url,
|
||||||
|
streamType: 'embed',
|
||||||
|
siteKey: stream.site_key || '',
|
||||||
|
siteName: stream.site_name || stream.site_key || 'Unknown',
|
||||||
|
quality: stream.quality || '',
|
||||||
|
isPlaying: true,
|
||||||
|
isMuted: false,
|
||||||
|
volume: 1,
|
||||||
|
showControls: true,
|
||||||
|
error: null,
|
||||||
|
videoEl: null,
|
||||||
|
containerEl: null,
|
||||||
|
hls: null,
|
||||||
|
controlsTimer: null,
|
||||||
|
};
|
||||||
|
players = [...players, newPlayer];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// m3u8 player — use hls.js
|
||||||
|
if (!Hls) return;
|
||||||
|
|
||||||
const proxyUrl = getProxyUrl(stream.url);
|
const proxyUrl = getProxyUrl(stream.url);
|
||||||
const newPlayer = {
|
const newPlayer = {
|
||||||
id: Date.now(),
|
id: Date.now(),
|
||||||
proxyUrl,
|
proxyUrl,
|
||||||
originalUrl: stream.url,
|
originalUrl: stream.url,
|
||||||
|
embedUrl: '',
|
||||||
|
streamType: 'm3u8',
|
||||||
siteKey: stream.site_key || '',
|
siteKey: stream.site_key || '',
|
||||||
siteName: stream.site_name || stream.site_key || 'Unknown',
|
siteName: stream.site_name || stream.site_key || 'Unknown',
|
||||||
quality: stream.quality || '',
|
quality: stream.quality || '',
|
||||||
|
|
@ -296,12 +325,23 @@
|
||||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Video -->
|
<!-- Video or Iframe -->
|
||||||
<video
|
{#if player.streamType === 'embed'}
|
||||||
bind:this={player.videoEl}
|
<iframe
|
||||||
class="w-full aspect-video bg-black"
|
src={player.embedUrl}
|
||||||
playsinline
|
class="w-full aspect-video bg-black"
|
||||||
></video>
|
allow="autoplay; encrypted-media; fullscreen; picture-in-picture"
|
||||||
|
allowfullscreen
|
||||||
|
frameborder="0"
|
||||||
|
title="{player.siteName} stream"
|
||||||
|
></iframe>
|
||||||
|
{:else}
|
||||||
|
<video
|
||||||
|
bind:this={player.videoEl}
|
||||||
|
class="w-full aspect-video bg-black"
|
||||||
|
playsinline
|
||||||
|
></video>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Controls Overlay -->
|
<!-- Controls Overlay -->
|
||||||
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent px-3 py-2 transition-opacity duration-300 {player.showControls ? 'opacity-100' : 'opacity-0'}">
|
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent px-3 py-2 transition-opacity duration-300 {player.showControls ? 'opacity-100' : 'opacity-0'}">
|
||||||
|
|
@ -388,7 +428,7 @@
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
{#each streamsData.streams as stream, i}
|
{#each streamsData.streams as stream, i}
|
||||||
{@const active = isStreamActive(stream.url)}
|
{@const active = isStreamActive(stream.stream_type === 'embed' ? stream.embed_url : stream.url)}
|
||||||
<div class="bg-f1-surface border rounded-lg px-4 py-3 flex items-center gap-4 {active ? 'border-f1-red' : 'border-f1-border hover:border-f1-border'}">
|
<div class="bg-f1-surface border rounded-lg px-4 py-3 flex items-center gap-4 {active ? 'border-f1-red' : 'border-f1-border hover:border-f1-border'}">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
|
@ -396,6 +436,9 @@
|
||||||
{#if stream.is_live}
|
{#if stream.is_live}
|
||||||
<span class="text-[10px] font-bold uppercase px-1.5 py-0.5 rounded bg-f1-red text-white">Live</span>
|
<span class="text-[10px] font-bold uppercase px-1.5 py-0.5 rounded bg-f1-red text-white">Live</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if stream.stream_type === 'embed'}
|
||||||
|
<span class="text-[10px] font-bold uppercase px-1.5 py-0.5 rounded bg-blue-600 text-white">Embed</span>
|
||||||
|
{/if}
|
||||||
{#if active}
|
{#if active}
|
||||||
<span class="text-[10px] font-bold uppercase px-1.5 py-0.5 rounded bg-green-600 text-white">Playing</span>
|
<span class="text-[10px] font-bold uppercase px-1.5 py-0.5 rounded bg-green-600 text-white">Playing</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue