From b7aa39353cdaed6ac3191bb9800def86f3a7d624 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sun, 1 Mar 2026 14:35:19 +0000 Subject: [PATCH] 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. --- .../files/backend/extractors/__init__.py | 7 +- .../files/backend/extractors/aceztrims.py | 154 +++++++++++++++ .../files/backend/extractors/daddylive.py | 181 ++++++++++++++++++ .../files/backend/extractors/demo.py | 6 +- .../files/backend/extractors/models.py | 4 + .../files/backend/extractors/service.py | 33 ++-- .../files/backend/extractors/streamed.py | 123 ++++++++++++ stacks/f1-stream/files/backend/health.py | 68 +++++++ .../frontend/src/routes/watch/+page.svelte | 63 +++++- 9 files changed, 614 insertions(+), 25 deletions(-) create mode 100644 stacks/f1-stream/files/backend/extractors/aceztrims.py create mode 100644 stacks/f1-stream/files/backend/extractors/daddylive.py create mode 100644 stacks/f1-stream/files/backend/extractors/streamed.py diff --git a/stacks/f1-stream/files/backend/extractors/__init__.py b/stacks/f1-stream/files/backend/extractors/__init__.py index 2b5fffdf..49b5c4d7 100644 --- a/stacks/f1-stream/files/backend/extractors/__init__.py +++ b/stacks/f1-stream/files/backend/extractors/__init__.py @@ -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 diff --git a/stacks/f1-stream/files/backend/extractors/aceztrims.py b/stacks/f1-stream/files/backend/extractors/aceztrims.py new file mode 100644 index 00000000..fb397928 --- /dev/null +++ b/stacks/f1-stream/files/backend/extractors/aceztrims.py @@ -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= → 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= — 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 diff --git a/stacks/f1-stream/files/backend/extractors/daddylive.py b/stacks/f1-stream/files/backend/extractors/daddylive.py new file mode 100644 index 00000000..631b0fdb --- /dev/null +++ b/stacks/f1-stream/files/backend/extractors/daddylive.py @@ -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']+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", + ) diff --git a/stacks/f1-stream/files/backend/extractors/demo.py b/stacks/f1-stream/files/backend/extractors/demo.py index d1cb2785..95b5cee1 100644 --- a/stacks/f1-stream/files/backend/extractors/demo.py +++ b/stacks/f1-stream/files/backend/extractors/demo.py @@ -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, ), ] diff --git a/stacks/f1-stream/files/backend/extractors/models.py b/stacks/f1-stream/files/backend/extractors/models.py index 5d665544..dc0e6fd6 100644 --- a/stacks/f1-stream/files/backend/extractors/models.py +++ b/stacks/f1-stream/files/backend/extractors/models.py @@ -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, } diff --git a/stacks/f1-stream/files/backend/extractors/service.py b/stacks/f1-stream/files/backend/extractors/service.py index 30b9b390..79fbfbd9 100644 --- a/stacks/f1-stream/files/backend/extractors/service.py +++ b/stacks/f1-stream/files/backend/extractors/service.py @@ -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]] = {} diff --git a/stacks/f1-stream/files/backend/extractors/streamed.py b/stacks/f1-stream/files/backend/extractors/streamed.py new file mode 100644 index 00000000..1e5f0b0e --- /dev/null +++ b/stacks/f1-stream/files/backend/extractors/streamed.py @@ -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 diff --git a/stacks/f1-stream/files/backend/health.py b/stacks/f1-stream/files/backend/health.py index 63f92fdb..c6a7b1c2 100644 --- a/stacks/f1-stream/files/backend/health.py +++ b/stacks/f1-stream/files/backend/health.py @@ -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]: diff --git a/stacks/f1-stream/files/frontend/src/routes/watch/+page.svelte b/stacks/f1-stream/files/frontend/src/routes/watch/+page.svelte index 48c2b48a..b34bc0b8 100644 --- a/stacks/f1-stream/files/frontend/src/routes/watch/+page.svelte +++ b/stacks/f1-stream/files/frontend/src/routes/watch/+page.svelte @@ -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 @@ - - + + {#if player.streamType === 'embed'} + + {:else} + + {/if}
@@ -388,7 +428,7 @@ {:else}
{#each streamsData.streams as stream, i} - {@const active = isStreamActive(stream.url)} + {@const active = isStreamActive(stream.stream_type === 'embed' ? stream.embed_url : stream.url)}
@@ -396,6 +436,9 @@ {#if stream.is_live} Live {/if} + {#if stream.stream_type === 'embed'} + Embed + {/if} {#if active} Playing {/if}