mirror of
https://github.com/Viren070/MediaFusion.git
synced 2025-12-01 23:21:11 +01:00
531 lines
20 KiB
Python
531 lines
20 KiB
Python
import logging
|
|
import random
|
|
from datetime import datetime, timedelta
|
|
from typing import Optional, List
|
|
from urllib.parse import urljoin
|
|
|
|
import httpx
|
|
import humanize
|
|
import pytz
|
|
from dateutil import parser as date_parser
|
|
|
|
from db.config import settings
|
|
from db.models import MediaFusionTVMetaData, TVStreams, MediaFusionEventsMetaData
|
|
from db.redis_database import REDIS_ASYNC_CLIENT
|
|
from utils import crypto
|
|
from utils.config import config_manager
|
|
from utils.runtime_const import SPORTS_ARTIFACTS
|
|
|
|
|
|
class DLHDScheduleService:
|
|
def __init__(
|
|
self, start_within_next_hours: int = 1, started_within_hours_ago: int = 6
|
|
):
|
|
self.name = "dlhd"
|
|
self.schedule_url = config_manager.get_scraper_config(self.name, "schedule_url")
|
|
self.m3u8_base_url = config_manager.get_scraper_config(
|
|
self.name, "m3u8_base_url"
|
|
)
|
|
self.server_lookup_url = config_manager.get_scraper_config(
|
|
self.name, "server_lookup_url"
|
|
)
|
|
self.referer = config_manager.get_scraper_config(self.name, "referer")
|
|
self.gmt = pytz.timezone("Etc/GMT")
|
|
self.start_within_next_hours = start_within_next_hours
|
|
self.started_within_hours_ago = started_within_hours_ago
|
|
self.category_map = config_manager.get_scraper_config(
|
|
self.name, "category_mapping"
|
|
)
|
|
self.last_scrape_key = "dlhd:last_scrape_time"
|
|
self._category_max_times = {}
|
|
|
|
async def get_last_scrape_time(self) -> Optional[float]:
|
|
"""Get the timestamp of the last schedule scrape"""
|
|
last_scrape = await REDIS_ASYNC_CLIENT.get(self.last_scrape_key)
|
|
return float(last_scrape) if last_scrape else None
|
|
|
|
async def update_last_scrape_time(self):
|
|
"""Update the timestamp of the last schedule scrape"""
|
|
current_time = datetime.now(tz=self.gmt).timestamp()
|
|
await REDIS_ASYNC_CLIENT.set(self.last_scrape_key, str(current_time))
|
|
|
|
async def should_refresh_schedule(self) -> bool:
|
|
"""
|
|
Check if schedule should be refreshed based on last scrape time
|
|
Returns True if last scrape was more than half of start_within_next_hours ago
|
|
"""
|
|
last_scrape = await self.get_last_scrape_time()
|
|
if not last_scrape:
|
|
return True
|
|
|
|
current_time = datetime.now(tz=self.gmt).timestamp()
|
|
refresh_threshold = (
|
|
self.start_within_next_hours * 3600
|
|
) / 2 # Half of the window in seconds
|
|
return (current_time - last_scrape) > refresh_threshold
|
|
|
|
def format_event_time(self, event_timestamp: int) -> str:
|
|
"""Format event time in specified timezone"""
|
|
event_time = datetime.fromtimestamp(event_timestamp)
|
|
return humanize.naturaltime(event_time)
|
|
|
|
async def fetch_and_parse_schedule(self) -> dict:
|
|
"""Fetch and parse the schedule data"""
|
|
logging.info("Fetching fresh schedule data from DLHD")
|
|
try:
|
|
async with httpx.AsyncClient(proxy=settings.requests_proxy_url) as client:
|
|
response = await client.get(
|
|
self.schedule_url,
|
|
headers={"Referer": urljoin(self.schedule_url, "/")},
|
|
)
|
|
if response.status_code == 200:
|
|
return response.json()
|
|
logging.error(f"Failed to fetch schedule: {response.status_code}")
|
|
except Exception as e:
|
|
logging.error(f"Error fetching schedule: {e}")
|
|
return {}
|
|
|
|
def create_event_id(self, title: str) -> str:
|
|
"""Create a unique event ID"""
|
|
return f"mfdlhd{crypto.get_text_hash(title)}"
|
|
|
|
def is_event_in_timewindow(self, event_datetime: datetime) -> bool:
|
|
"""Check if event is within the configured time window"""
|
|
current_time = datetime.now(tz=self.gmt)
|
|
time_difference = event_datetime - current_time
|
|
|
|
# For future events
|
|
if time_difference > timedelta(0):
|
|
return time_difference <= timedelta(hours=self.start_within_next_hours)
|
|
|
|
# For past events
|
|
return time_difference >= timedelta(hours=-self.started_within_hours_ago)
|
|
|
|
def create_event_metadata(
|
|
self, event: dict, date_str: str, mapped_category: str
|
|
) -> Optional[MediaFusionEventsMetaData]:
|
|
"""
|
|
Create event metadata from schedule data with corrected date handling.
|
|
All times are handled in UK time (GMT) to match the schedule format.
|
|
|
|
The function implements a "high water mark" algorithm for detecting day rollovers:
|
|
1. For each category, we track the maximum time seen so far in the sequence
|
|
2. If a new time is less than the max time seen, it's considered next day's event
|
|
3. If a new time is equal or greater than max time, it updates the max time
|
|
"""
|
|
try:
|
|
# Parse the schedule date and ensure it's in GMT
|
|
uk_date = date_parser.parse(date_str.split(" - ")[0]).date()
|
|
|
|
# Parse event time and combine with date in GMT
|
|
event_time = datetime.strptime(event["time"], "%H:%M").time()
|
|
event_datetime = datetime.combine(uk_date, event_time)
|
|
event_datetime_gmt = self.gmt.localize(event_datetime)
|
|
|
|
should_roll_to_next_day = False
|
|
|
|
# Get the maximum time seen for this category
|
|
max_time = self._category_max_times.get(mapped_category)
|
|
|
|
if max_time is not None:
|
|
if event_time < max_time:
|
|
# If current time is less than max time seen, it's a next day event
|
|
should_roll_to_next_day = True
|
|
logging.debug(
|
|
f"GMT time rollover in {mapped_category}: Current time {event_time} < Max time {max_time}"
|
|
)
|
|
else:
|
|
# Update max time if current time is equal or greater
|
|
self._category_max_times[mapped_category] = max(
|
|
max_time, event_time
|
|
)
|
|
logging.debug(
|
|
f"Updated max time for {mapped_category} to {self._category_max_times[mapped_category]}"
|
|
)
|
|
else:
|
|
# First event in the sequence
|
|
self._category_max_times[mapped_category] = event_time
|
|
|
|
# Adjust the date if needed
|
|
if should_roll_to_next_day:
|
|
event_datetime_gmt += timedelta(days=1)
|
|
logging.info(
|
|
f"Adjusted event date for {event['event']} "
|
|
f"from {uk_date} to {event_datetime_gmt.date()} GMT due to high water mark {max_time}"
|
|
)
|
|
|
|
# Check if event is within our time window
|
|
if not self.is_event_in_timewindow(event_datetime_gmt):
|
|
return None
|
|
|
|
event_start_timestamp = int(event_datetime_gmt.timestamp())
|
|
meta_id = self.create_event_id(event["event"])
|
|
|
|
return MediaFusionEventsMetaData(
|
|
id=meta_id,
|
|
title=event["event"],
|
|
description="",
|
|
genres=[mapped_category],
|
|
event_start_timestamp=event_start_timestamp,
|
|
poster=random.choice(SPORTS_ARTIFACTS[mapped_category]["poster"]),
|
|
background=random.choice(
|
|
SPORTS_ARTIFACTS[mapped_category]["background"]
|
|
),
|
|
logo=random.choice(SPORTS_ARTIFACTS[mapped_category]["logo"]),
|
|
is_add_title_to_poster=True,
|
|
streams=[],
|
|
)
|
|
except Exception as e:
|
|
logging.error(f"Error creating event metadata: {str(e)}")
|
|
return None
|
|
|
|
async def find_tv_channel(
|
|
self, channel_name: str
|
|
) -> Optional[MediaFusionTVMetaData]:
|
|
"""Find TV channel in database"""
|
|
return await MediaFusionTVMetaData.find_one({"title": channel_name})
|
|
|
|
def create_stream(
|
|
self,
|
|
server_key: str,
|
|
channel_id: str,
|
|
channel_name: str,
|
|
meta_id: str,
|
|
server_type: str,
|
|
) -> TVStreams:
|
|
"""Create a new stream for a channel"""
|
|
url = self.m3u8_base_url.format(
|
|
server_key=server_key,
|
|
server_type=server_type,
|
|
channel_id=channel_id,
|
|
)
|
|
|
|
return TVStreams(
|
|
meta_id=meta_id,
|
|
name=channel_name,
|
|
source="DaddyLiveHD",
|
|
url=url,
|
|
behaviorHints={
|
|
"notWebReady": True,
|
|
"proxyHeaders": {
|
|
"request": {
|
|
"Referer": self.referer,
|
|
"Origin": self.referer.rstrip("/"),
|
|
},
|
|
},
|
|
},
|
|
)
|
|
|
|
async def _process_single_channel(
|
|
self,
|
|
client: httpx.AsyncClient,
|
|
channel: dict,
|
|
meta_id: str,
|
|
is_sd: bool,
|
|
stats: dict,
|
|
) -> Optional[list[TVStreams]]:
|
|
"""Process a single channel and create/find its streams"""
|
|
try:
|
|
channel_name = channel.get("channel_name")
|
|
channel_id = channel.get("channel_id")
|
|
|
|
if not channel_name or not channel_id:
|
|
logging.warning(f"Skipping channel with missing data: {channel}")
|
|
stats["failed"] += 1
|
|
return None
|
|
|
|
tv_channel = await self.find_tv_channel(channel_name)
|
|
if (
|
|
tv_channel and not is_sd
|
|
): # Only look for existing streams for HD channels
|
|
try:
|
|
channel_streams = await TVStreams.find(
|
|
{
|
|
"meta_id": tv_channel.id,
|
|
"namespaces": "mediafusion",
|
|
"is_working": True,
|
|
}
|
|
).to_list(None)
|
|
|
|
if channel_streams:
|
|
stats["found"] += len(channel_streams)
|
|
return channel_streams
|
|
|
|
except Exception as e:
|
|
logging.error(
|
|
f"Error finding existing streams for channel {channel_name}: {str(e)}"
|
|
)
|
|
|
|
server_type = "bet" if is_sd else "premium"
|
|
server_url = self.server_lookup_url.format(
|
|
server_type=server_type, channel_id=channel_id
|
|
)
|
|
|
|
server_response = await client.get(
|
|
server_url,
|
|
headers={
|
|
"Referer": f"{self.referer}premiumtv/daddylivehd.php?id={channel_id}",
|
|
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
|
|
},
|
|
)
|
|
|
|
if (
|
|
server_response.status_code != 200
|
|
or server_response.headers.get("Content-Type") != "application/json"
|
|
):
|
|
logging.error(
|
|
f"Failed to fetch server data for channel: {channel_name} ({channel_id})"
|
|
)
|
|
stats["failed"] += 1
|
|
return None
|
|
|
|
server_data = server_response.json()
|
|
server_key = server_data.get("server_key")
|
|
if not server_key:
|
|
logging.error(
|
|
f"Failed to find server key for channel: {channel_name} ({channel_id})"
|
|
)
|
|
stats["failed"] += 1
|
|
return None
|
|
|
|
# Create new stream if no existing streams found or if it's SD
|
|
channel_name += (
|
|
"\n⚠️ Stream only available during the event" if is_sd else ""
|
|
)
|
|
try:
|
|
stream = self.create_stream(
|
|
server_key, channel_id, channel_name, meta_id, server_type
|
|
)
|
|
stats["created"] += 1
|
|
return [stream]
|
|
except Exception as e:
|
|
logging.error(
|
|
f"Error creating stream for channel {channel_name}: {str(e)}"
|
|
)
|
|
stats["failed"] += 1
|
|
return None
|
|
|
|
except Exception as e:
|
|
logging.error(f"Error processing channel: {str(e)}")
|
|
stats["failed"] += 1
|
|
return None
|
|
|
|
async def _process_channel_list(
|
|
self, channels: list | dict, meta_id: str, is_sd: bool, stats: dict
|
|
) -> List[TVStreams]:
|
|
"""Process a list or dictionary of channels"""
|
|
streams = []
|
|
|
|
if not channels: # Early return if channels is empty
|
|
return streams
|
|
client = httpx.AsyncClient(proxy=settings.requests_proxy_url)
|
|
|
|
try:
|
|
if isinstance(channels, dict):
|
|
channel_items = channels.values()
|
|
elif isinstance(channels, list):
|
|
channel_items = channels
|
|
else:
|
|
logging.warning(f"Unexpected channels format: {type(channels)}")
|
|
return streams
|
|
|
|
for channel in channel_items:
|
|
channel_streams = await self._process_single_channel(
|
|
client, channel, meta_id, is_sd, stats
|
|
)
|
|
if channel_streams:
|
|
streams.extend(channel_streams)
|
|
|
|
except Exception as e:
|
|
logging.error(f"Error processing channel list: {str(e)}")
|
|
|
|
await client.aclose()
|
|
return streams
|
|
|
|
async def get_event_streams(self, event: dict, meta_id: str) -> list[TVStreams]:
|
|
"""Get all available streams for an event"""
|
|
stats = {
|
|
"found": 0, # Number of existing streams found
|
|
"created": 0, # Number of new streams created
|
|
"failed": 0, # Number of failed attempts
|
|
}
|
|
|
|
streams = []
|
|
|
|
# Process main channels (HD)
|
|
if "channels" in event:
|
|
hd_streams = await self._process_channel_list(
|
|
event["channels"], meta_id, is_sd=False, stats=stats
|
|
)
|
|
streams.extend(hd_streams)
|
|
|
|
# Process SD channels
|
|
if "channels2" in event:
|
|
sd_streams = await self._process_channel_list(
|
|
event["channels2"], meta_id, is_sd=True, stats=stats
|
|
)
|
|
streams.extend(sd_streams)
|
|
|
|
if stats["failed"] > 0:
|
|
logging.warning(
|
|
"Event %s: %d channels failed to process", meta_id, stats["failed"]
|
|
)
|
|
|
|
logging.debug(
|
|
"Event %s: Found %s existing streams, created %s new streams, failed %s streams",
|
|
meta_id,
|
|
stats["found"],
|
|
stats["created"],
|
|
stats["failed"],
|
|
)
|
|
|
|
return streams
|
|
|
|
async def cache_event(self, event: MediaFusionEventsMetaData):
|
|
"""Cache single event in Redis with appropriate TTL"""
|
|
event_key = f"event:{event.id}"
|
|
event_json = event.model_dump_json(exclude_none=True)
|
|
|
|
# Calculate TTL: time until event start + buffer period after start
|
|
current_time = datetime.now(tz=self.gmt)
|
|
event_time = datetime.fromtimestamp(event.event_start_timestamp, self.gmt)
|
|
|
|
if event_time > current_time:
|
|
# Future event: TTL = time until event + buffer after start
|
|
ttl = int(
|
|
(event_time - current_time).total_seconds()
|
|
+ self.started_within_hours_ago * 3600
|
|
)
|
|
else:
|
|
# Past event: TTL = remaining time in the window
|
|
ttl = int(
|
|
(
|
|
event_time
|
|
+ timedelta(hours=self.started_within_hours_ago)
|
|
- current_time
|
|
).total_seconds()
|
|
)
|
|
|
|
# Ensure minimum TTL of 1 hour
|
|
ttl = max(3600, ttl)
|
|
|
|
await REDIS_ASYNC_CLIENT.set(event_key, event_json, ex=ttl)
|
|
await REDIS_ASYNC_CLIENT.zadd(
|
|
"events:all", {event_key: event.event_start_timestamp}
|
|
)
|
|
for genre in event.genres:
|
|
await REDIS_ASYNC_CLIENT.zadd(
|
|
f"events:genre:{genre}", {event_key: event.event_start_timestamp}
|
|
)
|
|
|
|
async def parse_and_cache_schedule(self) -> List[MediaFusionEventsMetaData]:
|
|
"""Parse schedule and cache it in Redis"""
|
|
schedule_data = await self.fetch_and_parse_schedule()
|
|
if not schedule_data:
|
|
return []
|
|
|
|
events_metadata = []
|
|
processed_count = 0
|
|
skipped_count = 0
|
|
|
|
for date_section, sports in schedule_data.items():
|
|
self._category_max_times = {}
|
|
|
|
for sport, events in sports.items():
|
|
# cleanup sport name
|
|
sport = sport.replace("</span>", "")
|
|
mapped_category = self.category_map.get(sport, "Other Sports")
|
|
for event in events:
|
|
metadata = self.create_event_metadata(
|
|
event, date_section, mapped_category
|
|
)
|
|
if not metadata:
|
|
skipped_count += 1
|
|
continue
|
|
|
|
metadata.streams = await self.get_event_streams(event, metadata.id)
|
|
await self.cache_event(metadata)
|
|
|
|
metadata.poster = (
|
|
f"{settings.poster_host_url}/poster/events/{metadata.id}.jpg"
|
|
)
|
|
events_metadata.append(metadata)
|
|
processed_count += 1
|
|
|
|
logging.info(
|
|
f"Processed {processed_count} events, skipped {skipped_count} events outside time window"
|
|
)
|
|
|
|
# Update last scrape time after successful processing
|
|
await self.update_last_scrape_time()
|
|
return events_metadata
|
|
|
|
async def get_scheduled_events(
|
|
self,
|
|
force_refresh: bool = False,
|
|
genre: Optional[str] = None,
|
|
skip: int = 0,
|
|
limit: int = 25,
|
|
) -> List[MediaFusionEventsMetaData]:
|
|
"""Get scheduled events with pagination and genre filtering"""
|
|
events = []
|
|
current_time = datetime.now(tz=self.gmt)
|
|
min_time = int(
|
|
(current_time - timedelta(hours=self.started_within_hours_ago)).timestamp()
|
|
)
|
|
max_time = int(
|
|
(current_time + timedelta(hours=self.start_within_next_hours)).timestamp()
|
|
)
|
|
|
|
# Check if we need to refresh based on last scrape time
|
|
should_refresh = await self.should_refresh_schedule()
|
|
|
|
if not force_refresh and not should_refresh:
|
|
cache_key = f"events:all" if not genre else f"events:genre:{genre}"
|
|
event_keys = await REDIS_ASYNC_CLIENT.zrevrangebyscore(
|
|
cache_key, max_time, min_time, start=skip, num=limit
|
|
)
|
|
|
|
if not event_keys and genre and skip != 0:
|
|
logging.info(
|
|
"No events found in cache for the specified time window, pagination or genre"
|
|
)
|
|
return []
|
|
|
|
logging.info(f"Found {len(event_keys)} events in cache")
|
|
for event_key in event_keys:
|
|
event_json = await REDIS_ASYNC_CLIENT.get(event_key)
|
|
if event_json:
|
|
event = MediaFusionEventsMetaData.model_validate_json(event_json)
|
|
event.poster = (
|
|
f"{settings.poster_host_url}/poster/events/{event.id}.jpg"
|
|
)
|
|
event.description = f"🎬 {event.title} - ⏰ {self.format_event_time(event.event_start_timestamp)}"
|
|
events.append(event)
|
|
else:
|
|
await REDIS_ASYNC_CLIENT.zrem(cache_key, event_key)
|
|
|
|
if events or genre or skip != 0:
|
|
# return cached events if found or genre is specified
|
|
return events
|
|
|
|
# If we need to refresh or don't have enough cached events, fetch new data
|
|
all_events = await self.parse_and_cache_schedule()
|
|
filtered_events = [
|
|
event for event in all_events if not genre or genre in event.genres
|
|
]
|
|
filtered_events.sort(key=lambda x: x.event_start_timestamp, reverse=True)
|
|
|
|
start_idx = min(skip, len(filtered_events))
|
|
end_idx = min(start_idx + limit, len(filtered_events))
|
|
events = filtered_events[start_idx:end_idx]
|
|
|
|
for event in events:
|
|
event.poster = f"{settings.poster_host_url}/poster/events/{event.id}.jpg"
|
|
event.description = f"🎬 {event.title} - ⏰ {self.format_event_time(event.event_start_timestamp)}"
|
|
|
|
return events
|
|
|
|
|
|
dlhd_schedule_service = DLHDScheduleService()
|