# streaming_providers/providers/rtlplus/models.py from dataclasses import dataclass from typing import Any, Dict, Optional from ...base.auth.base_auth import BaseAuthToken from ...base.auth.credentials import ClientCredentials, UserPasswordCredentials from ...base.models import Event, ContentType from .constants import RTLPlusDefaults @dataclass class RTLPlusUserCredentials(UserPasswordCredentials): """ RTL+ specific username/password credentials """ def __init__(self, username: str, password: str, client_id: Optional[str] = None, profile_id: Optional[str] = None): super().__init__( username=username, password=password, client_id=client_id or RTLPlusDefaults.CLIENT_ID, grant_type="password", ) self.profile_id = profile_id # Add this attribute def to_auth_payload(self) -> Dict[str, Any]: """Convert to authentication payload for RTL+""" payload = { "grant_type": self.grant_type, "username": self.username, "password": self.password, } if self.client_id: payload["client_id"] = self.client_id return payload @dataclass class RTLPlusClientCredentials(ClientCredentials): """ RTL+ specific client credentials (anonymous access) """ def __init__(self, client_id: Optional[str] = None, client_secret: Optional[str] = None): super().__init__( client_id=client_id or RTLPlusDefaults.ANONYMOUS_CLIENT_ID, client_secret=client_secret or RTLPlusDefaults.ANONYMOUS_CLIENT_SECRET, grant_type="client_credentials", ) class RTLPlusAuthToken(BaseAuthToken): """ RTL+ specific authentication token """ def __init__( self, access_token: str, token_type: str, expires_in: int, issued_at: float, refresh_token: Optional[str] = None, refresh_expires_in: int = 0, not_before_policy: Optional[int] = None, scope: str = "", login_client: Optional[str] = None, ): super().__init__( access_token=access_token, token_type=token_type, expires_in=expires_in, issued_at=issued_at, refresh_token=refresh_token, refresh_expires_in=refresh_expires_in, ) self.refresh_expires_in = refresh_expires_in self.not_before_policy = not_before_policy self.scope = scope # Which OAuth client_id this token (and its refresh_token) was # issued under. RTL+ registers separate clients per flow # (BEDROCK_CLIENT_ID for web login, DEVICE_CLIENT_ID for the # device-code/QR flow) and refresh_token grants must be replayed # against the SAME client_id or the auth server rejects them. # Mirrors the legacy addon's `login_client` tracking. self.login_client = login_client def to_dict(self) -> Dict[str, Any]: """Convert token to dictionary""" return { "access_token": self.access_token, "token_type": self.token_type, "expires_in": self.expires_in, "issued_at": self.issued_at, "refresh_token": self.refresh_token, "refresh_expires_in": self.refresh_expires_in, "not_before_policy": self.not_before_policy, "scope": self.scope, "login_client": self.login_client, } @classmethod def from_dict(cls, data: Dict[str, Any], issued_at: float) -> "RTLPlusAuthToken": """Create token from dictionary response""" return cls( access_token=data["access_token"], token_type=data.get("token_type", "Bearer"), expires_in=data.get("expires_in", 0), issued_at=issued_at, refresh_token=data.get("refresh_token"), refresh_expires_in=data.get("refresh_expires_in", 0), not_before_policy=data.get("not-before-policy"), scope=data.get("scope", ""), login_client=data.get("login_client"), ) def is_valid(self) -> bool: """Check if token is still valid""" import time if not self.access_token: return False current_time = time.time() buffer_time = 30 return current_time < (self.issued_at + self.expires_in - buffer_time) def is_anonymous_token(self) -> bool: """Check if this token was obtained via anonymous authentication.""" if not self.access_token: return True try: import base64 import json parts = self.access_token.split(".") if len(parts) < 2: return False payload_segment = parts[1] padding = 4 - len(payload_segment) % 4 if padding != 4: payload_segment += "=" * padding payload_json = base64.b64decode(payload_segment) payload = json.loads(payload_json) client_id = payload.get("clientId") is_guest = payload.get("isGuest", False) return is_guest and client_id == "anonymous-user" except Exception: return False @dataclass class RTLPlusChannel: """ RTL+ channel information """ id: str name: str slug: str logo_url: Optional[str] = None description: Optional[str] = None is_live: bool = True channel_type: str = "BROADCAST" sort_order: int = 0 def __post_init__(self): if not self.id or not self.name: raise ValueError("Channel must have both id and name") @classmethod def from_api_response(cls, data: Dict[str, Any]) -> "RTLPlusChannel": return cls( id=data["id"], name=data["name"], slug=data.get("slug", ""), logo_url=data.get("logoUrl"), description=data.get("description"), is_live=data.get("isLive", True), channel_type=data.get("channelType", "BROADCAST"), sort_order=data.get("sortOrder", 0), ) def to_dict(self) -> Dict[str, Any]: return { "id": self.id, "name": self.name, "slug": self.slug, "logo_url": self.logo_url, "description": self.description, "is_live": self.is_live, "channel_type": self.channel_type, "sort_order": self.sort_order, } @dataclass class RTLPlusLiveEvent: """ RTL+ API representation of a live event before conversion to the domain Event model. """ id: str title: str stream_start: str stream_end: str location: Optional[str] event_category: str event_sub_category: str description: Optional[str] short_description: Optional[str] logo_url: Optional[str] required_permission: str watch_path: str @classmethod def from_api_node(cls, node: Dict[str, Any]) -> "RTLPlusLiveEvent": details = node.get("details") or {} images = node.get("images") or {} return cls( id=node["id"], title=node["title"], stream_start=node["streamStart"], stream_end=node["streamEnd"], location=details.get("stadium"), event_category=node.get("eventCategory", "GENERIC"), event_sub_category=node.get("eventSubCategory", ""), description=node.get("description"), short_description=None, logo_url=( images.get("artworkLandscape", {}).get("url") or images.get("artworkPortrait", {}).get("url") ), required_permission=node.get("requiredPermission", RTLPlusDefaults.PERMISSION_PAY_TV), watch_path=node.get("urlData", {}).get("watchPath", ""), ) def to_event(self, provider: str = "rtlplus") -> "Event": """Convert to the domain Event model with lazy dateutil import.""" try: from dateutil.parser import isoparse except ImportError: # Fallback for environments without dateutil from datetime import datetime def isoparse(date_string): # Simple ISO parser for common formats return datetime.fromisoformat(date_string.replace('Z', '+00:00')) start = isoparse(self.stream_start) end = isoparse(self.stream_end) return Event( name=self.title, content_id=self.id, provider=provider, start_time=start, end_time=end, logo_url=self.logo_url, description=self.description, genre=self.event_sub_category, content_type=ContentType.LIVE, language="de", country="DE", venue=self.location, ) @dataclass class RTLPlusStreamInfo: """ RTL+ stream information """ manifest_url: str channel_id: str drm_license_url: Optional[str] = None drm_key_id: Optional[str] = None stream_type: str = "HLS" quality: str = "auto" def __post_init__(self): if not self.manifest_url or not self.channel_id: raise ValueError("Stream must have both manifest_url and channel_id") @classmethod def from_manifest_response(cls, data: Dict[str, Any], channel_id: str) -> "RTLPlusStreamInfo": return cls( manifest_url=data["url"], channel_id=channel_id, drm_license_url=data.get("drmLicenseUrl"), drm_key_id=data.get("drmKeyId"), stream_type=data.get("type", "HLS"), quality=data.get("quality", "auto"), ) def to_dict(self) -> Dict[str, Any]: return { "manifest_url": self.manifest_url, "channel_id": self.channel_id, "drm_license_url": self.drm_license_url, "drm_key_id": self.drm_key_id, "stream_type": self.stream_type, "quality": self.quality, } def has_drm(self) -> bool: return bool(self.drm_license_url and self.drm_key_id)