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
e36e192dd0
commit
b7aa39353c
9 changed files with 614 additions and 25 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
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,
|
||||
),
|
||||
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,
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]] = {}
|
||||
|
|
|
|||
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
|
||||
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]:
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue