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 78c0956ab5
commit 51b8081594
9 changed files with 614 additions and 25 deletions

View file

@ -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

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, 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,
), ),
] ]

View file

@ -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,
} }

View file

@ -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]] = {}

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 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]:

View file

@ -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}