diff --git a/lib/streaming_providers/providers/movetv/epg_manager.py b/lib/streaming_providers/providers/movetv/epg_manager.py index 1d7b74f..93e4664 100644 --- a/lib/streaming_providers/providers/movetv/epg_manager.py +++ b/lib/streaming_providers/providers/movetv/epg_manager.py @@ -31,7 +31,41 @@ from .constants import MoveTVConfig class MoveTvEpgManager: - """Fetches and normalises EPG data from the MoveTV API.""" + """ + Fetches and normalises EPG data from the MoveTV API. + + Returns programme objects with the following fields: + + Core identifiers: + epg_id, schedule_id, live_id, live_name, content_id + + Title & Description: + title (cleaned), original_title, plot, episode_title + + Episode Info (parsed from title): + season_num, episode_num, has_episode_info + + Time: + start, end (ISO-8601 UTC strings) + start_ms, end_ms (millisecond timestamps) + + Genre & Categories: + genre (name), genre_id (numeric) + categories (list of names) + category_ids (list of numeric IDs) + category_images (list of icon URLs) + + Credits: + director, cast (list), producer + + Metadata: + year, rating + + Images: + thumbnail (background fanart) - legacy + images dict containing any of: + background, icon, poster, square_logo, poster_mark, original_title_logo + """ def __init__(self, authenticator: Any) -> None: """ @@ -338,38 +372,71 @@ class MoveTvEpgManager: """ Normalise raw API programme objects into a consistent internal format. - Field mapping - ------------- - title ← item["title"] - plot ← item["epgDesc"] - start ← item["start"] (ms epoch → ISO-8601 UTC) - end ← item["end"] (ms epoch → ISO-8601 UTC) - start_ms ← item["start"] (raw ms, kept for easy sorting / math) - end_ms ← item["end"] (raw ms) - genre ← item["tagInfo"]["name"] (e.g. "SERIJA", "INFO") - categories ← list of category name strings - thumbnail ← item["picture"]["background"] (absolute URL) - director ← item["director"] - cast ← item["actor"] split on "," - year ← item["year"] - rating ← item["rating"] (parental-advisory integer) - epg_id ← item["epgId"] - schedule_id ← item["scheduleId"] - live_id ← item["liveId"] - live_name ← item["liveName"] + Field mapping (enhanced) + ------------------------ + Basic info: + epg_id, schedule_id, live_id, live_name, content_id + title, original_title, plot + + Time: + start, end (ISO-8601 UTC) + start_ms, end_ms (raw milliseconds) + + Episode (parsed from title): + season_num, episode_num + episode_title (title without season/episode suffix) + + Genre & Categories: + genre (name from tagInfo) + genre_id (numeric from tagInfo) + categories (list of category names) + category_ids (list of category IDs) + category_images (list of category icon URLs) + + Credits: + director, cast (list), producer + + Metadata: + year, rating + + Images (all available): + thumbnail (background) - primary for backward compatibility + images dict containing: + - background (wide fanart) + - icon (small channel/program icon) + - poster (movie/show poster) + - square_logo (square logo) + - poster_mark (promotional image) + - original_title_logo (title logo) """ + import re + parsed: List[Dict[str, Any]] = [] for item in items: start_ms: Optional[int] = item.get("start") - end_ms: Optional[int] = item.get("end") + end_ms: Optional[int] = item.get("end") - categories: List[str] = [ - cat["name"] - for cat in item.get("categories", []) - if cat.get("name") - ] + # ------------------------------------------------------------ + # Parse categories with their IDs and images + # ------------------------------------------------------------ + categories = [] + category_ids = [] + category_images = [] + for cat in item.get("categories", []): + if cat.get("name"): + categories.append(cat["name"]) + if cat.get("categoryId"): + category_ids.append(cat["categoryId"]) + if cat.get("picture", {}).get("icon"): + category_images.append( + MoveTVConfig.build_image_url(cat["picture"]["icon"]) + ) + + # ------------------------------------------------------------ + # Parse cast (split by comma) + # ------------------------------------------------------------ actor_raw: Optional[str] = item.get("actor") cast: List[str] = ( [a.strip() for a in actor_raw.split(",") if a.strip()] @@ -377,28 +444,109 @@ class MoveTvEpgManager: else [] ) - parsed.append( - { - "epg_id": item.get("epgId"), - "schedule_id": item.get("scheduleId"), - "live_id": item.get("liveId"), - "live_name": item.get("liveName"), - "title": item.get("title"), - "plot": item.get("epgDesc"), - "start": self._ms_to_utc_str(start_ms), - "end": self._ms_to_utc_str(end_ms), - "start_ms": start_ms, - "end_ms": end_ms, - "genre": item.get("tagInfo", {}).get("name"), - "categories": categories, - "rating": item.get("rating", 0), - "year": item.get("year"), - "director": item.get("director"), - "cast": cast, - "thumbnail": MoveTVConfig.build_image_url( - item.get("picture", {}).get("background") - ), - } - ) + # ------------------------------------------------------------ + # Parse season and episode from title + # Supports multiple patterns: + # - "Title S6:E2" (most common) + # - "Title S6E2" + # - "Title S6 E2" + # - "Title (S6, E2)" + # - "Title - Season 6 Episode 2" + # ------------------------------------------------------------ + raw_title = item.get("title", "") + title = raw_title + season_num = None + episode_num = None + + # Pattern 1: S6:E2 (MoveTV format) + match = re.search(r'S(\d+):E(\d+)', title, re.IGNORECASE) + if not match: + # Pattern 2: S6E2 + match = re.search(r'S(\d+)E(\d+)', title, re.IGNORECASE) + if not match: + # Pattern 3: S6 E2 + match = re.search(r'S(\d+)\s+E(\d+)', title, re.IGNORECASE) + if not match: + # Pattern 4: (S6, E2) or (S6 E2) + match = re.search(r'\(S(\d+)[,\s]+E(\d+)\)', title, re.IGNORECASE) + if not match: + # Pattern 5: Season 6 Episode 2 + match = re.search(r'Season\s+(\d+)\s+Episode\s+(\d+)', title, re.IGNORECASE) + + if match: + season_num = int(match.group(1)) + episode_num = int(match.group(2)) + # Remove season/episode from title for cleaner display + title = re.sub(r'\s*S\d+:[Ee]\d+\s*', '', title) + title = re.sub(r'\s*S\d+[Ee]\d+\s*', '', title) + title = re.sub(r'\s*\(S\d+[,\s]+E\d+\)\s*', '', title) + title = re.sub(r'\s*Season\s+\d+\s+Episode\s+\d+\s*', '', title) + title = title.strip() + + # ------------------------------------------------------------ + # Collect all available images + # ------------------------------------------------------------ + picture = item.get("picture", {}) + images = { + "background": MoveTVConfig.build_image_url(picture.get("background")), + "icon": MoveTVConfig.build_image_url(picture.get("icon")), + "poster": MoveTVConfig.build_image_url(picture.get("poster")), + "square_logo": MoveTVConfig.build_image_url(picture.get("squareLogo")), + "poster_mark": MoveTVConfig.build_image_url(picture.get("posterMark")), + "original_title_logo": MoveTVConfig.build_image_url( + picture.get("originalTitleLogo") + ), + } + # Remove None values + images = {k: v for k, v in images.items() if v} + + # ------------------------------------------------------------ + # Build the programme object + # ------------------------------------------------------------ + parsed.append({ + # Core identifiers + "epg_id": item.get("epgId"), + "schedule_id": item.get("scheduleId"), + "live_id": item.get("liveId"), + "live_name": item.get("liveName"), + "content_id": item.get("contentId"), + + # Title and description + "title": title, + "original_title": item.get("originalTitle"), + "plot": item.get("epgDesc"), + "episode_title": raw_title if season_num else None, # Original with S/E + + # Episode information (CRITICAL for PVR) + "season_num": season_num, + "episode_num": episode_num, + "has_episode_info": season_num is not None, + + # Time information + "start": self._ms_to_utc_str(start_ms), + "end": self._ms_to_utc_str(end_ms), + "start_ms": start_ms, + "end_ms": end_ms, + + # Genre and categories (enhanced) + "genre": item.get("tagInfo", {}).get("name"), + "genre_id": item.get("tagInfo", {}).get("tagId"), + "categories": categories, + "category_ids": category_ids, + "category_images": category_images if category_images else None, + + # Rating and year + "rating": item.get("rating", 0), + "year": item.get("year"), + + # Credits + "director": item.get("director"), + "cast": cast, + "producer": item.get("producer"), + + # Images (enhanced) + "thumbnail": images.get("background"), # Keep for backward compatibility + "images": images if images else None, + }) return parsed \ No newline at end of file