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:
Viktor Barzin 2026-03-01 14:35:19 +00:00
parent e36e192dd0
commit b7aa39353c
No known key found for this signature in database
GPG key ID: 0EB088298288D958
9 changed files with 614 additions and 25 deletions

View file

@ -11,10 +11,13 @@ Example:
registry.register(MySiteExtractor())
"""
from backend.extractors.aceztrims import AceztrimsExtractor
from backend.extractors.daddylive import DaddyLiveExtractor
from backend.extractors.demo import DemoExtractor
from backend.extractors.models import ExtractedStream
from backend.extractors.registry import ExtractorRegistry
from backend.extractors.service import ExtractionService
from backend.extractors.streamed import StreamedExtractor
__all__ = [
"ExtractedStream",
@ -34,7 +37,9 @@ def create_registry() -> ExtractorRegistry:
# --- Register extractors below ---
registry.register(DemoExtractor())
# registry.register(MySiteExtractor()) # Add new extractors here
registry.register(StreamedExtractor())
registry.register(DaddyLiveExtractor())
registry.register(AceztrimsExtractor())
return registry

View 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

View 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",
)

View file

@ -59,11 +59,11 @@ class DemoExtractor(BaseExtractor):
is_live=False,
),
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_name=self.site_name,
quality="",
title="Akamai Live Test Stream",
quality="1080p",
title="Tears of Steel (Test Stream)",
is_live=False,
),
]

View file

@ -18,6 +18,8 @@ class ExtractedStream:
response_time_ms: int = 0 # Health check response time (lower = better)
checked_at: str = "" # ISO timestamp of last health check
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:
"""Serialize to a plain dictionary for JSON responses."""
@ -32,4 +34,6 @@ class ExtractedStream:
"response_time_ms": self.response_time_ms,
"checked_at": self.checked_at,
"bitrate": self.bitrate,
"stream_type": self.stream_type,
"embed_url": self.embed_url,
}

View file

@ -45,18 +45,29 @@ class ExtractionService:
# Run health checks on all extracted streams
if streams:
stream_dicts = [s.to_dict() for s in streams]
health_map = await self._health_checker.check_all(stream_dicts)
# Separate m3u8 streams (need health check) from embed streams (skip)
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
for stream in 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
# Mark embed streams as live (no health check possible for iframes)
for stream in embed_streams:
stream.is_live = True
stream.response_time_ms = 0
stream.checked_at = start.isoformat()
# Health-check only m3u8 streams
if m3u8_streams:
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
new_cache: dict[str, list[ExtractedStream]] = {}

View 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

View file

@ -10,6 +10,7 @@ import logging
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from urllib.parse import urljoin
import httpx
@ -149,6 +150,21 @@ class StreamHealthChecker:
# Extract bitrate info if available
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(
url=url,
is_live=True,
@ -188,6 +204,58 @@ class StreamHealthChecker:
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(
self, streams: list[dict],
) -> dict[str, StreamHealth]:

View file

@ -97,21 +97,50 @@
}
function playStream(stream) {
if (!Hls) return;
// 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 (players.length >= MAX_PLAYERS) {
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 newPlayer = {
id: Date.now(),
proxyUrl,
originalUrl: stream.url,
embedUrl: '',
streamType: 'm3u8',
siteKey: stream.site_key || '',
siteName: stream.site_name || stream.site_key || 'Unknown',
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>
</button>
<!-- Video -->
<video
bind:this={player.videoEl}
class="w-full aspect-video bg-black"
playsinline
></video>
<!-- Video or Iframe -->
{#if player.streamType === 'embed'}
<iframe
src={player.embedUrl}
class="w-full aspect-video bg-black"
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 -->
<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}
<div class="space-y-2">
{#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="flex-1 min-w-0">
<div class="flex items-center gap-2">
@ -396,6 +436,9 @@
{#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>
{/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}
<span class="text-[10px] font-bold uppercase px-1.5 py-0.5 rounded bg-green-600 text-white">Playing</span>
{/if}