From ec96e629b70f6008396897902f70412f15a038b5 Mon Sep 17 00:00:00 2001 From: Nirvana Date: Mon, 29 Jun 2026 12:09:25 +0200 Subject: [PATCH] movetv: return eepg-entries --- .../providers/movetv/epg_manager.py | 358 +++++++----------- .../providers/movetv/provider.py | 6 +- 2 files changed, 149 insertions(+), 215 deletions(-) diff --git a/lib/streaming_providers/providers/movetv/epg_manager.py b/lib/streaming_providers/providers/movetv/epg_manager.py index 6128235..5c69a22 100644 --- a/lib/streaming_providers/providers/movetv/epg_manager.py +++ b/lib/streaming_providers/providers/movetv/epg_manager.py @@ -27,44 +27,44 @@ from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Tuple from ...base.utils.logger import logger +from ...base.models.epg_models import EPGEntry, EPGFlags from .constants import MoveTVConfig class MoveTvEpgManager: """ - Fetches and normalises EPG data from the MoveTV API. + Fetches and normalises EPG data from the MoveTV API into EPGEntry + objects (the same typed contract used by EPGOperations and the other + providers — see streaming_providers/base/models/epg_models.py). - Returns programme objects with the following fields: + Field mapping from the raw MoveTV API payload onto EPGEntry: - Core identifiers: - epg_id, schedule_id, live_id, live_name, content_id + epgId -> program_id, and broadcast_id when > 0 + (falls back to EPGEntry.encode_broadcast_id() + so the provider can still be recovered from + the broadcast_id alone for catchup lookups) + title (cleaned) -> title + title (raw, S/E) -> episode_name (only when season/episode parsed) + originalTitle -> original_title + epgDesc -> description + start / end (ms) -> start / end (seconds) + tagInfo.name -> genre_description + director -> directors (single-item list) + actor -> cast (list, comma-split) + year -> year + rating -> star_rating + picture.background-> icon + season/episode -> season_number / episode_number (+ IS_SERIES flag) - 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 + Known lossy fields + ------------------ + EPGEntry has no slots for: schedule_id, live_id, live_name, content_id, + producer, multiple categories/category_ids/category_images, genre_id, + or the secondary images (poster, square_logo, poster_mark, + original_title_logo). These were present in the old dict-based return + value and are now dropped. Confirm nothing else in the MoveTV pipeline + (e.g. catchup/manifest matching) reads those keys before relying on + this contract. """ def __init__(self, authenticator: Any) -> None: @@ -97,7 +97,7 @@ class MoveTvEpgManager: # Absorb any other kwargs forwarded by EPGOperations / provider.get_epg() # so we never raise on unknown arguments. **_kwargs: Any, - ) -> List[Dict[str, Any]]: + ) -> List[EPGEntry]: """ Fetch the EPG schedule for a single live channel. @@ -133,8 +133,8 @@ class MoveTvEpgManager: Returns ------- - List of normalised programme dicts (see ``_parse_items``), or an - empty list on any error. + List of EPGEntry objects (see ``_parse_items``), or an empty list + on any error or for items that fail EPGEntry validation. """ # ---------------------------------------------------------------- # Step 1 — resolve the time anchor and API window parameters @@ -238,7 +238,7 @@ class MoveTvEpgManager: logger.debug( f"MoveTV EPG: Received {len(items)} programme(s) for channel {channel_id}" ) - return self._parse_items(items) + return self._parse_items(items, channel_id) def get_channels_epg( self, @@ -247,14 +247,15 @@ class MoveTvEpgManager: forwards: int = 2, start_time: Optional[datetime] = None, end_time: Optional[datetime] = None, - ) -> Dict[str, List[Dict[str, Any]]]: + ) -> Dict[str, List[EPGEntry]]: """ Convenience wrapper: fetch EPG for multiple channels. - Returns a dict keyed by channel_id. Missing / failed channels are - present with an empty list so callers don't have to guard KeyError. + Returns a dict keyed by channel_id, each value a list of EPGEntry + objects. Missing / failed channels are present with an empty list + so callers don't have to guard KeyError. """ - result: Dict[str, List[Dict[str, Any]]] = {} + result: Dict[str, List[EPGEntry]] = {} for cid in channel_ids: result[cid] = self.get_channel_epg( cid, @@ -373,200 +374,131 @@ class MoveTvEpgManager: # Native backwards/forwards — anchor to now return datetime.now(tz=timezone.utc), backwards, forwards - @staticmethod - def _ms_to_utc_str(ms: Optional[int]) -> Optional[str]: + def _parse_items( + self, items: List[Dict[str, Any]], channel_id: str + ) -> List[EPGEntry]: """ - Convert a millisecond epoch timestamp to an ISO-8601 UTC string. + Normalise raw API programme objects into EPGEntry objects. - Example: 1775052120000 → '2026-04-30T12:02:00+00:00' + channel_id is needed (in addition to each item's own fields) only + as a fallback input to EPGEntry.encode_broadcast_id() for items + that arrive without a usable epgId. - Returns None when *ms* is falsy (None / 0). - """ - if not ms: - return None - dt = datetime.fromtimestamp(ms / 1000, tz=timezone.utc) - return dt.isoformat() - - def _parse_items(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """ - Normalise raw API programme objects into a consistent internal format. - - 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) + Items that are missing start/end, or that fail EPGEntry's own + validation (e.g. empty title after season/episode stripping), are + logged and skipped rather than raising — matching the previous + "skip and continue" behaviour for malformed entries. """ import re - parsed: List[Dict[str, Any]] = [] + parsed: List[EPGEntry] = [] for item in items: start_ms: Optional[int] = item.get("start") end_ms: Optional[int] = item.get("end") - # ------------------------------------------------------------ - # Parse categories with their IDs and images - # ------------------------------------------------------------ - categories = [] - category_ids = [] - category_images = [] + if not start_ms or not end_ms: + continue - for cat in item.get("categories") or []: - if cat.get("name"): - categories.append(cat["name"]) - if cat.get("categoryId"): - category_ids.append(cat["categoryId"]) - cat_picture = cat.get("picture") or {} - if cat_picture.get("icon"): - category_images.append( - MoveTVConfig.build_image_url(cat_picture["icon"]) + start_s = start_ms // 1000 + end_s = end_ms // 1000 + + try: + # ------------------------------------------------------------ + # 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()] + if actor_raw + else [] + ) + + # ------------------------------------------------------------ + # 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 + + match = re.search(r'S(\d+):E(\d+)', title, re.IGNORECASE) + if not match: + match = re.search(r'S(\d+)E(\d+)', title, re.IGNORECASE) + if not match: + match = re.search(r'S(\d+)\s+E(\d+)', title, re.IGNORECASE) + if not match: + match = re.search(r'\(S(\d+)[,\s]+E(\d+)\)', title, re.IGNORECASE) + if not match: + match = re.search( + r'Season\s+(\d+)\s+Episode\s+(\d+)', title, re.IGNORECASE ) - # ------------------------------------------------------------ - # 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()] - if actor_raw - else [] - ) + if match: + season_num = int(match.group(1)) + episode_num = int(match.group(2)) + 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() - # ------------------------------------------------------------ - # 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 + # ------------------------------------------------------------ + # Images — only "background" maps onto EPGEntry.icon; the + # other picture variants (poster, square_logo, poster_mark, + # original_title_logo) have no EPGEntry field and are lost. + # ------------------------------------------------------------ + picture = item.get("picture") or {} + icon = MoveTVConfig.build_image_url(picture.get("background")) - # 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) + # ------------------------------------------------------------ + # Broadcast ID — prefer the API's own epgId (stable across + # requests); fall back to the canonical provider-aware + # encoder so catchup code can still recover the provider + # from the broadcast_id alone (see EPGEntry.encode_broadcast_id + # / get_provider_hash docs in epg_models.py). + # ------------------------------------------------------------ + epg_id_raw = item.get("epgId") + broadcast_id = ( + int(epg_id_raw) + if epg_id_raw and int(epg_id_raw) > 0 + else EPGEntry.encode_broadcast_id( + "movetv", channel_id, start_s + ) + ) - 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() + entry = EPGEntry( + broadcast_id=broadcast_id, + title=title, + start=start_s, + end=end_s, + program_id=str(epg_id_raw) if epg_id_raw else None, + description=item.get("epgDesc"), + episode_name=raw_title if season_num else None, + original_title=item.get("originalTitle"), + year=item.get("year"), + icon=icon, + cast=cast, + directors=[item["director"]] if item.get("director") else [], + genre_description=(item.get("tagInfo") or {}).get("name"), + season_number=season_num, + episode_number=episode_num, + star_rating=item.get("rating") or None, + ) - # ------------------------------------------------------------ - # Collect all available images - # ------------------------------------------------------------ - picture = item.get("picture") or {} - 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} + if season_num is not None: + entry.set_flag(EPGFlags.IS_SERIES) - # ------------------------------------------------------------ - # 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"), + parsed.append(entry) - # 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") or {}).get("name"), - "genre_id": (item.get("tagInfo") or {}).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, - }) + except Exception as exc: + logger.warning(f"MoveTV EPG: Error parsing item: {exc} — {item}") + continue return parsed \ No newline at end of file diff --git a/lib/streaming_providers/providers/movetv/provider.py b/lib/streaming_providers/providers/movetv/provider.py index 2a8ad42..cc6fa0f 100644 --- a/lib/streaming_providers/providers/movetv/provider.py +++ b/lib/streaming_providers/providers/movetv/provider.py @@ -6,6 +6,7 @@ from typing import ClassVar, Dict, List, Optional, Any, Tuple from ...base.models import DRMConfig, StreamingChannel from ...base.models.proxy_models import ProxyConfig +from ...base.models.epg_models import EPGEntry from ...base.provider import StreamingProvider from ...base.utils.logger import logger from .auth import MoveTVAuthenticator @@ -716,7 +717,7 @@ class MoveTVProvider(StreamingProvider): backwards: int = 2, forwards: int = 2, **kwargs, - ) -> List[Dict]: + ) -> List[EPGEntry]: """ Return the EPG schedule for *channel_id*. @@ -732,7 +733,8 @@ class MoveTVProvider(StreamingProvider): Returns ------- - List of normalised programme dicts from ``MoveTvEpgManager``. + List of EPGEntry objects from ``MoveTvEpgManager`` (see + epg_manager.py for the field mapping and known lossy fields). """ # content_id IS the contentId the EPG endpoint expects — no mapping needed. return self._epg.get_channel_epg(