diff --git a/packages/envied/CONFIG.md b/packages/envied/CONFIG.md index 323bf17..499e234 100644 --- a/packages/envied/CONFIG.md +++ b/packages/envied/CONFIG.md @@ -125,7 +125,7 @@ curl_impersonate: ## directories (dict) -Override the default directories used across envied. +Override the default directories used across envied. The directories are set to common values by default. The following directories are available and may be overridden, @@ -246,7 +246,7 @@ decryption: mp4decrypt ## filenames (dict) -Override the default filenames used across envied. +Override the default filenames used across envied. The filenames use various variables that are replaced during runtime. The following filenames are available and may be overridden: @@ -305,7 +305,7 @@ accessible outside their hosting platform. ### Using an API Vault API vaults use a specific HTTP request format, therefore API or HTTP Key Vault APIs from other projects or services may -not work in envied. The API format can be seen in the [API Vault Code](envied/vaults/API.py). +not work in envied.The API format can be seen in the [API Vault Code](envied/vaults/API.py). ```yaml - type: API diff --git a/packages/envied/README.md b/packages/envied/README.md index 00fe3c4..fdc4d66 100644 --- a/packages/envied/README.md +++ b/packages/envied/README.md @@ -8,7 +8,7 @@ ## What is envied? Envied is a fork of [Devine](https://github.com/devine-dl/devine/). The name 'envied' is an anagram of Devine, and as such, pays homage to the original author. -Is is based on v 1.4.7 of envied. It is a powerful archival tool for downloading movies, TV shows, and music from streaming services. Built with a focus on modularity and extensibility, it provides a robust framework for content acquisition with support for DRM-protected content. +Is is based on v 1.4.7 of envied.It is a powerful archival tool for downloading movies, TV shows, and music from streaming services. Built with a focus on modularity and extensibility, it provides a robust framework for content acquisition with support for DRM-protected content. No commands have been changed 'uv run envied' still works as usual. diff --git a/packages/envied/src/envied/services/ADN/__init__.py b/packages/envied/src/envied/services/ADN/__init__.py index 30b7a51..dc9e2f2 100644 --- a/packages/envied/src/envied/services/ADN/__init__.py +++ b/packages/envied/src/envied/services/ADN/__init__.py @@ -33,28 +33,28 @@ from envied.core.tracks.subtitle import Subtitle class VideoNoAudio(Video): """ - Video track qui enlève automatiquement l'audio après téléchargement. - Nécessaire car ADN fournit des streams HLS avec audio muxé. + Video track that automatically removes audio after recording. + Necessary because ADN provides HLS streams with muxed audio. """ def download(self, session, prepare_drm, max_workers=None, progress=None, *, cdm=None): - """Override : télécharge puis demuxe pour enlever l'audio.""" + """Override: download then demux to remove audio.""" import logging log = logging.getLogger('ADN.VideoNoAudio') - # Téléchargement normal + # Normal download super().download(session, prepare_drm, max_workers, progress, cdm=cdm) - # Si pas de path, échec du téléchargement + # If no path, download failed if not self.path or not self.path.exists(): return - # Vérifier FFmpeg disponible + # Check FFmpeg available if not binaries.FFMPEG: log.warning("FFmpeg not found, cannot remove audio from video") return - # Demuxer : enlever l'audio + # Demux: remove audio if progress: progress(downloaded="Removing audio") @@ -68,8 +68,8 @@ class VideoNoAudio(Video): [ binaries.FFMPEG, '-i', str(original_path), - '-vcodec', 'copy', # Copie vidéo sans réencodage - '-an', # Enlève l'audio + '-vcodec', 'copy', # Copy video without re-encoding + '-an', # Remove audio '-y', str(noaudio_path) ], @@ -88,7 +88,7 @@ class VideoNoAudio(Video): noaudio_path.unlink(missing_ok=True) return - # Remplacer le fichier original + # Replace original file log.debug(f"Video demuxed successfully: {noaudio_path.stat().st_size} bytes") original_path.unlink() noaudio_path.rename(original_path) @@ -106,30 +106,30 @@ class VideoNoAudio(Video): class AudioExtracted(Audio): """ - Audio track déjà extrait d'un flux HLS muxé. - Override download() pour copier le fichier au lieu de télécharger. + Audio track already extracted from muxed HLS stream. + Override download() to copy the file instead of downloading. """ def __init__(self, *args, extracted_path: Path, **kwargs): - # URL vide pour éviter que curl essaie de télécharger + # Empty URL to prevent curl from trying to download super().__init__(*args, url="", **kwargs) self.extracted_path = extracted_path def download(self, session, prepare_drm, max_workers=None, progress=None, *, cdm=None): - """Override : copie le fichier extrait au lieu de télécharger.""" + """Override: copies the extracted file instead of downloading.""" if not self.extracted_path or not self.extracted_path.exists(): if progress: progress(downloaded="[red]FAILED") raise ValueError(f"Extracted audio file not found: {self.extracted_path}") - # Créer le path de destination (même logique que Track.download) + # Create destination path (same logic as Track.download) track_type = self.__class__.__name__ save_path = config.directories.temp / f"{track_type}_{self.id}.m4a" if progress: progress(downloaded="Copying", total=100, completed=0) - # Copier le fichier extrait vers le path final + # Copy the extracted file to the final destination config.directories.temp.mkdir(parents=True, exist_ok=True) shutil.copy2(self.extracted_path, save_path) @@ -141,30 +141,30 @@ class AudioExtracted(Audio): class SubtitleEmbedded(Subtitle): """ - Subtitle avec contenu embarqué (data URI). - Override download() pour écrire le contenu directement. + Subtitle with embedded content (data URI). + Override download() to write the content directly. """ def __init__(self, *args, embedded_content: str, **kwargs): - # URL vide pour éviter que curl essaie de télécharger + # Empty URL to prevent curl from trying to download super().__init__(*args, url="", **kwargs) self.embedded_content = embedded_content def download(self, session, prepare_drm, max_workers=None, progress=None, *, cdm=None): - """Override : écrit le contenu embarqué au lieu de télécharger.""" + """Override: writes the embedded content instead of downloading.""" if not self.embedded_content: if progress: progress(downloaded="[red]FAILED") raise ValueError("No embedded content in subtitle") - # Créer le path de destination + # Create destination path track_type = "Subtitle" save_path = config.directories.temp / f"{track_type}_{self.id}.{self.codec.extension}" if progress: progress(downloaded="Writing", total=100, completed=0) - # Écrire le contenu + # Write content config.directories.temp.mkdir(parents=True, exist_ok=True) save_path.write_text(self.embedded_content, encoding='utf-8') @@ -216,29 +216,29 @@ KhS+IFEqwvZqgbBpKuwIDAQAB @staticmethod @click.command( name="ADN", - short_help="Téléchargement depuis Animation Digital Network", + short_help="https://animationdigitalnetwork.com", help=( - "Télécharge des séries ou films depuis ADN.\n\n" - "TITLE : L'URL de la série ou son ID (ex: 1125).\n\n" - "SYSTÈME DE SÉLECTION :\n" - " - Simple : '-e 1-5' (épisodes 1 à 5)\n" - " - Saisons : '-e S2' ou '-e S02' (toute la saison 2) ou '-e S2E1-12'\n" - " - Mixte : '-e 1,3,S2E5' ou '-e 1,3,S02E05'\n" - " - Bonus : '-e NC1,OAV1'" + "Downloads series or movies from ADN.\n\n" + "TITLE: Series URL or ID (eg. 1125).\n\n" + "SELECTION SYSTEM:\n" + " - Simple: '-e 1-5' (episodes 1 to 5)\n" + " - Seasons: '-e S2' or '-e S02' (all season 2) or '-e S2E1-12'\n" + " - Mixed: '-e 1,3,S2E5' or '-e 1,3,S02E05'\n" + " - Bonus: '-e NC1,OAV1'" ) ) @click.argument("title", type=str, required=True) @click.option( "-e", "--episode", "select", type=str, - help="Sélection : numéros, plages (5-10), saisons (S1, S2) ou combiné (S1E5)." + help="Selection: numbers, ranges (5-10), seasons (S1, S2) or combined (S1E5)." ) @click.option( "--but", is_flag=True, - help="Inverse la sélection : télécharge tout SAUF les épisodes spécifiés avec -e." + help="Invert selection: download everything EXCEPT episodes specified with -e." ) @click.option( "--all", "all_eps", is_flag=True, - help="Ignore toutes les restrictions et télécharge l'intégralité de la série." + help="Ignore all restrictions and download the entire series." ) @click.pass_context def cli(ctx, **kwargs) -> "ADN": @@ -277,7 +277,7 @@ KhS+IFEqwvZqgbBpKuwIDAQAB } def ensure_authenticated(self) -> None: - """Vérifie le token et rafraîchit si nécessaire.""" + """Checks the token and refreshes if necessary.""" current_time = int(time.time()) if self.access_token and self.token_expiration and current_time < (self.token_expiration - 60): @@ -340,13 +340,13 @@ KhS+IFEqwvZqgbBpKuwIDAQAB self.token_expiration = int(time.time()) + expires_in self.session.headers.update(self.auth_header) - def _parse_select(self, ep_id: str, short_number: str, season_num: int) -> bool: - """Retourne True si l'épisode doit être inclus.""" + def _parse_select(self, ep_id: str, short_number: str, season_num: int, relative_number: Optional[int] = None) -> bool: + """Returns True if the episode should be included.""" if self.all_eps or not self.select_str: return True - # Préparation des identifiants possibles pour cet épisode - # On teste : "30353" (id), "1" (numéro), "S02E01" (format complet), "S02" (saison entière) + # Preparing possible identifiers for this episode + # We test: "30353" (id), "1" (number), "S02E01" (full format), "S02" (entire season) candidates = [ str(ep_id), str(short_number).lstrip("0"), @@ -354,25 +354,29 @@ KhS+IFEqwvZqgbBpKuwIDAQAB f"S{season_num:02d}" ] + # Add the relative match (e.g. S03E06 for episode 30 which is the 6th ep in S3) + if relative_number is not None: + candidates.append(f"S{season_num:02d}E{relative_number:02d}") + parts = re.split(r'[ ,]+', self.select_str.strip().upper()) selection: set[str] = set() for part in parts: if '-' in part: start_p, end_p = part.split('-', 1) - # Gestion des plages S02E01-S02E04 + # Handle ranges S02E01-S02E04 m_start = re.match(r'^S(\d+)E(\d+)$', start_p) m_end = re.match(r'^S(\d+)E(\d+)$', end_p) if m_start and m_end: s_start, e_start = map(int, m_start.groups()) s_end, e_end = map(int, m_end.groups()) - if s_start == s_end: # Même saison + if s_start == s_end: # Same season for i in range(e_start, e_end + 1): selection.add(f"S{s_start:02d}E{i:02d}") continue - # Plages classiques (1-10) + # Classic ranges (1-10) nums = re.findall(r'\d+', part) if len(nums) >= 2: for i in range(int(nums[0]), int(nums[1]) + 1): @@ -384,18 +388,18 @@ KhS+IFEqwvZqgbBpKuwIDAQAB return not included if self.but else included def get_titles(self) -> Series: - """Récupère les épisodes avec le titre réel de la série.""" + """Retrieves episodes with the actual title of the series.""" show_id = self.parse_show_id(self.title) - # 1. Récupérer d'abord les infos globales du show pour avoir le titre propre + # 1. Fetch the overall show info first to get the proper title show_url = self.config["endpoints"]["show"].format(show_id=show_id) show_res = self.session.get(show_url).json() - # On extrait le titre de la série (ex: "Demon Slave") - # C'est ce titre qui servira de nom au dossier unique + # We extract the series title (e.g. "Demon Slave") + # This title will be used as the unique folder name series_title = show_res["videos"][0]["show"]["title"] if show_res.get("videos") else "ADN Show" - # 2. Récupérer ensuite la structure par saisons + # 2. Fetch the season structure afterwards url_seasons = self.config["endpoints"].get("seasons") if not url_seasons: url_seasons = "https://gw.api.animationdigitalnetwork.com/video/show/{show_id}/seasons?maxAgeCategory=18&order=asc" @@ -403,7 +407,7 @@ KhS+IFEqwvZqgbBpKuwIDAQAB res = self.session.get(url_seasons.format(show_id=show_id)).json() if not res.get("seasons"): - self.log.error(f"Aucune saison trouvée pour l'ID {show_id}") + self.log.error(f"No seasons found for ID {show_id}") return Series([]) episodes = [] @@ -411,40 +415,178 @@ KhS+IFEqwvZqgbBpKuwIDAQAB s_val = str(season_data.get("season", "1")) season_num = int(s_val) if s_val.isdigit() else 1 - for vid in season_data.get("videos", []): + for idx, vid in enumerate(season_data.get("videos", []), 1): video_id = str(vid["id"]) - # Nettoyage du numéro d'épisode (on ne garde que les chiffres) + # Clean the episode number (keep only digits) num_match = re.search(r'\d+', str(vid.get("number", "0"))) short_number = num_match.group() if num_match else "0" - # Logique de sélection (SxxEyy) - if not self._parse_select(video_id, short_number, season_num): + # Selection logic (SxxEyy) - Relative and absolute support + if not self._parse_select(video_id, short_number, season_num, relative_number=idx): continue - # Création de l'épisode + # Create the episode episodes.append(Episode( id_=video_id, service=self.__class__, - title=series_title, # Dossier : "Demon Slave" - season=season_num, # Saison : 2 - number=int(short_number), - name=vid.get("name") or "", # Nom : "La grande réunion..." + title=series_title, # Folder: "Demon Slave" + season=season_num, # Season: 2 + number=idx, # Force relative number (e.g. 30 becomes 6 in S3) + name=vid.get("name") or "", # Name: "The big reunion..." data=vid )) episodes.sort(key=lambda x: (x.season, x.number)) return Series(episodes) + def get_discovery(self, n: int = 12) -> list[dict]: + """ + Fetch the latest releases (Series) via the catalog API. + """ + self.ensure_authenticated() + + try: + url = self.config["endpoints"]["search"] + response = self.session.get( + url, + params={ + "maxAgeCategory": 18, + "order": "new", + "limit": n + } + ) + + if response.status_code != 200: + self.log.error(f"Catalog fetch failed: {response.status_code}") + return [] + + data = response.json() + return data.get("shows", []) + + except Exception as e: + self.log.error(f"Error fetching discovery: {e}") + return [] + + def get_latest_releases_calendar(self, n: int = 12) -> list[dict]: + """ + Fetch the latest releases (Episodes) via the calendar API. + Returns recent episodes with their actual release dates. + """ + from datetime import datetime, timedelta + import requests + + # self.ensure_authenticated() + + try: + results = [] + seen_episodes = set() + + # Use a fresh session to avoid auth issues if calendar is public + cal_session = requests.Session() + cal_session.headers.update({ + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" + }) + + # Fetch calendar for the last 7 days including today + today = datetime.now() + + for day_offset in range(15): + date = today - timedelta(days=day_offset) + date_str = date.strftime("%Y-%m-%d") + + # Calendar endpoint + calendar_url = f"https://gw.api.animationdigitalnetwork.com/video/calendar?maxAgeCategory=18&date={date_str}" + + try: + res = cal_session.get(calendar_url, timeout=5) + if res.status_code != 200: continue + + data = res.json() + # Handle dict response (API returns {"videos": [...]}) + if isinstance(data, dict): + data = data.get("videos", []) + + for video in data: + show = video.get("show", {}) + show_id = str(show.get("id", "")) + ep_id = str(video.get("id", "")) + + if (show_id, ep_id) in seen_episodes: continue + seen_episodes.add((show_id, ep_id)) + + results.append(video) + except: + pass + + # Sort by releaseDate + results.sort(key=lambda x: x.get("releaseDate", ""), reverse=True) + return results[:n] + + except Exception as e: + self.log.error(f"Error fetching calendar discovery: {e}") + return [] + + def get_latest_episode(self, show_id: str) -> Episode | None: + """ + Retrieves the absolute latest episode of a series without filtering. + Used by the GUI to display 'Latest' info. + """ + try: + # 1. Fetch seasons + url_seasons = self.config["endpoints"].get("seasons") + if not url_seasons: + url_seasons = "https://gw.api.animationdigitalnetwork.com/video/show/{show_id}/seasons?maxAgeCategory=18&order=asc" + + res = self.session.get(url_seasons.format(show_id=show_id)) + if res.status_code != 200: + return None + + data = res.json() + if not data.get("seasons"): + return None + + episodes = [] + for season_data in data["seasons"]: + s_val = str(season_data.get("season", "1")) + season_num = int(s_val) if s_val.isdigit() else 1 + + for vid in season_data.get("videos", []): + # Simplified parsing similar to get_titles + num_match = re.search(r'\d+', str(vid.get("number", "0"))) + short_number = int(num_match.group()) if num_match else 0 + + episodes.append(Episode( + id_=str(vid["id"]), + service=self.__class__, + title="", # We don't need the series title here for simple display + season=season_num, + number=short_number, + name=vid.get("name") or "", + data=vid + )) + + if not episodes: + return None + + # Sort: Season desc, Number desc to find absolute latest? + # Or Season asc, Number asc and take [-1] + episodes.sort(key=lambda x: (x.season, x.number)) + return episodes[-1] + + except Exception as e: + self.log.error(f"Error in get_latest_episode: {e}") + return None + def get_tracks(self, title: Episode) -> Tracks: """ - Récupère les pistes en pré-extrayant les audios. - Les audios sont extraits maintenant et seront copiés pendant download(). + Fetches tracks by pre-extracting audio. + Audio is extracted now and will be copied during download(). """ self.ensure_authenticated() vid_id = title.id - # Configuration du lecteur + # Player configuration config_url = self.config["endpoints"]["player_config"].format(video_id=vid_id) config_res = self.session.get(config_url).json() @@ -452,7 +594,7 @@ KhS+IFEqwvZqgbBpKuwIDAQAB if not player_opts["user"]["hasAccess"]: raise PermissionError("No access to this video (Premium required?)") - # Token du lecteur + # Player token refresh_url = player_opts["user"].get("refreshTokenUrl") or self.config["endpoints"]["player_refresh"] token_res = self.session.post( refresh_url, @@ -462,7 +604,7 @@ KhS+IFEqwvZqgbBpKuwIDAQAB player_token = token_res["token"] links_url = player_opts["video"].get("url") or self.config["endpoints"]["player_links"].format(video_id=vid_id) - # Chiffrement RSA + # RSA Encryption rand_key = uuid.uuid4().hex[:16] payload = json.dumps({"k": rand_key, "t": player_token}).encode('utf-8') @@ -474,7 +616,7 @@ KhS+IFEqwvZqgbBpKuwIDAQAB encrypted = public_key.encrypt(payload, padding.PKCS1v15()) auth_header_val = base64.b64encode(encrypted).decode('utf-8') - # Récupération des liens + # Fetching links links_res = self.session.get( links_url, params={"freeWithAds": "true", "adaptive": "true", "withMetadata": "true", "source": "Web"}, @@ -484,7 +626,7 @@ KhS+IFEqwvZqgbBpKuwIDAQAB tracks = Tracks() streaming_links = links_res.get("links", {}).get("streaming", {}) - # Map des langues + # Language map lang_map = { "vf": "fr", "vostf": "ja", @@ -492,7 +634,7 @@ KhS+IFEqwvZqgbBpKuwIDAQAB "vostde": "ja", } - # Priorité: VOSTF (original) pour la vidéo principale + # Priority: VOSTF (original) for the main video priority_order = ["vostf", "vf", "vde", "vostde"] available_streams = {k: v for k, v in streaming_links.items() if k in lang_map} @@ -504,7 +646,7 @@ KhS+IFEqwvZqgbBpKuwIDAQAB if not sorted_streams: raise ValueError("No supported streams found") - # Vidéo principale (VOSTF ou premier disponible) + # Main video (VOSTF or first available) primary_stream = sorted_streams[0] primary_lang = lang_map[primary_stream] @@ -521,7 +663,7 @@ KhS+IFEqwvZqgbBpKuwIDAQAB tracks.add(video_track) self.log.info(f"Video track added: {video_track.width}x{video_track.height}") - # Extraire audios pour toutes les langues disponibles + # Extract audio for all available languages for stream_type in sorted_streams: audio_lang = lang_map[stream_type] is_original = stream_type in ["vostf", "vostde"] @@ -540,12 +682,12 @@ KhS+IFEqwvZqgbBpKuwIDAQAB tracks.add(audio_track, warn_only=True) self.log.info(f"Audio track added: {audio_lang}") - # Stocker les données de chapitres pour get_chapters() + # Store chapter data for get_chapters() if "video" in links_res: title.data["chapter_data"] = links_res["video"] self.log.debug(f"Stored chapter data: intro={links_res['video'].get('tcIntroStart')}, ending={links_res['video'].get('tcEndingStart')}") - # Sous-titres + # Subtitles self._process_subtitles(links_res, rand_key, title, tracks) if not tracks.videos: @@ -554,7 +696,7 @@ KhS+IFEqwvZqgbBpKuwIDAQAB return tracks def _get_video_track(self, stream_data: dict, stream_type: str, lang: str, is_original: bool): - """Récupère la piste vidéo principale (sans audio).""" + """Fetches the main video track (without audio).""" try: m3u8_url = self._resolve_stream_url(stream_data, stream_type) if not m3u8_url: @@ -567,13 +709,13 @@ KhS+IFEqwvZqgbBpKuwIDAQAB self.log.warning(f"No video tracks found for {stream_type}") return None - # Meilleure qualité + # Best quality best_video = max( hls_tracks.videos, key=lambda v: (v.height or 0, v.width or 0, v.bitrate or 0) ) - # Convertir en VideoNoAudio pour demuxer automatiquement + # Convert to VideoNoAudio to demux automatically video_no_audio = VideoNoAudio( id_=best_video.id, url=best_video.url, @@ -599,36 +741,67 @@ KhS+IFEqwvZqgbBpKuwIDAQAB def _extract_audio_track(self, stream_data: dict, stream_type: str, lang: str, is_original: bool, title: Episode): """ - Extrait l'audio et retourne un AudioExtracted. - L'audio est extrait MAINTENANT et sera copié pendant download(). + Extracts audio and returns an AudioExtracted. + Audio is extracted NOW and will be copied during download(). """ if not binaries.FFMPEG: self.log.warning("FFmpeg not found, cannot extract audio") return None try: - m3u8_url = self._resolve_stream_url(stream_data, stream_type) + m3u8_url = self._resolve_stream_url(stream_data, stream_type, prioritize_auto=True) if not m3u8_url: return None - # Créer un répertoire temp pour ADN dans le temp d'Unshackle + # Create an ADN temp directory inside envied.s temp adn_temp = config.directories.temp / "adn_audio_extracts" adn_temp.mkdir(parents=True, exist_ok=True) - # Nom de fichier unique basé sur video_id + langue + # Unique filename based on video_id + language audio_filename = f"audio_{title.id}_{stream_type}.m4a" audio_path = adn_temp / audio_filename - # Si déjà extrait, réutiliser + # If already extracted, reuse it if audio_path.exists() and audio_path.stat().st_size > 1000: self.log.debug(f"Reusing existing extracted audio: {audio_path}") else: - # Extraire avec FFmpeg + # Parse M3U8 to find best audio + best_m3u8_url = m3u8_url + try: + import m3u8 + variant_m3u8 = m3u8.load(m3u8_url) + + audio_playlists = [] + # Check for alternative audio in media + for media in variant_m3u8.media: + if media.type == "AUDIO" and media.uri: + audio_playlists.append(media.uri) + + # If no media, check strict playlists (variants) + if not audio_playlists and variant_m3u8.playlists: + # Sort by bandwidth descending + sorted_playlists = sorted(variant_m3u8.playlists, key=lambda x: x.stream_info.bandwidth or 0, reverse=True) + audio_playlists = [p.uri for p in sorted_playlists] + + if audio_playlists: + # Construct absolute URL if needed + from urllib.parse import urljoin + best_audio_uri = audio_playlists[0] + if not best_audio_uri.startswith("http"): + best_m3u8_url = urljoin(m3u8_url, best_audio_uri) + else: + best_m3u8_url = best_audio_uri + self.log.info(f"Selected best audio stream: {best_m3u8_url}") + + except Exception as e: + self.log.warning(f"Failed to parse M3U8 for best audio, using default: {e}") + + # Extract with FFmpeg result = subprocess.run( [ binaries.FFMPEG, - '-i', m3u8_url, + '-i', best_m3u8_url, '-vn', '-acodec', 'copy', '-y', @@ -649,14 +822,35 @@ KhS+IFEqwvZqgbBpKuwIDAQAB audio_path.unlink(missing_ok=True) return None - # Créer AudioExtracted avec le fichier pré-extrait + # Detect actual bitrate + detected_bitrate = 128000 + try: + from pymediainfo import MediaInfo + media_info = MediaInfo.parse(audio_path) + if media_info.audio_tracks: + track = media_info.audio_tracks[0] + if track.bit_rate: + detected_bitrate = int(track.bit_rate) + elif track.other_bit_rate: + # Fallback for some formats + try: + # other_bit_rate is typically list like ['128 kb/s'] + raw = track.other_bit_rate[0] + detected_bitrate = int(re.sub(r'[^\d]', '', raw)) * 1000 + except: + pass + self.log.debug(f"Detected audio bitrate: {detected_bitrate}") + except Exception as e: + self.log.warning(f"Failed to detect bitrate: {e}") + + # Create AudioExtracted with the pre-extracted file audio_track = AudioExtracted( id_=f"audio-{stream_type}-{lang}", extracted_path=audio_path, codec=Audio.Codec.AAC, language=Language.get(lang), is_original_lang=is_original, - bitrate=128000, + bitrate=detected_bitrate, channels=2.0, ) @@ -669,9 +863,12 @@ KhS+IFEqwvZqgbBpKuwIDAQAB self.log.error(f"Failed to extract audio for {stream_type}: {e}") return None - def _resolve_stream_url(self, stream_data: dict, stream_type: str) -> Optional[str]: - """Résout l'URL du stream.""" - preferred_keys = ["fhd", "hd", "auto", "sd", "mobile"] + def _resolve_stream_url(self, stream_data: dict, stream_type: str, prioritize_auto: bool = False) -> Optional[str]: + """Resolves the stream URL.""" + if prioritize_auto: + preferred_keys = ["auto", "fhd", "hd", "sd", "mobile"] + else: + preferred_keys = ["fhd", "hd", "auto", "sd", "mobile"] m3u8_url = None for key in preferred_keys: @@ -706,7 +903,7 @@ KhS+IFEqwvZqgbBpKuwIDAQAB return None def _process_subtitles(self, links_res: dict, rand_key: str, title: Episode, tracks: Tracks): - """Traite les sous-titres.""" + """Processes subtitles.""" subs_root = links_res.get("links", {}).get("subtitles", {}) if "all" not in subs_root: self.log.debug("No subtitles available") @@ -732,13 +929,13 @@ KhS+IFEqwvZqgbBpKuwIDAQAB decryptor = cipher.decryptor() decrypted_padded = decryptor.update(ciphertext) + decryptor.finalize() - # TOUJOURS retirer le padding PKCS7 (Python ne le fait pas automatiquement) + # ALWAYS remove PKCS7 padding (Python doesn't do it automatically) pad_len = decrypted_padded[-1] if not (1 <= pad_len <= 16): self.log.error(f"Invalid PKCS7 padding length: {pad_len}") return - # Vérifier que tous les bytes de padding ont la même valeur + # Ensure all padding bytes have the same value padding = decrypted_padded[-pad_len:] if not all(b == pad_len for b in padding): self.log.error(f"Invalid PKCS7 padding bytes") @@ -759,7 +956,7 @@ KhS+IFEqwvZqgbBpKuwIDAQAB self.log.warning("subs_data is empty!") return - # Debug chaque clé + # Debug each key for key in subs_data.keys(): value = subs_data[key] if isinstance(value, list) and len(value) > 0: @@ -781,24 +978,32 @@ KhS+IFEqwvZqgbBpKuwIDAQAB self.log.debug(f" Cues count: {len(cues)}") self.log.debug(f" First cue: {cues[0]}") - if "vf" in sub_lang_key.lower() or "vostf" in sub_lang_key.lower(): + if "vf" in sub_lang_key.lower(): target_lang = "fr" - elif "vde" in sub_lang_key.lower() or "vostde" in sub_lang_key.lower(): + is_forced = True + elif "vostf" in sub_lang_key.lower(): + target_lang = "fr" + is_forced = False + elif "vde" in sub_lang_key.lower(): target_lang = "de" + is_forced = True + elif "vostde" in sub_lang_key.lower(): + target_lang = "de" + is_forced = False else: self.log.debug(f"Skipping subtitle language: {sub_lang_key}") continue - if target_lang in processed_langs: - self.log.debug(f"Already processed {target_lang}, skipping") + if (target_lang, is_forced) in processed_langs: + self.log.debug(f"Already processed {target_lang} (forced={is_forced}), skipping") continue - processed_langs.add(target_lang) + processed_langs.add((target_lang, is_forced)) - # Convertir en ASS + # Convert to ASS ass_content = self._json_to_ass(cues, title.title, title.number) - # Vérifier si le fichier ASS a du contenu + # Check if ASS file has content event_count = ass_content.count("Dialogue:") self.log.debug(f"Generated ASS with {event_count} dialogue events") @@ -806,13 +1011,13 @@ KhS+IFEqwvZqgbBpKuwIDAQAB self.log.warning(f"ASS file has no dialogue events!") self.log.warning(f"First cue was: {cues[0] if cues else 'EMPTY LIST'}") - # Créer SubtitleEmbedded avec le contenu ASS directement + # Create SubtitleEmbedded directly with ASS content subtitle = SubtitleEmbedded( id_=f"sub-{target_lang}-{sub_lang_key}", - embedded_content=ass_content, # Contenu ASS directement + embedded_content=ass_content, # ASS content directly codec=Subtitle.Codec.SubStationAlphav4, language=Language.get(target_lang), - forced=False, + forced=is_forced, sdh=False, ) @@ -829,19 +1034,19 @@ KhS+IFEqwvZqgbBpKuwIDAQAB def get_chapters(self, title: Episode) -> Chapters: """ - Crée les chapitres à partir des timecodes ADN. - - Si tcIntroStart existe: - - Si tcIntroStart != "00:00:00": ajouter "Prologue" à 00:00:00 - - Ajouter "Opening" à tcIntroStart - - Ajouter "Episode" à tcIntroEnd - - Sinon: ajouter "Episode" à 00:00:00 - - Si tcEndingStart existe: - - Ajouter "Ending Start" à tcEndingStart - - Ajouter "Ending End" à tcEndingEnd + Creates chapters from ADN timecodes. + - If tcIntroStart exists: + - If tcIntroStart != "00:00:00": add "Prologue" at 00:00:00 + - Add "Opening" at tcIntroStart + - Add "Episode" at tcIntroEnd + - Otherwise: add "Episode" at 00:00:00 + - If tcEndingStart exists: + - Add "Ending Start" at tcEndingStart + - Add "Ending End" at tcEndingEnd """ chapters = Chapters() - # Récupérer les données de chapitres stockées dans get_tracks() + # Retrieve chapter data stored in get_tracks() chapter_data = title.data.get("chapter_data", {}) if not chapter_data: self.log.debug("No chapter data available") @@ -855,51 +1060,73 @@ KhS+IFEqwvZqgbBpKuwIDAQAB self.log.debug(f"Chapter timecodes: intro={tc_intro_start}->{tc_intro_end}, ending={tc_ending_start}->{tc_ending_end}") try: + chapter_num = 1 + if tc_intro_start: - # Si l'intro ne commence pas à 00:00:00, ajouter un prologue + # If intro does not start at 00:00:00, add a prologue (Chapter 1) if tc_intro_start != "00:00:00": chapters.add(Chapter( timestamp=0, - name="Prologue" + name=f"Chapter {chapter_num}" )) - self.log.debug("Added Prologue chapter at 00:00:00") + self.log.debug(f"Added Chapter {chapter_num} at 00:00:00") + chapter_num += 1 # Opening chapters.add(Chapter( timestamp=self._timecode_to_ms(tc_intro_start), name="Opening" )) - self.log.debug(f"Added Opening chapter at {tc_intro_start}") + self.log.debug(f"Added Opening at {tc_intro_start}") - # Episode (après l'intro) + # Episode (after intro) if tc_intro_end: chapters.add(Chapter( timestamp=self._timecode_to_ms(tc_intro_end), - name="Episode" + name=f"Chapter {chapter_num}" )) - self.log.debug(f"Added Episode chapter at {tc_intro_end}") + self.log.debug(f"Added Chapter {chapter_num} at {tc_intro_end}") + chapter_num += 1 else: - # Pas d'intro, épisode commence à 00:00:00 + # No intro, episode starts at 00:00:00 chapters.add(Chapter( timestamp=0, - name="Episode" + name=f"Chapter {chapter_num}" )) - self.log.debug("Added Episode chapter at 00:00:00 (no intro)") + self.log.debug(f"Added Chapter {chapter_num} at 00:00:00 (no intro)") + chapter_num += 1 # Ending if tc_ending_start: chapters.add(Chapter( timestamp=self._timecode_to_ms(tc_ending_start), - name="Ending Start" + name="Ending" )) - self.log.debug(f"Added Ending Start chapter at {tc_ending_start}") + self.log.debug(f"Added Ending at {tc_ending_start}") if tc_ending_end: - chapters.add(Chapter( - timestamp=self._timecode_to_ms(tc_ending_end), - name="Ending End" - )) - self.log.debug(f"Added Ending End chapter at {tc_ending_end}") + # Check if the remaining chapter has a significant duration (> 10s) + # to avoid micro-chapters of 2s, while keeping actual post-credits scenes. + + tc_end_ms = self._timecode_to_ms(tc_ending_end) + total_duration_s = title.data.get("duration", 0) + total_duration_ms = int(total_duration_s * 1000) + + # If duration is unknown or > 10 seconds remaining + should_add = True + if total_duration_ms > 0: + remaining_ms = total_duration_ms - tc_end_ms + if remaining_ms < 10000: # Less than 10s + should_add = False + self.log.debug(f"Skipping post-ending chapter (only {remaining_ms}ms remaining)") + + if should_add: + chapters.add(Chapter( + timestamp=tc_end_ms, + name=f"Chapter {chapter_num}" + )) + self.log.debug(f"Added Chapter {chapter_num} at {tc_ending_end}") + chapter_num += 1 self.log.info(f"✓ Created {len(chapters)} chapters") @@ -935,17 +1162,20 @@ KhS+IFEqwvZqgbBpKuwIDAQAB raise ValueError(f"Invalid ADN Show ID/URL: {input_str}") def _json_to_ass(self, cues: List[dict], title: str, ep_num: Union[int, str]) -> str: - """Convertit les sous-titres JSON en ASS.""" + """Converts JSON subtitles to ASS.""" header = """[Script Info] ScriptType: v4.00+ WrapStyle: 0 -PlayResX: 1280 -PlayResY: 720 +Collisions: Normal +PlayResX: 1920 +PlayResY: 1080 +Timer: 0.0000 +WrapStyle: 0 ScaledBorderAndShadow: yes [V4+ Styles] Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding -Style: Default,Arial,50,&H00FFFFFF,&H00FFFFFF,&H00000000,&H00000000,-1,0,0,0,100,100,0,0,1,1.95,0,2,0,0,70,0 +Style: Default,Trebuchet MS,66,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,-1,0,0,0,100,100,0,0,1,3,3,2,75,75,75,1 [Events] Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text @@ -955,7 +1185,7 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text line_align_map = {"middle": 8, "end": 4} def format_time(seconds: float) -> str: - """Format exact d'adn : HH:MM:SS.CC (centisecondes sur 2 chiffres)""" + """Exact ADN format: HH:MM:SS.CC (2-digit centiseconds)""" secs = int(seconds) centiseconds = round((seconds - secs) * 100) @@ -963,7 +1193,7 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text minutes = (secs % 3600) // 60 remaining_seconds = secs % 60 - # Padding sur 2 chiffres pour TOUT (hours inclus) + # 2-digit padding for EVERYTHING (including hours) return f"{hours:02d}:{minutes:02d}:{remaining_seconds:02d}.{centiseconds:02d}" for cue in cues: @@ -971,11 +1201,11 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text end_time = cue.get("endTime", 0) text = cue.get("text", "") - # Skip si texte vide + # Skip if text is empty if not text or not text.strip(): continue - # Nettoyage EXACT du code adn + # EXACT cleanup corresponding to ADN code text = text.replace(' \\N', '\\N') # remove space before \\N at end if text.endswith('\\N'): text = text[:-2] # remove \\N at end @@ -993,7 +1223,7 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text text = text[:-2] text = text.rstrip() # remove trailing spaces - # Skip après nettoyage si vide + # Skip after cleanup if empty if not text.strip(): continue @@ -1012,4 +1242,4 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text if not events: self.log.warning(f"No subtitle events generated - all cues were empty or invalid (total cues: {len(cues)})") - return header + "\n".join(events) \ No newline at end of file + return header + "\n".join(events) diff --git a/packages/envied/src/envied/services/ADN/config.yaml b/packages/envied/src/envied/services/ADN/config.yaml index 3e77996..ffe4f9a 100644 --- a/packages/envied/src/envied/services/ADN/config.yaml +++ b/packages/envied/src/envied/services/ADN/config.yaml @@ -1,22 +1,22 @@ -# Animation Digital Network API Configuration +# API Configuration -# Endpoints API +# API Endpoints endpoints: - # Authentification + # Authentication login: "https://gw.api.animationdigitalnetwork.com/authentication/login" refresh: "https://gw.api.animationdigitalnetwork.com/authentication/refresh" -# Catalogue +# Catalog search: "https://gw.api.animationdigitalnetwork.com/show/catalog" show: "https://gw.api.animationdigitalnetwork.com/video/show/{show_id}?maxAgeCategory=18&limit=-1&order=asc" seasons: "https://gw.api.animationdigitalnetwork.com/video/show/{show_id}/seasons?maxAgeCategory=18&order=asc" -# Player & Lecture +# Player & Playback player_config: "https://gw.api.animationdigitalnetwork.com/player/video/{video_id}/configuration" player_refresh: "https://gw.api.animationdigitalnetwork.com/player/refresh/token" player_links: "https://gw.api.animationdigitalnetwork.com/player/video/{video_id}/link" -# Headers par défaut +# Default Headers headers: User-Agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" Origin: "https://animationdigitalnetwork.com" @@ -24,6 +24,6 @@ headers: Content-Type: "application/json" X-Target-Distribution: "fr" -# Paramètres +# Parameters params: locale: "fr" \ No newline at end of file diff --git a/packages/envied/src/envied/services/AMZN/CHANGELOG_AMZN.docx b/packages/envied/src/envied/services/AMZN/CHANGELOG_AMZN.docx new file mode 100644 index 0000000..54b1328 Binary files /dev/null and b/packages/envied/src/envied/services/AMZN/CHANGELOG_AMZN.docx differ diff --git a/packages/envied/src/envied/services/AMZN/__init__.py b/packages/envied/src/envied/services/AMZN/__init__.py index a92aa78..65dadc4 100644 --- a/packages/envied/src/envied/services/AMZN/__init__.py +++ b/packages/envied/src/envied/services/AMZN/__init__.py @@ -1,24 +1,24 @@ import base64 import hashlib import json -from logging import Logger import os -from pathlib import Path import re -import sys +import secrets +import string +import time +import random from collections import defaultdict from http.cookiejar import CookieJar -import time +from pathlib import Path from typing import Any, Optional, Literal, Union -from urllib.parse import quote, urlencode, urlparse, urlunparse -from uuid import uuid4 - -import requests +from urllib.parse import quote, urlencode import click import jsonpickle -from langcodes import Language +import requests from click.core import ParameterSource +from langcodes import Language +from tldextract import tldextract from envied.core.cacher import Cacher from envied.core.credential import Credential @@ -26,41 +26,75 @@ from envied.core.manifests import DASH, ISM from envied.core.service import Service from envied.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T from envied.core.tracks import Chapter, Chapters, Subtitle, Tracks, Track, Video -from envied.core.tracks.audio import Audio from envied.core.utilities import is_close_match from envied.core.utils.collections import as_list -class AMZN(Service): + +def _build_ordered_lang_map_from_mpd(mpd_text: str) -> dict: + import xml.etree.ElementTree as ET + import re as _re + ns_strip = _re.compile(r'\{[^}]*\}') + rid_lang_re = _re.compile(r'^audio_([a-zA-Z]{2,3}-[a-zA-Z0-9]{2,5})') + result: dict = {} + try: + root = ET.fromstring(mpd_text) + for elem in root.iter(): + if ns_strip.sub('', elem.tag) != 'AdaptationSet': + continue + content_type = elem.get('contentType', '') or elem.get('mimeType', '') + if 'audio' not in content_type.lower(): + if not any('audio' in (r.get('mimeType', '')).lower() for r in elem): + continue + base_lang = elem.get('lang') or elem.get('language') or '' + if not base_lang: + continue + for r in elem: + if ns_strip.sub('', r.tag) != 'Representation': + continue + rid = r.get('id') or '' + m = rid_lang_re.match(rid) + precise = m.group(1) if m else base_lang + result.setdefault(base_lang, []).append(precise) + except Exception: + pass + return result + + +def _apply_ordered_lang_map(audio_tracks, lang_map: dict) -> None: + from langcodes import Language as _Lang + counters: dict = {} + for track in audio_tracks: + base = str(track.language) + if base not in lang_map: + continue + ordered = lang_map[base] + if not any('-' in p for p in ordered): + continue + idx = counters.get(base, 0) + if idx < len(ordered): + track.language = _Lang.get(ordered[idx]) + counters[base] = idx + 1 + +class AMZN(Service): """ Service code for Amazon VOD (https://amazon.com) and Amazon Prime Video (https://primevideo.com). \b - Authorization: Cookies - - Security: - - UHD@L1/SL3000 - FHD@L3(ChromeCDM)/SL2000 - SD@L3 - Certain SL2000 can do UHD + Security: UHD@L1 FHD@L3(ChromeCDM) SD@L3, Maintains their own license server like Netflix, be cautious. \b - - Maintains their own license server like Netflix, be cautious. - Region is chosen automatically based on domain extension found in cookies. Prime Video specific code will be run if the ASIN is detected to be a prime video variant. - Use 'Amazon Video ASIN Display' for Tampermonkey addon for ASIN - https://greasyfork.org/en/scripts/496577-amazon-video-asin-display - - vt dl --list -z uk -q 1080 Amazon B09SLGYLK8 """ - # GEOFENCE = ("",) - ALIASES = ("Amazon", "prime", 'amazon') - TITLE_RE = r"^(?:https?://(?:www\.)?(?Pamazon\.(?Pcom|co\.uk|de|co\.jp)|primevideo\.com)(?:/.+)?/)?(?P[A-Z0-9]{10,}|amzn1\.dv\.gti\.[a-f0-9-]+)" # noqa: E501 + + ALIASES = ["AMZN", "amazon", "prime"] + TITLE_RE = [ + r"^(?:https?://(?:www\.)?(?Pamazon\.(?Pcom|co\.uk|de|co\.jp)|primevideo\.com)(?:/.+)?/)?(?P[A-Z0-9]{10,}|amzn1\.dv\.gti\.[a-f0-9-]+)", + r"^(?:https?://(?:www\.)?(?Pamazon\.(?Pcom|co\.uk|de|co\.jp)|primevideo\.com)(?:/[^?]*)?(?:\?gti=)?)(?P[A-Z0-9]{10,}|amzn1\.dv\.gti\.[a-f0-9-]+)" + ] REGION_TLD_MAP = { "au": "com.au", @@ -76,94 +110,101 @@ class AMZN(Service): "HDR10": "Hdr10", "DV": "DolbyVision", } - VIDEO_CODEC_MAP = { - "H264": ["avc1"], - "H265": ["hvc1", "dvh1"] - } + @staticmethod @click.command(name="AMZN", short_help="https://amazon.com, https://primevideo.com", help=__doc__) @click.argument("title", type=str, required=False) @click.option("-b", "--bitrate", default="CBR", - type=click.Choice(["CVBR", "CBR", "CVBR+CBR"], case_sensitive=False), - help="Video Bitrate Mode to download in. CVBR=Constrained Variable Bitrate, CBR=Constant Bitrate.") + type=click.Choice(["CVBR", "CBR", "CVBR+CBR"], case_sensitive=False), + help="Video Bitrate Mode to download in. CVBR=Constrained Variable Bitrate, CBR=Constant Bitrate.") + @click.option("-p", "--player", default="html5", + type=click.Choice(["html5", "xp"], case_sensitive=False), + help="Video playerType to download in. html5, xp.") @click.option("-c", "--cdn", default=None, type=str, - help="CDN to download from, defaults to the CDN with the highest weight set by Amazon.") - # UHD, HD, SD. UHD only returns HEVC, ever, even for <=HD only content + help="CDN to download from, defaults to the CDN with the highest weight set by Amazon.") @click.option("-vq", "--vquality", default="HD", - type=click.Choice(["SD", "HD", "UHD"], case_sensitive=False), - help="Manifest quality to request.") + type=click.Choice(["SD", "HD", "UHD"], case_sensitive=False), + help="Manifest quality to request.") @click.option("-s", "--single", is_flag=True, default=False, - help="Force single episode/season instead of getting series ASIN.") - @click.option("-am", "--amanifest", default="H265", - type=click.Choice(["CVBR", "CBR", "H265"], case_sensitive=False), - help="Manifest to use for audio. Defaults to H265 if the video manifest is missing 640k audio.") + help="Force single episode/season instead of getting series ASIN.") + @click.option("-am", "--amanifest", default="CVBR", + type=click.Choice(["CVBR", "CBR", "H265"], case_sensitive=False), + help="Manifest to use for audio. Defaults to H265 if the video manifest is missing 640k audio.") @click.option("-aq", "--aquality", default="SD", - type=click.Choice(["SD", "HD", "UHD"], case_sensitive=False), - help="Manifest quality to request for audio. Defaults to the same as --quality.") - # @click.option("-ism", "--ism", is_flag=True, default=False, - # help="Set manifest override to SmoothStreaming. Defaults to DASH w/o this flag.") ## DPRECATED + type=click.Choice(["SD", "HD", "UHD"], case_sensitive=False), + help="Manifest quality to request for audio. Defaults to the same as --quality.") @click.option("-aa", "--atmos", is_flag=True, default=False, - help="Prefer Atmos audio if available, otherwise defaults to 640k audio.") + help="Prefer Atmos audio if available.") @click.option("-drm", "--drm-system", type=click.Choice(["widevine", "playready"], case_sensitive=False), default="playready", help="which drm system to use") - @click.pass_context def cli(ctx, **kwargs): return AMZN(ctx, **kwargs) - def __init__(self, ctx, title, bitrate: str, cdn: str, vquality: str, single: bool, amanifest: str, aquality: str, drm_system: Literal["widevine", "playready"], atmos: bool) -> None: - m = self.parse_title(ctx, title) - self.domain = m.get("domain") - self.domain_region = m.get("region") - self.drm_system = drm_system + def __init__(self, ctx, title, bitrate: str, player: str, cdn: str, vquality: str, single: bool, + amanifest: str, aquality: str, drm_system: str, atmos: bool): + super().__init__(ctx) + self.parse_title(ctx, title) self.bitrate = bitrate + self.player = player self.bitrate_source = ctx.get_parameter_source("bitrate") - self.vquality = vquality - self.vquality_source = ctx.get_parameter_source("vquality") self.cdn = cdn + self.vquality = vquality + self.vquality_source = ctx.get_parameter_source("vquality") self.single = single self.amanifest = amanifest self.aquality = aquality - self.atmos = atmos - super().__init__(ctx) + self.atmos = atmos + self.drm_system = drm_system assert ctx.parent is not None - + # envied.params self.chapters_only = ctx.parent.params.get("chapters_only") - self.quality = ctx.parent.params.get("quality") + self.quality = ctx.parent.params.get("quality") or 1080 + + vcodec = ctx.parent.params.get("vcodec") + range_ = ctx.parent.params.get("range_") + + self.range = range_[0].name if range_ else "SDR" + # Mapping envied.video codec enum to string + self.vcodec = "H265" if vcodec and "HEVC" in str(vcodec) else "H264" self.cdm = ctx.obj.cdm self.profile = ctx.obj.profile + self.playready = self.drm_system == "playready" + self.region: dict[str, str] = {} self.endpoints: dict[str, str] = {} self.device: dict[str, str] = {} - self.pv = self.domain == "primevideo.com" + self.pv = False + self.event = False self.device_token = None self.device_id = None self.customer_id = None self.client_id = "f22dbddb-ef2c-48c5-8876-bed0d47594fd" # browser client id + self.playbackEnvelope = None - vcodec = ctx.parent.params.get("vcodec") - range = ctx.parent.params.get("range_") - - self.range = range[0].name if range else "SDR" - self.vcodec = "H265" if vcodec and vcodec == Video.Codec.HEVC else "H264" - + # Logic from Vinetrimmer regarding quality overrides if self.vquality_source != ParameterSource.COMMANDLINE: - if any(q <= 576 for q in self.quality) and "SDR" == self.range: + # Check if quality is list (envied. or int + q_check = self.quality[0] if isinstance(self.quality, list) else self.quality + + if 0 < q_check <= 576 and self.range == "SDR": self.log.info(" + Setting manifest quality to SD") self.vquality = "SD" - if any(q > 1080 for q in self.quality): - self.log.info(" + Setting manifest quality to UHD and vcodec to H265 to be able to get 2160p video track") + if q_check > 1080: + self.log.info(" + Setting manifest quality to UHD to be able to get 2160p video track") self.vquality = "UHD" - self.vcodec = "H265" self.vquality = self.vquality or "HD" + if self.vquality == "UHD": + self.vcodec = "H265" + if self.bitrate_source != ParameterSource.COMMANDLINE: if self.vcodec == "H265" and self.range == "SDR" and self.bitrate != "CVBR+CBR": self.bitrate = "CVBR+CBR" @@ -175,134 +216,235 @@ class AMZN(Service): self.orig_bitrate = self.bitrate - - self.manifestTypeTry = "DASH" - self.log.info("Getting tracks from MPD manifest") - def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None: super().authenticate(cookies, credential) if not cookies: raise EnvironmentError("Service requires Cookies for Authentication.") - + self.session.cookies.update(cookies) self.configure() + + def configure(self) -> None: + if len(self.title) > 10: + self.pv = True + self.pv = True # always use primevideo endpoints + + self.log.info("Getting Account Region") + self.region = self.get_region() + if not self.region: + self.log.error(" - Failed to get Amazon Account region"); raise SystemExit(1) - # Abstracted functions + self.log.info(f" + Region: {self.region['code']}") + + # endpoints must be prepared AFTER region data is retrieved + self.endpoints = self.prepare_endpoints(self.config["endpoints"], self.region) + + self.session.headers.update({ + "Origin": f"https://{self.region['base']}" + }) + + _profile = self.profile or "default" + self.device = (self.config.get("device") or {}).get(_profile, {}) + + # Logic to decide if we need a specific device registration + need_device = False + if (isinstance(self.quality, list) and self.quality[0] > 1080) or self.vquality == "UHD" or self.range != "SDR": + need_device = True + + if self.device: + if need_device and self.vcodec == "H265": + self.log.info(f"Using device to get UHD manifests") + else: + self.log.info(f"Using configured device for profile: {_profile}") + self.register_device() + else: + # Falling back to browser-based device ID + self.log.warning( + "No Device information was provided for %s, using browser device...", + self.profile + ) + self.device_id = hashlib.sha224( + ("CustomerID" + self.session.headers["User-Agent"]).encode("utf-8") + ).hexdigest() + self.device = {"device_type": self.config["device_types"]["browser"]} def get_titles(self) -> Titles_T: res = self.session.get( url=self.endpoints["details"], - params={"titleID": self.title, "isElcano": "1", "sections": "Atf"}, - headers={"Accept": "application/json"}, - ).json()["widgets"] + params={ + "titleID": self.title, + "isElcano": "1", + "sections": ["Atf", "Btf"] + }, + headers={"Accept": "application/json"} + ) - entity = res["header"]["detail"].get("entityType") - if not entity: - self.log.error(" - Failed to get entity type") - sys.exit(1) + if not res.ok: + self.log.error(f"Unable to get title: {res.text} [{res.status_code}]"); raise SystemExit(1) - if entity == "Movie": - metadata = res["header"]["detail"] - return Movies( - [ - Movie( - id_=metadata.get("catalogId"), - year=metadata.get("releaseYear"), - name=metadata.get("title"), - service=self.__class__, - data=metadata, - ) - ] - ) - elif entity == "TV Show": - seasons = [x.get("titleID") for x in res["seasonSelector"]] + data = res.json()["widgets"] + product_details = data.get("productDetails", {}).get("detail") - episodes = [] + if not product_details: + error = res.json()["degradations"][0] + self.log.error(f"Unable to get title: {error['message']} [{error['code']}]"); raise SystemExit(1) + + if data["pageContext"]["subPageType"] == "Event": + self.event = True + + if data["pageContext"]["subPageType"] in ["Movie", "Event"]: + card = data["productDetails"]["detail"] + return Movies([Movie( + id_=card["catalogId"], + name=product_details["title"], + year=card.get("releaseYear", ""), + service=self.__class__, + data=card + )]) + else: + # TV Show logic with pagination support from Vinetrimmer + episodes_list = [] + seasons = [x.get("titleID") for x in data["seasonSelector"]] + + # If single flag is set, logic to filter seasons happens in main loop, + # but for envied.structure we usually return the whole series or let user filter. + # Implementing the Vinetrimmer pagination logic: + for season in seasons: + # If single mode and logic requires skipping, we should handle it here + # But strict Vinetrimmer logic handled title switching. + # For envied. we iterate all found seasons. + res = self.session.get( - url=self.endpoints["detail"], + url=self.endpoints["details"], params={"titleID": season, "isElcano": "1", "sections": "Btf"}, headers={"Accept": "application/json"}, ).json()["widgets"] - # cards = [x["detail"] for x in as_list(res["titleContent"][0]["cards"])] - cards = [ - {**x["detail"], "sequenceNumber": x["self"]["sequenceNumber"]} - for x in res["episodeList"]["episodes"] - ] + try: + episode_data_list = res["episodeList"]["episodes"] + except KeyError: + continue - product_details = res["productDetails"]["detail"] - - episodes.extend( - Episode( - id_=title.get("titleId") or title["catalogId"], - title=product_details.get("parentTitle") or product_details["title"], - year=title.get("releaseYear") or product_details.get("releaseYear"), - season=product_details.get("seasonNumber"), - number=title.get("sequenceNumber"), - name=title.get("title"), + product_details_season = res["productDetails"]["detail"] + # exit(product_details_season) + + # Process initial batch + for episode in episode_data_list: + details = episode["detail"] + episodes_list.append(Episode( + id_=details["catalogId"], + title=product_details["title"], + name=details["title"], + season=product_details_season["seasonNumber"], + number=episode["self"]["sequenceNumber"], service=self.__class__, - data=title, - ) - for title in cards - if title["entityType"] == "TV Show" - ) + data=episode + )) - return Series(episodes) + # Handle Pagination + pagination_data = res.get('episodeList', {}).get('actions', {}).get('pagination', []) + token = next((quote(item.get('token')) for item in pagination_data if item.get('tokenType') == 'NextPage'), None) + + while token: + res_page = self.session.get( + url=self.endpoints["getDetailWidgets"], + params={ + "titleID": self.title, + "isTvodOnRow": "1", + "widgets": f'[{{"widgetType":"EpisodeList","widgetToken":"{token}"}}]' + }, + headers={"Accept": "application/json"} + ).json() + + episodeListWidget = res_page['widgets'].get('episodeList', {}) + for item in episodeListWidget.get('episodes', []): + ep_num = int(item.get('self', {}).get('sequenceNumber', 0)) + episodes_list.append(Episode( + id_=item["detail"]["catalogId"], + name=item["detail"]["title"], + season=product_details_season["seasonNumber"], + number=ep_num, + service=self.__class__, + data=item + )) + + pagination_data = res_page['widgets'].get('episodeList', {}).get('actions', {}).get('pagination', []) + token = next((quote(item.get('token')) for item in pagination_data if item.get('tokenType') == 'NextPage'), None) + + return Series(episodes_list) def get_tracks(self, title: Title_T) -> Tracks: - tracks = Tracks() if self.chapters_only: - return [] - - #manifest, chosen_manifest, tracks = self.get_best_quality(title) + return Tracks([]) + # Main Video Manifest + # When DV is requested, declare HEVC_DOLBY_VISION codec so Amazon returns DV streams + # If the server rejects the DV request, fall back to HDR10 automatically + effective_vcodec = "HEVC_DOLBY_VISION" if self.range == "DV" and self.vcodec == "H265" else self.vcodec + effective_range = self.range manifest = self.get_manifest( title, - video_codec=self.vcodec, + video_codec=effective_vcodec, bitrate_mode=self.bitrate, quality=self.vquality, - hdr=self.range, - ignore_errors=False - + hdr=effective_range, + ignore_errors=self.range == "DV" ) - - # Move rightsException termination here so that script can attempt continuing - if "rightsException" in manifest["returnedTitleRendition"]["selectedEntitlement"]: + if self.range == "DV" and not manifest.get("vodPlaybackUrls"): + self.log.warning(" - Dolby Vision request rejected by server, retrying with HDR10...") + effective_vcodec = self.vcodec + effective_range = "HDR10" + manifest = self.get_manifest( + title, + video_codec=effective_vcodec, + bitrate_mode=self.bitrate, + quality=self.vquality, + hdr=effective_range, + ignore_errors=False + ) + + if "rightsException" in manifest.get("returnedTitleRendition", {}).get("selectedEntitlement", {}): self.log.error(" - The profile used does not have the rights to this title.") - return + return Tracks([]) - self.customer_id = manifest["returnedTitleRendition"]["selectedEntitlement"]["grantedByCustomerId"] - - default_url_set = manifest["playbackUrls"]["urlSets"][manifest["playbackUrls"]["defaultUrlSetId"]] - encoding_version = default_url_set["urls"]["manifest"]["encodingVersion"] - self.log.info(f" + Detected encodingVersion={encoding_version}") - - #print(manifest) chosen_manifest = self.choose_manifest(manifest, self.cdn) - if not chosen_manifest: - raise self.log.exit(f"No manifests available") + self.log.error(f"No manifests available"); raise SystemExit(1) - manifest_url = self.clean_mpd_url(chosen_manifest["avUrlInfoList"][0]["url"], False) - self.log.debug(manifest_url) - # if self.event: - # devicetype = self.device["device_type"] - # manifest_url = chosen_manifest["avUrlInfoList"][0]["url"] - # manifest_url = f"{manifest_url}?amznDtid={devicetype}&encoding=segmentBase" + manifest_url = self.clean_mpd_url(chosen_manifest["url"], False) + if self.event: + devicetype = self.device.get("device_type") + manifest_url = chosen_manifest["url"] + manifest_url = f"{manifest_url}?amznDtid={devicetype}&encoding=segmentBase" + self.log.info(" + Downloading Manifest") - if chosen_manifest["streamingTechnology"] == "DASH": - tracks = Tracks([ - x for x in iter(DASH.from_url(url=manifest_url, session=self.session).to_tracks(language="es")) - ]) - elif chosen_manifest["streamingTechnology"] == "SmoothStreaming": - tracks = Tracks([ - x for x in iter(ISM.from_url(url=manifest_url, session=self.session).to_tracks(language="es")) - ]) - else: - raise self.log.exit(f"Unsupported manifest type: {chosen_manifest['streamingTechnology']}") + _mpd_raw = self.session.get(manifest_url).text + _lang_order_map = _build_ordered_lang_map_from_mpd(_mpd_raw) + self.log.info(f" + MPD language map: {sum(len(v) for v in _lang_order_map.values())} representations indexed") + streamingProtocol = manifest["vodPlaybackUrls"]["result"]["playbackUrls"]["urlMetadata"]["streamingProtocol"] + + # Base Tracks object + tracks = Tracks() + + if streamingProtocol == "DASH": + tracks = Tracks([ + x for x in iter(DASH.from_url(url=manifest_url, session=self.session).to_tracks(language="en")) + ]) + elif streamingProtocol == "SmoothStreaming": + _ism_tracks = Tracks() + for _t in iter(ISM.from_url(url=manifest_url, session=self.session).to_tracks(language="en")): + _ism_tracks.add(_t, warn_only=True) + tracks = _ism_tracks + else: + self.log.error(f"Unsupported manifest type: {streamingProtocol}"); raise SystemExit(1) + + if _lang_order_map: + _apply_ordered_lang_map(tracks.audio, _lang_order_map) + + # Logic for separate audio (Higher Quality / Different Codec) need_separate_audio = ((self.aquality or self.vquality) != self.vquality or self.amanifest == "CVBR" and (self.vcodec, self.bitrate) != ("H264", "CVBR") or self.amanifest == "CBR" and (self.vcodec, self.bitrate) != ("H264", "CBR") @@ -310,191 +452,160 @@ class AMZN(Service): or self.amanifest != "H265" and self.vcodec == "H265") if not need_separate_audio: + # Check for low bitrate audio audios = defaultdict(list) for audio in tracks.audio: audios[audio.language].append(audio) - for lang in audios: if not any((x.bitrate or 0) >= 640000 for x in audios[lang]): need_separate_audio = True break - if need_separate_audio: # and not self.atmos: - tracks.audio.clear() - manifest_type = self.amanifest or "H265" + if need_separate_audio and not self.atmos: + manifest_type = self.amanifest self.log.info(f"Getting audio from {manifest_type} manifest for potential higher bitrate or better codec") + audio_manifest = self.get_manifest( title=title, - video_codec="H265" if manifest_type == "H265" else "H264", - bitrate_mode="CVBR" if manifest_type != "CBR" else "CBR", - quality=self.aquality or self.vquality, + video_codec="H264", + bitrate_mode="CVBR", + quality="HD", hdr=None, ignore_errors=True ) + if not audio_manifest: self.log.warning(f" - Unable to get {manifest_type} audio manifests, skipping") elif not (chosen_audio_manifest := self.choose_manifest(audio_manifest, self.cdn)): self.log.warning(f" - No {manifest_type} audio manifests available, skipping") else: - audio_mpd_url = self.clean_mpd_url(chosen_audio_manifest["avUrlInfoList"][0]["url"], optimise=False) - self.log.debug(audio_mpd_url) - # if self.event: - # devicetype = self.device["device_type"] - # audio_mpd_url = chosen_audio_manifest["avUrlInfoList"][0]["url"] - # audio_mpd_url = f"{audio_mpd_url}?amznDtid={devicetype}&encoding=segmentBase" - self.log.info(" + Downloading HEVC manifest") - + audio_mpd_url = self.clean_mpd_url(chosen_audio_manifest["url"], optimise=False) + if self.event: + devicetype = self.device.get("device_type") + audio_mpd_url = chosen_audio_manifest["url"] + audio_mpd_url = f"{audio_mpd_url}?amznDtid={devicetype}&encoding=segmentBase" + + self.log.info(" + Downloading Audio Manifest") try: - audio_mpd = Tracks([ - x for x in iter(DASH.from_url(url=audio_mpd_url, session=self.session).to_tracks(language="en")) - ]) - except KeyError: - self.log.warning(f" - Title has no {self.amanifest} stream, cannot get higher quality audio") - else: - tracks.audio = audio_mpd.audio # expecting possible dupes, ignore + audio_protocol = audio_manifest["vodPlaybackUrls"]["result"]["playbackUrls"]["urlMetadata"]["streamingProtocol"] + if audio_protocol == "DASH": + _a_raw = self.session.get(audio_mpd_url).text + _a_lang_order = _build_ordered_lang_map_from_mpd(_a_raw) + self.log.info(f" + Audio MPD language map: {sum(len(v) for v in _a_lang_order.values())} entries") + audio_tracks = DASH.from_url(url=audio_mpd_url, session=self.session).to_tracks(language="en") + if _a_lang_order: + _apply_ordered_lang_map(audio_tracks.audio, _a_lang_order) + elif audio_protocol == "SmoothStreaming": + audio_tracks = ISM.from_url(url=audio_mpd_url, session=self.session).to_tracks(language="en") + else: + audio_tracks = Tracks([]) + + tracks.add(audio_tracks.audio, warn_only=True) + except Exception as e: + self.log.warning(f" - Failed to parse audio manifest: {e}") + # Logic for UHD/Atmos Audio need_uhd_audio = self.atmos - if not self.amanifest and ((self.aquality == "UHD" and self.vquality != "UHD") or not self.aquality): - audios = defaultdict(list) - for audio in tracks.audio: - audios[audio.language].append(audio) - for lang in audios: - if not any((x.bitrate or 0) >= 640000 for x in audios[lang]): - need_uhd_audio = True - break + # Simple check if current tracks lack high bitrate + if all((x.bitrate or 0) < 640000 for x in tracks.audio): + need_uhd_audio = True if need_uhd_audio and (self.config.get("device") or {}).get(self.profile, None): self.log.info("Getting audio from UHD manifest for potential higher bitrate or better codec") temp_device = self.device - temp_device_token = self.device_token - temp_device_id = self.device_id - uhd_audio_manifest = None + temp_token = self.device_token + temp_id = self.device_id + + # Switch to device if on browser/playready + if self.playready or self.cdm.device.type == "CHROME": + self.register_device() # Switch to registered device context try: - if self.cdm.device.type in ["CHROME", "PLAYREADY"] and self.quality < 2160: - self.log.info(f" + Switching to device to get UHD manifest") - self.register_device() - uhd_audio_manifest = self.get_manifest( title=title, video_codec="H265", bitrate_mode="CVBR+CBR", quality="UHD", - hdr="DV", # Needed for 576kbps Atmos sometimes + hdr="DV", # Needed for 576kbps Atmos ignore_errors=True ) except: - pass - - self.device = temp_device - self.device_token = temp_device_token - self.device_id = temp_device_id - - if not uhd_audio_manifest: - self.log.warning(f" - Unable to get UHD manifests, skipping") - elif not (chosen_uhd_audio_manifest := self.choose_manifest(uhd_audio_manifest, self.cdn)): - self.log.warning(f" - No UHD manifests available, skipping") - else: - tracks.audio.clear() - uhd_audio_mpd_url = self.clean_mpd_url(chosen_uhd_audio_manifest["avUrlInfoList"][0]["url"], optimise=False) - self.log.debug(uhd_audio_mpd_url) - # if self.event: - # devicetype = self.device["device_type"] - # uhd_audio_mpd_url = chosen_uhd_audio_manifest["avUrlInfoList"][0]["url"] - # uhd_audio_mpd_url = f"{uhd_audio_mpd_url}?amznDtid={devicetype}&encoding=segmentBase" - self.log.info(" + Downloading UHD manifest") - - try: - uhd_audio_mpd = Tracks([ - x for x in iter(DASH.from_url(url=uhd_audio_mpd_url, session=self.session).to_tracks(language="en")) - ]) - except KeyError: - self.log.warning(f" - Title has no UHD stream, cannot get higher quality audio") - else: - # replace the audio tracks with DV manifest version if atmos is present - if any(x for x in uhd_audio_mpd.audio if x.atmos): - tracks.audio = uhd_audio_mpd.audio - - for video in tracks.videos: - video.hdr10 = chosen_manifest["hdrFormat"] == "Hdr10" - video.dv = chosen_manifest["hdrFormat"] == "DolbyVision" - - for audio in tracks.audio: - audio.descriptive = audio.data["dash"]["adaptation_set"].get("audioTrackSubtype") == "descriptive" - # Amazon @lang is just the lang code, no dialect, @audioTrackId has it. - audio_track_id = audio.data["dash"]["adaptation_set"].get("audioTrackId") - if audio_track_id: - audio.language = Language.get(audio_track_id.split("_")[0]) # e.g. es-419_ec3_blabla - # Remove any audio tracks with dialog boost! - if audio.data["dash"]["adaptation_set"] is not None and "boosteddialog" in audio.data["dash"]["adaptation_set"].get("audioTrackSubtype", ""): - audio.bitrate = 1 - - for sub in manifest.get("subtitleUrls", []) + manifest.get("forcedNarratives", []): - try: - tracks.add(Subtitle( - id_=sub.get( - "timedTextTrackId", - f"{sub['languageCode']}_{sub['type']}_{sub['subtype']}_{sub.get('index', 'default')}" - ), - url=os.path.splitext(sub["url"])[0] + ".srt", # DFXP -> SRT forcefully seems to work fine - # metadata - codec=Subtitle.Codec.from_codecs("srt"), # sub["format"].lower(), - language=sub["languageCode"], - #is_original_lang=title.original_lang and is_close_match(sub["languageCode"], [title.original_lang]), - forced="forced" in sub["displayName"], - sdh=sub["type"].lower() == "sdh" # TODO: what other sub types? cc? forced? - ), warn_only=True) # expecting possible dupes, ignore - except KeyError: - # Log the KeyError Exception but continue (as only the subtitles will be missing) - self.log.error("Unexpected subtitle track id data format, subtitles will be missing", exc_info=True) + uhd_audio_manifest = None - for track in tracks: - track.needs_proxy = False + # Restore context + self.device = temp_device + self.device_token = temp_token + self.device_id = temp_id - tracks.audio = self._dedupe(tracks.audio) + if uhd_audio_manifest and (chosen_uhd := self.choose_manifest(uhd_audio_manifest, self.cdn)): + uhd_url = self.clean_mpd_url(chosen_uhd["url"], optimise=False) + self.log.info(" + Downloading UHD Manifest") + try: + uhd_prot = uhd_audio_manifest["vodPlaybackUrls"]["result"]["playbackUrls"]["urlMetadata"]["streamingProtocol"] + if uhd_prot == "DASH": + uhd_tracks = DASH.from_url(url=uhd_url, session=self.session).to_tracks(language="en") + elif uhd_prot == "SmoothStreaming": + uhd_tracks = ISM.from_url(url=uhd_url, session=self.session).to_tracks(language="en") + else: + uhd_tracks = Tracks([]) + + # If atmos found, replace + if any(x for x in uhd_tracks.audio if "atmos" in (x.codec or "").lower() or x.channels >= 6): + tracks.add(uhd_tracks.audio, warn_only=True) + except Exception: + pass + + # Post-process video tracks (HDR info) + actual_range = manifest["vodPlaybackUrls"]["result"]["playbackUrls"]["urlMetadata"]["dynamicRange"] + for video in tracks.videos: + video.hdr10 = actual_range == "Hdr10" + video.dv = actual_range == "DolbyVision" + + if self.range == "DV" and actual_range != "DolbyVision": + friendly = {"Hdr10": "HDR10", "None": "SDR"}.get(actual_range, actual_range) + self.log.warning(f" - Dolby Vision not available for this title/region. Server returned: {friendly}") + + # Subtitles + for sub in manifest.get("timedTextUrls", {}).get("result", {}).get("subtitleUrls", []) + manifest.get("timedTextUrls", {}).get("result", {}).get("forcedNarrativeUrls", []): + url = sub["url"] + + url_path = url.split("?")[0] + url_ext = os.path.splitext(url_path)[1].lstrip(".").lower() + codec_map = { + "ttml": Subtitle.Codec.TimedTextMarkupLang, + "dfxp": Subtitle.Codec.TimedTextMarkupLang, + "vtt": Subtitle.Codec.WebVTT, + "srt": Subtitle.Codec.SubRip, + } + detected_codec = codec_map.get(url_ext, Subtitle.Codec.TimedTextMarkupLang) + + sub_obj = Subtitle( + id_=f"{sub['trackGroupId']}_{sub['languageCode']}_{sub['type']}_{sub['subtype']}", + url=url, + codec=detected_codec, + language=sub["languageCode"], + forced="ForcedNarrative" in sub["type"], + sdh=sub["type"].lower() == "sdh" + ) + + tracks.add(sub_obj, warn_only=True) return tracks - @staticmethod - def _dedupe(items: list) -> list: - if not items: - return items - if isinstance(items[0].url, list): - return items - - # Create a more specific key for deduplication that includes resolution/bitrate - seen = {} - for item in items: - # For video tracks, use codec + resolution + bitrate as key - if hasattr(item, 'width') and hasattr(item, 'height'): - key = f"{item.codec}_{item.width}x{item.height}_{item.bitrate}" - # For audio tracks, use codec + language + bitrate + channels as key - elif hasattr(item, 'channels'): - key = f"{item.codec}_{item.language}_{item.bitrate}_{item.channels}" - # Fallback to URL for other track types - else: - key = item.url - - # Keep the item if we haven't seen this exact combination - if key not in seen: - seen[key] = item - - return list(seen.values()) - def get_chapters(self, title: Title_T) -> Chapters: - """Get chapters from Amazon's XRay Scenes API.""" manifest = self.get_manifest( title, video_codec=self.vcodec, bitrate_mode=self.bitrate, - quality="UHD", + quality=self.vquality, hdr=self.range ) - if "xrayMetadata" in manifest: - xray_params = manifest["xrayMetadata"]["parameters"] - elif self.chapters_only: + if "vodXrayMetadata" in manifest: + if "error" in manifest["vodXrayMetadata"]: + return [] + xray_params = { "pageId": "fullScreen", "pageType": "xray", @@ -502,7 +613,7 @@ class AMZN(Service): "consumptionType": "Streaming", "deviceClass": "normal", "playbackMode": "playback", - "vcid": manifest["returnedTitleRendition"]["contentId"], + "vcid": json.loads(manifest["vodXrayMetadata"]["result"]["parameters"]["serviceToken"])["vcid"] }) } else: @@ -510,7 +621,7 @@ class AMZN(Service): xray_params.update({ "deviceID": self.device_id, - "deviceTypeID": self.config["device_types"]["browser"], # must be browser device type + "deviceTypeID": self.config["device_types"]["browser"], "marketplaceID": self.region["marketplace_id"], "gascEnabled": str(self.pv).lower(), "decorationScheme": "none", @@ -519,228 +630,352 @@ class AMZN(Service): "featureScheme": "XRAY_WEB_2020_V1" }) - xray = self.session.get( - url=self.endpoints["xray"], - params=xray_params - ).json().get("page") + try: + xray = self.session.get( + url=self.endpoints["xray"], + params=xray_params + ).json().get("page") + except: + return [] if not xray: return [] - widgets = xray["sections"]["center"]["widgets"]["widgetList"] - - scenes = next((x for x in widgets if x["tabType"] == "scenesTab"), None) - if not scenes: + try: + widgets = xray["sections"]["center"]["widgets"]["widgetList"] + scenes = next((x for x in widgets if x["tabType"] == "scenesTab"), None) + if not scenes: + return [] + scenes = scenes["widgets"]["widgetList"][0]["items"]["itemList"] + except (KeyError, IndexError): return [] - scenes = scenes["widgets"]["widgetList"][0]["items"]["itemList"] chapters = [] - for scene in scenes: chapter_title = scene["textMap"]["PRIMARY"] match = re.search(r"(\d+\. |)(.+)", chapter_title) if match: chapter_title = match.group(2) - chapters.append(Chapter( - name=chapter_title, - timestamp=scene["textMap"]["TERTIARY"].replace("Starts at ", "") - )) + + timecode = scene["textMap"]["TERTIARY"].replace("Starts at ", "") + chapters.append(Chapter(name=chapter_title, timestamp=timecode)) return chapters + def playbackEnvelope_data(self, title): + try: + res = self.session.get( + url=self.endpoints["metadata"], + params={ + 'metadataToEnrich': json.dumps({"placement": "HOVER", "playback": "true", "preroll": "true", "trailer": "true", "watchlist": "true"}), + 'titleIDsToEnrich': json.dumps([title.id]) + }, + headers={'x-requested-with': 'XMLHttpRequest'} + ) + + if res.status_code == 200: + try: + data = res.json() + if ( + title.id in data["enrichments"] + and "focusMessage" in data["enrichments"][title.id].get("entitlementCues", {}) + and data["enrichments"][title.id]["entitlementCues"]["focusMessage"].get("message") == "Watch with a free Prime trial" + ): + self.log.error("Invalid Cookies"); raise SystemExit(1) + + try: + playbackEnvelope = data["enrichments"][title.id]["playbackActions"][0]["playbackExperienceMetadata"]["playbackEnvelope"] + except: + playbackEnvelope = data['enrichments'][title.id]['prerollsEnvelope']['playbackEnvelope'] + return playbackEnvelope + except Exception as e: + self.log.error(f"Unable to get playbackEnvelope: {e}"); raise SystemExit(1) + else: + self.log.error(f"Unable to get playbackEnvelope: {res.text}"); raise SystemExit(1) + except Exception: + self.log.error("Unable to get playbackEnvelope"); raise SystemExit(1) + + def get_manifest(self, title, video_codec, bitrate_mode, quality, hdr, ignore_errors=False): + self.playbackEnvelope = self.playbackEnvelope_data(title) + + # Construct Payload (Vinetrimmer Style) + data_dict = { + "globalParameters": { + "deviceCapabilityFamily": "WebPlayer" if not self.device_token else "AndroidPlayer", + "playbackEnvelope": self.playbackEnvelope, + "capabilityDiscriminators": { + "operatingSystem": {"name": "Windows", "version": "10.0"}, + "middleware": {"name": "EdgeNext", "version": "136.0.0.0"}, + "nativeApplication": {"name": "EdgeNext", "version": "136.0.0.0"}, + "hfrControlMode": "Legacy", + "displayResolution": {"height": 2304, "width": 4096} + } if not self.device_token else { + "discriminators": {"software": {}, "version": 1} + } + }, + "auditPingsRequest": { + **({"device": {"category": "Tv", "platform": "Android"}} if self.device_token else {}) + }, + "playbackDataRequest": {}, + "timedTextUrlsRequest": { + "supportedTimedTextFormats": ["TTMLv2", "DFXP"] + }, + "trickplayUrlsRequest": {}, + "transitionTimecodesRequest": {}, + "vodPlaybackUrlsRequest": { + "device": { + "hdcpLevel": "2.2" if quality == "UHD" else "1.4", + "maxVideoResolution": ("1080p" if quality == "HD" else "480p" if quality == "SD" else "2160p"), + "supportedStreamingTechnologies": ["DASH"], + "streamingTechnologies": { + "DASH": { + "bitrateAdaptations": ["CVBR", "CBR"] if bitrate_mode in ("CVBR+CBR", "CVBR,CBR") else [bitrate_mode], + "codecs": [video_codec], + "drmKeyScheme": "SingleKey" if self.playready else "DualKey", + "drmType": "PlayReady" if self.playready else "Widevine", + "dynamicRangeFormats": self.VIDEO_RANGE_MAP.get(hdr, "None"), + "fragmentRepresentations": ["ByteOffsetRange"], + "frameRates": ["Standard"], + "stitchType": "MultiPeriod", + "segmentInfoType": "Base", + "timedTextRepresentations": ["NotInManifestNorStream", "SeparateStreamInManifest"], + "trickplayRepresentations": ["NotInManifestNorStream"], + "variableAspectRatio": "supported" + } + }, + "displayWidth": 4096, + "displayHeight": 2304 + }, + "ads": { + "sitePageUrl": "", + "gdpr": {"enabled": "false", "consentMap": {}} + }, + "playbackCustomizations": {}, + "playbackSettingsRequest": { + "firmware": "UNKNOWN", + "playerType": self.player, + "responseFormatVersion": "1.0.0", + "titleId": title.id + } + } if not self.device_token else { + # Android/Device Payload + "ads": {}, + "device": { + "displayBasedVending": "supported", + "displayHeight": 2304, + "displayWidth": 4096, + "streamingTechnologies": { + "DASH": { + "fragmentRepresentations": ["ByteOffsetRange"], + "manifestThinningToSupportedResolution": "Forbidden", + "segmentInfoType": "List", + "stitchType": "MultiPeriod", + "timedTextRepresentations": ["BurnedIn", "NotInManifestNorStream", "SeparateStreamInManifest"], + "trickplayRepresentations": ["NotInManifestNorStream"], + "variableAspectRatio": "supported", + "vastTimelineType": "Absolute", + "bitrateAdaptations": ["CVBR", "CBR"] if bitrate_mode in ("CVBR+CBR", "CVBR,CBR") else [bitrate_mode], + "codecs": [video_codec], + "drmKeyScheme": "SingleKey", + "drmStrength": "L40", + "drmType": "PlayReady" if self.playready else "Widevine", + "dynamicRangeFormats": [self.VIDEO_RANGE_MAP.get(hdr, "None")], + "frameRates": ["Standard"] + }, + "SmoothStreaming": { + "fragmentRepresentations": ["ByteOffsetRange"], + "manifestThinningToSupportedResolution": "Forbidden", + "segmentInfoType": "List", + "stitchType": "MultiPeriod", + "timedTextRepresentations": ["BurnedIn", "NotInManifestNorStream", "SeparateStreamInManifest"], + "trickplayRepresentations": ["NotInManifestNorStream"], + "variableAspectRatio": "supported", + "vastTimelineType": "Absolute", + "bitrateAdaptations": ["CVBR", "CBR"] if bitrate_mode in ("CVBR+CBR", "CVBR,CBR") else [bitrate_mode], + "codecs": [video_codec], + "drmKeyScheme": "SingleKey", + "drmStrength": "L40", + "drmType": "PlayReady", + "dynamicRangeFormats": [self.VIDEO_RANGE_MAP.get(hdr, "None")], + "frameRates": ["Standard"] + } + }, + "acceptedCreativeApis": [], + "category": "Tv", + "hdcpLevel": "2.2", + "maxVideoResolution": "2160p", + "platform": "Android", + "supportedStreamingTechnologies": ["DASH", "SmoothStreaming"] + }, + "playbackCustomizations": {}, + "playbackSettingsRequest": { + "firmware": "UNKNOWN", + "playerType": self.player, + "responseFormatVersion": "1.0.0", + "titleId": title.id + } + }, + "vodXrayMetadataRequest": { + "xrayDeviceClass": "normal", + "xrayPlaybackMode": "playback", + "xrayToken": "XRAY_WEB_2023_V2" + } + } + + json_data = json.dumps(data_dict) + + res = self.session.post( + url=self.endpoints["playback"], + params={ + 'deviceID': self.device_id, + 'deviceTypeID': self.device["device_type"], + 'gascEnabled': str(self.pv).lower(), + 'marketplaceID': self.region["marketplace_id"], + 'uxLocale': 'en_EN', + 'firmware': 1, + 'titleId': title.id, + 'nerid': self.generate_nerid(), + }, + data=json_data, + headers={ + "Authorization": f"Bearer {self.device_token}" if self.device_token else None, + }, + ) + + try: + manifest = res.json() + except json.JSONDecodeError: + if ignore_errors: return {} + self.log.error(f" - Amazon reported an error when obtaining the Playback Manifest\n{res.text}"); raise SystemExit(1) + + if "error" in manifest.get("vodPlaybackUrls", {}): + if ignore_errors: return {} + message = manifest["vodPlaybackUrls"]["error"]["message"] + self.log.error(f" - Amazon reported an error when obtaining the Playback Manifest: {message}"); raise SystemExit(1) + + return manifest + def get_widevine_service_certificate(self, **_: Any) -> str: return self.config["certificate"] - def get_widevine_license(self, *, challenge: bytes, title: Title_T, track) -> None: - response = self.session.post( - url=self.endpoints["license"], + def get_widevine_license(self, challenge: bytes, title: Title_T, track: Tracks, **_) -> str: + return self._get_license(challenge, title, track, widevine=True) + + def get_playready_license(self, challenge: bytes, title: Title_T, track: Tracks, **_) -> str: + return self._get_license(challenge, title, track, widevine=False) + + def _get_license(self, challenge: bytes, title: Title_T, track: Tracks, widevine: bool): + # Determine SessionHandoffToken from tracks extra data or similar + # In Vinetrimmer this is passed in track.extra, but in envied.tracks are standard objects. + # We need to rely on the fact that envied.usually doesn't need the sessionHandoffToken for the license request + # UNLESS Amazon specifically enforces it for the payload. + # Vinetrimmer payload: + + # NOTE: envied.Track objects might not carry the sessionHandoffToken unless we subclassed DASH parser. + # For migration purposes, we will attempt without it or fetch from manifest if strictly needed. + # Vinetrimmer logic extracts it from manifest during track parsing. + # Ideally we would pass it via the track object. + + session_handoff_token = None + # Try to find it in track extras if stored (envied.doesn't natively store custom extras easily) + + challenge_bytes = challenge if isinstance(challenge, bytes) else challenge.encode("utf-8") + encoded_challenge = base64.b64encode(challenge_bytes).decode("utf-8") + + data_lic = { + "includeHdcpTestKeyInLicense": "true", + "licenseChallenge": encoded_challenge, + "playbackEnvelope": self.playbackEnvelope, + } + + endpoint = self.endpoints["licence_pr"] if not widevine else self.endpoints["licence"] + + res = self.session.post( + url=endpoint, params={ - "asin": title.id, - "consumptionType": "Streaming", - "desiredResources": "Widevine2License", - "deviceTypeID": self.device["device_type"], - "deviceID": self.device_id, - "firmware": 1, - "gascEnabled": str(self.pv).lower(), - "marketplaceID": self.region["marketplace_id"], - "resourceUsage": "ImmediateConsumption", - "videoMaterialType": "Feature", - "operatingSystemName": "Linux" if any(q <= 576 for q in self.quality) else "Windows", - "operatingSystemVersion": "unknown" if any(q <= 576 for q in self.quality) else "10.0", - "customerID": self.customer_id, - "deviceDrmOverride": "CENC", - "deviceStreamingTechnologyOverride": "DASH", - "deviceVideoQualityOverride": "HD", - "deviceHdrFormatsOverride": "None", + 'deviceID': self.device_id, + 'deviceTypeID': self.device["device_type"], + 'gascEnabled': str(self.pv).lower(), + 'marketplaceID': self.region["marketplace_id"], + 'uxLocale': 'en_EN', + 'firmware': 1, + 'titleId': title.id, + 'nerid': self.generate_nerid(), }, headers={ "Accept": "application/json", - "Content-Type": "application/x-www-form-urlencoded", - "Authorization": f"Bearer {self.device_token}", - }, - data={ - "widevine2Challenge": base64.b64encode(challenge).decode(), - "includeHdcpTestKeyInLicense": "false", + "Content-Type": "application/json", + "Authorization": f"Bearer {self.device_token}" }, + json=data_lic ).json() - if "errorsByResource" in response: - error_code = response["errorsByResource"]["Widevine2License"] - if "errorCode" in error_code: - error_code = error_code["errorCode"] - elif "type" in error_code: - error_code = error_code["type"] - if error_code in ["PRS.NoRights.AnonymizerIP", "PRS.NoRights.NotOwned"]: - self.log.error("Proxy detected, Unable to License") - elif error_code == "PRS.Dependency.DRM.Widevine.UnsupportedCdmVersion": - self.log.error("Cdm version not supported") + req_preview = {k: (v[:80] if isinstance(v, str) else v) for k, v in data_lic.items()} + if "errorsByResource" in res: + error = res["errorsByResource"] + if "errorCode" in error: + code = error["errorCode"] + elif "type" in error: + code = error["type"] else: - self.log.error(f" x Error from Amazon's License Server: [{error_code}]") - sys.exit(1) - - return response["widevine2License"]["license"] - - def get_playready_license(self, challenge: Union[bytes, str], title: Title_T, **_): - lic_list = [] - lic_challenge = base64.b64encode(challenge).decode("utf-8") if isinstance(challenge, bytes) else base64.b64encode(challenge.encode("utf-8")).decode("utf-8") - self.log.debug(f"Challenge - {lic_challenge}") - params = { - "asin": title.id, - "consumptionType": "Streaming", # Streaming or Download both work - "desiredResources": "PlayReadyLicense", - "deviceTypeID": self.device["device_type"], - "deviceID": self.device_id, - "firmware": 1, - "gascEnabled": str(self.pv).lower(), - "marketplaceID": self.region["marketplace_id"], - "resourceUsage": "ImmediateConsumption", - "videoMaterialType": "Feature", - "operatingSystemName": "Windows", - "operatingSystemVersion": "10.0", - "customerID": self.customer_id, - "deviceDrmOverride": "CENC", #CENC or Playready both work - "deviceStreamingTechnologyOverride": "DASH", # or SmoothStreaming - "deviceVideoQualityOverride": self.vquality, - "deviceHdrFormatsOverride": self.VIDEO_RANGE_MAP.get(self.range, "None"), - } - headers = { - "Accept": "application/json", - "Content-Type": "application/x-www-form-urlencoded", - "Authorization": f"Bearer {self.device_token}" - } - data = { - "playReadyChallenge": lic_challenge, # expects base64 - "includeHdcpTestKeyInLicense": "true" - } - lic = self.session.post( - url=self.endpoints["licence"], - params=params, - headers=headers, - data=data - ).json() - lic_list.append(lic) - # params["deviceStreamingTechnologyOverride"] = "SmoothStreaming" - params["deviceDrmOverride"] = "Playready" - lic = self.session.post( - url=self.endpoints["licence"], - params=params, - headers=headers, - data=data - ).json() - lic_list.append(lic) - - for lic in lic_list: - if "errorsByResource" in lic: - error_code = lic["errorsByResource"]["PlayReadyLicense"] - self.log.debug(error_code) - if "errorCode" in error_code: - error_code = error_code["errorCode"] - elif "type" in error_code: - error_code = error_code["type"] - if error_code == "PRS.NoRights.AnonymizerIP": - self.log.error(" - Amazon detected a Proxy/VPN and refused to return a license!") - continue - message = lic["errorsByResource"]["PlayReadyLicense"]["message"] - self.log.error(f" - Amazon reported an error during the License request: {message} [{error_code}]") - continue - elif "error" in lic: - error_code = lic["error"] - if "errorCode" in error_code: - error_code = error_code["errorCode"] - elif "type" in error_code: - error_code = error_code["type"] - if error_code == "PRS.NoRights.AnonymizerIP": - self.log.error(" - Amazon detected a Proxy/VPN and refused to return a license!") - continue - message = lic["error"]["message"] - self.log.error(f" - Amazon reported an error during the License request: {message} [{error_code}]") - continue - else: - xmrlic = base64.b64decode(lic["playReadyLicense"]["encodedLicenseResponse"].encode("utf-8")).decode("utf-8") - self.log.debug(xmrlic) - return xmrlic # Return Xml licence + code = "Unknown" - # Service specific functions - - def configure(self): - if len(self.title) > 10 and not (self.domain or "").startswith("amazon."): - self.pv = True - - self.log.info("Getting account region") - self.region = self.get_region() - if not self.region: - self.log.error(" - Failed to get Amazon account region") - sys.exit(1) - # self.GEOFENCE.append(self.region["code"]) - self.log.info(f" + Region: {self.region['code'].upper()}") - - # endpoints must be prepared AFTER region data is retrieved - self.endpoints = self.prepare_endpoints(self.config["endpoints"], self.region) - - self.session.headers.update({"Origin": f"https://{self.region['base']}"}) - - self.device = (self.config.get("device") or {}).get(self.profile, "default") - - if (int(self.quality[0]) > 1080 or self.range != "SDR" or self.atmos): - self.log.info(f"Using device to get UHD manifests") - self.register_device() + if code == "PRS.NoRights.AnonymizerIP": + self.log.error(" - Amazon detected a Proxy/VPN and refused to return a license!"); raise SystemExit(1) - elif not self.device or self.vquality != "UHD" or self.drm_system == "widevine": - # falling back to browser-based device ID - if not self.device: - self.log.warning( - "No Device information was provided for %s, using browser device...", - self.profile - ) - self.device_id = hashlib.sha224( - ("CustomerID" + self.session.headers["User-Agent"]).encode("utf-8") - ).hexdigest() - self.device = {"device_type": self.config["device_types"]["browser"]} - else: - self.register_device() - - def register_device(self) -> None: - self.device = (self.config.get("device") or {}).get(self.profile, "default") - device_cache_path = f"device_tokens_{self.profile}_{hashlib.md5(json.dumps(self.device).encode()).hexdigest()[0:6]}" - self.device_token = self.DeviceRegistration( - device=self.device, - endpoints=self.endpoints, - log=self.log, - cache_path=device_cache_path, - session=self.session - ).bearer - self.device_id = self.device.get("device_serial") - if not self.device_id: - raise self.log.error(f" - A device serial is required in the config, perhaps use: {os.urandom(8).hex()}") + self.log.error(f" - Amazon reported an error during the License request: [{code}]"); raise SystemExit(1) - def get_region(self): + if "error" in res: + self.log.error(f" - License Error: {res['error']['message']}"); raise SystemExit(1) + + if widevine: + return res["widevineLicense"]["license"] + else: + return res["playReadyLicense"]["license"] + + # --- Helpers --- + + def choose_manifest(self, manifest: dict, cdn=None): + if not manifest or "vodPlaybackUrls" not in manifest: + return {} + + url_sets = manifest["vodPlaybackUrls"]["result"]["playbackUrls"].get("urlSets", []) + if not url_sets: return {} + + if cdn: + cdn = cdn.lower() + return next((x for x in url_sets if x["cdn"].lower() == cdn), {}) + + return random.choice(url_sets) + + @staticmethod + def generate_nerid(length=24): + chars = string.ascii_letters + string.digits + return ''.join(secrets.choice(chars) for _ in range(length)) + + @staticmethod + def clean_mpd_url(mpd_url, optimise=False): + if optimise: + return mpd_url.replace("~", "") + "?encoding=segmentBase" + if match := re.match(r"(https?://.*/)d.?/.*~/(.*)", mpd_url): + mpd_url = "".join(match.groups()) + else: + try: + mpd_url = "".join( + re.split(r"(?i)(/)", mpd_url)[:5] + re.split(r"(?i)(/)", mpd_url)[9:] + ) + except IndexError: + pass + return mpd_url + + def get_region(self) -> dict: domain_region = self.get_domain_region() if not domain_region: return {} region = self.config["regions"].get(domain_region) if not region: - raise self.log.error(f" - There's no region configuration data for the region: {domain_region}") + self.log.error(f" - There's no region configuration data for the region: {domain_region}"); raise SystemExit(1) region["code"] = domain_region @@ -750,7 +985,7 @@ class AMZN(Service): if match: pv_region = match.group(2).lower() else: - raise self.log.error(" - Failed to get PrimeVideo region") + self.log.error(" - Failed to get PrimeVideo region"); raise SystemExit(1) pv_region = {"na": "atv-ps"}.get(pv_region, f"atv-ps-{pv_region}") region["base_manifest"] = f"{pv_region}.primevideo.com" region["base"] = "www.primevideo.com" @@ -758,195 +993,240 @@ class AMZN(Service): return region def get_domain_region(self): - """Get the region of the cookies from the domain.""" - tld = (self.domain_region or "").split(".")[-1] - if not tld: - domains = [x.domain for x in self.session.cookies if x.domain_specified] - tld = next((x.split(".")[-1] for x in domains if x.startswith((".amazon.", ".primevideo."))), None) - return {"com": "us", "uk": "gb"}.get(tld, tld) - + tlds = [tldextract.extract(x.domain) for x in self.session.cookies if x.domain_specified] + tld = next((x.suffix for x in tlds if x.domain.lower() in ("amazon", "primevideo")), None) + if tld: + tld = tld.split(".")[-1] + region = {"com": "us", "uk": "gb"}.get(tld, tld) + + if region == "us": + lc_cookie = next( + (x.value for x in self.session.cookies + if x.name in ("lc-main-av", "lc-main") and x.domain_specified), + None + ) + if lc_cookie: + parts = lc_cookie.replace("-", "_").split("_") + if len(parts) >= 2: + country = parts[-1].lower() + if country not in ("us", ""): + mapped = {"uk": "gb"}.get(country, country) + if mapped in self.config.get("regions", {}): + region = mapped + + return region + def prepare_endpoint(self, name: str, uri: str, region: dict) -> str: - if name in ("browse", "playback", "licence", "xray"): + if name in ("browse", "playback", "licence", "licence_pr", "xray"): return f"https://{(region['base_manifest'])}{uri}" - if name in ("ontv", "ontvold", "mytv", "devicelink", "details", "getDetailWidgets"): + if name in ("ontv", "devicelink", "details", "getDetailWidgets", "metadata"): if self.pv: host = "www.primevideo.com" else: - host = region["base"] + if name in ("metadata"): + host = f"{region['base']}/gp/video" + else: + host = region["base"] return f"https://{host}{uri}" if name in ("codepair", "register", "token"): - return f"https://{self.config['regions']['us']['base_api']}{uri}" + base_api = region.get("base_api") or self.config["regions"]["us"]["base_api"] + return f"https://{base_api}{uri}" raise ValueError(f"Unknown endpoint: {name}") - + def prepare_endpoints(self, endpoints: dict, region: dict) -> dict: return {k: self.prepare_endpoint(k, v, region) for k, v in endpoints.items()} - def choose_manifest(self, manifest: dict, cdn=None): - """Get manifest URL for the title based on CDN weight (or specified CDN).""" - if cdn: - cdn = cdn.lower() - manifest = next((x for x in manifest["audioVideoUrls"]["avCdnUrlSets"] if x["cdn"].lower() == cdn), {}) - if not manifest: - raise self.log.exit(f" - There isn't any DASH manifests available on the CDN \"{cdn}\" for this title") + def register_device(self) -> None: + _profile = self.profile or "default" + self.device = dict((self.config.get("device") or {}).get(_profile, {})) + + # Resolve unique device identity from cache, or generate and persist it. + # This avoids collisions when multiple users share the same config values, + # which can cause Amazon to deregister devices that appear duplicated. + identity_cache = Cacher("AMZN") + identity_key = f"device_identity_{_profile}" + cached_identity = identity_cache.get(identity_key) + + if cached_identity and cached_identity.data: + identity = cached_identity.data + self.log.debug(" + Using cached device identity") else: - manifest = next((x for x in sorted([x for x in manifest["audioVideoUrls"]["avCdnUrlSets"]], key=lambda x: int(x["cdnWeightsRank"]))), {}) + # Generate a unique serial (16 hex chars, same format as real Android devices) + unique_serial = secrets.token_hex(8) + # Build a plausible device name: keep the base from config but make it unique + base_name = self.device.get("device_name", "%FIRST_NAME%'s Shield TV") + # Strip any existing DUPE_STRATEGY placeholder so we can add our suffix cleanly + clean_name = re.sub(r"%DUPE_STRATEGY[^%]*%", "", base_name).rstrip() + suffix = secrets.token_hex(2).upper() # e.g. "A3F1" — short, looks like a serial suffix + unique_name = f"{clean_name}-{suffix}" + identity = {"device_serial": unique_serial, "device_name": unique_name} + # Persist indefinitely (10 years TTL) — identity should never rotate on its own + cached_identity = identity_cache.get(identity_key) + cached_identity.set(identity, int(time.time()) + 60 * 60 * 24 * 3650) + self.log.info(f" + Generated unique device identity: serial={unique_serial}, name={unique_name!r}") - return manifest + self.device["device_serial"] = identity["device_serial"] + self.device["device_name"] = identity["device_name"] - def get_manifest( - self, title, video_codec: str, bitrate_mode: str, quality: str, hdr=None, - ignore_errors: bool = False - ) -> dict: - res = self.session.get( - url=self.endpoints["playback"], - params={ - "asin": title.id, - "consumptionType": "Streaming", - "desiredResources": ",".join([ - "PlaybackUrls", - "AudioVideoUrls", - "CatalogMetadata", - "ForcedNarratives", - "SubtitlePresets", - "SubtitleUrls", - "TransitionTimecodes", - "TrickplayUrls", - "CuepointPlaylist", - "XRayMetadata", - "PlaybackSettings", - ]), - "deviceID": self.device_id, - "deviceTypeID": self.device["device_type"], - "firmware": 1, - "gascEnabled": str(self.pv).lower(), - "marketplaceID": self.region["marketplace_id"], - "resourceUsage": "CacheResources", - "videoMaterialType": "Feature", - "playerType": "html5", - "clientId": self.client_id, - **({ - "operatingSystemName": "Linux" if quality == "SD" else "Windows", - "operatingSystemVersion": "unknown" if quality == "SD" else "10.0", - } if not self.device_token else {}), - "deviceDrmOverride": "CENC", - "deviceStreamingTechnologyOverride": "DASH", - "deviceProtocolOverride": "Https", - "deviceVideoCodecOverride": video_codec, - "deviceBitrateAdaptationsOverride": bitrate_mode.replace("+", ","), - "deviceVideoQualityOverride": quality, - "deviceHdrFormatsOverride": self.VIDEO_RANGE_MAP.get(hdr, "None"), - "supportedDRMKeyScheme": "DUAL_KEY", # ? - "liveManifestType": "live,accumulating", # ? - "titleDecorationScheme": "primary-content", - "subtitleFormat": "TTMLv2", - "languageFeature": "MLFv2", # ? - "uxLocale": "en_US", - "xrayDeviceClass": "normal", - "xrayPlaybackMode": "playback", - "xrayToken": "XRAY_WEB_2020_V1", - "playbackSettingsFormatVersion": "1.0.0", - "playerAttributes": json.dumps({"frameRate": "HFR"}), - # possibly old/unused/does nothing: - "audioTrackId": "all", - }, - headers={ - "Authorization": f"Bearer {self.device_token}" if self.device_token else None, - }, - ) - try: - manifest = res.json() - except json.JSONDecodeError: - if ignore_errors: - return {} + device_hash = hashlib.md5(json.dumps(self.device, sort_keys=True).encode()).hexdigest()[0:6] + device_cache_path = f"device_tokens_{_profile}_{device_hash}" - raise self.log.exit(" - Amazon didn't return JSON data when obtaining the Playback Manifest.") + self.device_token = self.DeviceRegistration( + device=self.device, + endpoints=self.endpoints, + log=self.log, + cache_path=device_cache_path, + session=self.session + ).bearer - if "error" in manifest: - if ignore_errors: - return {} - raise self.log.exit(" - Amazon reported an error when obtaining the Playback Manifest.") + self.device_id = self.device.get("device_serial") + if not self.device_id: + self.log.error(f" - A device serial is required in the config, perhaps use: {os.urandom(8).hex()}"); raise SystemExit(1) - # Commented out as we move the rights exception check elsewhere - # if "rightsException" in manifest["returnedTitleRendition"]["selectedEntitlement"]: - # if ignore_errors: - # return {} - # raise self.log.exit(" - The profile used does not have the rights to this title.") + class DeviceRegistration: + def __init__(self, device: dict, endpoints: dict, cache_path: str, session: requests.Session, log): + self.session = session + self.device = device + self.endpoints = endpoints + self.cache_path = cache_path + self.log = log + self.cache = Cacher('AMZN') + + self.device = {k: str(v) if not isinstance(v, str) else v for k, v in self.device.items()} + self.bearer = None - # Below checks ignore NoRights errors + # Retrieve from Cacher + cached_data = self.cache.get(self.cache_path) + + if cached_data: + # Check expiration + if cached_data.data.get("expires_in", 0) > int(time.time()): + self.log.info(" + Using cached device bearer") + self.bearer = cached_data.data["access_token"] + else: + self.log.info("Cached device bearer expired, refreshing...") + # Note: Vinetrimmer uses a specific refresh logic calling self.refresh + # We need to extract refresh token from cache + refresh_token = cached_data.data.get("refresh_token") + if refresh_token: + refreshed_tokens = self.refresh(self.device, refresh_token) + refreshed_tokens["refresh_token"] = refresh_token + # Fix: fallback to 3600s if expires_in is missing or zero + expires_seconds = int(refreshed_tokens.get("expires_in") or 3600) + refreshed_tokens["expires_in"] = int(time.time()) + expires_seconds - if ( - manifest.get("errorsByResource", {}).get("PlaybackUrls") and - manifest["errorsByResource"]["PlaybackUrls"].get("errorCode") != "PRS.NoRights.NotOwned" - ): - if ignore_errors: - return {} - error = manifest["errorsByResource"]["PlaybackUrls"] - raise self.log.exit(f" - Amazon had an error with the Playback Urls: {error['message']} [{error['errorCode']}]") + # Fix: persist refreshed token to cache (was only updated in memory before) + cached_data.data = refreshed_tokens + cached_data.set(refreshed_tokens, refreshed_tokens["expires_in"]) + self.bearer = refreshed_tokens["access_token"] + else: + self.log.info(" + Registering new device bearer (No refresh token)") + self.bearer = self.register(self.device) + else: + self.log.info(" + Registering new device bearer") + self.bearer = self.register(self.device) - if ( - manifest.get("errorsByResource", {}).get("AudioVideoUrls") and - manifest["errorsByResource"]["AudioVideoUrls"].get("errorCode") != "PRS.NoRights.NotOwned" - ): - if ignore_errors: - return {} - error = manifest["errorsByResource"]["AudioVideoUrls"] - raise self.log.exit(f" - Amazon had an error with the A/V Urls: {error['message']} [{error['errorCode']}]") + def register(self, device: dict) -> str: + code_pair = self.get_code_pair(device) + public_code = code_pair["public_code"] - return manifest + self.log.info(f" + Visit https://www.primevideo.com/mytv and enter the code: {public_code}") + self.log.info(f" Waiting for authorisation (up to 5 minutes)...") - @staticmethod - def get_original_language(manifest): - """Get a title's original language from manifest data.""" - try: - return next( - x["language"].replace("_", "-") - for x in manifest["catalogMetadata"]["playback"]["audioTracks"] - if x["isOriginalLanguage"] - ) - except (KeyError, StopIteration): - pass + interval = 10 # seconds between polls + deadline = int(time.time()) + 300 # 5 minute timeout - if "defaultAudioTrackId" in manifest.get("playbackUrls", {}): - try: - return manifest["playbackUrls"]["defaultAudioTrackId"].split("_")[0] - except IndexError: - pass + while int(time.time()) < deadline: + res = self.session.post( + url=self.endpoints["register"], + headers={"Content-Type": "application/json", "Accept-Language": "en-US"}, + json={ + "auth_data": {"code_pair": code_pair}, + "registration_data": device, + "requested_token_type": ["bearer"], + "requested_extensions": ["device_info", "customer_info"] + }, + cookies=None + ) + data = res.json() - try: - return sorted( - manifest["audioVideoUrls"]["audioTrackMetadata"], - key=lambda x: x["index"] - )[0]["languageCode"] - except (KeyError, IndexError): - pass + if res.status_code == 200 and "success" in data.get("response", {}): + break - return None + error_code = data.get("response", {}).get("error", {}).get("code", "") + if error_code == "Unauthorized": + time.sleep(interval) + continue + else: + self.log.error(f"Unable to register: {res.text}"); raise SystemExit(1) + else: + self.log.error("Device registration timed out — code was not approved in time."); raise SystemExit(1) - @staticmethod - def clean_mpd_url(mpd_url, optimise=True): - print(f"MPD URL: {mpd_url}, optimise: {optimise}") - """Clean up an Amazon MPD manifest url.""" - if 'akamaihd.net' in mpd_url: - match = re.search(r'[^/]*\$[^/]*/', mpd_url) - if match: - dollar_sign_part = match.group(0) - mpd_url = mpd_url.replace(dollar_sign_part, '', 1) - return mpd_url - - if optimise: - return mpd_url.replace("~", "") + "?encoding=segmentBase" - else: - if match := re.match(r"(https?://.*/)d.?/.*~/(.*)", mpd_url): - print(f"returned: {''.join(match.groups())}") - return "".join(match.groups()) - elif match := re.match(r"(https?://.*/)d.?/.*\$.*?/(.*)", mpd_url): - print(f"returned: {''.join(match.groups())}") - return "".join(match.groups()) - elif match := re.match(r"(https?://.*/).*\$.*?/(.*)", mpd_url): - print(f"returned: {''.join(match.groups())}") - return "".join(match.groups()) - raise ValueError("Unable to parse MPD URL") + bearer = data["response"]["success"]["tokens"]["bearer"] + expires_val = bearer.get("expires_in", 3600) + if isinstance(expires_val, dict): + expires_val = expires_val.get("value", 3600) + bearer_data = { + "access_token": bearer["access_token"], + "refresh_token": bearer.get("refresh_token", ""), + "expires_in": int(time.time()) + int(expires_val), + } + keyed_cache = self.cache.get(self.cache_path) + keyed_cache.set(bearer_data, int(time.time()) + int(expires_val)) + self.log.info(" + Device registered and token cached successfully") + return bearer_data["access_token"] + + def refresh(self, device: dict, refresh_token: str) -> dict: + res = self.session.post( + url=self.endpoints["token"], + json={ + "app_name": device["app_name"], + "app_version": device["app_version"], + "source_token_type": "refresh_token", + "source_token": refresh_token, + "requested_token_type": "access_token" + } + ).json() + + if "error" in res: + # Invalidate cache if error + # self.cache.delete(self.cache_path) # If method existed + self.log.error(f"Failed to refresh device token: {res.get('error_description')}"); raise SystemExit(1) + + return res + + def get_csrf_token(self) -> str: + res = self.session.get(self.endpoints["ontv"]) + if 'name="appAction" value="SIGNIN"' in res.text or 'SIGNIN_PWD_COLLECT' in res.text: + self.log.error("Cookies are signed out, cannot get ontv CSRF token.") + raise SystemExit(1) + for match in re.finditer(r'', res.text, re.DOTALL): + try: + prop = json.loads(match.group(1)) + token = prop.get("props", {}).get("codeEntry", {}).get("token") + if token: return token + token = prop.get("codeEntry", {}).get("token") + if token: return token + except Exception: + pass + ce_idx = res.text.find('"codeEntry"') + if ce_idx != -1: + snippet = res.text[ce_idx:ce_idx+2000] + m2 = re.search(r'"token"\s*:\s*"([^"]+)"', snippet) + if m2: return m2.group(1) + self.log.error("Unable to get ontv CSRF token") + raise SystemExit(1) + def get_code_pair(self, device: dict) -> dict: + res = self.session.post( + url=self.endpoints["codepair"], + headers={"Content-Type": "application/json", "Accept-Language": "en-US"}, + json={"code_data": device} + ).json() + if "error" in res: + self.log.error(f"Unable to get code pair: {res['error']}"); raise SystemExit(1) + return res + # Misc def parse_title(self, ctx, title): title = title or ctx.parent.params.get("title") if not title: @@ -961,214 +1241,3 @@ class AMZN(Service): return m.groupdict() self.log.warning(f" - Unable to parse title ID {title!r}, using as-is") self.title = title - - # Service specific classes - - class DeviceRegistration: - def __init__(self, device: dict, endpoints: dict, cache_path: str, session: requests.Session, log: Logger): - self.session = session - self.device = device - self.endpoints = endpoints - self.cache_path = cache_path - self.log = log - self.cache = Cacher('AMZN') - # self.device = {k: str(v) if not isinstance(v, str) else v for k, v in self.device.items()} - - self.bearer = None - - self._cache = self.cache.get(self.cache_path) - if self._cache: - if self._cache.data.get("expires_in", 0) > int(time.time()): - self.log.info(" + Using cached device bearer") - self.bearer = self._cache["access_token"] - else: - self.log.info("Refreshing cached device bearer...") - refreshed_tokens = self.refresh(self.device, self._cache.data["refresh_token"], self._cache.data["access_token"]) - refreshed_tokens["refresh_token"] = self._cache.data["refresh_token"] - # expires_in seems to be in minutes, create a unix timestamp and add the minutes in seconds - refreshed_tokens["expires_in"] = int(time.time()) + int(refreshed_tokens["expires_in"]) - self._cache.data = refreshed_tokens - self.bearer = refreshed_tokens["access_token"] - else: - self.log.info(" + Registering new device bearer") - self.bearer = self.register(self.device) - - def register(self, device: dict) -> dict: - """ - Register device to the account - :param device: Device data to register - :return: Device bearer tokens - """ - # OnTV csrf - csrf_token, referer = self.get_csrf_token() - - # Code pair - code_pair = self.get_code_pair(device) - - # Device link - response = self.session.post( - url=self.endpoints["devicelink"], - headers={ - "Accept": "*/*", - "Accept-Language": "en-US,en;q=0.9,es-US;q=0.8,es;q=0.7", # needed? - "Content-Type": "application/x-www-form-urlencoded", - "Referer": referer - }, - params=urlencode({ - # any reason it urlencodes here? requests can take a param dict... - "ref_": "atv_set_rd_reg", - "publicCode": code_pair["public_code"], # public code pair - "token": csrf_token # csrf token - }) - ) - if response.status_code != 200: - raise self.log.error(f"Unexpected response with the codeBasedLinking request: {response.text} [{response.status_code}]") - - # Register - response = self.session.post( - url=self.endpoints["register"], - headers={ - "Content-Type": "application/json", - "Accept-Language": "en-US", - }, - json={ - "auth_data": { - "code_pair": code_pair - }, - "registration_data": device, - "requested_token_type": ["bearer"], - "requested_extensions": ["device_info", "customer_info"] - }, - cookies=None # for some reason, may fail if cookies are present. Odd. - ) - if response.status_code != 200: - raise self.log.error(f"Unable to register: {response.text} [{response.status_code}]") - bearer = response.json()["response"]["success"]["tokens"]["bearer"] - bearer["expires_in"] = int(time.time()) + int(bearer["expires_in"]) - - # Cache bearer - self._cache.set(bearer) - # os.makedirs(os.path.dirname(self.cache_path), exist_ok=True) - # with open(self.cache_path, "w", encoding="utf-8") as fd: - # fd.write(jsonpickle.encode(bearer)) - - return bearer["access_token"] - - def refresh(self, device: dict, refresh_token: str, access_token: str) -> dict: - # using the refresh token get the cookies needed for making calls to *.amazon.com - response = requests.post( - url=self.endpoints["token"], - headers={ - 'User-Agent': 'AmazonWebView/Amazon Alexa/2.2.223830.0/iOS/11.4.1/iPhone', # https://gitlab.com/keatontaylor/alexapy/-/commit/540b6333d973177bbc98e6ef39b00134f80ef0bb - 'Accept-Language': 'en-US', - 'Accept-Charset': 'utf-8', - 'Connection': 'keep-alive', - 'Content-Type': 'application/x-www-form-urlencoded', - 'Accept': '*/*' - }, - cookies={ - 'at-main': access_token, - }, - data={ - **device, - 'domain': '.' + self.endpoints["token"].split("/")[-3], - 'source_token': str(refresh_token), - 'requested_token_type': 'auth_cookies', - 'source_token_type': 'refresh_token', - } - ) - response_json = response.json() - cookies = {} - self.log.debug(response_json) - if response.status_code == 200: - # Extract the cookies from the response - raw_cookies = response_json['response']['tokens']['cookies']['.amazon.com'] - for cookie in raw_cookies: - cookies[cookie['Name']] = cookie['Value'] - else: - error = response_json['response']["error"] - self.cache_path.unlink(missing_ok=True) - raise self.log.error(f"Error when refreshing cookies: {error['message']} [{error['code']}]") - - response = requests.post( - url=self.endpoints["token"], - headers={ - 'Content-Type': 'application/json; charset=utf-8', - 'Accept-Encoding': 'gzip, deflate, br', - 'Connection': 'keep-alive', - 'Accept': 'application/json; charset=utf-8', - 'User-Agent': "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36", - 'Accept-Language': 'en-US,en-US;q=1.0', - 'x-amzn-identity-auth-domain': self.endpoints["token"].split("/")[-3], - 'x-amzn-requestid': str(uuid4()).replace('-', '') - }, - json={ - **device, - 'requested_token_type': 'access_token', - 'source_token_type': 'refresh_token', - 'source_token': str(refresh_token), - }, # https://github.com/Sandmann79/xbmc/blob/dab17d913ee877d96115e6f799623bca158f3f24/plugin.video.amazon-test/resources/lib/login.py#L593 - cookies=cookies - ) - response_json = response.json() - - if response.status_code != 200 or "error" in response_json: - self.cache_path.unlink(missing_ok=True) # Remove the cached device as its tokens have expired - raise self.log.error(f"Failed to refresh device token -> {response_json['error_description']} [{response_json['error']}]") - self.log.debug(response_json) - if response_json["token_type"] != "bearer": - raise self.log.error("Unexpected returned refreshed token type") - - return response_json - - def get_csrf_token(self) -> str: - """ - On the amazon website, you need a token that is in the html page, - this token is used to register the device - :return: OnTV Page's CSRF Token - """ - try: - res = self.session.get(self.endpoints["ontv"]) - response = res.text - if 'input type="hidden" name="appAction" value="SIGNIN"' in response: - raise self.log.error( - "Cookies are signed out, cannot get ontv CSRF token. " - f"Expecting profile to have cookies for: {self.endpoints['ontv']}" - ) - for match in re.finditer(r"", response): - prop = json.loads(match.group(1)) - prop = prop.get("props", {}).get("codeEntry", {}).get("token") - if prop: - return prop, self.endpoints["ontv"] - raise self.log.error(f"Unable to get ontv CSRF token - Navigate to {self.endpoints['mytv']}, login and save cookies from code pair page to default.txt") - except: - res = self.session.get(self.endpoints["ontvold"]) - response = res.text - if 'input type="hidden" name="appAction" value="SIGNIN"' in response: - raise self.log.error( - "Cookies are signed out, cannot get ontv CSRF token. " - f"Expecting profile to have cookies for: {self.endpoints['ontvold']}" - ) - for match in re.finditer(r"", response): - prop = json.loads(match.group(1)) - prop = prop.get("props", {}).get("codeEntry", {}).get("token") - if prop: - return prop, self.endpoints["ontvold"] - raise self.log.error(f"Unable to get ontv CSRF token - Navigate to {self.endpoints['mytv']}, login and save cookies from code pair page to default.txt") - - def get_code_pair(self, device: dict) -> dict: - """ - Getting code pairs based on the device that you are using - :return: public and private code pairs - """ - res = self.session.post( - url=self.endpoints["codepair"], - headers={ - "Content-Type": "application/json", - "Accept-Language": "en-US", - }, - json={"code_data": device} - ).json() - if "error" in res: - raise self.log.error(f"Unable to get code pair: {res['error_description']} [{res['error']}]") - return res diff --git a/packages/envied/src/envied/services/AMZN/config.yaml b/packages/envied/src/envied/services/AMZN/config.yaml index 37bf920..970194b 100644 --- a/packages/envied/src/envied/services/AMZN/config.yaml +++ b/packages/envied/src/envied/services/AMZN/config.yaml @@ -9,72 +9,29 @@ certificate: | uGl1ComHxXV9i1AqHwzzY2JY2vFqo73jR3IElr6oChPIwcNokmNc0D4TXtjE0BoYkbWKJfHvJJihzMOvDicWUsemVHvua9/FBtpbHgpbgwijFPjtQF9Ldb 8Swf +# device: +# new: +# domain: Device +# app_name: 'com.amazon.amazonvideo.livingroom' # AIV, com.amazon.amazonvideo.livingroom +# app_version: '1.1' # AIV: 3.12.0, livingroom: 1.1 +# device_model: 'Nexus Player' # SHIELD Android TV, Nexus Player +# os_version: '8.0.0' # 25, 8.0.0 +# device_type: 'A2SNKIF736WF4T' +# device_name: '%FIRST_NAME%''s%DUPE_STRATEGY_1ST% Nexus Player' +# device_serial: 'a906a7f9bfd6a7ab' # f8eb5625fd608718 + + device: - - # old: # !<< take note that this is done per-profile - # domain: Device - # app_name: AIV - # app_version: '3.12.0' - # device_model: 'SHIELD Android TV' - # os_version: '28' - # device_type: A1KAXIG6VXSG8Y - # device_serial: '13f5b56b4a17de5d136f0e4c28236109' # os.urandom(16).hex() - # device_name: "Build/LMY47D Shield TV" - # software_version: '248' - - # old2: - # 'domain': 'DeviceLegacy' - # 'device_type': A1KAXIG6VXSG8Y, - # 'device_serial': '870f53d1b509594c2f8cd5e340a7d374' - # 'app_name': 'com.amazon.avod.thirdpartyclient' - # 'app_version': '296016847' - # 'device_model': 'mdarcy/nvidia/SHIELD Android TV' - # 'os_version': 'NVIDIA/mdarcy/mdarcy:11/RQ1A.210105.003/7094531_2971.7725:user/release-keys' - - # old3: - # domain: Device - # app_name: com.amazon.amazonvideo.livingroom - # app_version: '1.4' - # device_model: PadFone - # os_version: '6.2.5' - # device_type: 'A2SNKIF736WF4T' - # device_name: 'T008 Build/JSS15Q PadFone' # "%FIRST_NAME%'s%DUPE_STRATEGY_1ST% PadFone" - # device_serial: 'c1ebbb433da4afdf' - - # old4: - # domain: Device - # app_name: com.amazon.amazonvideo.livingroom - # app_version: '1.4' - # device_model: 'Hisense TV' - # os_version: '3.9.5' - # device_type: 'A3T3XXY42KZQNP' # A2B5DGIWVDH8J3, A3GTP8TAF8V3YG, AFTHA001 # https://developer.amazon.com/docs/fire-tv/identify-amazon-fire-tv-devices.html, https://github.com/giofrida/Hisense-Amazon-Enabler - # device_name: "%FIRST_NAME%'s%DUPE_STRATEGY_1ST% Hisense" # KS964, Build/RP1A.201005.001 - # device_serial: '8e3ddf49ee384247' - - # old5: - # domain: Device - # app_name: com.amazon.amazonvideo.livingroom - # app_version: '1.1' - # device_model: Hisense - # os_version: '6.0.1' #6.10.19 - # device_type: 'A3REWRVYBYPKUM' - # device_name: '%FIRST_NAME%''s%DUPE_STRATEGY_1ST% Hisense' - # device_serial: 'cd24294bffb75a46' # os.urandom(8).hex() - default: domain: Device - app_name: com.amazon.amazonvideo.livingroom - app_version: '1.4' - device_model: 'MTC' - os_version: '6.0.1' #6.10.19 - device_type: 'A2HYAJ0FEWP6N3' - device_name: '%FIRST_NAME%''s%DUPE_STRATEGY_1ST% MTC' - device_serial: 'e6eb1ecdc8e34320' - -#Hisense_HU32E5600FHWV: A2RGJ95OVLR12U -#Hisense_HU50A6100UW: AAJ692ZPT1X85 -#Hisense_HE55A700EUWTS: A3REWRVYBYPKUM -#MTC_ATV: A2HYAJ0FEWP6N3 + app_name: 'com.amazon.amazonvideo.livingroom' + app_version: '7.3.4' + device_model: 'SHIELD Android TV' + device_name: "%FIRST_NAME%'s%DUPE_STRATEGY_1ST% Shield TV" + os_version: '28' + device_type: 'A1KAXIG6VXSG8Y' + device_serial: '9a4740e6fa227483' + software_version: '248' device_types: browser: 'AOAGZA014O5RE' # all browsers? all platforms? @@ -104,19 +61,18 @@ endpoints: browse: '/cdp/catalog/Browse' details: '/gp/video/api/getDetailPage' getDetailWidgets: '/gp/video/api/getDetailWidgets' - playback: '/cdp/catalog/GetPlaybackResources' - licence: '/cdp/catalog/GetPlaybackResources' + playback: '/playback/prs/GetVodPlaybackResources' + metadata: '/api/enrichItemMetadata' + licence: '/playback/drm-vod/GetWidevineLicense' + licence_pr: '/playback/drm-vod/GetPlayReadyLicense' # chapters/scenes xray: '/swift/page/xray' # device registration - ontv: '/region/eu/ontv/code?ref_=atv_auth_red_aft' #/gp/video/ontv/code - ontvold: '/gp/video/ontv/code/ref=atv_device_code' - mytv: '/mytv' + ontv: '/gp/video/ontv/code' devicelink: '/gp/video/api/codeBasedLinking' codepair: '/auth/create/codepair' register: '/auth/register' token: '/auth/token' - #cookies: '/ap/exchangetoken/cookies' regions: us: @@ -131,6 +87,12 @@ regions: base_manifest: 'atv-ps-eu.amazon.co.uk' marketplace_id: 'A2IR4J4NTCP2M5' # A1F83G8C2ARO7P is also another marketplace_id + es: + base: 'www.amazon.es' + base_api: 'api.amazon.es' + base_manifest: 'atv-ps-eu.primevideo.com' + marketplace_id: 'A1RKKUPIHCS9HS' + it: base: 'www.amazon.it' base_api: 'api.amazon.it' @@ -159,4 +121,4 @@ regions: base: 'www.amazon.com' base_api: 'api.amazon.com' base_manifest: 'atv-ps-eu.primevideo.com' - marketplace_id: 'A3K6Y4MI8GDYMT' + marketplace_id: 'A3K6Y4MI8GDYMT' \ No newline at end of file diff --git a/packages/envied/src/envied/services/AMZN/subtitle.py b/packages/envied/src/envied/services/AMZN/subtitle.py new file mode 100644 index 0000000..30fd0d9 --- /dev/null +++ b/packages/envied/src/envied/services/AMZN/subtitle.py @@ -0,0 +1,1349 @@ +from __future__ import annotations + +import logging +import re +import subprocess +from collections import defaultdict +from enum import Enum +from functools import partial +from io import BytesIO +from pathlib import Path +from typing import Any, Callable, Iterable, Optional, Union + +import pycaption +import pysubs2 +import requests +from construct import Container +from pycaption import Caption, CaptionList, CaptionNode, WebVTTReader +from pycaption.geometry import Layout +from pymp4.parser import MP4 +from subby import CommonIssuesFixer, SAMIConverter, SDHStripper, WebVTTConverter, WVTTConverter +from subtitle_filter import Subtitles + +from envied.core import binaries +from envied.core.config import config +from envied.core.tracks.track import Track +from envied.core.utilities import try_ensure_utf8 +from envied.core.utils.webvtt import merge_segmented_webvtt + +# silence srt library INFO logging +logging.getLogger("srt").setLevel(logging.ERROR) + + +class Subtitle(Track): + class Codec(str, Enum): + SubRip = "SRT" # https://wikipedia.org/wiki/SubRip + SubStationAlpha = "SSA" # https://wikipedia.org/wiki/SubStation_Alpha + SubStationAlphav4 = "ASS" # https://wikipedia.org/wiki/SubStation_Alpha#Advanced_SubStation_Alpha= + TimedTextMarkupLang = "TTML" # https://wikipedia.org/wiki/Timed_Text_Markup_Language + WebVTT = "VTT" # https://wikipedia.org/wiki/WebVTT + SAMI = "SMI" # https://wikipedia.org/wiki/SAMI + MicroDVD = "SUB" # https://wikipedia.org/wiki/MicroDVD + MPL2 = "MPL2" # MPL2 subtitle format + TMP = "TMP" # TMP subtitle format + # MPEG-DASH box-encapsulated subtitle formats + fTTML = "STPP" # https://www.w3.org/TR/2018/REC-ttml-imsc1.0.1-20180424 + fVTT = "WVTT" # https://www.w3.org/TR/webvtt1 + + @property + def extension(self) -> str: + return self.value.lower() + + @staticmethod + def from_mime(mime: str) -> Subtitle.Codec: + mime = mime.lower().strip().split(".")[0] + if mime == "srt": + return Subtitle.Codec.SubRip + elif mime == "ssa": + return Subtitle.Codec.SubStationAlpha + elif mime == "ass": + return Subtitle.Codec.SubStationAlphav4 + elif mime == "ttml": + return Subtitle.Codec.TimedTextMarkupLang + elif mime == "vtt": + return Subtitle.Codec.WebVTT + elif mime in ("smi", "sami"): + return Subtitle.Codec.SAMI + elif mime in ("sub", "microdvd"): + return Subtitle.Codec.MicroDVD + elif mime == "mpl2": + return Subtitle.Codec.MPL2 + elif mime == "tmp": + return Subtitle.Codec.TMP + elif mime == "stpp": + return Subtitle.Codec.fTTML + elif mime == "wvtt": + return Subtitle.Codec.fVTT + raise ValueError(f"The MIME '{mime}' is not a supported Subtitle Codec") + + @staticmethod + def from_codecs(codecs: str) -> Subtitle.Codec: + for codec in codecs.lower().split(","): + mime = codec.strip().split(".")[0] + try: + return Subtitle.Codec.from_mime(mime) + except ValueError: + pass + raise ValueError(f"No MIME types matched any supported Subtitle Codecs in '{codecs}'") + + @staticmethod + def from_netflix_profile(profile: str) -> Subtitle.Codec: + profile = profile.lower().strip() + if profile.startswith("webvtt"): + return Subtitle.Codec.WebVTT + if profile.startswith("dfxp"): + return Subtitle.Codec.TimedTextMarkupLang + raise ValueError(f"The Content Profile '{profile}' is not a supported Subtitle Codec") + + # WebVTT sanitization patterns (compiled once for performance) + _CUE_ID_PATTERN = re.compile(r"^[A-Za-z]+\d+$") + _TIMING_START_PATTERN = re.compile(r"^\d+:\d+[:\.]") + _TIMING_LINE_PATTERN = re.compile(r"^((?:\d+:)?\d+:\d+[.,]\d+)\s*-->\s*((?:\d+:)?\d+:\d+[.,]\d+)(.*)$") + _LINE_POS_PATTERN = re.compile(r"line:(\d+(?:\.\d+)?%?)") + + def __init__( + self, + *args: Any, + codec: Optional[Subtitle.Codec] = None, + cc: bool = False, + sdh: bool = False, + forced: bool = False, + **kwargs: Any, + ): + """ + Create a new Subtitle track object. + + Parameters: + codec: A Subtitle.Codec enum representing the subtitle format. + If not specified, MediaInfo will be used to retrieve the format + once the track has been downloaded. + cc: Closed Caption. + - Intended as if you couldn't hear the audio at all. + - Can have Sound as well as Dialogue, but doesn't have to. + - Original source would be from an EIA-CC encoded stream. Typically all + upper-case characters. + Indicators of it being CC without knowing original source: + - Extracted with CCExtractor, or + - >>> (or similar) being used at the start of some or all lines, or + - All text is uppercase or at least the majority, or + - Subtitles are Scrolling-text style (one line appears, oldest line + then disappears). + Just because you downloaded it as a SRT or VTT or such, doesn't mean it + isn't from an EIA-CC stream. And I wouldn't take the streaming services + (CC) as gospel either as they tend to get it wrong too. + sdh: Deaf or Hard-of-Hearing. Also known as HOH in the UK (EU?). + - Intended as if you couldn't hear the audio at all. + - MUST have Sound as well as Dialogue to be considered SDH. + - It has no "syntax" or "format" but is not transmitted using archaic + forms like EIA-CC streams, would be intended for transmission via + SubRip (SRT), WebVTT (VTT), TTML, etc. + If you can see important audio/sound transcriptions and not just dialogue + and it doesn't have the indicators of CC, then it's most likely SDH. + If it doesn't have important audio/sounds transcriptions it might just be + regular subtitling (you wouldn't mark as CC or SDH). This would be the + case for most translation subtitles. Like Anime for example. + forced: Typically used if there's important information at some point in time + like watching Dubbed content and an important Sign or Letter is shown + or someone talking in a different language. + Forced tracks are recommended by the Matroska Spec to be played if + the player's current playback audio language matches a subtitle + marked as "forced". + However, that doesn't mean every player works like this but there is + no other way to reliably work with Forced subtitles where multiple + forced subtitles may be in the output file. Just know what to expect + with "forced" subtitles. + + Note: If codec is not specified some checks may be skipped or assume a value. + Specifying as much information as possible is highly recommended. + + Information on Subtitle Types: + https://bit.ly/2Oe4fLC (3PlayMedia Blog on SUB vs CC vs SDH). + However, I wouldn't pay much attention to the claims about SDH needing to + be in the original source language. It's logically not true. + + CC == Closed Captions. Source: Basically every site. + SDH = Subtitles for the Deaf or Hard-of-Hearing. Source: Basically every site. + HOH = Exact same as SDH. Is a term used in the UK. Source: https://bit.ly/2PGJatz (ICO UK) + + More in-depth information, examples, and stuff to look for can be found in the Parameter + explanation list above. + """ + super().__init__(*args, **kwargs) + + if not isinstance(codec, (Subtitle.Codec, type(None))): + raise TypeError(f"Expected codec to be a {Subtitle.Codec}, not {codec!r}") + if not isinstance(cc, (bool, int)) or (isinstance(cc, int) and cc not in (0, 1)): + raise TypeError(f"Expected cc to be a {bool} or bool-like {int}, not {cc!r}") + if not isinstance(sdh, (bool, int)) or (isinstance(sdh, int) and sdh not in (0, 1)): + raise TypeError(f"Expected sdh to be a {bool} or bool-like {int}, not {sdh!r}") + if not isinstance(forced, (bool, int)) or (isinstance(forced, int) and forced not in (0, 1)): + raise TypeError(f"Expected forced to be a {bool} or bool-like {int}, not {forced!r}") + + self.codec = codec + + self.cc = bool(cc) + self.sdh = bool(sdh) + self.forced = bool(forced) + + if self.cc and self.sdh: + raise ValueError("A text track cannot be both CC and SDH.") + + if self.forced and (self.cc or self.sdh): + raise ValueError("A text track cannot be CC/SDH as well as Forced.") + + # TODO: Migrate to new event observer system + # Called after Track has been converted to another format + self.OnConverted: Optional[Callable[[Subtitle.Codec], None]] = None + + def __str__(self) -> str: + return " | ".join( + filter( + bool, + ["SUB", f"[{self.codec.value}]" if self.codec else None, str(self.language), self.get_track_name()], + ) + ) + + def get_track_name(self) -> Optional[str]: + """Return the base Track Name.""" + track_name = super().get_track_name() or "" + flag = self.cc and "CC" or self.sdh and "SDH" or self.forced and "Forced" + if flag: + if track_name: + flag = f" ({flag})" + track_name += flag + return track_name or None + + def download( + self, + session: requests.Session, + prepare_drm: partial, + max_workers: Optional[int] = None, + progress: Optional[partial] = None, + *, + cdm: Optional[object] = None, + ): + super().download(session, prepare_drm, max_workers, progress, cdm=cdm) + if not self.path: + return + + if self.codec == Subtitle.Codec.fTTML: + self.convert(Subtitle.Codec.TimedTextMarkupLang) + elif self.codec == Subtitle.Codec.fVTT: + self.convert(Subtitle.Codec.WebVTT) + elif self.codec == Subtitle.Codec.WebVTT: + text = self.path.read_text("utf8") + if self.descriptor == Track.Descriptor.DASH: + if len(self.data["dash"]["segment_durations"]) > 1: + text = merge_segmented_webvtt( + text, + segment_durations=self.data["dash"]["segment_durations"], + timescale=self.data["dash"]["timescale"], + ) + elif self.descriptor == Track.Descriptor.HLS: + if len(self.data["hls"]["segment_durations"]) > 1: + text = merge_segmented_webvtt( + text, + segment_durations=self.data["hls"]["segment_durations"], + timescale=1, # ? + ) + + # Sanitize WebVTT timestamps before parsing + text = Subtitle.sanitize_webvtt_timestamps(text) + # Remove cue identifiers that confuse parsers like pysubs2 + text = Subtitle.sanitize_webvtt_cue_identifiers(text) + # Merge overlapping cues with line positioning into single multi-line cues + text = Subtitle.merge_overlapping_webvtt_cues(text) + + preserve_formatting = config.subtitle.get("preserve_formatting", True) + + if preserve_formatting: + self.path.write_text(text, encoding="utf8") + else: + try: + caption_set = pycaption.WebVTTReader().read(text) + Subtitle.merge_same_cues(caption_set) + Subtitle.filter_unwanted_cues(caption_set) + subtitle_text = pycaption.WebVTTWriter().write(caption_set) + self.path.write_text(subtitle_text, encoding="utf8") + except pycaption.exceptions.CaptionReadSyntaxError: + # If first attempt fails, try more aggressive sanitization + text = Subtitle.sanitize_webvtt(text) + try: + caption_set = pycaption.WebVTTReader().read(text) + Subtitle.merge_same_cues(caption_set) + Subtitle.filter_unwanted_cues(caption_set) + subtitle_text = pycaption.WebVTTWriter().write(caption_set) + self.path.write_text(subtitle_text, encoding="utf8") + except Exception: + # Keep the sanitized version even if parsing failed + self.path.write_text(text, encoding="utf8") + + @staticmethod + def sanitize_webvtt_timestamps(text: str) -> str: + """ + Fix invalid timestamps in WebVTT files, particularly negative timestamps. + + Parameters: + text: The WebVTT content as string + + Returns: + Sanitized WebVTT content + """ + # Replace negative timestamps with 00:00:00.000 + return re.sub(r"(-\d+:\d+:\d+\.\d+)", "00:00:00.000", text) + + @staticmethod + def has_webvtt_cue_identifiers(text: str) -> bool: + """ + Check if WebVTT content has cue identifiers that need removal. + + Parameters: + text: The WebVTT content as string + + Returns: + True if cue identifiers are detected, False otherwise + """ + lines = text.split("\n") + + for i, line in enumerate(lines): + line = line.strip() + if Subtitle._CUE_ID_PATTERN.match(line): + # Look ahead to see if next non-empty line is a timing line + j = i + 1 + while j < len(lines) and not lines[j].strip(): + j += 1 + if j < len(lines) and ("-->" in lines[j] or Subtitle._TIMING_START_PATTERN.match(lines[j].strip())): + return True + return False + + @staticmethod + def sanitize_webvtt_cue_identifiers(text: str) -> str: + """ + Remove WebVTT cue identifiers that can confuse subtitle parsers. + + Some services use cue identifiers like "Q0", "Q1", etc. + that appear on their own line before the timing line. These can be + incorrectly parsed as part of the previous cue's text content by + some parsers (like pysubs2). + + Parameters: + text: The WebVTT content as string + + Returns: + Sanitized WebVTT content with cue identifiers removed + """ + if not Subtitle.has_webvtt_cue_identifiers(text): + return text + + lines = text.split("\n") + sanitized_lines = [] + + i = 0 + while i < len(lines): + line = lines[i].strip() + + # Check if this line is a cue identifier followed by a timing line + if Subtitle._CUE_ID_PATTERN.match(line): + # Look ahead to see if next non-empty line is a timing line + j = i + 1 + while j < len(lines) and not lines[j].strip(): + j += 1 + if j < len(lines) and ("-->" in lines[j] or Subtitle._TIMING_START_PATTERN.match(lines[j].strip())): + # This is a cue identifier, skip it + i += 1 + continue + + sanitized_lines.append(lines[i]) + i += 1 + + return "\n".join(sanitized_lines) + + @staticmethod + def _parse_vtt_time(t: str) -> int: + """Parse WebVTT timestamp to milliseconds. Returns 0 for malformed input.""" + try: + t = t.replace(",", ".") + parts = t.split(":") + if len(parts) == 2: + m, s = parts + h = "0" + elif len(parts) >= 3: + h, m, s = parts[:3] + else: + return 0 + sec_parts = s.split(".") + secs = int(sec_parts[0]) + # Handle variable millisecond digits (e.g., .5 = 500ms, .50 = 500ms, .500 = 500ms) + ms = int(sec_parts[1].ljust(3, "0")[:3]) if len(sec_parts) > 1 else 0 + return int(h) * 3600000 + int(m) * 60000 + secs * 1000 + ms + except (ValueError, IndexError): + return 0 + + @staticmethod + def has_overlapping_webvtt_cues(text: str) -> bool: + """ + Check if WebVTT content has overlapping cues that need merging. + + Detects cues with start times within 50ms of each other and the same end time, + which indicates multi-line subtitles split into separate cues. + + Parameters: + text: The WebVTT content as string + + Returns: + True if overlapping cues are detected, False otherwise + """ + timings = [] + for line in text.split("\n"): + match = Subtitle._TIMING_LINE_PATTERN.match(line) + if match: + start_str, end_str = match.group(1), match.group(2) + timings.append((Subtitle._parse_vtt_time(start_str), Subtitle._parse_vtt_time(end_str))) + + # Check for overlapping cues (within 50ms start, same end) + for i in range(len(timings) - 1): + curr_start, curr_end = timings[i] + next_start, next_end = timings[i + 1] + if abs(curr_start - next_start) <= 50 and curr_end == next_end: + return True + + return False + + @staticmethod + def merge_overlapping_webvtt_cues(text: str) -> str: + """ + Merge WebVTT cues that have overlapping/near-identical times but different line positions. + + Some services use separate cues for each line of a multi-line subtitle, with + slightly different start times (1ms apart) and different line: positions. + This merges them into single cues with proper line ordering based on the + line: position (lower percentage = higher on screen = first line). + + Parameters: + text: The WebVTT content as string + + Returns: + WebVTT content with overlapping cues merged + """ + if not Subtitle.has_overlapping_webvtt_cues(text): + return text + + lines = text.split("\n") + cues = [] + header_lines = [] + in_header = True + i = 0 + + while i < len(lines): + line = lines[i] + + if in_header: + if "-->" in line: + in_header = False + else: + header_lines.append(line) + i += 1 + continue + + match = Subtitle._TIMING_LINE_PATTERN.match(line) + if match: + start_str, end_str, settings = match.groups() + line_pos = 100.0 # Default to bottom + line_match = Subtitle._LINE_POS_PATTERN.search(settings) + if line_match: + pos_str = line_match.group(1).rstrip("%") + line_pos = float(pos_str) + + content_lines = [] + i += 1 + while i < len(lines) and lines[i].strip() and "-->" not in lines[i]: + content_lines.append(lines[i]) + i += 1 + + cues.append( + { + "start_ms": Subtitle._parse_vtt_time(start_str), + "end_ms": Subtitle._parse_vtt_time(end_str), + "start_str": start_str, + "end_str": end_str, + "line_pos": line_pos, + "content": "\n".join(content_lines), + "settings": settings, + } + ) + else: + i += 1 + + # Merge overlapping cues (within 50ms of each other with same end time) + merged_cues = [] + i = 0 + while i < len(cues): + current = cues[i] + group = [current] + + j = i + 1 + while j < len(cues): + other = cues[j] + if abs(current["start_ms"] - other["start_ms"]) <= 50 and current["end_ms"] == other["end_ms"]: + group.append(other) + j += 1 + else: + break + + if len(group) > 1: + # Sort by line position (lower % = higher on screen = first) + group.sort(key=lambda x: x["line_pos"]) + # Use the earliest start time from the group + earliest = min(group, key=lambda x: x["start_ms"]) + merged_cues.append( + { + "start_str": earliest["start_str"], + "end_str": group[0]["end_str"], + "content": "\n".join(c["content"] for c in group), + "settings": "", + } + ) + else: + merged_cues.append( + { + "start_str": current["start_str"], + "end_str": current["end_str"], + "content": current["content"], + "settings": current["settings"], + } + ) + + i = j if len(group) > 1 else i + 1 + + result_lines = header_lines[:] + if result_lines and result_lines[-1].strip(): + result_lines.append("") + + for cue in merged_cues: + result_lines.append(f"{cue['start_str']} --> {cue['end_str']}{cue['settings']}") + result_lines.append(cue["content"]) + result_lines.append("") + + return "\n".join(result_lines) + + @staticmethod + def sanitize_webvtt(text: str) -> str: + """ + More thorough sanitization of WebVTT files to handle multiple potential issues. + + Parameters: + text: The WebVTT content as string + + Returns: + Sanitized WebVTT content + """ + # Make sure we have a proper WEBVTT header + if not text.strip().startswith("WEBVTT"): + text = "WEBVTT\n\n" + text + + lines = text.split("\n") + sanitized_lines = [] + timestamp_pattern = re.compile(r"^((?:\d+:)?\d+:\d+\.\d+)\s+-->\s+((?:\d+:)?\d+:\d+\.\d+)") + + # Skip invalid headers - keep only WEBVTT + header_done = False + for line in lines: + if not header_done: + if line.startswith("WEBVTT"): + sanitized_lines.append("WEBVTT") + header_done = True + continue + + # Replace negative timestamps + if "-" in line and "-->" in line: + line = re.sub(r"(-\d+:\d+:\d+\.\d+)", "00:00:00.000", line) + + # Validate timestamp format + match = timestamp_pattern.match(line) + if match: + start_time = match.group(1) + end_time = match.group(2) + + # Ensure proper format with hours if missing + if start_time.count(":") == 1: + start_time = f"00:{start_time}" + if end_time.count(":") == 1: + end_time = f"00:{end_time}" + + line = f"{start_time} --> {end_time}" + + sanitized_lines.append(line) + + return "\n".join(sanitized_lines) + + def convert_with_subby(self, codec: Subtitle.Codec) -> Path: + """ + Convert subtitle using subby library for better format support and processing. + + This method leverages subby's advanced subtitle processing capabilities + including better WebVTT handling, SDH stripping, and common issue fixing. + """ + + if not self.path or not self.path.exists(): + raise ValueError("You must download the subtitle track first.") + + if self.codec == codec: + return self.path + + output_path = self.path.with_suffix(f".{codec.value.lower()}") + original_path = self.path + + try: + # Convert to SRT using subby first + srt_subtitles = None + + if self.codec == Subtitle.Codec.WebVTT: + converter = WebVTTConverter() + srt_subtitles = converter.from_file(self.path) + if self.codec == Subtitle.Codec.fVTT: + converter = WVTTConverter() + srt_subtitles = converter.from_file(self.path) + elif self.codec == Subtitle.Codec.SAMI: + converter = SAMIConverter() + srt_subtitles = converter.from_file(self.path) + + if srt_subtitles is not None: + # Apply common fixes + fixer = CommonIssuesFixer() + fixed_srt, _ = fixer.from_srt(srt_subtitles) + + # If target is SRT, we're done + if codec == Subtitle.Codec.SubRip: + fixed_srt.save(output_path, encoding="utf8") + else: + # Convert from SRT to target format using existing pycaption logic + temp_srt_path = self.path.with_suffix(".temp.srt") + fixed_srt.save(temp_srt_path, encoding="utf8") + + # Parse the SRT and convert to target format + caption_set = self.parse(temp_srt_path.read_bytes(), Subtitle.Codec.SubRip) + self.merge_same_cues(caption_set) + + writer = { + Subtitle.Codec.TimedTextMarkupLang: pycaption.DFXPWriter, + Subtitle.Codec.WebVTT: pycaption.WebVTTWriter, + }.get(codec) + + if writer: + subtitle_text = writer().write(caption_set) + output_path.write_text(subtitle_text, encoding="utf8") + else: + # Fall back to existing conversion method + temp_srt_path.unlink() + return self._convert_standard(codec) + + temp_srt_path.unlink() + + if original_path.exists() and original_path != output_path: + original_path.unlink() + + self.path = output_path + self.codec = codec + + if callable(self.OnConverted): + self.OnConverted(codec) + + return output_path + else: + # Fall back to existing conversion method + return self._convert_standard(codec) + + except Exception: + # Fall back to existing conversion method on any error + return self._convert_standard(codec) + + def convert_with_pysubs2(self, codec: Subtitle.Codec) -> Path: + """ + Convert subtitle using pysubs2 library for broad format support. + + pysubs2 is a pure-Python library supporting SubRip (SRT), SubStation Alpha + (SSA/ASS), WebVTT, TTML, SAMI, MicroDVD, MPL2, and TMP formats. + """ + if not self.path or not self.path.exists(): + raise ValueError("You must download the subtitle track first.") + + if self.codec == codec: + return self.path + + output_path = self.path.with_suffix(f".{codec.value.lower()}") + original_path = self.path + + codec_to_pysubs2_format = { + Subtitle.Codec.SubRip: "srt", + Subtitle.Codec.SubStationAlpha: "ssa", + Subtitle.Codec.SubStationAlphav4: "ass", + Subtitle.Codec.WebVTT: "vtt", + Subtitle.Codec.TimedTextMarkupLang: "ttml", + Subtitle.Codec.SAMI: "sami", + Subtitle.Codec.MicroDVD: "microdvd", + Subtitle.Codec.MPL2: "mpl2", + Subtitle.Codec.TMP: "tmp", + } + + pysubs2_output_format = codec_to_pysubs2_format.get(codec) + if pysubs2_output_format is None: + return self._convert_standard(codec) + + try: + subs = pysubs2.load(str(self.path), encoding="utf-8") + + subs.save(str(output_path), format_=pysubs2_output_format, encoding="utf-8") + + if original_path.exists() and original_path != output_path: + original_path.unlink() + + self.path = output_path + self.codec = codec + + if callable(self.OnConverted): + self.OnConverted(codec) + + return output_path + + except Exception: + return self._convert_standard(codec) + + def convert(self, codec: Subtitle.Codec) -> Path: + """ + Convert this Subtitle to another Format. + + The conversion method is determined by the 'conversion_method' setting in config: + - 'auto' (default): Uses subby for WebVTT/fVTT/SAMI; for SSA/ASS/MicroDVD/MPL2/TMP + uses SubtitleEdit if available, otherwise pysubs2; standard for others + - 'subby': Always uses subby with CommonIssuesFixer + - 'subtitleedit': Uses SubtitleEdit when available, falls back to pycaption + - 'pycaption': Uses only pycaption library + - 'pysubs2': Uses pysubs2 library + """ + # Check configuration for conversion method + conversion_method = config.subtitle.get("conversion_method", "auto") + + if conversion_method == "subby": + return self.convert_with_subby(codec) + elif conversion_method == "subtitleedit": + return self._convert_standard(codec) + elif conversion_method == "pycaption": + return self._convert_pycaption_only(codec) + elif conversion_method == "pysubs2": + return self.convert_with_pysubs2(codec) + elif conversion_method == "auto": + if self.codec in (Subtitle.Codec.WebVTT, Subtitle.Codec.fVTT, Subtitle.Codec.SAMI): + return self.convert_with_subby(codec) + elif self.codec in ( + Subtitle.Codec.SubStationAlpha, + Subtitle.Codec.SubStationAlphav4, + Subtitle.Codec.MicroDVD, + Subtitle.Codec.MPL2, + Subtitle.Codec.TMP, + ): + if binaries.SubtitleEdit: + return self._convert_standard(codec) + else: + return self.convert_with_pysubs2(codec) + else: + return self._convert_standard(codec) + else: + return self._convert_standard(codec) + + def _convert_pycaption_only(self, codec: Subtitle.Codec) -> Path: + """ + Convert subtitle using only pycaption library (no SubtitleEdit, no subby). + + This is the original conversion method that only uses pycaption. + """ + if not self.path or not self.path.exists(): + raise ValueError("You must download the subtitle track first.") + + if self.codec == codec: + return self.path + + output_path = self.path.with_suffix(f".{codec.value.lower()}") + original_path = self.path + + # Use only pycaption for conversion + writer = { + Subtitle.Codec.SubRip: pycaption.SRTWriter, + Subtitle.Codec.TimedTextMarkupLang: pycaption.DFXPWriter, + Subtitle.Codec.WebVTT: pycaption.WebVTTWriter, + }.get(codec) + + if writer is None: + raise NotImplementedError(f"Cannot convert {self.codec.name} to {codec.name} using pycaption only.") + + caption_set = self.parse(self.path.read_bytes(), self.codec) + Subtitle.merge_same_cues(caption_set) + if codec == Subtitle.Codec.WebVTT: + Subtitle.filter_unwanted_cues(caption_set) + subtitle_text = writer().write(caption_set) + + output_path.write_text(subtitle_text, encoding="utf8") + + if original_path.exists() and original_path != output_path: + original_path.unlink() + + self.path = output_path + self.codec = codec + + if callable(self.OnConverted): + self.OnConverted(codec) + + return output_path + + def _convert_standard(self, codec: Subtitle.Codec) -> Path: + """ + Convert this Subtitle to another Format. + + The file path location of the Subtitle data will be kept at the same + location but the file extension will be changed appropriately. + + Supported formats: + - SubRip - SubtitleEdit or pycaption.SRTWriter + - TimedTextMarkupLang - SubtitleEdit or pycaption.DFXPWriter + - WebVTT - SubtitleEdit or pycaption.WebVTTWriter + - SubStationAlphav4 - SubtitleEdit + - SAMI - subby.SAMIConverter (when available) + - fTTML* - custom code using some pycaption functions + - fVTT* - custom code using some pycaption functions + *: Can read from format, but cannot convert to format + + Note: It currently prioritizes using SubtitleEdit over PyCaption as + I have personally noticed more oddities with PyCaption parsing over + SubtitleEdit. Especially when working with TTML/DFXP where it would + often have timecodes and stuff mixed in/duplicated. + + Returns the new file path of the Subtitle. + """ + if not self.path or not self.path.exists(): + raise ValueError("You must download the subtitle track first.") + + if self.codec == codec: + return self.path + + output_path = self.path.with_suffix(f".{codec.value.lower()}") + original_path = self.path + + if binaries.SubtitleEdit and self.codec not in (Subtitle.Codec.fTTML, Subtitle.Codec.fVTT): + sub_edit_format = { + Subtitle.Codec.SubRip: "subrip", + Subtitle.Codec.SubStationAlpha: "substationalpha", + Subtitle.Codec.SubStationAlphav4: "advancedsubstationalpha", + Subtitle.Codec.TimedTextMarkupLang: "timedtext1.0", + Subtitle.Codec.WebVTT: "webvtt", + Subtitle.Codec.SAMI: "sami", + Subtitle.Codec.MicroDVD: "microdvd", + }.get(codec, codec.name.lower()) + sub_edit_args = [ + str(binaries.SubtitleEdit), + "/convert", + str(self.path), + sub_edit_format, + f"/outputfilename:{output_path.name}", + "/encoding:utf8", + ] + if codec == Subtitle.Codec.SubRip: + sub_edit_args.append("/ConvertColorsToDialog") + subprocess.run(sub_edit_args, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + if codec == Subtitle.Codec.SubRip and output_path.exists(): + _ALLOWED_SRT_TAGS = {"i", "b", "u", "s"} + _TAG_RE = re.compile(r"<(/?)([a-zA-Z][a-zA-Z0-9]*)(\s[^>]*)?>") + def _clean_tag(m: re.Match) -> str: + tag = m.group(2).lower() + attrs = (m.group(3) or "").strip() + if tag in _ALLOWED_SRT_TAGS and not attrs: + return m.group(0) + return "" + srt_text = output_path.read_text(encoding="utf-8", errors="replace") + cleaned = _TAG_RE.sub(_clean_tag, srt_text) + if cleaned != srt_text: + output_path.write_text(cleaned, encoding="utf-8") + else: + writer = { + # pycaption generally only supports these subtitle formats + Subtitle.Codec.SubRip: pycaption.SRTWriter, + Subtitle.Codec.TimedTextMarkupLang: pycaption.DFXPWriter, + Subtitle.Codec.WebVTT: pycaption.WebVTTWriter, + }.get(codec) + if writer is None: + raise NotImplementedError(f"Cannot yet convert {self.codec.name} to {codec.name}.") + + caption_set = self.parse(self.path.read_bytes(), self.codec) + Subtitle.merge_same_cues(caption_set) + if codec == Subtitle.Codec.WebVTT: + Subtitle.filter_unwanted_cues(caption_set) + subtitle_text = writer().write(caption_set) + + output_path.write_text(subtitle_text, encoding="utf8") + + if original_path.exists() and original_path != output_path: + original_path.unlink() + + self.path = output_path + self.codec = codec + + if callable(self.OnConverted): + self.OnConverted(codec) + + return output_path + + @staticmethod + def parse(data: bytes, codec: Subtitle.Codec) -> pycaption.CaptionSet: + if not isinstance(data, bytes): + raise ValueError(f"Subtitle data must be parsed as bytes data, not {type(data).__name__}") + + try: + if codec == Subtitle.Codec.SubRip: + text = try_ensure_utf8(data).decode("utf8") + caption_set = pycaption.SRTReader().read(text) + elif codec == Subtitle.Codec.fTTML: + caption_lists: dict[str, pycaption.CaptionList] = defaultdict(pycaption.CaptionList) + for segment in ( + Subtitle.parse(box.data, Subtitle.Codec.TimedTextMarkupLang) + for box in MP4.parse_stream(BytesIO(data)) + if box.type == b"mdat" + ): + for lang in segment.get_languages(): + caption_lists[lang].extend(segment.get_captions(lang)) + caption_set: pycaption.CaptionSet = pycaption.CaptionSet(caption_lists) + elif codec == Subtitle.Codec.TimedTextMarkupLang: + text = try_ensure_utf8(data).decode("utf8") + text = text.replace("tt:", "") + # negative size values aren't allowed in TTML/DFXP spec, replace with 0 + text = re.sub(r"-(\d+(?:\.\d+)?)(px|em|%|c|pt)", r"0\2", text) + caption_set = pycaption.DFXPReader().read(text) + elif codec == Subtitle.Codec.fVTT: + caption_lists: dict[str, pycaption.CaptionList] = defaultdict(pycaption.CaptionList) + caption_list, language = Subtitle.merge_segmented_wvtt(data) + caption_lists[language] = caption_list + caption_set: pycaption.CaptionSet = pycaption.CaptionSet(caption_lists) + elif codec == Subtitle.Codec.WebVTT: + text = try_ensure_utf8(data).decode("utf8") + text = Subtitle.sanitize_broken_webvtt(text) + text = Subtitle.space_webvtt_headers(text) + caption_set = pycaption.WebVTTReader().read(text) + elif codec == Subtitle.Codec.SAMI: + # Use subby for SAMI parsing + converter = SAMIConverter() + srt_subtitles = converter.from_bytes(data) + # Convert SRT back to CaptionSet for compatibility + srt_text = str(srt_subtitles).encode("utf8") + caption_set = Subtitle.parse(srt_text, Subtitle.Codec.SubRip) + else: + raise ValueError(f'Unknown Subtitle format "{codec}"...') + except pycaption.exceptions.CaptionReadSyntaxError as e: + raise SyntaxError(f'A syntax error has occurred when reading the "{codec}" subtitle: {e}') + except pycaption.exceptions.CaptionReadNoCaptions: + return pycaption.CaptionSet({"en": []}) + + # remove empty caption lists or some code breaks, especially if it's the first list + for language in caption_set.get_languages(): + if not caption_set.get_captions(language): + # noinspection PyProtectedMember + del caption_set._captions[language] + + return caption_set + + @staticmethod + def sanitize_broken_webvtt(text: str) -> str: + """ + Remove or fix corrupted WebVTT lines, particularly those with invalid timestamps. + + Parameters: + text: The WebVTT content as string + + Returns: + Sanitized WebVTT content with corrupted lines removed + """ + lines = text.splitlines() + sanitized_lines = [] + + i = 0 + while i < len(lines): + # Skip empty lines + if not lines[i].strip(): + sanitized_lines.append(lines[i]) + i += 1 + continue + + # Check for timestamp lines + if "-->" in lines[i]: + # Validate timestamp format + timestamp_parts = lines[i].split("-->") + if len(timestamp_parts) != 2 or not timestamp_parts[1].strip() or timestamp_parts[1].strip() == "0": + # Skip this timestamp and its content until next timestamp or end + j = i + 1 + while j < len(lines) and "-->" not in lines[j] and lines[j].strip(): + j += 1 + i = j + continue + + # Add valid timestamp line + sanitized_lines.append(lines[i]) + else: + # Add non-timestamp line + sanitized_lines.append(lines[i]) + + i += 1 + + return "\n".join(sanitized_lines) + + @staticmethod + def space_webvtt_headers(data: Union[str, bytes]): + """ + Space out the WEBVTT Headers from Captions. + + Segmented VTT when merged may have the WEBVTT headers part of the next caption + as they were not separated far enough from the previous caption and ended up + being considered as caption text rather than the header for the next segment. + """ + if isinstance(data, bytes): + data = try_ensure_utf8(data).decode("utf8") + elif not isinstance(data, str): + raise ValueError(f"Expecting data to be a str, not {data!r}") + + text = ( + data.replace("WEBVTT", "\n\nWEBVTT").replace("\r", "").replace("\n\n\n", "\n \n\n").replace("\n\n<", "\n<") + ) + + return text + + @staticmethod + def merge_same_cues(caption_set: pycaption.CaptionSet): + """Merge captions with the same timecodes and text as one in-place.""" + for lang in caption_set.get_languages(): + captions = caption_set.get_captions(lang) + last_caption = None + concurrent_captions = pycaption.CaptionList() + merged_captions = pycaption.CaptionList() + for caption in captions: + if last_caption: + if (caption.start, caption.end) == (last_caption.start, last_caption.end): + if caption.get_text() != last_caption.get_text(): + concurrent_captions.append(caption) + last_caption = caption + continue + else: + merged_captions.append(pycaption.base.merge(concurrent_captions)) + concurrent_captions = [caption] + last_caption = caption + + if concurrent_captions: + merged_captions.append(pycaption.base.merge(concurrent_captions)) + if merged_captions: + caption_set.set_captions(lang, merged_captions) + + @staticmethod + def filter_unwanted_cues(caption_set: pycaption.CaptionSet): + """ + Filter out subtitle cues containing only   or whitespace. + """ + for lang in caption_set.get_languages(): + captions = caption_set.get_captions(lang) + filtered_captions = pycaption.CaptionList() + + for caption in captions: + text = caption.get_text().strip() + if not text or text == " " or all(c in " \t\n\r\xa0" for c in text.replace(" ", "\xa0")): + continue + + filtered_captions.append(caption) + + caption_set.set_captions(lang, filtered_captions) + + @staticmethod + def merge_segmented_wvtt(data: bytes, period_start: float = 0.0) -> tuple[CaptionList, Optional[str]]: + """ + Convert Segmented DASH WebVTT cues into a pycaption Caption List. + Also returns an ISO 639-2 alpha-3 language code if available. + + Code ported originally by xhlove to Python from shaka-player. + Has since been improved upon by rlaphoenix using pymp4 and + pycaption functions. + """ + captions = CaptionList() + + # init: + saw_wvtt_box = False + timescale = None + language = None + + # media: + # > tfhd + default_duration = None + # > tfdt + saw_tfdt_box = False + base_time = 0 + # > trun + saw_trun_box = False + samples = [] + + def flatten_boxes(box: Container) -> Iterable[Container]: + for child in box: + if hasattr(child, "children"): + yield from flatten_boxes(child.children) + del child["children"] + if hasattr(child, "entries"): + yield from flatten_boxes(child.entries) + del child["entries"] + # some boxes (mainly within 'entries') uses format not type + child["type"] = child.get("type") or child.get("format") + yield child + + for box in flatten_boxes(MP4.parse_stream(BytesIO(data))): + # init + if box.type == b"mdhd": + timescale = box.timescale + language = box.language + + if box.type == b"wvtt": + saw_wvtt_box = True + + # media + if box.type == b"styp": + # essentially the start of each segment + # media var resets + # > tfhd + default_duration = None + # > tfdt + saw_tfdt_box = False + base_time = 0 + # > trun + saw_trun_box = False + samples = [] + + if box.type == b"tfhd": + if box.flags.default_sample_duration_present: + default_duration = box.default_sample_duration + + if box.type == b"tfdt": + saw_tfdt_box = True + base_time = box.baseMediaDecodeTime + + if box.type == b"trun": + saw_trun_box = True + samples = box.sample_info + + if box.type == b"mdat": + if not timescale: + raise ValueError("Timescale was not found in the Segmented WebVTT.") + if not saw_wvtt_box: + raise ValueError("The WVTT box was not found in the Segmented WebVTT.") + if not saw_tfdt_box: + raise ValueError("The TFDT box was not found in the Segmented WebVTT.") + if not saw_trun_box: + raise ValueError("The TRUN box was not found in the Segmented WebVTT.") + + vttc_boxes = MP4.parse_stream(BytesIO(box.data)) + current_time = base_time + period_start + + for sample, vttc_box in zip(samples, vttc_boxes): + duration = sample.sample_duration or default_duration + if sample.sample_composition_time_offsets: + current_time += sample.sample_composition_time_offsets + + start_time = current_time + end_time = current_time + (duration or 0) + current_time = end_time + + if vttc_box.type == b"vtte": + # vtte is a vttc that's empty, skip + continue + + layout: Optional[Layout] = None + nodes: list[CaptionNode] = [] + + for cue_box in vttc_box.children: + if cue_box.type == b"vsid": + # this is a V(?) Source ID box, we don't care + continue + if cue_box.type == b"sttg": + layout = Layout(webvtt_positioning=cue_box.settings) + elif cue_box.type == b"payl": + nodes.extend( + [ + node + for line in cue_box.cue_text.split("\n") + for node in [ + CaptionNode.create_text(WebVTTReader()._decode(line)), + CaptionNode.create_break(), + ] + ] + ) + nodes.pop() + + if nodes: + caption = Caption( + start=start_time * timescale, # as microseconds + end=end_time * timescale, + nodes=nodes, + layout_info=layout, + ) + p_caption = captions[-1] if captions else None + if p_caption and caption.start == p_caption.end and str(caption.nodes) == str(p_caption.nodes): + # it's a duplicate, but lets take its end time + p_caption.end = caption.end + continue + captions.append(caption) + + return captions, language + + def strip_hearing_impaired(self) -> None: + """ + Strip captions for hearing impaired (SDH). + + The SDH stripping method is determined by the 'sdh_method' setting in config: + - 'auto' (default): Tries subby first, then SubtitleEdit, then filter-subs + - 'subby': Uses subby's SDHStripper + - 'subtitleedit': Uses SubtitleEdit when available + - 'filter-subs': Uses subtitle-filter library + """ + if not self.path or not self.path.exists(): + raise ValueError("You must download the subtitle track first.") + + # Check configuration for SDH stripping method + sdh_method = config.subtitle.get("sdh_method", "auto") + + if sdh_method == "subby" and self.codec == Subtitle.Codec.SubRip: + # Use subby's SDHStripper directly on the file + fixer = CommonIssuesFixer() + stripper = SDHStripper() + srt, _ = fixer.from_file(self.path) + stripped, status = stripper.from_srt(srt) + if status is True: + stripped.save(self.path) + return + elif sdh_method == "subtitleedit" and binaries.SubtitleEdit: + # Force use of SubtitleEdit + pass # Continue to SubtitleEdit section below + elif sdh_method == "filter-subs": + # Force use of filter-subs + sub = Subtitles(self.path) + try: + sub.filter(rm_fonts=True, rm_ast=True, rm_music=True, rm_effects=True, rm_names=True, rm_author=True) + except ValueError as e: + if "too many values to unpack" in str(e): + # Retry without name removal if the error is due to multiple colons in time references + # This can happen with lines like "at 10:00 and 2:00" + sub = Subtitles(self.path) + sub.filter( + rm_fonts=True, rm_ast=True, rm_music=True, rm_effects=True, rm_names=False, rm_author=True + ) + else: + raise + sub.save() + return + elif sdh_method == "auto": + # Try subby first for SRT files, then fall back + if self.codec == Subtitle.Codec.SubRip: + try: + fixer = CommonIssuesFixer() + stripper = SDHStripper() + srt, _ = fixer.from_file(self.path) + stripped, status = stripper.from_srt(srt) + if status is True: + stripped.save(self.path) + return + except Exception: + pass # Fall through to other methods + + conversion_method = config.subtitle.get("conversion_method", "auto") + use_subtitleedit = sdh_method == "subtitleedit" or ( + sdh_method == "auto" and conversion_method in ("auto", "subtitleedit") + ) + + if binaries.SubtitleEdit and use_subtitleedit: + output_format = { + Subtitle.Codec.SubRip: "subrip", + Subtitle.Codec.SubStationAlpha: "substationalpha", + Subtitle.Codec.SubStationAlphav4: "advancedsubstationalpha", + Subtitle.Codec.TimedTextMarkupLang: "timedtext1.0", + Subtitle.Codec.WebVTT: "webvtt", + Subtitle.Codec.SAMI: "sami", + Subtitle.Codec.MicroDVD: "microdvd", + }.get(self.codec, self.codec.name.lower()) + subprocess.run( + [ + str(binaries.SubtitleEdit), + "/convert", + str(self.path), + output_format, + "/encoding:utf8", + "/overwrite", + "/RemoveTextForHI", + ], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + else: + if config.subtitle.get("convert_before_strip", True) and self.codec != Subtitle.Codec.SubRip: + self.path = self.convert(Subtitle.Codec.SubRip) + self.codec = Subtitle.Codec.SubRip + + try: + sub = Subtitles(self.path) + try: + sub.filter( + rm_fonts=True, rm_ast=True, rm_music=True, rm_effects=True, rm_names=True, rm_author=True + ) + except ValueError as e: + if "too many values to unpack" in str(e): + # Retry without name removal if the error is due to multiple colons in time references + # This can happen with lines like "at 10:00 and 2:00" + sub = Subtitles(self.path) + sub.filter( + rm_fonts=True, rm_ast=True, rm_music=True, rm_effects=True, rm_names=False, rm_author=True + ) + else: + raise + sub.save() + except (IOError, OSError) as e: + if "is not valid subtitle file" in str(e): + self.log.warning(f"Failed to strip SDH from {self.path.name}: {e}") + self.log.warning("Continuing without SDH stripping for this subtitle") + else: + raise + + def reverse_rtl(self) -> None: + """ + Reverse RTL (Right to Left) Start/End on Captions. + This can be used to fix the positioning of sentence-ending characters. + """ + if not self.path or not self.path.exists(): + raise ValueError("You must download the subtitle track first.") + + if not binaries.SubtitleEdit: + raise EnvironmentError("SubtitleEdit executable not found...") + + output_format = { + Subtitle.Codec.SubRip: "subrip", + Subtitle.Codec.SubStationAlpha: "substationalpha", + Subtitle.Codec.SubStationAlphav4: "advancedsubstationalpha", + Subtitle.Codec.TimedTextMarkupLang: "timedtext1.0", + Subtitle.Codec.WebVTT: "webvtt", + Subtitle.Codec.SAMI: "sami", + Subtitle.Codec.MicroDVD: "microdvd", + }.get(self.codec, self.codec.name.lower()) + + subprocess.run( + [ + str(binaries.SubtitleEdit), + "/convert", + str(self.path), + output_format, + "/ReverseRtlStartEnd", + "/encoding:utf8", + "/overwrite", + ], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + +__all__ = ("Subtitle",) diff --git a/packages/envied/src/envied/services/CR/__init__.py b/packages/envied/src/envied/services/CR/__init__.py index 14e7d40..9e823b6 100644 --- a/packages/envied/src/envied/services/CR/__init__.py +++ b/packages/envied/src/envied/services/CR/__init__.py @@ -1,4 +1,3 @@ -from hashlib import md5 import re import time import uuid @@ -10,16 +9,13 @@ import jwt from langcodes import Language from envied.core.manifests import DASH -from envied.core.manifests.hls import HLS from envied.core.search_result import SearchResult from envied.core.service import Service from envied.core.session import session from envied.core.titles import Episode, Series -from envied.core.tracks import Attachment, Chapters, Tracks -from envied.core.tracks.audio import Audio +from envied.core.tracks import Chapters, Tracks from envied.core.tracks.chapter import Chapter from envied.core.tracks.subtitle import Subtitle -from envied.core.tracks.video import Video class CR(Service): @@ -27,70 +23,55 @@ class CR(Service): Service code for Crunchyroll streaming service (https://www.crunchyroll.com). \b - Version: 2.1.1 - Author: sp4rk.y & Dex - Date: 2026-02-28 + Version: 3.0.1 + Author: sp4rk.y + Date: 2026-03-26 Authorization: Credentials Robustness: Widevine: L3: 1080p, AAC2.0 - PlayReady: - SL3000: 1080p, AAC2.0 - SL2000: 1080p, AAC2.0 + \b Tips: - - Input should be complete URL or series ID or Episode ID + - Input should be complete URL or series ID https://www.crunchyroll.com/series/GRMG8ZQZR/series-name OR GRMG8ZQZR - https://www.crunchyroll.com/en-US/watch/G2XU02X75/ryomen-sukuna OR G2XU02X75 - Supports multiple audio and subtitle languages - Device ID is cached for consistent authentication across runs \b Notes: - - Uses password-based authentication with token caching + - Emulates Android TV client (v3.58.0) with Playback API v3 + - Uses password-based authentication with refresh token caching + - Refresh tokens are cached for 30 days for cross-session reuse - Manages concurrent stream limits automatically """ - TITLE_RE = r"^(?:https?://(?:www\.)?crunchyroll\.com/(?:[a-z]{2}(?:-[a-z]{2})?/)?(?:series|watch)/)?(?P[A-Z0-9]+)" + TITLE_RE = r"^(?:https?://(?:www\.)?crunchyroll\.com/(?:series|watch)/)?(?P[A-Z0-9]+)" LICENSE_LOCK = Lock() MAX_CONCURRENT_STREAMS = 3 ACTIVE_STREAMS: list[tuple[str, str]] = [] - - # Reverse map for audio_locale to match 2 chars languages in a_lang param - LANG_MAP: dict = { - "es-LA": "es-419", - "ar-ME": "ar-SA", - "de-DE": "de", - "en-US": "en", - "es-ES": "es", - "fr-FR": "fr", - "hi-IN": "hi", - "it-IT": "it", - "ja-JP": "ja", - "ko-KR": "kr", - "ru-RU": "ru", - "zh-CN": "zh", - "en-GB": "en" - } - + + @staticmethod + def get_session(): + return session("okhttp4") + @staticmethod @click.command(name="CR", short_help="https://crunchyroll.com") @click.argument("title", type=str, required=True) - @click.option("-hb", "--high-audio-bitrate", is_flag=True, default=False, help="Fetch audio tracks with highest bitrate (192kbps AAC)") - @click.pass_context def cli(ctx, **kwargs) -> "CR": return CR(ctx, **kwargs) - def __init__(self, ctx, title: str, high_audio_bitrate: bool): - super().__init__(ctx) + def __init__(self, ctx, title: str): self.title = title self.account_id: Optional[str] = None self.access_token: Optional[str] = None + self.refresh_token: Optional[str] = None + self.profile_id: Optional[str] = None self.token_expiration: Optional[int] = None self.anonymous_id = str(uuid.uuid4()) - self.audio_lang = ctx.parent.params.get("lang") or ctx.parent.params.get("a_lang") - self.high_audio_bitrate = high_audio_bitrate + + super().__init__(ctx) device_cache_key = "cr_device_id" cached_device = self.cache.get(device_cache_key) @@ -110,10 +91,6 @@ class CR(Service): self.session.headers.update(self.config.get("headers", {})) self.session.headers["etp-anonymous-id"] = self.anonymous_id - @staticmethod - def get_session(): - return session("okhttp4") - @property def auth_header(self) -> dict: """Return authorization header dict.""" @@ -128,21 +105,23 @@ class CR(Service): if cached and not cached.expired: self.access_token = cached.data["access_token"] self.account_id = cached.data.get("account_id") + self.profile_id = cached.data.get("profile_id") + self.refresh_token = cached.data.get("refresh_token") self.token_expiration = cached.data.get("token_expiration") self.session.headers.update(self.auth_header) - self.log.debug("Loaded authentication from cache") else: - self.log.debug("No valid cached token, authenticating") self.authenticate(credential=self.credential) return current_time = int(time.time()) if current_time >= (self.token_expiration - 60): - self.log.debug("Authentication token expired or expiring soon, re-authenticating") - self.authenticate(credential=self.credential) + if self.refresh_token: + self._refresh_access_token() + else: + self.authenticate(credential=self.credential) def authenticate(self, cookies=None, credential=None) -> None: - """Authenticate using username and password credentials.""" + """Authenticate using username and password credentials, with refresh token support.""" super().authenticate(cookies, credential) cache_key = f"cr_auth_token_{credential.sha1 if credential else 'default'}" @@ -151,10 +130,37 @@ class CR(Service): if cached and not cached.expired: self.access_token = cached.data["access_token"] self.account_id = cached.data.get("account_id") + self.profile_id = cached.data.get("profile_id") + self.refresh_token = cached.data.get("refresh_token") self.token_expiration = cached.data.get("token_expiration") else: + refresh_cache_key = f"cr_refresh_token_{credential.sha1 if credential else 'default'}" + refresh_cached = self.cache.get(refresh_cache_key) + + if refresh_cached and not refresh_cached.expired and refresh_cached.data.get("refresh_token"): + self.refresh_token = refresh_cached.data["refresh_token"] + try: + self._refresh_access_token() + self._cache_auth(cache_key) + self.session.headers.update(self.auth_header) + + if self.ACTIVE_STREAMS: + self.ACTIVE_STREAMS.clear() + + try: + self.clear_all_sessions() + except Exception as e: + self.log.warning(f"Failed to clear previous sessions: {e}") + return + except Exception: + self.log.warning("Refresh token expired or invalid, falling back to password login") + self.refresh_token = None + if not credential: - raise ValueError("Username and password credential required for authentication") + raise ValueError("No credential provided for authentication") + + # Get anonymous client token first (required before password grant) + self._get_client_token() response = self.session.post( url=self.config["endpoints"]["token"], @@ -176,36 +182,18 @@ class CR(Service): ) if response.status_code != 200: - self.log.error(f"Login failed: {response.status_code}") try: error_data = response.json() error_msg = error_data.get("error", "Unknown error") error_code = error_data.get("code", "") - self.log.error(f"Error: {error_msg} ({error_code})") + raise ValueError(f"Login failed: {response.status_code} - {error_msg} ({error_code})") + except ValueError: + raise except Exception: - self.log.error(f"Response: {response.text}") - response.raise_for_status() + raise ValueError(f"Login failed: {response.status_code} - {response.text}") - token_data = response.json() - self.access_token = token_data["access_token"] - self.account_id = self.get_account_id() - - try: - decoded_token = jwt.decode(self.access_token, options={"verify_signature": False}) - self.token_expiration = decoded_token.get("exp") - except Exception: - self.token_expiration = int(time.time()) + token_data.get("expires_in", 3600) - - cached.set( - data={ - "access_token": self.access_token, - "account_id": self.account_id, - "token_expiration": self.token_expiration, - }, - expiration=self.token_expiration - if isinstance(self.token_expiration, int) and self.token_expiration > int(time.time()) - else 3600, - ) + self._apply_token_response(response.json()) + self._cache_auth(cache_key) self.session.headers.update(self.auth_header) @@ -217,51 +205,136 @@ class CR(Service): except Exception as e: self.log.warning(f"Failed to clear previous sessions: {e}") + def _apply_token_response(self, token_data: dict) -> None: + """Extract and store auth fields from a token response.""" + self.access_token = token_data["access_token"] + self.refresh_token = token_data.get("refresh_token", self.refresh_token) + self.account_id = token_data.get("account_id", self.account_id) + self.profile_id = token_data.get("profile_id", self.profile_id) + + try: + decoded_token = jwt.decode(self.access_token, options={"verify_signature": False}) + self.token_expiration = decoded_token.get("exp") + except Exception: + self.token_expiration = int(time.time()) + token_data.get("expires_in", 300) + + def _cache_auth(self, cache_key: str) -> None: + """Cache current auth state (access token + refresh token).""" + ttl = 300 + if isinstance(self.token_expiration, int): + remaining = self.token_expiration - int(time.time()) + if remaining > 0: + ttl = remaining + + cached = self.cache.get(cache_key) + cached.set( + data={ + "access_token": self.access_token, + "account_id": self.account_id, + "profile_id": self.profile_id, + "refresh_token": self.refresh_token, + "token_expiration": self.token_expiration, + }, + expiration=ttl, + ) + + # Cache refresh token separately with a long TTL for cross-session reuse + if self.refresh_token: + refresh_cache_key = cache_key.replace("cr_auth_token_", "cr_refresh_token_") + refresh_cached = self.cache.get(refresh_cache_key) + refresh_cached.set( + data={"refresh_token": self.refresh_token}, + expiration=60 * 60 * 24 * 30, # 30 days + ) + + def _get_client_token(self) -> None: + """Get an anonymous client token. Required before password grant on fresh sessions.""" + response = self.session.post( + url=self.config["endpoints"]["token"], + headers={ + "content-type": "application/x-www-form-urlencoded; charset=UTF-8", + }, + data={ + "grant_type": "client_id", + "scope": "offline_access", + "client_id": self.config["client"]["id"], + "client_secret": self.config["client"]["secret"], + }, + ) + if response.status_code == 200: + token_data = response.json() + self.access_token = token_data["access_token"] + self.session.headers.update(self.auth_header) + else: + self.log.warning(f"Failed to get anonymous client token: {response.status_code}") + + def _refresh_access_token(self) -> None: + """Refresh the access token using the stored refresh token.""" + if not self.refresh_token: + raise ValueError("No refresh token available") + + response = self.session.post( + url=self.config["endpoints"]["token"], + headers={ + "content-type": "application/x-www-form-urlencoded; charset=UTF-8", + }, + data={ + "grant_type": "refresh_token", + "refresh_token": self.refresh_token, + "scope": "offline_access", + "client_id": self.config["client"]["id"], + "client_secret": self.config["client"]["secret"], + "device_type": self.device_type, + "device_id": self.device_id, + "device_name": self.device_name, + }, + ) + + if response.status_code != 200: + self.refresh_token = None + raise ValueError(f"Token refresh failed: {response.status_code}") + + self._apply_token_response(response.json()) + + cache_key = f"cr_auth_token_{self.credential.sha1 if self.credential else 'default'}" + self._cache_auth(cache_key) + self.session.headers.update(self.auth_header) + def get_titles(self) -> Union[Series]: """Fetch series and episode information.""" series_id = self.parse_series_id(self.title) - series_response = self.session.get( + series_http = self.session.get( url=self.config["endpoints"]["series"].format(series_id=series_id), params={"locale": self.config["params"]["locale"]}, - ).json() + ) + series_response = series_http.json() - if "code" in series_response: - # Fallback to try with episode id - episode_response = self.session.get( - url=self.config["endpoints"]["episodes"].format(episode_id=series_id), - params={"locale": self.config["params"]["locale"]}, - ).json() + if "error" in series_response: + raise ValueError(f"Series not found: {series_id} - {series_response.get('error')}") - if "code" in episode_response: - raise ValueError(f"Series or episode not found: {series_id}") - - # Extract series id from episodes response - series_id = episode_response["data"][0]["episode_metadata"]["series_id"] - - series_response = self.session.get( - url=self.config["endpoints"]["series"].format(series_id=series_id), - params={"locale": self.config["params"]["locale"]}, - ).json() - - if "code" in series_response: - raise ValueError(f"Series not found from episode metadata: {series_id}") - series_data = ( series_response.get("data", [{}])[0] if isinstance(series_response.get("data"), list) else series_response ) series_title = series_data.get("title", "Unknown Series") - seasons_response = self.session.get( + seasons_http = self.session.get( url=self.config["endpoints"]["seasons"].format(series_id=series_id), - params={"locale": self.config["params"]["locale"]}, - ).json() + params={ + "locale": self.config["params"]["locale"], + "preferred_audio_language": self.config.get("params", {}).get("preferred_audio_language", "en-US"), + }, + ) + seasons_response = seasons_http.json() seasons_data = seasons_response.get("data", []) if not seasons_data: raise ValueError(f"No seasons found for series: {series_id}") + used_season_numbers: set[int] = set() + season_id_to_number: dict[str, int] = {} + all_episode_data = [] special_episodes = [] @@ -269,10 +342,22 @@ class CR(Service): season_id = season["id"] season_number = season.get("season_number", 0) - episodes_response = self.session.get( + effective_season_number = season_number + if isinstance(season_number, int) and season_number > 0: + if season_number in used_season_numbers: + candidate = season_number + 1 + while candidate in used_season_numbers: + candidate += 1 + effective_season_number = candidate + used_season_numbers.add(effective_season_number) + + season_id_to_number[season_id] = effective_season_number + + episodes_http = self.session.get( url=self.config["endpoints"]["season_episodes"].format(season_id=season_id), params={"locale": self.config["params"]["locale"]}, - ).json() + ) + episodes_response = episodes_http.json() episodes_data = episodes_response.get("data", []) @@ -282,7 +367,7 @@ class CR(Service): if episode_number is None or isinstance(episode_number, float): special_episodes.append(episode_data) - all_episode_data.append((episode_data, season_number)) + all_episode_data.append((episode_data, effective_season_number)) if not all_episode_data: raise ValueError(f"No episodes found for series: {series_id}") @@ -336,18 +421,22 @@ class CR(Service): return Series(episodes) - def set_track_metadata(self, tracks: Tracks, episode_id: str, is_original: bool) -> None: + def set_track_metadata( + self, tracks: Tracks, episode_id: str, is_original: bool, audio_locale: Optional[str] = None + ) -> None: """Set metadata for video and audio tracks.""" - for track in tracks: - if isinstance(track, Video): - track.needs_repack = True - if isinstance(track, (Audio,Video)): - track.data["episode_id"] = episode_id - track.is_original_lang = is_original - if is_original: - track.name = f"{track.name} [Original]" + for video in tracks.videos: + video.needs_repack = True + video.data["episode_id"] = episode_id + video.is_original_lang = is_original + if audio_locale: + video.data["audio_locale"] = audio_locale + for audio in tracks.audio: + audio.data["episode_id"] = episode_id + audio.is_original_lang = is_original + if audio_locale: + audio.data["audio_locale"] = audio_locale - def get_tracks(self, title: Episode) -> Tracks: """Fetch video, audio, and subtitle tracks for an episode.""" self.ensure_authenticated() @@ -359,7 +448,12 @@ class CR(Service): self.clear_all_sessions() - initial_response = self.get_playback_data(episode_id, track_stream=False) + endpoints_to_try = ["playback", "playback_download"] + + preferred_audio = self.config.get("params", {}).get("preferred_audio_language", "en-US") + initial_response = self.get_playback_data( + episode_id, track_stream=False, endpoint_key="playback", audio_locale=preferred_audio + ) versions = initial_response.get("versions", []) if not versions: @@ -368,169 +462,136 @@ class CR(Service): tracks = None - #endpoints_playback = ["playback"] - - sdh_list = [] for idx, version in enumerate(versions): audio_locale = version.get("audio_locale") version_guid = version.get("guid") is_original = version.get("original", False) - # Verify mapped audio locale to match CR locales - mapped_locale = self.LANG_MAP.get(audio_locale, audio_locale) - if self.audio_lang and ("all" in self.audio_lang or "best" in self.audio_lang or ("orig" in self.audio_lang and is_original)): - pass - elif not audio_locale or (audio_locale not in self.audio_lang and mapped_locale not in self.audio_lang): + if not audio_locale: continue request_episode_id = version_guid if version_guid else episode_id - - #for endpoint in endpoints_playback: - try: - if idx == 0 and not version_guid: # and endpoint == "playback": - version_response = initial_response - version_token = version_response.get("token") - else: - if idx == 1 and not versions[0].get("guid"): # and endpoint == "playback": - initial_token = initial_response.get("token") - if initial_token: - self.close_stream(episode_id, initial_token) - try: - version_response = self.get_playback_data(request_episode_id, track_stream=False) - except ValueError as e: - self.log.warning(f"Could not get playback info for audio {audio_locale}: {e}") + for endpoint_key in endpoints_to_try: + try: + if idx == 0 and not version_guid and endpoint_key == "playback": + version_response = initial_response + version_token = version_response.get("token") + else: + if idx == 1 and not versions[0].get("guid") and endpoint_key == "playback": + initial_token = initial_response.get("token") + if initial_token: + self.close_stream(episode_id, initial_token) + + try: + version_response = self.get_playback_data( + request_episode_id, + track_stream=False, + endpoint_key=endpoint_key, + audio_locale=audio_locale, + ) + except ValueError as e: + self.log.warning( + f"Could not get playback info for audio {audio_locale} from {endpoint_key}: {e}" + ) + continue + + version_token = version_response.get("token") + + hard_subs = version_response.get("hardSubs", {}) + + root_url = version_response.get("url") + + if root_url and "/clean/" in root_url: + dash_url = root_url + elif "none" in hard_subs: + dash_url = hard_subs["none"].get("url") + elif "fr-FR" in hard_subs: + dash_url = hard_subs["fr-FR"].get("url") + elif hard_subs: + first_key = list(hard_subs.keys())[0] + dash_url = hard_subs[first_key].get("url") + else: + dash_url = None + + if not dash_url: + if version_token: + self.close_stream(request_episode_id, version_token) continue - version_token = version_response.get("token") + try: + version_tracks = DASH.from_url( + url=dash_url, + session=self.session, + ).to_tracks(language=audio_locale) - hard_subs = version_response.get("hardSubs", {}) - dash_url = None - root_url = version_response.get("url") - - if root_url and "/clean/" in root_url: - dash_url = root_url - - elif "none" in hard_subs: - dash_url = hard_subs["none"].get("url") - - elif hard_subs: - first_key = list(hard_subs.keys())[0] - dash_url = hard_subs[first_key].get("url") + if tracks is None: + tracks = version_tracks + self.set_track_metadata(tracks, request_episode_id, is_original, audio_locale) + else: + self.set_track_metadata(version_tracks, request_episode_id, is_original, audio_locale) + for video in version_tracks.videos: + if not any(v.id == video.id for v in tracks.videos): + tracks.add(video) + for audio in version_tracks.audio: + existing_audio = next((a for a in tracks.audio if a.language == audio.language), None) + if existing_audio is None or ( + hasattr(audio, "bitrate") + and hasattr(existing_audio, "bitrate") + and audio.bitrate > existing_audio.bitrate + ): + tracks.add(audio) + elif existing_audio is None: + tracks.add(audio) + + except Exception as e: + self.log.warning( + f"Failed to parse DASH manifest for audio {audio_locale} from {endpoint_key}: {e}" + ) + if version_token: + self.close_stream(request_episode_id, version_token) + continue + + if is_original and endpoint_key == "playback": + captions = version_response.get("captions", {}) + subtitles_data = version_response.get("subtitles", {}) + all_subs = {**captions, **subtitles_data} + + for lang_code, sub_data in all_subs.items(): + if lang_code == "none": + continue + + if isinstance(sub_data, dict) and "url" in sub_data: + try: + lang = Language.get(lang_code) + except (ValueError, LookupError): + lang = Language.get("fr") + + subtitle_format = sub_data.get("format", "vtt").lower() + if subtitle_format == "ass" or subtitle_format == "ssa": + codec = Subtitle.Codec.SubStationAlphav4 + else: + codec = Subtitle.Codec.WebVTT + + tracks.add( + Subtitle( + id_=f"subtitle-{audio_locale}-{lang_code}", + url=sub_data["url"], + codec=codec, + language=lang, + forced=False, + sdh=False, + ), + warn_only=True, + ) - if not dash_url: - self.log.warning(f"No DASH manifest found for audio {audio_locale}, skipping") if version_token: self.close_stream(request_episode_id, version_token) - continue - - try: - - version_tracks = (HLS if "m3u8" in dash_url else DASH).from_url( - url=dash_url, - session=self.session, - ).to_tracks(language=audio_locale) - - # Select high audio bitrate replacing 0 and 1 in mpd url - if self.high_audio_bitrate: - try: - version_tracks.audio.clear(); - - high_bitrate_tracks = (HLS if "m3u8" in dash_url else DASH).from_url( - url=dash_url.replace("/0/", "/1/"), - session=self.session, - ).to_tracks(language=audio_locale) - - version_tracks.audio = high_bitrate_tracks.audio - except Exception as e: - self.log.warning(f"Failed to fetch high bitrate audio for {audio_locale}: {e}") - - if tracks is None: - tracks = version_tracks - self.set_track_metadata(tracks, request_episode_id, is_original) - else: - self.set_track_metadata(version_tracks, request_episode_id, is_original) - # Add videos - for video in version_tracks.videos: - if not any(v.id == video.id for v in tracks.videos): - tracks.add(video) - - # Add audios non-existing in tracks or with higher bitrate - for audio in version_tracks.audio: - if audio.channels == "1.0": - audio.channels = "2.0" # change audio channels for correct channel - - existing_audio = next((a for a in tracks.audio if a.language == audio.language), None) - if existing_audio is None or (hasattr(audio, 'bitrate') and hasattr(existing_audio, 'bitrate') and audio.bitrate > existing_audio.bitrate): - tracks.add(audio) - elif existing_audio is None: - tracks.add(audio) except Exception as e: - self.log.warning(f"Failed to parse DASH manifest for audio {audio_locale}: {e}") - if version_token: - self.close_stream(request_episode_id, version_token) + self.log.warning(f"Error processing endpoint {endpoint_key} for version {idx}: {e}") continue - #if endpoint == "playback": - # Handling subtitles code - try: - lang_audio = [x for x in version_response.get("versions", []) if x["guid"] == version_guid][0]["audio_locale"] - except (IndexError, KeyError, TypeError): - lang_audio = title.language - - captions = version_response.get("captions", {}) - subtitles_data = version_response.get("subtitles", {}) - - if captions: - for subtitle in captions.values(): - - if subtitle["language"] == "none": - continue - - lang = subtitle["language"] - subtitle_format = subtitle.get("format", "vtt").lower() - codec = Subtitle.Codec.SubStationAlphav4 if subtitle_format in ["ass", "ssa"] else Subtitle.Codec.WebVTT - sdh_list.append(lang) - tracks.add( - Subtitle( - id_=md5(subtitle["url"].encode()).hexdigest()[0:6], - url=subtitle["url"], - codec=codec, - language=Language.get(lang), - sdh=True, - ), - warn_only=True, - ) - - if subtitles_data: - for subtitle in subtitles_data.values(): - if subtitle["language"] == "none": - continue - - lang = subtitle["language"] - subtitle_format = subtitle.get("format", "vtt").lower() - codec = Subtitle.Codec.SubStationAlphav4 if subtitle_format in ["ass", "ssa"] else Subtitle.Codec.WebVTT - tracks.add( - Subtitle( - id_=md5(subtitle["url"].encode()).hexdigest()[0:6], - url=subtitle["url"], - codec=codec, - language=Language.get(lang), - forced=False if (str(lang_audio) == str(title.language)) and (lang not in sdh_list) else True, - sdh=False, - ), - warn_only=True, - ) - - if version_token: - self.close_stream(request_episode_id, version_token) - - # continuar si no se pudo procesar el endpoint - except Exception as e: - self.log.warning(f"Error processing version {idx}: {e}") - continue - if versions and versions[0].get("guid"): initial_token = initial_response.get("token") if initial_token: @@ -539,74 +600,18 @@ class CR(Service): if tracks is None: raise ValueError(f"Failed to fetch any tracks for episode: {episode_id}") - - images = title.data.get("images", {}) - thumbnails = images.get("thumbnail", []) - if thumbnails: - thumb_variants = thumbnails[0] if isinstance(thumbnails[0], list) else [thumbnails[0]] - if thumb_variants: - thumb_index = min(7, len(thumb_variants) - 1) - thumb = thumb_variants[thumb_index] - if isinstance(thumb, dict) and "source" in thumb: - raw_name = f"{title.name or title.title} - S{title.season:02d}E{title.number:02d}" - thumbnail_name = self.sanitize_filename(raw_name) - tracks.add(Attachment.from_url(url=thumb["source"], name=thumbnail_name)) + for track in tracks.audio + tracks.subtitles: + if track.language: + try: + lang_obj = Language.get(str(track.language)) + base_lang = Language.get(lang_obj.language) + lang_display = base_lang.language_name() + track.name = lang_display + except (ValueError, LookupError): + pass return tracks - def get_playready_license(self, challenge: bytes, title: Episode, track) -> bytes: - """ - Get PlayReady license for decryption. - - Creates a fresh playback session for each track, gets the license, then immediately - closes the stream. This prevents hitting the 3 concurrent stream limit. - CDN authorization is embedded in the manifest URLs, not tied to active sessions. - """ - self.ensure_authenticated() - - track_episode_id = track.data.get("episode_id", title.id) - - with self.LICENSE_LOCK: - playback_token = None - try: - playback_data = self.get_playback_data(track_episode_id, track_stream=True) - playback_token = playback_data.get("token") - - if not playback_token: - raise ValueError(f"No playback token in response for {track_episode_id}") - - track.data["playback_token"] = playback_token - - license_response = self.session.post( - url=self.config["endpoints"]["license_playready"], - params={"specConform": "true"}, - data=challenge, - headers={ - **self.auth_header, - "content-type": "application/octet-stream", - "accept": "application/octet-stream", - "x-cr-content-id": track_episode_id, - "x-cr-video-token": playback_token, - }, - ) - - if license_response.status_code != 200: - self.log.error(f"License request failed with status {license_response.status_code}") - self.log.error(f"Response: {license_response.text[:500]}") - self.close_stream(track_episode_id, playback_token) - raise ValueError(f"License request failed: {license_response.status_code}") - - self.close_stream(track_episode_id, playback_token) - return license_response.content - - except Exception: - if playback_token: - try: - self.close_stream(track_episode_id, playback_token) - except Exception: - pass - raise - def get_widevine_license(self, challenge: bytes, title: Episode, track) -> bytes: """ Get Widevine license for decryption. @@ -622,7 +627,8 @@ class CR(Service): with self.LICENSE_LOCK: playback_token = None try: - playback_data = self.get_playback_data(track_episode_id, track_stream=True) + audio_locale = track.data.get("audio_locale") + playback_data = self.get_playback_data(track_episode_id, track_stream=True, audio_locale=audio_locale) playback_token = playback_data.get("token") if not playback_token: @@ -642,12 +648,13 @@ class CR(Service): "x-cr-video-token": playback_token, }, ) - if license_response.status_code != 200: - self.log.error(f"License request failed with status {license_response.status_code}") - self.log.error(f"Response: {license_response.text[:500]}") self.close_stream(track_episode_id, playback_token) - raise ValueError(f"License request failed: {license_response.status_code}") + try: + error_detail = license_response.text[:200] + except Exception: + error_detail = "Unknown error" + raise ValueError(f"License request failed: {license_response.status_code} - {error_detail}") self.close_stream(track_episode_id, playback_token) return license_response.content @@ -693,6 +700,7 @@ class CR(Service): chapter_response = self.session.get( url=self.config["endpoints"]["skip_events"].format(episode_id=title.id), ) + special_chapters = [] if chapter_response.status_code == 200: try: @@ -704,14 +712,54 @@ class CR(Service): for chapter_type in ["intro", "recap", "credits", "preview"]: if chapter_info := chapter_data.get(chapter_type): try: - chapters.add( - Chapter( - timestamp=int(chapter_info["start"] * 1000), - name=chapter_info["type"].capitalize(), - ) + start_time = int(chapter_info["start"] * 1000) + end_time = int(chapter_info.get("end", chapter_info["start"]) * 1000) + special_chapters.append( + { + "start": start_time, + "end": end_time, + "name": chapter_info["type"].capitalize(), + "type": chapter_type, + } ) - except Exception as e: - self.log.debug(f"Failed to add {chapter_type} chapter: {e}") + except Exception: + pass + + special_chapters.sort(key=lambda x: x["start"]) + + all_chapters = [] + chapter_counter = 1 + + all_chapters.append({"timestamp": 0, "name": f"Chapter {chapter_counter}"}) + chapter_counter += 1 + + for idx, special in enumerate(special_chapters): + all_chapters.append({"timestamp": special["start"], "name": special["name"]}) + + should_add_chapter_after = False + + if special["end"] > special["start"]: + if idx + 1 < len(special_chapters): + next_special = special_chapters[idx + 1] + if next_special["start"] - special["end"] > 2000: + should_add_chapter_after = True + else: + should_add_chapter_after = True + + if should_add_chapter_after: + all_chapters.append({"timestamp": special["end"], "name": f"Chapter {chapter_counter}"}) + chapter_counter += 1 + + for chapter in all_chapters: + try: + chapters.add( + Chapter( + timestamp=chapter["timestamp"], + name=chapter["name"], + ) + ) + except Exception: + pass return chapters @@ -730,8 +778,7 @@ class CR(Service): ) if response.status_code != 200: - self.log.error(f"Search request failed with status {response.status_code}") - return + raise ValueError(f"Search request failed with status {response.status_code}") search_data = response.json() for result_group in search_data.get("data", []): @@ -759,40 +806,44 @@ class CR(Service): ) except Exception as e: - self.log.error(f"Search failed: {e}") - return - - def get_account_id(self) -> str: - """Fetch and return the account ID.""" - response = self.session.get(url=self.config["endpoints"]["account_me"], headers=self.auth_header) - - if response.status_code != 200: - self.log.error(f"Failed to get account info: {response.status_code}") - self.log.error(f"Response: {response.text}") - response.raise_for_status() - - data = response.json() - return data["account_id"] + raise ValueError(f"Search failed: {e}") def close_stream(self, episode_id: str, token: str) -> None: """Close an active playback stream to free up concurrent stream slots.""" - should_remove = False - try: - response = self.session.delete( - url=self.config["endpoints"]["playback_delete"].format(episode_id=episode_id, token=token), - headers=self.auth_header, - ) - if response.status_code in (200, 204, 403): - should_remove = True - else: - self.log.error( - f"Failed to close stream for {episode_id} (status {response.status_code}): {response.text[:200]}" - ) - except Exception as e: - self.log.error(f"Error closing stream for {episode_id}: {e}") - finally: - if should_remove and (episode_id, token) in self.ACTIVE_STREAMS: - self.ACTIVE_STREAMS.remove((episode_id, token)) + delete_url = self.config["endpoints"]["playback_delete"].format(episode_id=episode_id, token=token) + closed = False + + for attempt in range(3): + try: + response = self.session.delete(url=delete_url, headers=self.auth_header) + if response.status_code in (200, 204): + closed = True + break + elif response.status_code == 403: + # Auth issue — re-authenticate and retry + if attempt < 2: + self.ensure_authenticated() + continue + self.log.warning(f"Could not close stream for {episode_id}: 403 after re-auth") + break + else: + self.log.warning( + f"Failed to close stream for {episode_id} " + f"(status {response.status_code}, attempt {attempt + 1})" + ) + if attempt < 2: + time.sleep(1) + except Exception as e: + self.log.warning(f"Error closing stream for {episode_id} (attempt {attempt + 1}): {e}") + if attempt < 2: + time.sleep(1) + + # Always remove from local tracking to avoid stale entries + if (episode_id, token) in self.ACTIVE_STREAMS: + self.ACTIVE_STREAMS.remove((episode_id, token)) + + if not closed: + self.log.warning(f"Stream {episode_id}/{token[:12]}... may not have been released server-side") def get_active_sessions(self) -> list: """Get all active streaming sessions for the account.""" @@ -803,7 +854,8 @@ class CR(Service): ) if response.status_code == 200: data = response.json() - return data.get("items", []) + items = data.get("items", []) + return items else: self.log.warning(f"Failed to get active sessions (status {response.status_code})") return [] @@ -869,13 +921,21 @@ class CR(Service): return cleared - def get_playback_data(self, episode_id: str, track_stream: bool = True) -> dict: + def get_playback_data( + self, + episode_id: str, + track_stream: bool = True, + endpoint_key: str = "playback", + audio_locale: Optional[str] = None, + ) -> dict: """ Get playback data for an episode with automatic retry on stream limits. Args: episode_id: The episode ID to get playback data for track_stream: Whether to track this stream in active_streams (False for temporary streams) + endpoint_key: Which endpoint to use ('playback' or 'playback_download') + audio_locale: Preferred audio locale (e.g. 'en-US', 'ja-JP') Returns: dict: The playback response data @@ -885,44 +945,46 @@ class CR(Service): """ self.ensure_authenticated() - max_retries = 2 + params: dict[str, str] = {"queue": "false"} + if audio_locale: + params["audio"] = audio_locale + + max_retries = 3 for attempt in range(max_retries + 1): - response = self.session.get( - url=self.config["endpoints"]["playback"].format(episode_id=episode_id), - params={"queue": "false"}, - ) - - # Handle 429 response status code before json parsing - if response.status_code == 429: - retry_after = int(response.headers.get("Retry-After", 60)) - self.log.warning(f"Rate limited (429). Waiting {retry_after}s before retry... (attempt {attempt + 1}/{max_retries + 1})") + try: + http_response = self.session.get( + url=self.config["endpoints"][endpoint_key].format(episode_id=episode_id), + params=params, + ) + except Exception as e: + # Session layer exhausted its own retries (e.g. repeated 429s) if attempt < max_retries: - cleared = self.clear_all_sessions() - time.sleep(retry_after) + self.log.warning(f"Playback request failed for {episode_id}: {e}") + self._recover_from_rate_limit(attempt) continue - raise ValueError(f"Rate limit exceeded for episode: {episode_id}") + raise ValueError(f"Playback request failed for {episode_id} after retries: {e}") - response = response.json() - - if "code" in response: + if http_response.status_code == 429: + if attempt < max_retries: + self.log.warning(f"Rate limited (429) on playback for {episode_id}") + self._recover_from_rate_limit(attempt) + continue + raise ValueError(f"Rate limited on playback for {episode_id} after {max_retries} retries") + + try: + response = http_response.json() + except Exception as e: + raise ValueError(f"Playback: failed to parse JSON (episode_id={episode_id}): {e}") + + if "error" in response: error_code = response.get("code", "") error_msg = response.get("message", response.get("error", "Unknown error")) if error_code == "TOO_MANY_ACTIVE_STREAMS" and attempt < max_retries: self.log.warning(f"Hit stream limit: {error_msg}") - cleared = self.clear_all_sessions() - - if cleared == 0 and attempt == 0: - wait_time = 30 - self.log.warning( - f"Found orphaned sessions from previous run. Waiting {wait_time}s for them to expire..." - ) - time.sleep(wait_time) - + self._recover_from_rate_limit(attempt) continue - self.log.error(f"Playback API error: {error_msg}") - self.log.debug(f"Full response: {response}") raise ValueError(f"Could not get playback info for episode: {episode_id} - {error_msg}") playback_token = response.get("token") @@ -933,13 +995,19 @@ class CR(Service): raise ValueError(f"Failed to get playback data for episode: {episode_id}") + def _recover_from_rate_limit(self, attempt: int) -> None: + """Clear all sessions and wait with exponential backoff before retrying.""" + cleared = self.clear_all_sessions() + wait_time = min(5 * (2**attempt), 30) + if cleared == 0: + wait_time = max(wait_time, 15) + self.log.warning(f"Cleared {cleared} sessions, waiting {wait_time}s before retry...") + time.sleep(wait_time) + def parse_series_id(self, title_input: str) -> str: """Parse series ID from URL or direct ID input.""" match = re.match(self.TITLE_RE, title_input, re.IGNORECASE) if not match: raise ValueError(f"Could not parse series ID from: {title_input}") - return match.group("id") - - @staticmethod - def sanitize_filename(name): - return re.sub(r'[<>:"/\\|?*¿¡]', '', name).strip() \ No newline at end of file + series_id = match.group("id") + return series_id diff --git a/packages/envied/src/envied/services/CR/config.yaml b/packages/envied/src/envied/services/CR/config.yaml index 8aee436..110483a 100644 --- a/packages/envied/src/envied/services/CR/config.yaml +++ b/packages/envied/src/envied/services/CR/config.yaml @@ -1,7 +1,7 @@ # Crunchyroll API Configuration client: - id: "lkesi7snsy9oojmi2r9h" - secret: "-aGDXFFNTluZMLYXERngNYnEjvgH5odv" + id: "bludpho6aoup2q0lmw99" + secret: "bEmjmOi1INunmi96t98OFAjtMJq0TwER" # API Endpoints endpoints: @@ -15,24 +15,20 @@ endpoints: # Content Metadata series: "https://www.crunchyroll.com/content/v2/cms/series/{series_id}" seasons: "https://www.crunchyroll.com/content/v2/cms/series/{series_id}/seasons" - episodes: "https://www.crunchyroll.com/content/v2/cms/objects/{episode_id}" season_episodes: "https://www.crunchyroll.com/content/v2/cms/seasons/{season_id}/episodes" skip_events: "https://static.crunchyroll.com/skip-events/production/{episode_id}.json" # Playback - playback: "https://www.crunchyroll.com/playback/v2/{episode_id}/tv/android_tv/play" - #playback_download: "https://www.crunchyroll.com/playback/v2/{episode_id}/android/phone/download" + playback: "https://www.crunchyroll.com/playback/v3/{episode_id}/tv/android_tv/play" + playback_download: "https://www.crunchyroll.com/playback/v3/{episode_id}/android/phone/download" playback_delete: "https://www.crunchyroll.com/playback/v1/token/{episode_id}/{token}" playback_sessions: "https://www.crunchyroll.com/playback/v1/sessions/streaming" license_widevine: "https://cr-license-proxy.prd.crunchyrollsvc.com/v1/license/widevine" - license_playready: "https://cr-license-proxy.prd.crunchyrollsvc.com/v1/license/playReady" - - # Discovery search: "https://www.crunchyroll.com/content/v2/discover/search" # Headers for Android TV client headers: - user-agent: "Crunchyroll/ANDROIDTV/3.49.1_22281 (Android 11; en-US; SHIELD Android TV)" + user-agent: "Crunchyroll/ANDROIDTV/3.58.0_22336 (Android 11; en-US; SHIELD Android TV)" accept: "application/json" accept-charset: "UTF-8" accept-encoding: "gzip" @@ -41,7 +37,8 @@ headers: # Query parameters params: - locale: "es-419" + locale: "en-US" + preferred_audio_language: "en-US" # Device parameters for authentication device: diff --git a/packages/envied/src/envied/services/DSNP/README.md b/packages/envied/src/envied/services/DSNP/README.md index fc56e90..94e6abf 100644 --- a/packages/envied/src/envied/services/DSNP/README.md +++ b/packages/envied/src/envied/services/DSNP/README.md @@ -1,10 +1,10 @@ # DisneyPlus(디즈니플러스) ``` -uv run unshackle dl -vl all -al orig -sl ko,en,ja -q 2160 -v h.265 -r HDR10 DSNP entity-4d12671a-f0ad-4c3f-8526-09ae6772390b +uv run envied.dl -vl all -al orig -sl ko,en,ja -q 1080,2160 -v h.264,h.265 -r SDR,HDR10,DV DSNP entity-4d12671a-f0ad-4c3f-8526-09ae6772390b ``` -## Information +## Information(정보) - Authorization: Credentials, Web Token - Security: UHD@L1/SL3000, FHD@L1/SL3000, HD@L3/SL2000 @@ -14,7 +14,7 @@ uv run unshackle dl -vl all -al orig -sl ko,en,ja -q 2160 -v h.265 -r HDR10 DSNP - Audio: AAC, AC3, ATMOS, DTS:X(P2:IMAX) - Range: SDR, HDR10, DV -## Support Args +## Support Args(지원하는 명령어 인자) - `-i`, `--imax`: Prefer IMAX Enhanced version if available. - `-r`, `--remastered-ar`: Prefer Remastered Aspect Ratio if available. @@ -23,18 +23,51 @@ uv run unshackle dl -vl all -al orig -sl ko,en,ja -q 2160 -v h.265 -r HDR10 DSNP ## Tips +- To enable the web refresh token-based login method, please comment out or delete the DSNP section under credentials in `envied.yaml`. + 웹 리프레시 토큰 기반 로그인 방식을 활성화하려면 `envied.yaml`의 credentials에서 DSNP부분을 주석 처리하거나 삭제하세요. + + ``` + credentials: + ... + # DSNP: example@example.com:example + ``` + - Configure user settings within the `envied.yaml` file. + 사용자 설정은 `envied.yaml`에서 다음과 같이 사용하세요. + ``` services: DSNP: - # Specifies the index of the profile to use. (0 = first profile, 1 = second profile, etc.) - # Automatically select a profile or language when commenting. - # Language setting is only available in languages supported by Disney+. + ## 사용자 환경설정 + ## User configuration + # 해당 설정값이 주석처리 되어 있는 경우에는 설정값들이 자동으로 선택됩니다. + # If these settings are commented out, values will be selected automatically. preferences: + # 사용할 프로필의 인덱스 번호를 지정합니다. (0 = 첫 번째 프로필, 1 = 두 번째 프로필 등) + # Specifies the index of the profile to use. (0 = first profile, 1 = second profile, etc.) + # 값이 설정되지 않은 경우에는 자동으로 PIN이 안 걸려 있고 키즈 모드가 아닌 프로필로 자동 선택됩니다. + # If no value is set, a profile without a PIN and not in Kids Mode will be automatically selected. profile: 0 + + # 서비스 내에서 표시되는 메타데이터 언어를 선택합니다. + # Selects the metadata language displayed within the service. + # 언어 설정은 Disney+에서 지원하는 언어 코드(예: "ko", "en")만 사용 가능합니다. + # Language settings are only available for language codes supported by Disney+ (e.g., "ko", "en"). + # 값이 설정되지 않은 경우에는 현재 프로필에 설정된 언어 설정을 사용합니다. + # If no value is set, the language settings of the current profile will be used. # language: "ko" + + # 매니페스트 로그 출력 레벨을 설정합니다. + # Sets the manifest log output level. + # 로그를 항상 표시해야 하는 경우 "info"를 사용하고, 그 외의 모든 경우에는 가급적 "debug"를 사용하십시오. + # Use "info" if the log must always be displayed; otherwise, use "debug" whenever possible. + # 값이 설정되지 않은 경우 기본값은 "debug"로 적용됩니다. + # If no value is set, the default level is "debug". + # manifest_log: "info" ``` + - To enable the tier_unlimits command by default, add the following to `envied.yaml`. + tier_unlimits 명령을 기본값으로 활성화하려면 `envied.yaml`에 다음을 추가하세요. ``` dl: ... diff --git a/packages/envied/src/envied/services/DSNP/__init__.py b/packages/envied/src/envied/services/DSNP/__init__.py index 162ea17..68bb05b 100644 --- a/packages/envied/src/envied/services/DSNP/__init__.py +++ b/packages/envied/src/envied/services/DSNP/__init__.py @@ -32,7 +32,7 @@ from . import queries class DSNP(Service): """ Service code for Disney+ Streaming Service (https://disneyplus.com).\n - Version: 26.02.27 + Version: 26.03.28 Author: Made by CodeName393 with Special Thanks to narakama, Sam\n Authorization: Credentials, Web Token\n @@ -575,7 +575,10 @@ class DSNP(Service): if self.tier_unlimits: parsed_url = urlparse(manifest_url) manifest_url = urlunparse(parsed_url._replace(query="")) # Delete tier params - self.log.debug(f" + Manifest URL: {manifest_url}") + + log_level = self.config.get("preferences", {}).get("manifest_log", "debug").lower() + log_func = getattr(self.log, log_level, self.log.debug) + log_func(f" + Manifest URL: {manifest_url}") tracks = HLS.from_url(url=manifest_url, session=self.session).to_tracks(title.language) artwork_type = "background" if isinstance(title, Movie) else "thumbnail" @@ -610,7 +613,7 @@ class DSNP(Service): if audio.channels == 6.0: audio.channels = 5.1 if audio.channels == 10.0: # DTS-UHD - audio.channels = "5.1.4" # Unshackle does not recommend + audio.channels = "5.1.4" # envied.does not recommend audio.codec = Audio.Codec.DTS audio.drm = None # It need HW decording @@ -623,7 +626,7 @@ class DSNP(Service): try: editorial = self.playback_data[title.id]["editorial"] if not editorial: - return Chapters() + return [] LABEL_MAP = { "intro_start": "intro_start", diff --git a/packages/envied/src/envied/services/DSNP/config.yaml b/packages/envied/src/envied/services/DSNP/config.yaml index 8a98da0..8d6da8c 100644 --- a/packages/envied/src/envied/services/DSNP/config.yaml +++ b/packages/envied/src/envied/services/DSNP/config.yaml @@ -1,3 +1,9 @@ +## DO NOT EDIT THIS FILE +# 해당 config 파일은 개발자 외에는 수정하지 마세요. +# This configuration file should not be modified by anyone other than developers. +# 사용자 환경설정은 반드시 "envied.yaml"에서만 사용하세요. +# User configuration must be performed only in "envied.yaml". + certificate: | CAUSugUKtAIIAxIQbj3s4jO5oUyWjDWqjfr9WRjA2afZBSKOAjCCAQoCggEBALhKWfnyA+FGn5P3tl6ffDjoGq2Oq86hKGl6aZIaGaF7XHPO5mIk7Q35ml ZIgg1A458Udb4eXRws1n+kJFqtZXCY5S1yElLP0Om1WQsoEY2stpl+PZTGnVv/CsOJGKQ8K4KMr7rKjZem9lA9BrBoxgfXY3tbwlnSf3wTEohyANb5Qfpa @@ -10,31 +16,34 @@ certificate: | ## config ( {configVersion}/{clientId}/{deviceFamily}/{sdkVersion}/{applicationRuntime}/{deviceProfile}/{environment} ) ## # Browser (windows, chrome) : /browser/v34.4/windows/chrome/prod.json -# Android Phone : /android/v16.0.0/google/handset/prod.json -# Android TV : /android/v16.0.0/google/tv/prod.json -# Amazon Fire TV : /android/v16.0.0/amazon/tv/prod.json +# Android Phone : /android/v18.0.0/google/handset/prod.json +# Android TV : /android/v18.0.0/google/tv/prod.json +# Amazon Fire TV : /android/v18.0.0/amazon/tv/prod.json +# Apple Iphone(old) : https://bam-sdk-configs.bamgrid.com/bam-sdk/v2.0/disney-svod-3d9324fc/apple/v9.10.0/ios/iphone/prod.json +# Apple Ipad(old) : https://bam-sdk-configs.bamgrid.com/bam-sdk/v2.0/disney-svod-3d9324fc/apple/v9.10.0/ios/ipad/prod.json endpoints: - config: "https://client-sdk-configs.bamgrid.com/bam-sdk/v7.0/disney-svod-3d9324fc/android/v16.0.0/google/tv/prod.json" + config: "https://client-sdk-configs.bamgrid.com/bam-sdk/v7.0/disney-svod-3d9324fc/android/v18.0.0/google/tv/prod.json" ## user_agent (okhttp/5.0.0-alpha.14) ## -# android-phone : BAMSDK/v16.0.0 (disney-svod-3d9324fc 26.0.2+rc1-2026.01.29.0; v7.0/v16.0.0; android; phone) -# android-tv : BAMSDK/v16.0.0 (disney-svod-3d9324fc 26.0.2+rc1-2026.01.29.0; v7.0/v16.0.0; android; tv) +# android-phone : BAMSDK/v18.0.0 (disney-svod-3d9324fc 26.0.2+rc1-2026.01.29.0; v7.0/v18.0.0; android; phone) +# android-tv : BAMSDK/v18.0.0 (disney-svod-3d9324fc 26.0.2+rc1-2026.01.29.0; v7.0/v18.0.0; android; tv) ## api_key ## # browser : ZGlzbmV5JmJyb3dzZXImMS4wLjA.Cu56AgSfBTDag5NiRA81oLHkDZfu5L3CKadnefEAY84 # android : ZGlzbmV5JmFuZHJvaWQmMS4wLjA.bkeb0m230uUhv8qrAXuNu39tbE_mD5EEhM_NAcohjyA +# apple : ZGlzbmV5JmFwcGxlJjEuMC4w.H9L7eJvc2oPYwDgmkoar6HzhBJRuUUzt_PcaC3utBI4 ## yp_service_id ## # browser : 63626081279ebe65eb50fb54 # android : 624b805dafc5c73635b1a216 bamsdk: - sdk_version: "16.0.0" - application_version: "26.0.2+rc1-2026.01.29.0" + sdk_version: "18.0.1" + application_version: "26.3.0+rc4-2026.03.12.0" explore_version: "v1.13" client: "disney-svod-3d9324fc" - user_agent: "BAMSDK/v16.0.0 (disney-svod-3d9324fc 26.0.2+rc1-2026.01.29.0; v7.0/v16.0.0; android; tv)" + user_agent: "BAMSDK/v18.0.1 (disney-svod-3d9324fc 26.3.0+rc4-2026.03.12.0; v7.0/v18.0.0; android; tv)" api_key: "ZGlzbmV5JmFuZHJvaWQmMS4wLjA.bkeb0m230uUhv8qrAXuNu39tbE_mD5EEhM_NAcohjyA" yp_service_id: "624b805dafc5c73635b1a216" @@ -47,9 +56,29 @@ device: operatingSystem: "Android" operatingSystemVersion: "16" -# Specifies the index of the profile to use. (0 = first profile, 1 = second profile, etc.) -# Automatically select a profile or language when commenting. -# Language setting is only available in languages supported by Disney+. +# ## 사용자 환경설정 +# ## User configuration +# # 해당 설정값이 주석처리 되어 있는 경우에는 설정값들이 자동으로 선택됩니다. +# # If these settings are commented out, values will be selected automatically. # preferences: +# # 사용할 프로필의 인덱스 번호를 지정합니다. (0 = 첫 번째 프로필, 1 = 두 번째 프로필 등) +# # Specifies the index of the profile to use. (0 = first profile, 1 = second profile, etc.) +# # 값이 설정되지 않은 경우에는 자동으로 PIN이 안 걸려 있고 키즈 모드가 아닌 프로필로 자동 선택됩니다. +# # If no value is set, a profile without a PIN and not in Kids Mode will be automatically selected. # profile: 0 + +# # 서비스 내에서 표시되는 메타데이터 언어를 선택합니다. +# # Selects the metadata language displayed within the service. +# # 언어 설정은 Disney+에서 지원하는 언어 코드(예: "ko", "en")만 사용 가능합니다. +# # Language settings are only available for language codes supported by Disney+ (e.g., "ko", "en"). +# # 값이 설정되지 않은 경우에는 현재 프로필에 설정된 언어 설정을 사용합니다. +# # If no value is set, the language settings of the current profile will be used. # language: "ko" + +# # 매니페스트 로그 출력 레벨을 설정합니다. +# # Sets the manifest log output level. +# # 로그를 항상 표시해야 하는 경우 "info"를 사용하고, 그 외의 모든 경우에는 가급적 "debug"를 사용하십시오. +# # Use "info" if the log must always be displayed; otherwise, use "debug" whenever possible. +# # 값이 설정되지 않은 경우 기본값은 "debug"로 적용됩니다. +# # If no value is set, the default level is "debug". +# manifest_log: "debug" diff --git a/packages/envied/src/envied/services/HMAX/__init__.py b/packages/envied/src/envied/services/HMAX/__init__.py new file mode 100755 index 0000000..678ec37 --- /dev/null +++ b/packages/envied/src/envied/services/HMAX/__init__.py @@ -0,0 +1,714 @@ +import hashlib +import json +import re +import uuid +from datetime import datetime +from hashlib import md5 +from typing import Optional, Union, Generator +from http.cookiejar import CookieJar +from collections import defaultdict +from copy import deepcopy +from zlib import crc32 + +import click +import requests +import xmltodict +from lxml import etree +from langcodes import Language + +from envied.core.constants import AnyTrack +from envied.core.credential import Credential +from envied.core.manifests import DASH +from envied.core.search_result import SearchResult +from envied.core.service import Service +from envied.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T +from envied.core.tracks import Attachment, Chapter, Chapters, Subtitle, Tracks, Video +from envied.core.utilities import is_close_match + + +class HMAX(Service): + ALIASES = ("HMAX", "max", "hbomax") + #GEOFENCE = ("US",) + + TITLE_RE = r"^(?:https?://(?:www\.|play\.)?hbomax\.com/)?(?P[^/]+)/(?P[^/]+)" + + VIDEO_CODEC_MAP = { + "H264": [Video.Codec.AVC], + "H265": [Video.Codec.HEVC] + } + + AUDIO_CODEC_MAP = { + "AAC": "mp4a", + "AC3": "ac-3", + "EC3": "ec-3" + } + + @staticmethod + @click.command(name="HMAX", short_help="https://max.com") + @click.argument("title", type=str) + @click.option("-vcodec", "--video-codec", default=None, help="Video codec preference") + @click.option("-acodec", "--audio-codec", default=None, help="Audio codec preference") + @click.pass_context + def cli(ctx, **kwargs): + return HMAX(ctx, **kwargs) + + def __init__(self, ctx, title, video_codec, audio_codec): + super().__init__(ctx) + + self.title = title + self.vcodec = video_codec + self.acodec = audio_codec + + range_param = ctx.parent.params.get("range_") + self.range = range_param[0].name if range_param else "SDR" + + if self.range == 'HDR10': + self.vcodec = "H265" + + def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None: + super().authenticate(cookies, credential) + if not cookies: + raise EnvironmentError("Service requires Cookies for Authentication.") + + try: + token = next(cookie.value for cookie in cookies if cookie.name == "st") + session_data = next(cookie.value for cookie in cookies if cookie.name == "session") + device_id = json.loads(session_data) + except (StopIteration, json.JSONDecodeError): + raise EnvironmentError("Required authentication cookies not found.") + + self.session.headers.update({ + 'User-Agent': 'BEAM-Android/1.0.0.104 (SONY/XR-75X95EL)', + 'Accept': 'application/json, text/plain, */*', + 'Content-Type': 'application/json', + 'x-disco-client': 'SAMSUNGTV:124.0.0.0:beam:4.0.0.118', + 'x-disco-params': 'realm=bolt,bid=beam,features=ar', + 'x-device-info': 'beam/4.0.0.118 (Samsung/Samsung-Unknown; Tizen/124.0.0.0; f198a6c1-c582-4725-9935-64eb6b17c3cd/87a996fa-4917-41ae-9b6d-c7f521f0cb78)', + 'traceparent': '00-315ac07a3de9ad1493956cf1dd5d1313-988e057938681391-01', + 'tracestate': f'wbd=session:{device_id}', + 'Origin': 'https://play.hbomax.com', + 'Referer': 'https://play.hbomax.com/', + }) + + auth_token = self._get_device_token() + self.session.headers.update({ + "x-wbd-session-state": auth_token + }) + + def search(self) -> Generator[SearchResult, None, None]: + search_url = "https://default.prd.api.hbomax.com/search" + + try: + response = self.session.get(search_url, params={"q": self.title}) + response.raise_for_status() + + search_data = response.json() + + for result in search_data.get("results", []): + yield SearchResult( + id_=result.get("id"), + title=result.get("title", "Unknown"), + label=result.get("type", "UNKNOWN").upper(), + url=f"https://play.hbomax.com/{result.get('type', 'content')}/{result.get('id')}" + ) + + except Exception as e: + self.log.warning(f"Search functionality not fully implemented: {e}") + return + yield + + def get_titles(self) -> Titles_T: + match = re.match(self.TITLE_RE, self.title) + if not match: + raise ValueError("Invalid title format. Expected format: type/id or full URL") + + content_type = match.group('type') + external_id = match.group('id') + + response = self.session.get( + self.config['endpoints']['contentRoutes'] % (content_type, external_id) + ) + response.raise_for_status() + + try: + content_data = [x for x in response.json()["included"] if "attributes" in x and "title" in + x["attributes"] and x["attributes"]["alias"] == "generic-%s-blueprint-page" % (re.sub(r"-", "", content_type))][0]["attributes"] + content_title = content_data["title"] + except: + content_data = [x for x in response.json()["included"] if "attributes" in x and "alternateId" in + x["attributes"] and x["attributes"]["alternateId"] == external_id and x["attributes"].get("originalName")][0]["attributes"] + content_title = content_data["originalName"] + + if content_type == "sport" or content_type == "event": + included_dt = response.json()["included"] + + for included in included_dt: + for key, data in included.items(): + if key == "attributes": + for k, d in data.items(): + if d == "VOD": + event_data = included + + release_date = event_data["attributes"].get("airDate") or event_data["attributes"].get("firstAvailableDate") + year = datetime.strptime(release_date, '%Y-%m-%dT%H:%M:%SZ').year + + return Movies([ + Movie( + id_=external_id, + service=self.__class__, + name=content_title.title(), + year=year, + data=event_data, + ) + ]) + + if content_type == "movie" or content_type == "standalone": + metadata = self.session.get( + url=self.config['endpoints']['moviePages'] % external_id + ).json()['data'] + + try: + edit_id = metadata['relationships']['edit']['data']['id'] + except: + for x in response.json()["included"]: + if x.get("type") == "video" and x.get("relationships", {}).get("show", {}).get("data", {}).get("id") == external_id: + metadata = x + + release_date = metadata["attributes"].get("airDate") or metadata["attributes"].get("firstAvailableDate") + year = datetime.strptime(release_date, '%Y-%m-%dT%H:%M:%SZ').year + + return Movies([ + Movie( + id_=external_id, + service=self.__class__, + name=content_title, + year=year, + data=metadata, + ) + ]) + + if content_type in ["show", "mini-series", "topical"]: + episodes = [] + if content_type == "mini-series": + alias = "generic-miniseries-page-rail-episodes" + elif content_type == "topical": + alias = "generic-topical-show-page-rail-episodes" + else: + alias = "-%s-page-rail-episodes-tabbed-content" % (content_type) + + included_dt = response.json()["included"] + + season_data = [data for included in included_dt for key, data in included.items() + if key == "attributes" for k, d in data.items() if alias in str(d).lower()][0] + + season_data = season_data["component"]["filters"][0] + + seasons = [int(season["value"]) for season in season_data["options"]] + + season_parameters = [(int(season["value"]), season["parameter"]) for season in season_data["options"] + for season_number in seasons if int(season["value"]) == int(season_number)] + + if not season_parameters: + raise ValueError("No seasons found") + + image_map = {} # accumulate images across all seasons + for (value, parameter) in season_parameters: + data = self.session.get( + url=self.config['endpoints']['showPages'] % (external_id, parameter) + ).json() + + try: + episodes_dt = sorted([dt for dt in data["included"] if "attributes" in dt and "videoType" in + dt["attributes"] and dt["attributes"]["videoType"] == "EPISODE" + and int(dt["attributes"]["seasonNumber"]) == int(parameter.split("=")[-1])], + key=lambda x: x["attributes"]["episodeNumber"]) + except KeyError: + raise ValueError("Season episodes were not found") + + episodes.extend(episodes_dt) + # Accumulate image objects across all seasons + image_map.update({ + obj["id"]: obj + for obj in data["included"] + if obj.get("type") == "image" + }) + + episode_titles = [] + release_date = episodes[0]["attributes"].get("airDate") or episodes[0]["attributes"].get("firstAvailableDate") + year = datetime.strptime(release_date, '%Y-%m-%dT%H:%M:%SZ').year + + season_map = {int(item[1].split("=")[-1]): item[0] for item in season_parameters} + + for episode in episodes: + # Resolve image objects for this episode using relationship IDs + ep_image_ids = [ + img["id"] + for img in episode.get("relationships", {}).get("images", {}).get("data", []) + ] + ep_images = [image_map[img_id] for img_id in ep_image_ids if img_id in image_map] + ep_data = dict(episode) + ep_data["_images"] = ep_images + episode_titles.append( + Episode( + id_=episode['id'], + service=self.__class__, + title=content_title, + season=season_map.get(episode['attributes'].get('seasonNumber')), + number=episode['attributes']['episodeNumber'], + name=episode['attributes']['name'], + year=year, + data=ep_data + ) + ) + + return Series(episode_titles) + + def get_tracks(self, title: Title_T) -> Tracks: + edit_id = title.data['relationships']['edit']['data']['id'] + + response = self.session.post( + url=self.config['endpoints']['playbackInfo'], + json={ + 'appBundle': 'beam', + 'consumptionType': 'streaming', + 'deviceInfo': { + 'deviceId': '2dec6cb0-eb34-45f9-bbc9-a0533597303c', + 'browser': { + 'name': 'chrome', + 'version': '113.0.0.0', + }, + 'make': 'Microsoft', + 'model': 'XBOX-Unknown', + 'os': { + 'name': 'Windows', + 'version': '113.0.0.0', + }, + 'platform': 'XBOX', + 'deviceType': 'xbox', + 'player': { + 'sdk': { + 'name': 'Beam Player Console', + 'version': '1.0.2.4', + }, + 'mediaEngine': { + 'name': 'GLUON_BROWSER', + 'version': '1.20.1', + }, + 'playerView': { + 'height': 1080, + 'width': 1920, + }, + }, + }, + 'editId': edit_id, + 'capabilities': { + 'manifests': { + 'formats': { + 'dash': {}, + }, + }, + 'codecs': { + 'video': { + 'hdrFormats': [ + 'hlg', + 'hdr10', + 'dolbyvision5', + 'dolbyvision8', + ], + 'decoders': [ + { + 'maxLevel': '6.2', + 'codec': 'h265', + 'levelConstraints': { + 'width': { + 'min': 1920, + 'max': 3840, + }, + 'height': { + 'min': 1080, + 'max': 2160, + }, + 'framerate': { + 'min': 15, + 'max': 60, + }, + }, + 'profiles': [ + 'main', + 'main10', + ], + }, + { + 'maxLevel': '4.2', + 'codec': 'h264', + 'levelConstraints': { + 'width': { + 'min': 640, + 'max': 3840, + }, + 'height': { + 'min': 480, + 'max': 2160, + }, + 'framerate': { + 'min': 15, + 'max': 60, + }, + }, + 'profiles': [ + 'high', + 'main', + 'baseline', + ], + }, + ], + }, + 'audio': { + 'decoders': [ + { + 'codec': 'aac', + 'profiles': [ + 'lc', + 'he', + 'hev2', + 'xhe', + ], + }, + ], + }, + }, + 'devicePlatform': { + 'network': { + 'lastKnownStatus': { + 'networkTransportType': 'unknown', + }, + 'capabilities': { + 'protocols': { + 'http': { + 'byteRangeRequests': True, + }, + }, + }, + }, + 'videoSink': { + 'lastKnownStatus': { + 'width': 1290, + 'height': 2796, + }, + 'capabilities': { + 'colorGamuts': [ + 'standard', + 'wide', + ], + 'hdrFormats': [ + 'dolbyvision', + 'hdr10plus', + 'hdr10', + 'hlg', + ], + }, + }, + }, + }, + 'gdpr': False, + 'firstPlay': False, + 'playbackSessionId': str(uuid.uuid4()), + 'applicationSessionId': str(uuid.uuid4()), + 'userPreferences': {}, + 'features': [], + } + ) + response.raise_for_status() + + playback_data = response.json() + + video_info = next(x for x in playback_data['videos'] if x['type'] == 'main') + title.language = Language.get(video_info['defaultAudioSelection']['language']) + + fallback_url = playback_data["fallback"]["manifest"]["url"] + fallback_url = fallback_url.replace('fly', 'akm').replace('gcp', 'akm') + + try: + self.wv_license_url = playback_data["drm"]["schemes"]["widevine"]["licenseUrl"] + except (KeyError, IndexError): + self.wv_license_url = None + + try: + self.pr_license_url = playback_data["drm"]["schemes"]["playready"]["licenseUrl"] + except (KeyError, IndexError): + self.pr_license_url = None + + manifest_url = fallback_url.replace('_fallback', '') + self.log.info(f" + Manifest: {manifest_url}") + + dash_manifest = DASH.from_url(url=manifest_url, session=self.session) + tracks = dash_manifest.to_tracks(language=title.language) + + + self.log.debug(tracks) + + tracks.videos = self._dedupe(tracks.videos) + tracks.audio = self._dedupe(tracks.audio) + + tracks.subtitles.clear() + + new_subtitles = self._process_max_subtitles(dash_manifest, title.language) + + for subtitle in new_subtitles: + tracks.add(subtitle) + + if self.vcodec: + tracks.videos = [x for x in tracks.videos if x.codec in self.VIDEO_CODEC_MAP[self.vcodec]] + + if self.acodec: + tracks.audio = [x for x in tracks.audio if (x.codec or "")[:4] == self.AUDIO_CODEC_MAP[self.acodec]] + + for track in tracks: + if isinstance(track, Video): + codec = track.data.get("dash", {}).get("representation", {}).get("codecs", "") + track.hdr10 = track.range == Video.Range.HDR10 + track.dv = codec[:4] in ("dvh1", "dvhe") + if isinstance(track, Subtitle) and not track.codec: + track.codec = Subtitle.Codec.WebVTT + + title.data['info'] = video_info + + for track in tracks.audio: + if hasattr(track, 'data') and track.data.get("dash", {}).get("adaptation_set"): + role = track.data["dash"]["adaptation_set"].find("Role") + if role is not None and role.get("value") in ["description", "alternative", "alternate"]: + track.descriptive = True + + # Attachment: episode/movie image resolved from relationships._images + # _images is built in get_titles by cross-referencing the included image objects + # HBO Max image attributes use "src" (not "url") and "kind" for type + try: + images = title.data.get("_images", []) + image_url = None + # Prefer landscape (default/wide) images; fallback to first available + PREFERRED_KINDS = ("default", "tile", "episode", "cover", "banner", + "centered-background-small", "background") + kind_map = {} + for img in images: + attrs = img.get("attributes", {}) + kind = attrs.get("kind", "").lower() + src = attrs.get("src", "") or attrs.get("url", "") + if src and kind not in kind_map: + kind_map[kind] = src + for k in PREFERRED_KINDS: + if k in kind_map: + image_url = kind_map[k] + break + # Fallback: take first available src + if not image_url and images: + for img in images: + attrs = img.get("attributes", {}) + src = attrs.get("src", "") or attrs.get("url", "") + if src: + image_url = src + break + if image_url: + if isinstance(title, Movie): + image_name = title.name + else: + image_name = f"{title.title} - S{title.season:02d}E{title.number:02d}" + tracks.add(Attachment.from_url( + url=image_url, + name=image_name, + mime_type="image/jpeg", + session=self.session, + )) + except Exception as e: + self.log.warning(f" - Attachment failed: {e}") + + return tracks + + def get_chapters(self, title: Title_T) -> Chapters: + chapters = [] + video_info = title.data.get('info', {}) + if 'annotations' in video_info: + chapters.append(Chapter(timestamp=0.0, name='Chapter 1')) + chapters.append(Chapter(timestamp=self._convert_timecode(video_info['annotations'][0]['start']), name='Credits')) + chapters.append(Chapter(timestamp=self._convert_timecode(video_info['annotations'][0]['end']), name='Chapter 2')) + + return Chapters(chapters) + + def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]: + if not self.wv_license_url: + return None + + response = self.session.post( + url=self.wv_license_url, + data=challenge + ) + response.raise_for_status() + return response.content + + def get_playready_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[bytes]: + if not self.pr_license_url: + return None + + if isinstance(challenge, bytes): + decoded_challenge = challenge.decode('utf-8') + else: + decoded_challenge = str(challenge) + + response = self.session.post( + url=self.pr_license_url, + data=decoded_challenge, + headers={ + 'Content-Type': 'text/xml; charset=utf-8', + 'SOAPAction': 'http://schemas.microsoft.com/DRM/2007/03/protocols/AcquireLicense' + } + ) + + response.raise_for_status() + return response.content + + def _get_device_token(self): + response = self.session.post(self.config['endpoints']['bootstrap']) + response.raise_for_status() + return response.headers.get('x-wbd-session-state') + + @staticmethod + def _convert_timecode(time_seconds): + return float(time_seconds) + + def _process_max_subtitles(self, dash_manifest, language): + subtitle_groups = defaultdict(list) + + for period in dash_manifest.manifest.findall("Period"): + for adaptation_set in period.findall("AdaptationSet"): + content_type = adaptation_set.get("contentType") + if content_type != "text": + continue + + lang = adaptation_set.get("lang") + if not lang: + continue + + role = adaptation_set.find("Role") + role_value = role.get("value") if role is not None else "subtitle" + label = adaptation_set.find("Label") + label_text = label.text if label is not None else "" + + key = (lang, role_value, label_text) + subtitle_groups[key].append((period, adaptation_set)) + + final_tracks = [] + + for (lang, role_value, label_text), adapt_pairs in subtitle_groups.items(): + if not adapt_pairs: + continue + + first_period, first_adapt = adapt_pairs[0] + first_rep = first_adapt.find("Representation") + if not first_rep: + continue + + combined_adapt = deepcopy(first_adapt) + combined_rep = combined_adapt.find("Representation") + + seg_template = combined_rep.find("SegmentTemplate") + if seg_template is None: + seg_template = combined_adapt.find("SegmentTemplate") + if seg_template is None: + continue + combined_adapt.remove(seg_template) + seg_template = deepcopy(seg_template) + combined_rep.append(seg_template) + + timeline = etree.Element("SegmentTimeline") + + segments_info = [] + for period, adapt in adapt_pairs: + rep = adapt.find("Representation") + if not rep: + continue + + template = rep.find("SegmentTemplate") + if not template: + template = adapt.find("SegmentTemplate") + if not template: + continue + + start_num = int(template.get("startNumber", 1)) + + existing_timeline = template.find("SegmentTimeline") + if existing_timeline is not None: + for s in existing_timeline.findall("S"): + t = int(s.get("t", 0)) + d = int(s.get("d", 0)) + r = int(s.get("r", 0)) + segments_info.append((start_num, t, d, r)) + + segments_info.sort(key=lambda x: x[0]) + + if segments_info: + for _, t, d, r in segments_info: + s_elem = etree.Element("S") + s_elem.set("t", str(t)) + s_elem.set("d", str(d)) + if r > 0: + s_elem.set("r", str(r)) + timeline.append(s_elem) + + old_timeline = seg_template.find("SegmentTimeline") + if old_timeline is not None: + seg_template.remove(old_timeline) + seg_template.append(timeline) + + seg_template.set("startNumber", "1") + seg_template.set("endNumber", str(len(segments_info))) + + track_id = hex(crc32(f"sub-{lang}-{role_value}-{label_text}".encode()))[2:] + + is_sdh = role_value == "caption" or "sdh" in label_text.lower() + is_forced = role_value in ["forced-subtitle", "forced_subtitle"] or "forced" in label_text.lower() + + lang_obj = Language.get(lang) + display_name = lang_obj.display_name() + + subtitle_track = Subtitle( + id_=track_id, + url=dash_manifest.url, + codec=Subtitle.Codec.WebVTT, + language=Language.get(lang), + is_original_lang=bool(language and is_close_match(Language.get(lang), [language])), + descriptor=Video.Descriptor.DASH, + sdh=is_sdh, + forced=is_forced, + name=display_name, + data={ + "dash": { + "manifest": dash_manifest.manifest, + "period": first_period, + "adaptation_set": combined_adapt, + "representation": combined_rep, + } + }, + ) + final_tracks.append(subtitle_track) + + return final_tracks + + @staticmethod + def _dedupe(items: list) -> list: + if not items: + return items + if isinstance(items[0].url, list): + return items + + seen = {} + for item in items: + if hasattr(item, 'width') and hasattr(item, 'height'): + key = f"{item.codec}_{item.width}x{item.height}_{item.bitrate}" + elif hasattr(item, 'channels'): + key = f"{item.codec}_{item.language}_{item.bitrate}_{item.channels}" + else: + key = item.url + + if key not in seen: + seen[key] = item + + return list(seen.values()) diff --git a/packages/envied/src/envied/services/HMAX/config.yaml b/packages/envied/src/envied/services/HMAX/config.yaml new file mode 100755 index 0000000..449da4c --- /dev/null +++ b/packages/envied/src/envied/services/HMAX/config.yaml @@ -0,0 +1,6 @@ +endpoints: + contentRoutes: 'https://default.prd.api.hbomax.com/cms/routes/%s/%s?include=default' + moviePages: 'https://default.prd.api.hbomax.com/content/videos/%s/activeVideoForShow?&include=edit' + playbackInfo: 'https://default.prd.api.hbomax.com/any/playback/v1/playbackInfo' + showPages: 'https://default.prd.api.hbomax.com/cms/collections/generic-show-page-rail-episodes-tabbed-content?include=default&pf[show.id]=%s&%s' + bootstrap: 'https://default.prd.api.hbomax.com/session-context/headwaiter/v1/bootstrap' diff --git a/packages/envied/src/envied/services/MGMP/__init__.py b/packages/envied/src/envied/services/MGMP/__init__.py new file mode 100644 index 0000000..27a6be7 --- /dev/null +++ b/packages/envied/src/envied/services/MGMP/__init__.py @@ -0,0 +1,1033 @@ +from __future__ import annotations + +import base64 +import json +import os +import re +import time +import uuid +from http.cookiejar import CookieJar +from typing import Any, Generator, Optional, Union + +import click +from langcodes import Language + +from envied.core.constants import AnyTrack +from envied.core.credential import Credential +from envied.core.manifests import DASH +from envied.core.search_result import SearchResult +from envied.core.service import Service +from envied.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T +from envied.core.tracks import Chapters, Subtitle, Tracks +from envied.services.MGMP import queries + + +class MGMP(Service): + """ + Service code for MGM+ (https://www.mgmplus.com). + + \b + Version: 2.0.2 + Author: sp4rk.y + Date: 2026-02-19 + Authorization: Device Login Pair + Robustness: + Widevine: + L3: 2160p + + \b + Tips: + - Input can be a full MGM+ URL or a movie/series slug/ID + - Uses device login pair flow — a code will be displayed for activation + - Visit https://www.mgmplus.com/activate and enter the code shown + - Session tokens are cached automatically for reuse + - Series watch URLs (`/series//watch/season//episode/`) are supported + + \b + Notes: + - Authentication uses the EPIX/MGM+ device pairing API (api.epixnow.com) + - Primary playback uses AndroidTV GraphQL PlayFlow for DASH+Widevine (4K) + - Fallback uses web session + Amazon playback pipeline for non-DRM content + - DRM: BuyDRM KeyOS (AndroidTV path) or Amazon Widevine (fallback path) + - Supports 4K UHD HEVC with Dolby Digital Plus / Atmos audio + - Subtitles are sourced from WebVTT URLs in the PlayFlow response + """ + + ALIASES = ("mgmplus",) + GEOFENCE = ("us",) + TITLE_RE = r"^(?:https?://(?:www\.)?mgmplus\.com/(?:movie|series)/)?(?P[^/?#]+)" + MOVIE_URL_RE = r"^https?://(?:www\.)?mgmplus\.com/movie/(?P<slug>[^/?#]+)" + SERIES_URL_RE = r"^https?://(?:www\.)?mgmplus\.com/series/(?P<slug>[^/?#]+)" + SERIES_WATCH_RE = r"^https?://(?:www\.)?mgmplus\.com/series/(?P<slug>[^/?#]+)/watch/season/(?P<season>\d+)/episode/(?P<episode>\d+)" + + def __init__(self, ctx, title: str): + self.title = title.strip() + super().__init__(ctx) + + guid_cache = self.cache.get("session_guid") + if guid_cache and guid_cache.data: + self.session_guid = str(guid_cache.data) + else: + self.session_guid = str(uuid.uuid4()) + guid_cache.set(self.session_guid) + + vendor_cache = self.cache.get("vendor_id") + if vendor_cache and vendor_cache.data: + self.vendor_id = str(vendor_cache.data) + else: + self.vendor_id = uuid.uuid4().hex[:16] + vendor_cache.set(self.vendor_id) + + self.session_token = None + self.web_session_token = None + self.amazon_device_id = str(uuid.uuid4()) + self.session.headers.update(self.config.get("headers", {})) + + def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None: + super().authenticate(cookies, credential) + + cache = self.cache.get("session_token") + session_data = self._create_device_session() + device_session = session_data["device_session"] + + if device_session.get("user") is not None: + self.log.info("Refreshed AndroidTV session token") + self._cache_session_token(device_session["session_token"], cache) + return + + self.log.info("Starting device login pair flow...") + anon_token = device_session["session_token"] + + code_data = self._get_registration_code(anon_token) + code = code_data["device"]["code"] + + self.log.info("Go to: https://www.mgmplus.com/activate") + self.log.info(f"Enter code: {code}") + self.log.info("Waiting for activation...") + + for _ in range(200): + time.sleep(3) + session_data = self._create_device_session() + device_session = session_data["device_session"] + + if device_session.get("user") is not None: + self.log.info("Device paired successfully!") + self._cache_session_token(device_session["session_token"], cache) + return + + raise EnvironmentError("Device pairing timed out. Please try again.") + + def _cache_session_token(self, token: str, cache: Any = None) -> None: + """Store token, apply it to headers, and cache with correct TTL.""" + self.session_token = token + self._apply_session_token() + + if cache is None: + cache = self.cache.get("session_token") + + ttl = 86400 + try: + payload = self._decode_jwt_payload(token) + exp = payload.get("exp", 0) + remaining = int(exp - time.time()) + if remaining > 60: + ttl = remaining + except Exception: + pass + cache.set(self.session_token, expiration=ttl - 60) + + def _apply_session_token(self) -> None: + """Extract GUID from JWT. Token is passed per-request, not on session headers.""" + self.session.headers.pop("x-session-token", None) + try: + payload = self._decode_jwt_payload(self.session_token) + guid = payload.get("guid") + if guid: + self.session_guid = guid + except Exception: + pass + + def _is_token_expired(self, token: str) -> bool: + """Check if a JWT session token has expired or will expire within 60 seconds.""" + try: + payload = self._decode_jwt_payload(token) + return time.time() >= payload.get("exp", 0) - 60 + except Exception: + return True + + def _ensure_token_fresh(self) -> None: + """Silently refresh the session token if it is near expiry.""" + if self.session_token and not self._is_token_expired(self.session_token): + return + self._refresh_android_session() + + def _refresh_android_session(self) -> None: + """Force-refresh the AndroidTV device session token.""" + session_data = self._create_device_session() + device_session = session_data["device_session"] + if device_session.get("user") is None: + raise EnvironmentError("Device is no longer paired. Please re-authenticate.") + self._cache_session_token(device_session["session_token"]) + + @staticmethod + def _decode_jwt_payload(token: str) -> dict: + """Decode the payload section of a JWT without verification.""" + parts = token.split(".") + if len(parts) < 2: + raise ValueError("Invalid JWT") + payload_b64 = parts[1] + ("=" * (-len(parts[1]) % 4)) + return json.loads(base64.urlsafe_b64decode(payload_b64.encode("utf-8")).decode("utf-8")) + + def _create_device_session(self) -> dict: + """Create a device session via the epixnow API. Returns user data when paired.""" + device_info = dict(self.config["client"]["device"]) + device_info["guid"] = self.session_guid + device_info["vendor_id"] = self.vendor_id + + response = self.session.post( + url=self.config["endpoints"]["sessions"], + json={ + "apikey": self.config["client"]["apikey"], + "amazon_receipt": {"receipt_id": "", "user_id": ""}, + "device": device_info, + }, + headers={"Accept": "application/json", "Content-Type": "application/json"}, + ) + response.raise_for_status() + return response.json() + + def _get_registration_code(self, session_token: str) -> dict: + """Request a device registration code for display on TV.""" + response = self.session.post( + url=self.config["endpoints"]["registration_code"], + data=b"", + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + "x-session-token": session_token, + }, + ) + response.raise_for_status() + return response.json() + + def search(self) -> Generator[SearchResult, None, None]: + self._ensure_token_fresh() + r = self.session.get( + url=f"{self.config['endpoints']['search']}/{self.title}", + params={"page": 1, "per": 16, "requested_types": "movies,shows"}, + headers={"x-session-token": self.session_token}, + ) + r.raise_for_status() + results = r.json() + + for item in (results.get("data") or {}).get("items") or []: + content = item.get("content") or {} + content_type = item.get("type", "") + title = content.get("title", "Unknown") + content_id = content.get("short_name") or str(content.get("id", "")) + + label = "movie" if content_type == "movie" else "series" if content_type == "series" else content_type + year = content.get("years") or content.get("release_year") + description = content.get("synopsis") or content.get("short_description") or "" + if len(description) > 200: + description = description[:197] + "..." + + yield SearchResult( + id_=content_id, + title=title, + description=description, + label=f"{label} ({year})" if year else label, + url=f"https://www.mgmplus.com/{'movie' if content_type == 'movie' else 'series'}/{content_id}", + ) + + def get_titles(self) -> Titles_T: + parsed = self._parse_title_input(self.title) + preferred_kind = parsed.get("kind") + title_id = parsed["id"] + + if preferred_kind != "series": + movie_id = title_id + title_match = re.match(self.TITLE_RE, title_id) + if title_match: + extracted = title_match.group("title") + movie_id = ( + base64.b64encode(extracted.encode("utf-8")).decode("utf-8") + if extracted.startswith("movie;") + else extracted + ) + + movie = self._graphql_movie(movie_id) + if movie: + return Movies( + [ + Movie( + id_=movie["id"], + service=self.__class__, + name=movie["title"], + year=movie.get("releaseYear"), + language=Language.get("en"), + description=movie.get("synopsis"), + data={ + "resource_id": movie["id"], + "short_name": movie.get("shortName"), + "duration": movie.get("duration"), + }, + ) + ] + ) + if preferred_kind == "movie": + raise ValueError(f"Unable to find movie by id/slug: {self.title}") + + series = self._graphql_series(title_id) + if not series: + raise ValueError(f"Unable to find MGM+ title by id/slug: {self.title}") + + seasons_nodes = (series.get("seasons") or {}).get("nodes") or [] + canonical_year = self._get_series_canonical_year(seasons_nodes) + + season_filter = parsed.get("season") + episode_filter = parsed.get("episode") + + if season_filter is not None and episode_filter is not None and series.get("shortName"): + direct_episode = self._graphql_episode(series["shortName"], season_filter, episode_filter) + if direct_episode: + return Series( + [ + Episode( + id_=direct_episode["id"], + service=self.__class__, + title=series["title"], + season=int(direct_episode.get("seasonNumber") or season_filter), + number=int(direct_episode.get("number") or episode_filter), + name=direct_episode.get("shortTitle"), + year=canonical_year or direct_episode.get("releaseYear"), + language=Language.get("en"), + description=direct_episode.get("description"), + data={ + "resource_id": direct_episode["id"], + "duration": direct_episode.get("duration"), + "series_id": series.get("id"), + "series_short_name": (direct_episode.get("series") or {}).get("shortName") + or series.get("shortName"), + "season_id": None, + }, + ) + ] + ) + + episodes = [] + + if season_filter is None or (season_filter is not None and season_filter > 0): + for season in seasons_nodes: + season_id = season.get("id") + if not season_id: + continue + + if season_filter is not None and int(season.get("number") or 0) != season_filter: + continue + + for episode in self._graphql_episodes(season_id): + ep_season = int(episode.get("seasonNumber") or season.get("number") or 0) + ep_number = int(episode.get("number") or 0) + + if season_filter is not None and ep_season != season_filter: + continue + if episode_filter is not None and ep_number != episode_filter: + continue + + episodes.append( + Episode( + id_=episode["id"], + service=self.__class__, + title=series["title"], + season=ep_season, + number=ep_number, + name=episode.get("shortTitle"), + year=canonical_year or episode.get("releaseYear"), + language=Language.get("en"), + description=episode.get("description"), + data={ + "resource_id": episode["id"], + "duration": episode.get("duration"), + "series_id": series.get("id"), + "series_short_name": (episode.get("series") or {}).get("shortName") + or series.get("shortName"), + "season_id": season_id, + }, + ) + ) + + if not episodes: + if season_filter is not None and episode_filter is not None: + raise ValueError( + f"No episodes found for S{season_filter:02}E{episode_filter:02} in series '{series.get('title')}'." + ) + raise ValueError(f"No episodes found for series '{series.get('title')}'.") + + return Series(episodes) + + def get_tracks(self, title: Title_T) -> Tracks: + # Force-refresh sessions before each title to avoid stale state between episodes + self._refresh_android_session() + self.web_session_token = None + + resource_id = title.data["resource_id"] + play_flow = self._get_play_flow_content(resource_id) + + if play_flow.get("__typename") == "ShowNotice": + notice_title = play_flow.get("title", "Unavailable") + notice_desc = play_flow.get("description", "") + msg = f"{notice_title}: {notice_desc}" if notice_desc else notice_title + self.log.info(f"Not available for streaming: {msg}") + return Tracks() + + all_streams = play_flow.get("streams") or [] + stream = self._select_best_stream(all_streams) + + # Try Amazon playback for audio (and video fallback) + amazon_result = None + try: + amazon_result = self._get_amazon_playback(resource_id) + except Exception as e: + self.log.warning(f"Amazon playback request failed: {e}") + + if stream: + # MGM CENC video from AndroidTV PlayFlow + manifest_url = stream["playlistUrl"] + tracks = DASH.from_url(url=manifest_url, session=self.session).to_tracks(language=title.language) + + widevine = stream.get("widevine") or {} + for track in [*tracks.videos, *tracks.audio]: + track.data.setdefault("mgmp", {}).update( + { + "license_url": widevine.get("licenseServerUrl"), + "auth_token": widevine.get("authenticationToken"), + } + ) + + # Use Amazon audio if it offers better bitrates than MGM CENC + if amazon_result: + mpd_url, playback_envelope, session_handoff_token, playback_id, web_play_flow, manifest = amazon_result + try: + amzn_tracks = DASH.from_url(url=mpd_url, session=self.session).to_tracks(language=title.language) + mgm_best = max((float(a.bitrate or 0) for a in tracks.audio), default=0) + amzn_best = max((float(a.bitrate or 0) for a in amzn_tracks.audio), default=0) + + if amzn_tracks.audio and amzn_best > mgm_best: + for audio in amzn_tracks.audio: + audio.data["amazon"] = { + "playback_envelope": playback_envelope, + "session_handoff_token": session_handoff_token, + "playback_id": playback_id, + } + if "dash" in audio.data: + audio_track_id = audio.data["dash"]["adaptation_set"].get("audioTrackId") + sub_type = audio.data["dash"]["adaptation_set"].get("audioTrackSubtype") + if audio_track_id is not None: + audio.language = Language.get(audio_track_id.split("_")[0]) + if sub_type is not None and "descriptive" in sub_type.lower(): + audio.descriptive = True + tracks.audio = list(amzn_tracks.audio) + self._add_amazon_subtitles(tracks, manifest) + except Exception: + pass + + return tracks + + # No MGM CENC streams — full Amazon fallback + self.log.info("No DRM streams from AndroidTV, trying Amazon via web session...") + if amazon_result: + mpd_url, playback_envelope, session_handoff_token, playback_id, web_play_flow, manifest = amazon_result + tracks = DASH.from_url(url=mpd_url, session=self.session).to_tracks(language=title.language) + for track in [*tracks.videos, *tracks.audio]: + track.data["amazon"] = { + "playback_envelope": playback_envelope, + "session_handoff_token": session_handoff_token, + "playback_id": playback_id, + } + # Fix audio language from DASH audioTrackId + for audio in tracks.audio: + if "dash" in audio.data: + audio_track_id = audio.data["dash"]["adaptation_set"].get("audioTrackId") + sub_type = audio.data["dash"]["adaptation_set"].get("audioTrackSubtype") + if audio_track_id is not None: + audio.language = Language.get(audio_track_id.split("_")[0]) + if sub_type is not None and "descriptive" in sub_type.lower(): + audio.descriptive = True + + # Add Amazon timed text subtitles + self._add_amazon_subtitles(tracks, manifest) + + # Fallback to PlayFlow VTT if no timed text subtitles + if not tracks.subtitles: + cc = web_play_flow.get("closedCaptions") or {} + vtt_url = (cc.get("vtt") or {}).get("location") + if vtt_url: + tracks.add( + Subtitle( + url=vtt_url, + codec=Subtitle.Codec.WebVTT, + language=title.language or Language.get("en"), + forced=False, + ) + ) + return tracks + + try: + web_pf = self._web_play_flow(resource_id) + if web_pf and web_pf.get("__typename") == "ShowNotice": + notice_title = web_pf.get("title", "Unavailable") + notice_desc = web_pf.get("description", "") + msg = f"{notice_title}: {notice_desc}" if notice_desc else notice_title + self.log.info(f"Not available for streaming: {msg}") + return Tracks() + except Exception: + pass + + available = [ + f"{s.get('packagingSystem')}/{s.get('encryptionScheme')} widevine={bool(s.get('widevine'))}" + for s in all_streams + ] + raise ValueError(f"No DASH streams found (AndroidTV or Amazon). Available: {available or 'none'}") + + def _add_amazon_subtitles(self, tracks: Tracks, manifest: dict) -> None: + """Extract timed text subtitles from Amazon playback manifest response.""" + timed_text = (manifest.get("timedTextUrls") or {}).get("result") or {} + subtitle_urls = timed_text.get("subtitleUrls") or [] + forced_urls = timed_text.get("forcedNarrativeUrls") or [] + + for sub in subtitle_urls + forced_urls: + sub_type = sub.get("type", "").lower() + is_forced = sub_type == "forcednarrative" + lang_code = sub.get("languageCode", "en") + name = Language.get(lang_code).display_name() + url = sub.get("url", "") + if not url: + continue + # Prefer SRT format + url = os.path.splitext(url)[0] + ".srt" + tracks.add( + Subtitle( + id_=sub.get( + "timedTextTrackId", + sub.get("timedSubtitleId", f"{lang_code}_{sub.get('type', '')}_{sub.get('subtype', '')}"), + ), + url=url, + codec=Subtitle.Codec.from_codecs("srt"), + language=lang_code, + name=name, + forced=is_forced, + sdh=sub_type == "sdh", + cc=sub_type == "cc", + ), + warn_only=True, + ) + + def get_chapters(self, title: Title_T) -> Chapters: + return Chapters() + + def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]: + amazon_ctx = (track.data or {}).get("amazon") + if amazon_ctx: + return self._get_amazon_widevine_license(challenge, amazon_ctx) + + mgmp_ctx = (track.data or {}).get("mgmp") or {} + license_url = mgmp_ctx.get("license_url") + auth_token = mgmp_ctx.get("auth_token") + + if not license_url or not auth_token: + raise ValueError("Track is missing license context (license_url or auth_token).") + + response = self.session.post( + url=license_url, + headers={ + "x-keyos-authorization": auth_token, + "Content-Type": "application/octet-stream", + }, + data=challenge, + ) + response.raise_for_status() + return response.content + + def get_widevine_service_certificate(self, **_: Any) -> Optional[str]: + return None + + def _parse_title_input(self, value: str) -> dict: + text = value.strip() + + watch_match = re.match(self.SERIES_WATCH_RE, text) + if watch_match: + return { + "kind": "series", + "id": watch_match.group("slug"), + "season": int(watch_match.group("season")), + "episode": int(watch_match.group("episode")), + } + + movie_match = re.match(self.MOVIE_URL_RE, text) + if movie_match: + return {"kind": "movie", "id": movie_match.group("slug"), "season": None, "episode": None} + + series_match = re.match(self.SERIES_URL_RE, text) + if series_match: + return {"kind": "series", "id": series_match.group("slug"), "season": None, "episode": None} + + if text.startswith("movie;"): + return {"kind": "movie", "id": text, "season": None, "episode": None} + if text.startswith("series;"): + return {"kind": "series", "id": text, "season": None, "episode": None} + + return {"kind": None, "id": text, "season": None, "episode": None} + + def _graphql(self, operation: str, variables: dict, query: str) -> dict: + self._ensure_token_fresh() + response = self.session.post( + url=self.config["endpoints"]["graphql"], + json={ + "operationName": operation, + "variables": variables, + "query": query, + "extensions": {"clientLibrary": {"name": "apollo-kotlin", "version": "4.3.3"}}, + }, + headers={ + "Accept": "multipart/mixed;deferSpec=20220824, application/graphql-response+json, application/json", + "Content-Type": "application/json", + "x-session-token": self.session_token, + }, + ) + response.raise_for_status() + data = response.json() + if data.get("errors"): + raise ValueError(f"GraphQL error for {operation}: {data['errors']}") + return data + + def _graphql_movie(self, title_id: str) -> Optional[dict]: + data = self._graphql("Movie", {"id": title_id}, queries.MOVIE) + return (data.get("data") or {}).get("movie") + + def _graphql_series(self, series_id: str) -> Optional[dict]: + data = self._graphql("Series", {"id": series_id}, queries.SERIES) + return (data.get("data") or {}).get("series") + + def _graphql_episodes(self, season_id: str) -> list[dict]: + episodes: list[dict] = [] + after = None + while True: + variables = {"seasonId": season_id, "first": 100, "after": after} + data = self._graphql("Episodes", variables, queries.EPISODES) + payload = (data.get("data") or {}).get("episodes") or {} + episodes.extend(payload.get("nodes") or []) + page_info = payload.get("pageInfo") or {} + if not page_info.get("hasNextPage"): + break + after = page_info.get("endCursor") + if not after: + break + return episodes + + def _graphql_episode(self, series_short_name: str, season_number: int, episode_number: int) -> Optional[dict]: + variables = { + "seriesShortName": series_short_name, + "seasonNumber": int(season_number), + "episodeNumber": int(episode_number), + } + data = self._graphql("Episode", variables, queries.EPISODE) + return (data.get("data") or {}).get("episode") + + def _get_series_canonical_year(self, seasons_nodes: list[dict]) -> Optional[int]: + for season in sorted(seasons_nodes, key=lambda x: int((x or {}).get("number") or 10**9)): + season_id = season.get("id") + if not season_id: + continue + for episode in self._graphql_episodes(season_id): + raw_year = episode.get("releaseYear") + if isinstance(raw_year, int) and raw_year > 0: + return raw_year + if isinstance(raw_year, str) and raw_year.isdigit() and int(raw_year) > 0: + return int(raw_year) + return None + + def _graphql_play_flow(self, resource_id: str, context: Optional[str] = None) -> dict: + """Call the Play GraphQL query matching the AndroidTV apollo-kotlin client.""" + variables = { + "id": resource_id, + "context": context or "", + "behavior": "DEFAULT", + "supportedActions": ["noop", "continue_play", "play_content", "show_notice", "log_in", "start_billing"], + } + data = self._graphql("Play", variables, queries.PLAY) + return (data.get("data") or {}).get("playFlow") + + def _get_play_flow_content(self, resource_id: str) -> dict: + """Resolve PlayFlow to actual content, skipping pre-rolls via continuation. + + Pre-rolls are PlayContent responses that lack DRM streams. We chase their + continuationContext to reach the real content with DASH+Widevine streams. + """ + play_flow = self._graphql_play_flow(resource_id) + seen_contexts: set[str] = set() + + for attempt in range(6): + if not play_flow: + self.log.warning(f"PlayFlow attempt {attempt + 1}: empty response") + break + + typename = play_flow.get("__typename", "") + pf_type = play_flow.get("type", "") + + if typename == "PlayContent" or pf_type == "play_content": + streams = play_flow.get("streams") or [] + if any(s.get("packagingSystem") == "DASH" and s.get("widevine") for s in streams): + return play_flow + context = play_flow.get("continuationContext") + if context and context not in seen_contexts: + seen_contexts.add(context) + play_flow = self._graphql_play_flow(resource_id, context=context) + continue + return play_flow + + context = play_flow.get("continuationContext") + if not context: + for action in play_flow.get("actions") or []: + if isinstance(action, dict) and action.get("continuationContext"): + context = action["continuationContext"] + break + if context and context not in seen_contexts: + seen_contexts.add(context) + play_flow = self._graphql_play_flow(resource_id, context=context) + continue + + break + + if play_flow: + return play_flow + + raise ValueError(f"PlayFlow did not return playable content for {resource_id}.") + + def _create_web_session(self) -> str: + """Create a web session on api.mgmplus.com using the same paired GUID.""" + if self.web_session_token and not self._is_token_expired(self.web_session_token): + return self.web_session_token + + web_config = self.config["web"] + device_info = dict(web_config["device"]) + device_info["guid"] = self.session_guid + + response = self.session.post( + url=web_config["endpoints"]["sessions"], + json={ + "device": device_info, + "apikey": web_config["apikey"], + }, + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + "Origin": "https://www.mgmplus.com", + "Referer": "https://www.mgmplus.com/", + }, + ) + response.raise_for_status() + data = response.json() + self.web_session_token = data["device_session"]["session_token"] + return self.web_session_token + + def _web_play_flow(self, resource_id: str) -> Optional[dict]: + """Call PlayFlow via the web GraphQL endpoint to get amazonPlayback data.""" + web_token = self._create_web_session() + web_config = self.config["web"] + + response = self.session.post( + url=web_config["endpoints"]["graphql"], + json={ + "operationName": "PlayFlow", + "variables": { + "id": resource_id, + "supportedActions": [ + "open_url", + "show_notice", + "start_billing", + "play_content", + "log_in", + "noop", + "confirm_provider", + "unlinked_provider", + ], + "streamTypes": [ + {"encryptionScheme": "CBCS", "packagingSystem": "DASH"}, + {"encryptionScheme": "CENC", "packagingSystem": "DASH"}, + {"encryptionScheme": "NONE", "packagingSystem": "HLS"}, + {"encryptionScheme": "AES_128", "packagingSystem": "HLS"}, + {"encryptionScheme": "SAMPLE_AES", "packagingSystem": "HLS"}, + ], + }, + "query": queries.WEB_PLAY, + }, + headers={ + "accept": "application/json", + "content-type": "application/json", + "x-session-token": web_token, + "Origin": "https://www.mgmplus.com", + "Referer": "https://www.mgmplus.com/", + }, + ) + response.raise_for_status() + data = response.json() + if data.get("errors"): + self.log.warning(f"Web PlayFlow GraphQL errors: {data['errors']}") + return None + return (data.get("data") or {}).get("playFlow") + + def _get_amazon_playback(self, resource_id: str) -> Optional[tuple[str, str, str, str, dict, dict]]: + """Get Amazon DASH playback via web PlayFlow + GetVodPlaybackResources. + + Uses WebPlayer profile (compatible with unauthenticated MGM web session) + with upgraded codec/resolution/bitrate parameters for better quality. + Returns (mpd_url, playback_envelope, session_handoff_token, playback_id, web_play_flow, manifest) or None. + """ + play_flow = self._web_play_flow(resource_id) + if not play_flow: + return None + + amazon = play_flow.get("amazonPlayback") or {} + playback_envelope = amazon.get("playbackEnvelope") + playback_id = amazon.get("playbackId") + if not playback_envelope or not playback_id: + self.log.info("Web PlayFlow returned no amazonPlayback data") + return None + + amazon_config = self.config["amazon"] + + response = self.session.post( + url=f"{amazon_config['base']}/playback/prs/GetVodPlaybackResources", + params={ + "deviceID": self.amazon_device_id, + "deviceTypeID": amazon_config["device_type"], + "gascEnabled": "false", + "marketplaceID": amazon_config["marketplace"], + "uxLocale": "en_US", + "firmware": 1, + "titleId": playback_id, + "nerid": self._generate_nerid(0), + }, + headers={ + "Accept": "*/*", + "Content-Type": "text/plain", + "x-atv-client-type": "XpPlayer", + "Origin": "https://www.mgmplus.com", + "Referer": "https://www.mgmplus.com/", + }, + json={ + "globalParameters": { + "deviceCapabilityFamily": "WebPlayer", + "playbackEnvelope": playback_envelope, + "capabilityDiscriminators": { + "operatingSystem": {"name": "Windows", "version": "10.0"}, + "middleware": {"name": "Chrome", "version": "145.0.0.0"}, + "nativeApplication": {"name": "Chrome", "version": "145.0.0.0"}, + "hfrControlMode": "Legacy", + "displayResolution": {"height": 2160, "width": 3840}, + }, + }, + "auditPingsRequest": {}, + "playbackDataRequest": {}, + "timedTextUrlsRequest": {"supportedTimedTextFormats": ["TTMLv2", "DFXP"]}, + "trickplayUrlsRequest": {}, + "transitionTimecodesRequest": {}, + "vodPlaybackUrlsRequest": { + "device": { + "hdcpLevel": "2.2", + "maxVideoResolution": "2160p", + "supportedStreamingTechnologies": ["DASH"], + "streamingTechnologies": { + "DASH": { + "bitrateAdaptations": ["CVBR", "CBR"], + "codecs": ["H265", "H264"], + "drmKeyScheme": "DualKey", + "drmType": "Widevine", + "dynamicRangeFormats": ["None"], + "edgeDeliveryAuthorizationSchemes": ["PVExchangeV1", "Transparent"], + "fragmentRepresentations": ["ByteOffsetRange", "SeparateFile"], + "frameRates": ["Standard", "High"], + "stitchType": "MultiPeriod", + "segmentInfoType": "Base", + "timedTextRepresentations": [ + "NotInManifestNorStream", + "SeparateStreamInManifest", + ], + "trickplayRepresentations": ["NotInManifestNorStream"], + "variableAspectRatio": "supported", + } + }, + "displayWidth": 3840, + "displayHeight": 2160, + }, + "playbackCustomizations": {}, + "playbackSettingsRequest": { + "firmware": "UNKNOWN", + "playerType": "xp", + "responseFormatVersion": "1.0.0", + "titleId": playback_id, + }, + }, + "vodXrayMetadataRequest": { + "xrayDeviceClass": "normal", + "xrayPlaybackMode": "playback", + "xrayToken": "XRAY_WEB_2023_V2", + }, + }, + ) + response.raise_for_status() + result = response.json() + + mpd_url = self._extract_amazon_mpd_url(result) + if not mpd_url: + self.log.warning("Amazon GetVodPlaybackResources returned no MPD URL") + return None + + session_handoff_token = (result.get("sessionization") or {}).get("sessionHandoffToken", "") + if not session_handoff_token: + self.log.warning("Amazon response missing sessionHandoffToken") + return None + + return mpd_url, playback_envelope, session_handoff_token, playback_id, play_flow, result + + @staticmethod + def _extract_amazon_mpd_url(result: dict) -> Optional[str]: + """Extract the best DASH MPD URL from Amazon GetVodPlaybackResources response. + + Handles the playlisted format (vodPlaylistedPlaybackUrls) with CDN preference, + falling back to the legacy format (vodPlaybackUrls) if needed. + """ + cdn_preference = ["akamai", "cloudfront"] + + # Playlisted format (vodPlaylistedPlaybackUrlsRequest response) + playlisted = (result.get("vodPlaylistedPlaybackUrls") or {}).get("result") or {} + playback_urls = playlisted.get("playbackUrls") or {} + playlist_items = playback_urls.get("intraTitlePlaylist") or [] + + if playlist_items: + main_item = next((p for p in playlist_items if p.get("type") == "Main"), None) + if not main_item and playlist_items: + main_item = playlist_items[0] + if main_item: + urls = main_item.get("urls") or [] + # Try CDN preference order + for preferred in cdn_preference: + for u in urls: + if isinstance(u, dict) and u.get("cdn", "").lower() == preferred and u.get("url"): + return MGMP._clean_mpd_url(u["url"]) + # Fallback to first available URL + for u in urls: + if isinstance(u, dict) and u.get("url"): + return MGMP._clean_mpd_url(u["url"]) + + # Legacy format (vodPlaybackUrlsRequest response) + vod_urls = (result.get("vodPlaybackUrls") or {}).get("result") or {} + legacy_urls = vod_urls.get("playbackUrls") or {} + url_sets = legacy_urls.get("urlSets") or [] + + if isinstance(url_sets, list): + for url_set in url_sets: + if isinstance(url_set, dict) and url_set.get("url"): + return MGMP._clean_mpd_url(url_set["url"]) + + if isinstance(url_sets, dict): + for url_set in url_sets.values(): + if isinstance(url_set, dict): + if url_set.get("url"): + return MGMP._clean_mpd_url(url_set["url"]) + for cdn_info in (url_set.get("urls") or {}).values(): + if isinstance(cdn_info, dict) and cdn_info.get("url"): + return MGMP._clean_mpd_url(cdn_info["url"]) + + return None + + @staticmethod + def _clean_mpd_url(mpd_url: str) -> str: + """Clean up an Amazon MPD manifest URL by stripping custom/dm segments.""" + from urllib.parse import urlparse, urlunparse + + if "custom=true" in mpd_url: + return mpd_url + mpd_url = re.sub(r".@[^/]+/|custom=true&", "", mpd_url) + try: + parsed_url = urlparse(mpd_url) + new_path = "/".join( + segment for segment in parsed_url.path.split("/") if not any(sub in segment for sub in ["$", "dm"]) + ) + return urlunparse(parsed_url._replace(path=new_path)) + except Exception: + return mpd_url + + def _get_amazon_widevine_license(self, challenge: bytes, amazon_ctx: dict) -> bytes: + """Get a Widevine license from Amazon's DRM endpoint.""" + amazon_config = self.config["amazon"] + playback_id = amazon_ctx["playback_id"] + + response = self.session.post( + url=f"{amazon_config['base']}/playback/drm-vod/GetWidevineLicense", + params={ + "deviceID": self.amazon_device_id, + "deviceTypeID": amazon_config["device_type"], + "gascEnabled": "false", + "marketplaceID": amazon_config["marketplace"], + "uxLocale": "en_US", + "firmware": 1, + "titleId": playback_id, + "nerid": self._generate_nerid(0), + }, + headers={ + "Accept": "*/*", + "Content-Type": "text/plain", + "x-atv-client-type": "XpPlayer", + "Origin": "https://www.mgmplus.com", + "Referer": "https://www.mgmplus.com/", + }, + json={ + "includeHdcpTestKey": True, + "licenseChallenge": base64.b64encode(challenge).decode(), + "playbackEnvelope": amazon_ctx["playback_envelope"], + "sessionHandoffToken": amazon_ctx["session_handoff_token"], + }, + ) + response.raise_for_status() + result = response.json() + license_b64 = (result.get("widevineLicense") or {}).get("license") + if not license_b64: + raise ValueError(f"Amazon returned no Widevine license: {result}") + return base64.b64decode(license_b64) + + @staticmethod + def _generate_nerid(e: int) -> str: + """Generate a network edge request ID (Amazon format).""" + base64_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + timestamp = int(time.time() * 1000) + epoch_chars = [] + for _ in range(7): + epoch_chars.append(base64_chars[timestamp % 64]) + timestamp //= 64 + base64_epoch = "".join(reversed(epoch_chars)) + random_bytes = os.urandom(15) + random_chars = [base64_chars[b % 64] for b in random_bytes] + random_part = "".join(random_chars) + suffix = f"{e % 100:02d}" + return base64_epoch + random_part + suffix + + def _select_best_stream(self, streams: list[dict]) -> Optional[dict]: + """Select the highest resolution DASH stream with Widevine (any encryption scheme).""" + dash_wv = [ + s for s in streams if s.get("packagingSystem") == "DASH" and s.get("playlistUrl") and s.get("widevine") + ] + if not dash_wv: + return None + return max(dash_wv, key=lambda s: (s.get("videoQuality") or {}).get("height", 0)) + + @staticmethod + @click.command(name="MGMP", short_help="https://www.mgmplus.com") + @click.argument("title", type=str) + @click.pass_context + def cli(ctx, **kwargs): + return MGMP(ctx, **kwargs) + + +__all__ = ("MGMP",) diff --git a/packages/envied/src/envied/services/MGMP/config.yaml b/packages/envied/src/envied/services/MGMP/config.yaml new file mode 100644 index 0000000..4184c85 --- /dev/null +++ b/packages/envied/src/envied/services/MGMP/config.yaml @@ -0,0 +1,43 @@ +endpoints: + sessions: https://api.epixnow.com/v2/sessions + registration_code: https://api.epixnow.com/v2/devices/registration_code + graphql: https://api.epixnow.com/graphql + search: https://api.epixnow.com/v2/search + +headers: + Accept: application/json + User-Agent: okhttp/5.1.0 + +client: + apikey: 592c205299de839aa3948643addfe0bc + device: + app_version: "225.0.2025225001" + device: android + display_height: 2160 + display_width: 3840 + dpi: 320 + format: tv + manufacturer: NVIDIA + model: SHIELD Android TV + os: fire + os_version: "11" + +web: + endpoints: + sessions: https://api.mgmplus.com/v2/sessions + graphql: https://api.mgmplus.com/graphql + apikey: "53e208a9bbaee479903f43b39d7301f7" + device: + format: console + os: web + display_width: 2560 + display_height: 1440 + app_version: "1.0.2" + model: browser + manufacturer: google + device_interface_type: desktop + +amazon: + base: https://q423ce52d0d3f1.na.api.amazonvideo.com + device_type: AOAGZA014O5RE + marketplace: ATVPDKIKX0DER diff --git a/packages/envied/src/envied/services/MGMP/queries.py b/packages/envied/src/envied/services/MGMP/queries.py new file mode 100644 index 0000000..51d703f --- /dev/null +++ b/packages/envied/src/envied/services/MGMP/queries.py @@ -0,0 +1,226 @@ +MOVIE = """ +query Movie($id: ID!) { + movie(id: $id) { + id + title + shortName + releaseYear + synopsis + duration + mpaaRating + underlyingId + genres { name } + labels { text } + descriptiveAudio { available } + } +} +""" + +SERIES = """ +query Series($id: ID!) { + series(id: $id) { + id + title + shortName + shortDescription + mpaaRating + genres { name } + labels { text } + descriptiveAudio { available } + seasons { + nodes { + id + title + number + __typename + } + __typename + } + __typename + } +} +""" + +EPISODES = """ +query Episodes($seasonId: ID!, $first: Int, $after: String) { + episodes(seasonId: $seasonId, first: $first, after: $after) { + pageInfo { + endCursor + hasNextPage + __typename + } + nodes { + id + title + releaseYear + duration + number + description + seasonNumber + shortTitle + rating { value } + series { + shortName + __typename + } + __typename + } + __typename + } +} +""" + +EPISODE = """ +query Episode($seriesShortName: String!, $seasonNumber: Int!, $episodeNumber: Int!) { + episode( + seriesShortName: $seriesShortName + seasonNumber: $seasonNumber + episodeNumber: $episodeNumber + ) { + id + title + releaseYear + duration + number + description + seasonNumber + shortTitle + rating { value } + series { + shortName + __typename + } + __typename + } +} +""" + +PLAY = """ +query Play($id: String!, $context: String, $behavior: BehaviorEnum, $supportedActions: [PlayFlowActionEnum!]!) { + playFlow(id: $id, context: $context, behavior: $behavior, supportedActions: $supportedActions) { + __typename + ... on PlayContent { + type + continuationContext + playheadPosition + streams(types: [ + { packagingSystem: DASH, encryptionScheme: CBCS }, + { packagingSystem: DASH, encryptionScheme: CENC }, + { packagingSystem: HLS, encryptionScheme: NONE } + ]) { + id + internalStreamId + videoQuality { width height } + widevine { authenticationToken licenseServerUrl } + packagingSystem + encryptionScheme + playlistUrl + } + closedCaptions { + vtt { location } + } + currentItem { + content { + __typename + ... on Movie { + id + title + underlyingId + duration + } + ... on Episode { + id + title + underlyingId + duration + } + } + } + hints { + videoId + videoTitle + resourceId + isLive + } + } + ... on ShowNotice { + type + actions { + continuationContext + text + } + } + ... on ContinuePlay { + type + } + ... on LogIn { + type + } + ... on Noop { + type + } + } +} +""" + +WEB_PLAY = """ +query PlayFlow($id: String!, $supportedActions: [PlayFlowActionEnum!]!, $context: String, $behavior: BehaviorEnum = DEFAULT, $streamTypes: [StreamDefinition!]) { + playFlow( + id: $id + supportedActions: $supportedActions + context: $context + behavior: $behavior + ) { + ... on ShowNotice { + type + actions { continuationContext text } + description + title + __typename + } + ... on PlayContent { + type + continuationContext + heartbeatToken + currentItem { + content { + __typename + ... on Movie { id shortName amazonContentId duration } + ... on Episode { + id + series { shortName __typename } + seasonNumber + number + amazonContentId + duration + } + } + } + amazonPlayback { + playbackEnvelope + playbackId + __typename + } + playheadPosition + closedCaptions { + vtt { location __typename } + __typename + } + streams(types: $streamTypes) { + id + playlistUrl + packagingSystem + encryptionScheme + videoQuality { height width __typename } + widevine { authenticationToken licenseServerUrl __typename } + __typename + } + __typename + } + ... on ContinuePlay { type __typename } + ... on LogIn { type __typename } + ... on Noop { type __typename } + __typename + } +} +""" diff --git a/packages/envied/src/envied/services/RKTN/__init__.py b/packages/envied/src/envied/services/RKTN/__init__.py index 012a6e4..de025a7 100644 --- a/packages/envied/src/envied/services/RKTN/__init__.py +++ b/packages/envied/src/envied/services/RKTN/__init__.py @@ -44,7 +44,7 @@ class RKTN(Service): \b Command for Titles with no SDR (if not set range to HDR10 it will fail): - uv run unshackle dl -r HDR10 [OPTIONS] RKTN -m https://www.rakuten.tv/... + uv run envied.dl -r HDR10 [OPTIONS] RKTN -m https://www.rakuten.tv/... \b TODO: - TV Shows are not yet supported as there's 0 TV Shows to purchase, rent, or watch in my region @@ -587,7 +587,7 @@ class RKTN(Service): audio.channels = audio_info.channel_s or "2.0" # Actualizar codec - # Para Unshackle, necesitas mantener el formato correcto + # Para envied. necesitas mantener el formato correcto audio.codec = Audio.Codec.from_codecs(codec_name) # Agregar el track diff --git a/packages/envied/src/envied/services/SEVEN/__init__.py b/packages/envied/src/envied/services/SEVEN/__init__.py index 48d425a..228e9d3 100644 --- a/packages/envied/src/envied/services/SEVEN/__init__.py +++ b/packages/envied/src/envied/services/SEVEN/__init__.py @@ -42,9 +42,9 @@ class SEVEN(Service): \b Examples: - - SERIES: unshackle dl -w s01e01 7plus https://7plus.com.au/ncis-los-angeles - - EPISODE: unshackle dl 7plus https://7plus.com.au/ncis-los-angeles?episode-id=NCIL01-001 - - MOVIE: unshackle dl 7plus --movie https://7plus.com.au/puss-in-boots-the-last-wish + - SERIES: envied.dl -w s01e01 7plus https://7plus.com.au/ncis-los-angeles + - EPISODE: envied.dl 7plus https://7plus.com.au/ncis-los-angeles?episode-id=NCIL01-001 + - MOVIE: envied.dl 7plus --movie https://7plus.com.au/puss-in-boots-the-last-wish """ diff --git a/packages/envied/src/envied/services/SKST/__init__.py b/packages/envied/src/envied/services/SKST/__init__.py new file mode 100644 index 0000000..2616a4e --- /dev/null +++ b/packages/envied/src/envied/services/SKST/__init__.py @@ -0,0 +1,1062 @@ +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import re +import sys +import time +import uuid +from collections import defaultdict +from copy import deepcopy +from http.cookiejar import CookieJar +from typing import Any, Optional, Generator +from urllib.parse import urlparse, urlencode +from zlib import crc32 +from lxml import etree + +import click +from langcodes import Language + +from envied.core.search_result import SearchResult +from envied.core.constants import AnyTrack +from envied.core.credential import Credential +from envied.core.manifests import DASH +from envied.core.service import Service +from envied.core.titles import Movie, Movies, Series, Episode, Title_T, Titles_T +from envied.core.tracks import Chapter, Tracks, Subtitle, Track, Video +from envied.core.utilities import is_close_match + + +class SkySignature: + """SkyShowtime API signature generator.""" + + def __init__(self, app_id: str, signature_key: str, version: str = "1.0"): + self.app_id = app_id + self.signature_key = signature_key.encode('utf-8') + self.sig_version = version + + def calculate_signature(self, method: str, url: str, headers: dict, + payload: bytes = b'', timestamp: Optional[int] = None) -> dict: + if timestamp is None: + timestamp = int(time.time()) + + if url.startswith('http'): + parsed_url = urlparse(url) + path = parsed_url.path + if parsed_url.query: + path += '?' + parsed_url.query + else: + path = url + + text_headers = '' + for key in sorted(headers.keys()): + if key.lower().startswith('x-skyott') or key.lower().startswith('x-showmax'): + text_headers += key.lower() + ': ' + str(headers[key]) + '\n' + + headers_md5 = hashlib.md5(text_headers.encode()).hexdigest() + + if isinstance(payload, str): + payload = payload.encode('utf-8') + payload_md5 = hashlib.md5(payload).hexdigest() + + to_hash = ( + f'{method}\n' + f'{path}\n' + f'\n' + f'{self.app_id}\n' + f'{self.sig_version}\n' + f'{headers_md5}\n' + f'{timestamp}\n' + f'{payload_md5}\n' + ) + + hashed = hmac.new(self.signature_key, to_hash.encode('utf8'), hashlib.sha1).digest() + signature = base64.b64encode(hashed).decode('utf8') + + return { + 'x-sky-signature': f'SkyOTT client="{self.app_id}",signature="{signature}",timestamp="{timestamp}",version="{self.sig_version}"' + } + + +class SKST(Service): + """ + \b + Service code for SkyShowtime streaming service (https://skyshowtime.com). + + \b + Author: FairTrade + Authorization: Cookies or Credentials + Robustness: + Widevine: + L3: 1080p + + \b + Tips: + - Use -t/--territory to specify your region (e.g., -t ES for Spain) + - Use -p/--profile to select a specific profile by name or ID + - Use --list-profiles to see all available profiles + """ + + ALIASES = ("skyshowtime", "sst") + TITLE_RE = r"^(?:https?://(?:www\.)?skyshowtime\.com)?/(?:[a-z]{2}/)?watch/asset/(?P<type>tv|movies?)/(?P<slug>[a-z0-9-]+)/(?P<uuid>[a-f0-9-]+).*$" + + @staticmethod + @click.command(name="SKST", short_help="https://skyshowtime.com", help=__doc__) + @click.argument("title", type=str) + @click.option("-t", "--territory", type=str, default="PL", help="Territory code (e.g., PL, NL, ES)") + @click.option("-p", "--profile", type=str, default=None, help="Profile name or ID to use") + @click.option("--list-profiles", is_flag=True, default=False, help="List all available profiles and exit") + @click.pass_context + def cli(ctx, **kwargs): + return SKST(ctx, **kwargs) + + def __init__(self, ctx, title: str, territory: str = "PL", profile: Optional[str] = None, list_profiles: bool = False): + super().__init__(ctx) + + self.territory = territory.upper() + self.language = self._get_language_for_territory(territory) + self.requested_profile = profile + self.list_profiles_only = list_profiles + + # Initialize signature generator from config + sig_config = self.config.get("signature", {}) + self.signer = SkySignature( + app_id=sig_config.get("app_id", "SHOWMAX-ANDROID-v1"), + signature_key=sig_config.get("key", ""), + version=sig_config.get("version", "1.0") + ) + + m = re.match(self.TITLE_RE, title, re.IGNORECASE) + if not m: + self.search_term = title + self.title_url = None + self.content_slug = None + self.content_uuid = None + self.content_type = None + return + + content_type = m.group("type").lower() + self.content_type = "movie" if content_type.startswith("movie") else "tv" + self.content_slug = m.group("slug") + self.content_uuid = m.group("uuid") + self.title_url = title + self.search_term = None + + self.user_token: Optional[str] = None + self.device_id: Optional[str] = None + self.persona_id: Optional[str] = None + self.persona_data: Optional[dict] = None + self.all_personas: list[dict] = [] + + self.drm_license_url: Optional[str] = None + self.license_token: Optional[str] = None + + self.cdm = ctx.obj.cdm + _vcodec = ctx.parent.params.get("vcodec") + self.vcodec = "H265" if _vcodec and "HEVC" in str(_vcodec) else "H264" + + def _get_language_for_territory(self, territory: str) -> str: + territory_languages = { + "PL": "pl-PL", "NL": "nl-NL", "ES": "es-ES", "PT": "pt-PT", + "SE": "sv-SE", "NO": "nb-NO", "DK": "da-DK", "FI": "fi-FI", + "CZ": "cs-CZ", "SK": "sk-SK", "HU": "hu-HU", "RO": "ro-RO", + "BG": "bg-BG", "HR": "hr-HR", "SI": "sl-SI", "BA": "bs-BA", + "RS": "sr-RS", "ME": "sr-ME", "MK": "mk-MK", "AL": "sq-AL", + } + return territory_languages.get(territory.upper(), "en-US") + + def _get_common_headers(self) -> dict: + return { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:138.0) Gecko/20100101 Firefox/138.0", + "Accept": "*/*", + "Accept-Language": "en-US,en;q=0.5", + "Origin": "https://www.skyshowtime.com", + "Referer": "https://www.skyshowtime.com/", + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-site", + } + + def _get_skyott_headers(self, extra: Optional[dict] = None) -> dict: + params = self.config.get("params", {}) + headers = { + "X-SkyOTT-Provider": params.get("provider", "SKYSHOWTIME"), + "X-SkyOTT-Territory": self.territory, + "X-SkyOTT-Proposition": params.get("proposition", "SKYSHOWTIME"), + "X-SkyOTT-Platform": params.get("platform", "PC"), + "X-SkyOTT-Device": params.get("device", "COMPUTER"), + "X-SkyOTT-ActiveTerritory": self.territory, + } + if extra: + headers.update(extra) + return headers + + def _get_atom_headers(self) -> dict: + params = self.config.get("params", {}) + headers = self._get_common_headers() + headers.update(self._get_skyott_headers({ + "X-SkyOTT-Language": "en-US", + "X-SkyOTT-Client-Version": params.get("client_version", "6.11.21-gsp"), + })) + return headers + + def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None: + super().authenticate(cookies, credential) + + self.device_id = self._get_cookie_value(cookies, "deviceid") or str(uuid.uuid4()) + + if cookies: + sky_umv = self._get_cookie_value(cookies, "skyUMV") + if sky_umv: + self.user_token = sky_umv + self.log.info("Using existing session from cookies.") + else: + raise PermissionError("skyUMV cookie not found. Please provide fresh cookies.") + elif credential: + self._authenticate_with_credentials(credential) + self._get_user_token() + else: + raise PermissionError("SKST requires either cookies or credentials for authentication.") + + self._fetch_personas() + + if self.list_profiles_only: + self._display_profiles() + sys.exit(0) + + self._select_profile() + + self.log.info("SkyShowtime authentication successful.") + + def _get_cookie_value(self, cookies: Optional[CookieJar], name: str) -> Optional[str]: + if not cookies: + return None + for cookie in cookies: + if cookie.name == name: + return cookie.value + return None + + def _authenticate_with_credentials(self, credential: Credential) -> None: + self.log.info(f"Logging in as {credential.username}...") + + signin_url = self.config["endpoints"]["signin"] + + headers = self._get_common_headers() + headers.update({ + "Accept": "application/vnd.siren+json", + "Content-Type": "application/x-www-form-urlencoded", + }) + headers.update(self._get_skyott_headers()) + + r = self.session.post(signin_url, headers=headers, data=urlencode({ + "userIdentifier": credential.username, + "password": credential.password, + "rememberMe": "true", + "isWeb": "true" + })) + + if r.status_code != 200: + raise PermissionError(f"Sign-in failed: {r.status_code} - {r.text}") + + signin_response = r.json() + + if signin_response.get("class") != ["success"]: + raise PermissionError(f"Sign-in failed: {signin_response}") + + if "properties" in signin_response and "data" in signin_response["properties"]: + self.device_id = signin_response["properties"]["data"].get("deviceid", self.device_id) + + self.log.debug(f"Sign-in successful. Device ID: {self.device_id}") + + def _get_user_token(self) -> None: + token_url = self.config["endpoints"]["tokens"] + params = self.config.get("params", {}) + + skyott_headers = self._get_skyott_headers({ + "X-SkyOTT-Language": self.language, + }) + + payload = { + "auth": { + "authScheme": "MESSO", + "authIssuer": "NOWTV", + "provider": params.get("provider", "SKYSHOWTIME"), + "providerTerritory": self.territory, + "proposition": params.get("proposition", "SKYSHOWTIME") + }, + "device": { + "type": params.get("device", "COMPUTER"), + "platform": params.get("platform", "PC"), + "id": self.device_id[:20] if self.device_id else str(uuid.uuid4())[:20], + "drmDeviceId": "UNKNOWN" + } + } + + payload_str = json.dumps(payload, separators=(',', ':')) + + sig_result = self.signer.calculate_signature( + method="POST", + url=token_url, + headers=skyott_headers, + payload=payload_str.encode('utf-8') + ) + + headers = self._get_common_headers() + headers.update({ + "Accept": "application/vnd.tokens.v1+json", + "Content-Type": "application/vnd.tokens.v1+json", + }) + headers.update(skyott_headers) + headers.update(sig_result) + + r = self.session.post(token_url, headers=headers, data=payload_str) + + if r.status_code != 200: + self.log.warning(f"Token request failed: {r.status_code}") + return + + token_data = r.json() + self.user_token = token_data.get("userToken") + + if self.user_token: + self.log.debug("User token obtained successfully.") + + def _fetch_personas(self) -> None: + persona_url = self.config["endpoints"]["personas"] + params = self.config.get("params", {}) + + query_params = { + "personaType": "Adult", + "in_setup": "false" + } + + headers = self._get_common_headers() + headers.update(self._get_skyott_headers({ + "X-SkyOTT-Language": "en-US", + "X-SkyOTT-Client-Version": params.get("client_version", "6.11.21-gsp"), + })) + headers["Accept"] = "application/json" + headers["content-type"] = "application/json" + + r = self.session.post(persona_url, headers=headers, params=query_params) + + if r.status_code != 200: + self.log.warning(f"Failed to get personas: {r.status_code}") + return + + persona_data = r.json() + self.all_personas = persona_data.get("personas", []) + + def _display_profiles(self) -> None: + if not self.all_personas: + self.log.info("No profiles available.") + return + + self.log.info("\n" + "=" * 60) + self.log.info("Available Profiles:") + self.log.info("=" * 60) + + for idx, persona in enumerate(self.all_personas, 1): + display_name = persona.get("displayName", "Unknown") + persona_id = persona.get("id", "N/A") + is_account_holder = persona.get("isAccountHolder", False) + + controls = persona.get("controls", {}) + maturity_rating = controls.get("maturityRatingLabel", controls.get("maturityRating", "N/A")) + + holder_badge = " [Account Holder]" if is_account_holder else "" + + self.log.info(f" [{idx}] {display_name}{holder_badge}") + self.log.info(f" ID: {persona_id}") + self.log.info(f" Maturity Rating: {maturity_rating}") + + self.log.info("=" * 60) + + def _select_profile(self) -> None: + if not self.all_personas: + return + + selected_persona = None + + if self.requested_profile: + for persona in self.all_personas: + if persona.get("displayName", "").lower() == self.requested_profile.lower(): + selected_persona = persona + break + + if not selected_persona: + for persona in self.all_personas: + if persona.get("id") == self.requested_profile: + selected_persona = persona + break + + if not selected_persona: + try: + idx = int(self.requested_profile) - 1 + if 0 <= idx < len(self.all_personas): + selected_persona = self.all_personas[idx] + except ValueError: + pass + + if not selected_persona: + self._display_profiles() + raise ValueError(f"Profile '{self.requested_profile}' not found.") + else: + selected_persona = self.all_personas[0] + + self.persona_id = selected_persona.get("id") + self.persona_data = selected_persona + + display_name = selected_persona.get("displayName", "Unknown") + self.log.info(f"Using profile: {display_name}") + + def get_titles(self) -> Titles_T: + if not self.content_uuid: + raise ValueError("No content UUID found.") + + headers = self._get_atom_headers() + atom_url = self.config["endpoints"]["atom_node"] + + if self.content_type == "movie": + slug_paths = [ + f"/movies/{self.content_slug}/{self.content_uuid}", + f"/movie/{self.content_slug}/{self.content_uuid}", + f"/film/{self.content_slug}/{self.content_uuid}", + ] + else: + slug_paths = [ + f"/tv/{self.content_slug}/{self.content_uuid}", + ] + + data = None + for slug_path in slug_paths: + params = {"slug": slug_path} + r = self.session.get(atom_url, headers=headers, params=params) + + if r.status_code == 200: + data = r.json() + break + + if not data: + alt_url = f"{atom_url}/provider_variant_id/{self.content_uuid}" + r = self.session.get(alt_url, headers=headers) + + if r.status_code == 200: + data = r.json() + else: + uuid_url = f"{atom_url}/uuid/{self.content_uuid}" + r = self.session.get(uuid_url, headers=headers) + + if r.status_code == 200: + data = r.json() + else: + raise RuntimeError(f"Failed to get content details. UUID: {self.content_uuid}") + + content_type = data.get("type", "") + + if "SERIES" in content_type: + return self._parse_series(data) + elif "MOVIE" in content_type or "FILM" in content_type: + return self._parse_movie(data) + else: + attrs = data.get("attributes", {}) + if attrs.get("availableSeasonCount") or attrs.get("availableEpisodeCount"): + return self._parse_series(data) + else: + return self._parse_movie(data) + + def _parse_movie(self, data: dict) -> Movies: + attrs = data.get("attributes", {}) + + title = attrs.get("title", attrs.get("titleMedium", "Unknown Title")) + year = attrs.get("year") + + formats = attrs.get("formats", {}) + content_id = None + + for fmt_key in ["UHDHDR", "UHD4K", "HDSDR", "HD", "SD"]: + if fmt_key in formats: + fmt_data = formats[fmt_key] + if "contentId" in fmt_data: + content_id = fmt_data["contentId"] + break + + if not content_id: + for fmt_key, fmt_data in formats.items(): + if isinstance(fmt_data, dict) and "contentId" in fmt_data: + content_id = fmt_data["contentId"] + break + + provider_variant_id = attrs.get("providerVariantId", attrs.get("programmeUuid", self.content_uuid)) + + if not content_id: + content_id = data.get("id") + + original_lang = attrs.get("mainOriginalLanguage", attrs.get("productionLanguage", "en")) + if "-" in str(original_lang): + original_lang = original_lang.split("-")[0] + + return Movies([ + Movie( + id_=content_id, + service=self.__class__, + name=title, + year=year, + language=Language.get(original_lang), + data={ + "content_id": content_id, + "provider_variant_id": provider_variant_id, + "attrs": attrs + } + ) + ]) + + def _parse_series(self, data: dict) -> Series: + attrs = data.get("attributes", {}) + + series_title = attrs.get("title", attrs.get("titleMedium", "Unknown Series")) + series_uuid = attrs.get("seriesUuid", attrs.get("providerSeriesId", self.content_uuid)) + + original_lang = attrs.get("mainOriginalLanguage", attrs.get("productionLanguage", "en")) + if "-" in str(original_lang): + original_lang = original_lang.split("-")[0] + + episodes = self._fetch_all_episodes(series_uuid, series_title, original_lang) + + return Series(episodes) + + def _fetch_all_episodes(self, series_uuid: str, series_title: str, original_lang: str) -> list[Episode]: + episodes = [] + + atom_url = f"{self.config['endpoints']['atom_node']}/provider_series_id/{series_uuid}" + + params = { + "slug": f"/tv/{self.content_slug}/{series_uuid}", + "represent": "(items(items),recs[take=8],collections(items(items[take=8])),trailers)" + } + + headers = self._get_atom_headers() + r = self.session.get(atom_url, headers=headers, params=params) + + if r.status_code != 200: + self.log.warning(f"Failed to fetch series details: {r.status_code}") + return episodes + + data = r.json() + + relationships = data.get("relationships", {}) + items = relationships.get("items", {}).get("data", []) + + for season_data in items: + if season_data.get("type") != "CATALOGUE/SEASON": + continue + + season_attrs = season_data.get("attributes", {}) + season_number = season_attrs.get("seasonNumber", 1) + + season_relationships = season_data.get("relationships", {}) + season_items = season_relationships.get("items", {}).get("data", []) + + for ep_data in season_items: + if ep_data.get("type") != "ASSET/EPISODE": + continue + + ep_attrs = ep_data.get("attributes", {}) + ep_number = ep_attrs.get("episodeNumber", 1) + ep_title = ep_attrs.get("title", ep_attrs.get("episodeName", f"Episode {ep_number}")) + + formats = ep_attrs.get("formats", {}) + content_id = None + for fmt_key, fmt_data in formats.items(): + if isinstance(fmt_data, dict) and "contentId" in fmt_data: + content_id = fmt_data["contentId"] + break + + provider_variant_id = ep_attrs.get("providerVariantId", ep_attrs.get("programmeUuid")) + + episodes.append( + Episode( + id_=content_id or ep_data.get("id"), + service=self.__class__, + title=series_title, + season=season_number, + number=ep_number, + name=ep_title, + language=Language.get(original_lang), + data={ + "content_id": content_id, + "provider_variant_id": provider_variant_id, + "attrs": ep_attrs + } + ) + ) + + return episodes + + def get_tracks(self, title: Title_T) -> Tracks: + content_id = title.data.get("content_id") + provider_variant_id = title.data.get("provider_variant_id") + + if not content_id: + raise ValueError("No content_id found for this title") + + want_uhd = self.vcodec == "H265" + vcodec_str = "H265" if want_uhd else "H264" + if self.content_type == "movie" and ":" in content_id: + if want_uhd and "_UHD" not in content_id and "_HDSDR" not in content_id: + content_id = content_id + "_UHDHDR" + elif not want_uhd and "_HDSDR" not in content_id and "_UHD" not in content_id: + content_id = content_id + "_HDSDR" + + playback_url = self.config["endpoints"]["playback"] + + skyott_headers = self._get_skyott_headers({ + "X-SkyOTT-PinOverride": "false", + "X-SkyOTT-UserToken": self.user_token, + "X-SkyOTT-COPPA": "false", + "X-SkyOTT-JourneyContext": "PRE_FETCH", + "X-SkyOTT-PrePlayout": "true", + "X-SkyOTT-Language": "en-US", + }) + + attrs = title.data.get("attrs", {}) + if attrs.get("isKidsContent", False): + skyott_headers["X-SkyOTT-COPPA"] = "true" + + persona_maturity = "9" + if self.persona_data: + controls = self.persona_data.get("controls", {}) + persona_maturity = controls.get("maturityRating", "9") + + payload = { + "device": { + "capabilities": [ + { + "protection": "WIDEVINE", + "container": "ISOBMFF", + "transport": "DASH", + "acodec": "AAC", + "vcodec": vcodec_str + }, + { + "protection": "NONE", + "container": "ISOBMFF", + "transport": "DASH", + "acodec": "AAC", + "vcodec": vcodec_str + } + ], + "maxVideoFormat": "UHD" if want_uhd else "HD", + "supportedColourSpaces": ["HDR10", "HLG", "DOLBY_VISION", "SDR"] if want_uhd else ["SDR"], + "model": "PC", + "hdcpEnabled": True + }, + "client": { + "thirdParties": ["FREEWHEEL", "MEDIATAILOR", "CONVIVA"], + "variantCapable": True + }, + "contentId": content_id, + "providerVariantId": provider_variant_id, + "parentalControlPin": None, + "personaParentalControlRating": persona_maturity + } + + payload_str = json.dumps(payload, separators=(',', ':')) + + sig_result = self.signer.calculate_signature( + method="POST", + url=playback_url, + headers=skyott_headers, + payload=payload_str.encode('utf-8') + ) + + headers = self._get_common_headers() + headers.update({ + "Accept": "application/vnd.playvod.v1+json", + "Content-Type": "application/vnd.playvod.v1+json", + }) + headers.update(skyott_headers) + headers.update(sig_result) + + r = self.session.post(playback_url, headers=headers, data=payload_str) + + if r.status_code != 200: + raise RuntimeError(f"Failed to get playback info: {r.status_code} - {r.text}") + + playback_data = r.json() + + asset = playback_data.get("asset", {}) + endpoints = asset.get("endpoints", []) + + manifest_url = None + for endpoint in endpoints: + if endpoint.get("cdn") == "CLOUDFRONT": + manifest_url = endpoint.get("url") + break + + if not manifest_url and endpoints: + manifest_url = endpoints[0].get("url") + + if not manifest_url: + raise ValueError("No manifest URL found in playback response") + + protection = playback_data.get("protection", {}) + self.drm_license_url = protection.get("licenceAcquisitionUrl") + self.license_token = protection.get("licenceToken") + + manifest_url = manifest_url + "&audio=all&subtitle=all" + + dash = DASH.from_url(manifest_url, session=self.session) + tracks = dash.to_tracks(language=title.language) + + colour_space = playback_data.get("asset", {}).get("format", {}).get("colourSpace", "") + range_map = { + "HDR10": Video.Range.HDR10, + "HLG": Video.Range.HLG, + "DOLBY_VISION": Video.Range.DV, + "DV": Video.Range.DV, + } + forced_range = range_map.get(colour_space) + if forced_range: + for video_track in tracks.videos: + video_track.range = forced_range + + return tracks + + @staticmethod + def _process_subtitles(dash: DASH, language: str) -> list[Subtitle]: + subtitle_groups = defaultdict(list) + manifest = dash.manifest + # Define namespace map for DASH MPD + nsmap = { + 'mpd': 'urn:mpeg:dash:schema:mpd:2011', + 'cenc': 'urn:mpeg:cenc:2013', + } + + # Try to find periods with and without namespace + periods = manifest.findall("Period", namespaces=None) + if not periods: + periods = manifest.findall("{urn:mpeg:dash:schema:mpd:2011}Period") + if not periods: + # Try xpath with namespace + periods = manifest.xpath("//mpd:Period", namespaces={'mpd': 'urn:mpeg:dash:schema:mpd:2011'}) + if not periods: + # Last resort: find all Period elements regardless of namespace + periods = manifest.iter() + periods = [el for el in manifest.iter() if el.tag.endswith('Period')] + + for period in periods: + # Find AdaptationSets - try multiple methods + adapt_sets = period.findall("AdaptationSet", namespaces=None) + if not adapt_sets: + adapt_sets = period.findall("{urn:mpeg:dash:schema:mpd:2011}AdaptationSet") + if not adapt_sets: + adapt_sets = [el for el in period.iter() if el.tag.endswith('AdaptationSet')] + + for adapt_set in adapt_sets: + content_type = adapt_set.get("contentType", "") + mime_type = adapt_set.get("mimeType", "") + lang = adapt_set.get("lang") + + # Check if this is a text/subtitle adaptation set + is_text = ( + content_type == "text" or + "text/vtt" in mime_type or + "application/ttml" in mime_type or + adapt_set.get("group") == "3" # Based on your MPD, group 3 is subtitles + ) + + if not is_text or not lang: + continue + + # Find Role element + role = adapt_set.find("Role", namespaces=None) + if role is None: + role = adapt_set.find("{urn:mpeg:dash:schema:mpd:2011}Role") + if role is None: + for el in adapt_set.iter(): + if el.tag.endswith('Role'): + role = el + break + + # Find Label element + label = adapt_set.find("Label", namespaces=None) + if label is None: + label = adapt_set.find("{urn:mpeg:dash:schema:mpd:2011}Label") + if label is None: + for el in adapt_set.iter(): + if el.tag.endswith('Label'): + label = el + break + + # Also check for Label attribute (some MPDs use it as attribute) + label_text = "" + if label is not None and label.text: + label_text = label.text + elif adapt_set.get("Label"): + label_text = adapt_set.get("Label") + + role_value = role.get("value") if role is not None else "subtitle" + + key = (lang, role_value, label_text) + subtitle_groups[key].append((period, adapt_set)) + + final_tracks = [] + for (lang, role_value, label_text), adapt_set_group in subtitle_groups.items(): + first_period, first_adapt = adapt_set_group[0] + + # Find Representation + rep = first_adapt.find("Representation", namespaces=None) + if rep is None: + rep = first_adapt.find("{urn:mpeg:dash:schema:mpd:2011}Representation") + if rep is None: + for el in first_adapt.iter(): + if el.tag.endswith('Representation'): + rep = el + break + + if rep is None: + continue + + s_elements_with_context = [] + for _, adapt_set in adapt_set_group: + # Find Representation in this adapt_set + current_rep = adapt_set.find("Representation", namespaces=None) + if current_rep is None: + current_rep = adapt_set.find("{urn:mpeg:dash:schema:mpd:2011}Representation") + if current_rep is None: + for el in adapt_set.iter(): + if el.tag.endswith('Representation'): + current_rep = el + break + + if current_rep is None: + continue + + # Find SegmentTemplate - check both Representation and AdaptationSet level + template = None + for parent in [current_rep, adapt_set]: + template = parent.find("SegmentTemplate", namespaces=None) + if template is None: + template = parent.find("{urn:mpeg:dash:schema:mpd:2011}SegmentTemplate") + if template is None: + for el in parent.iter(): + if el.tag.endswith('SegmentTemplate'): + template = el + break + if template is not None: + break + + if template is None: + continue + + # Find SegmentTimeline + timeline = template.find("SegmentTimeline", namespaces=None) + if timeline is None: + timeline = template.find("{urn:mpeg:dash:schema:mpd:2011}SegmentTimeline") + if timeline is None: + for el in template.iter(): + if el.tag.endswith('SegmentTimeline'): + timeline = el + break + + if timeline is not None: + start_num = int(template.get("startNumber", 0)) + + # Find S elements + s_elements = timeline.findall("S", namespaces=None) + if not s_elements: + s_elements = timeline.findall("{urn:mpeg:dash:schema:mpd:2011}S") + if not s_elements: + s_elements = [el for el in timeline.iter() if el.tag.endswith('}S') or el.tag == 'S'] + + s_elements_with_context.extend((start_num, s_elem) for s_elem in s_elements) + + if not s_elements_with_context: + # No timeline found, but we might still have a valid subtitle track + # Continue with empty timeline handling + pass + + s_elements_with_context.sort(key=lambda x: x[0]) + + combined_adapt = deepcopy(first_adapt) + + # Find combined_rep + combined_rep = combined_adapt.find("Representation", namespaces=None) + if combined_rep is None: + combined_rep = combined_adapt.find("{urn:mpeg:dash:schema:mpd:2011}Representation") + if combined_rep is None: + for el in combined_adapt.iter(): + if el.tag.endswith('Representation'): + combined_rep = el + break + + if combined_rep is None: + continue + + # Find or create SegmentTemplate + seg_template = None + for parent in [combined_rep, combined_adapt]: + seg_template = parent.find("SegmentTemplate", namespaces=None) + if seg_template is None: + seg_template = parent.find("{urn:mpeg:dash:schema:mpd:2011}SegmentTemplate") + if seg_template is None: + for el in parent.iter(): + if el.tag.endswith('SegmentTemplate'): + seg_template = el + break + if seg_template is not None: + break + + if seg_template is None: + # Try to find at AdaptationSet level and move to Representation + template_at_adapt = None + for el in combined_adapt.iter(): + if el.tag.endswith('SegmentTemplate'): + template_at_adapt = el + break + + if template_at_adapt is not None: + seg_template = deepcopy(template_at_adapt) + combined_rep.append(seg_template) + try: + combined_adapt.remove(template_at_adapt) + except ValueError: + pass + else: + continue + + # Remove existing SegmentTimeline if present + existing_timeline = None + for el in seg_template.iter(): + if el.tag.endswith('SegmentTimeline'): + existing_timeline = el + break + + if existing_timeline is not None: + try: + seg_template.remove(existing_timeline) + except ValueError: + pass + + # Create new timeline with collected S elements + if s_elements_with_context: + new_timeline = etree.Element("SegmentTimeline") + new_timeline.extend(deepcopy(s) for _, s in s_elements_with_context) + seg_template.append(new_timeline) + + seg_template.set("startNumber", "0") + if "endNumber" in seg_template.attrib: + del seg_template.attrib["endNumber"] + + track_id = hex(crc32(f"sub-{lang}-{role_value}-{label_text}".encode()) & 0xFFFFFFFF)[2:] + lang_obj = Language.get(lang) + track_name = "Original" if (language and is_close_match(lang_obj, [language])) else lang_obj.display_name() + + # Determine codec from mimeType + mime_type = first_adapt.get("mimeType", "text/vtt") + if "ttml" in mime_type.lower(): + codec = Subtitle.Codec.TimedTextMarkupLang + else: + codec = Subtitle.Codec.WebVTT + + final_tracks.append( + Subtitle( + id_=track_id, + url=dash.url, + codec=codec, + language=lang_obj, + is_original_lang=bool(language and is_close_match(lang_obj, [language])), + descriptor=Track.Descriptor.DASH, + sdh="sdh" in label_text.lower() or role_value == "caption", + forced="forced" in label_text.lower() or "forced" in role_value.lower(), + name=track_name, + data={ + "dash": { + "manifest": manifest, + "period": first_period, + "adaptation_set": combined_adapt, + "representation": combined_rep, + } + }, + ) + ) + + return final_tracks + + def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> bytes: + if not self.drm_license_url: + raise ValueError("DRM license URL not available.") + + sig_result = self.signer.calculate_signature( + method="POST", + url=self.drm_license_url, + headers={}, + payload=challenge + ) + + headers = self._get_common_headers() + headers.update({ + "Content-Type": "text/plain", + }) + headers.update(sig_result) + + r = self.session.post( + self.drm_license_url, + data=challenge, + headers=headers + ) + + if r.status_code != 200: + raise RuntimeError(f"License request failed: {r.status_code} - {r.text}") + + return r.content + + # def search(self) -> Generator[SearchResult, None, None]: + # if not self.search_term: + # return + + # search_url = self.config["endpoints"]["atom_search"] + + # params = { + # "q": self.search_term, + # "take": 20, + # "skip": 0 + # } + + # headers = self._get_atom_headers() + # r = self.session.get(search_url, headers=headers, params=params) + + # if r.status_code != 200: + # return + + # data = r.json() + # results = data.get("results", data.get("data", [])) + + # for result in results: + # attrs = result.get("attributes", {}) + # content_type = result.get("type", "").lower() + + # title = attrs.get("title", attrs.get("titleMedium", "Unknown")) + # year = attrs.get("year") + # slug = attrs.get("slug", "") + + # if "series" in content_type: + # type_str = "series" + # elif "movie" in content_type or "film" in content_type: + # type_str = "movie" + # else: + # type_str = "unknown" + + # yield SearchResult( + # id_=result.get("id"), + # title=title, + # year=year, + # type_=type_str, + # url=f"https://www.skyshowtime.com{slug}" if slug else None + # ) + + def get_chapters(self, title: Title_T) -> list[Chapter]: + return [] diff --git a/packages/envied/src/envied/services/SKST/changelog_skst.docx b/packages/envied/src/envied/services/SKST/changelog_skst.docx new file mode 100644 index 0000000..e5fbabc Binary files /dev/null and b/packages/envied/src/envied/services/SKST/changelog_skst.docx differ diff --git a/packages/envied/src/envied/services/SKST/config.yaml b/packages/envied/src/envied/services/SKST/config.yaml new file mode 100644 index 0000000..a202614 --- /dev/null +++ b/packages/envied/src/envied/services/SKST/config.yaml @@ -0,0 +1,42 @@ +endpoints: + signin: "https://rango.id.skyshowtime.com/signin/service/international" + tokens: "https://ovp.skyshowtime.com/auth/tokens" + personas: "https://web.clients.skyshowtime.com/bff/personas/v2" + atom_node: "https://atom.skyshowtime.com/adapter-calypso/v3/query/node" + atom_search: "https://atom.skyshowtime.com/adapter-calypso/v3/query/search" + playback: "https://ovp.skyshowtime.com/video/playouts/vod" + +params: + provider: "SKYSHOWTIME" + proposition: "SKYSHOWTIME" + platform: "PC" + device: "COMPUTER" + client_version: "6.11.21-gsp" + +signature: + app_id: "SHOWMAX-ANDROID-v1" + key: "kC2UFjsH6PHrc5ENGfyTgC5bPA7aBVZ4aJAyqBBP" + version: "1.0" + +territories: + - NL + - PL + - ES + - PT + - SE + - NO + - DK + - FI + - CZ + - SK + - HU + - RO + - BG + - HR + - SI + - BA + - RS + - ME + - MK + - AL + - XK \ No newline at end of file diff --git a/packages/envied/src/envied/services/TOD/__init__.py b/packages/envied/src/envied/services/TOD/__init__.py new file mode 100644 index 0000000..1622b58 --- /dev/null +++ b/packages/envied/src/envied/services/TOD/__init__.py @@ -0,0 +1,572 @@ +import re +import html +import json +import click +import base64 +import unicodedata + +from langcodes import Language +from typing import Optional, Union +from http.cookiejar import CookieJar +from urllib.parse import urlparse, parse_qsl, urlencode + +from envied.core.service import Service +from envied.core.constants import AnyTrack +from envied.core.manifests import DASH, HLS +from envied.core.credential import Credential +from envied.core.tracks import Audio, Chapters, Tracks, Video +from envied.core.session import session as create_curl_session +from envied.core.titles import Episode, Movie, Movies, Series, Title_T, Titles_T + + +class TOD(Service): + """ + Service code for TODTV (TR) [https://todtv.com.tr] + Version: 2.0.0 + Author: PREDATOR + Authorization: Cookie + Author Recommendation: Recommend using the "requests" download method. + Security: FHD@L3 [Widevine] + """ + + TITLE_RE = r'^(?:https?://(?:www\.)?todtv\.com\.tr/)?(?:(?P<type>diziler|dizi|film|tod-studios|bein-series|belgesel)/)?(?P<slug>[^/\?]+)(?:/(?P<season>[^/\?]+))?(?:/(?P<episode>[^/\?]+))?' + ID_RE = r'^(?P<prefix>PS|PZ|PT)(?P<id>\d+)$' + PLAYER_CONFIG_RE = r'var\s+playerConfig\s*=\s*(\{[\s\S]*?\});' + SEASONS_RE = r'var\s+seasons\s*=\s*(\[[\s\S]*?\]);' + SEASON_HREF_RE = r'href="([^"]*?(\d+)sezon-[^"]*)"' + + @staticmethod + @click.command(name="TOD", short_help="https://todtv.com.tr", help=__doc__) + @click.argument("title", type=str) + @click.pass_context + def cli(ctx, **kwargs): + return TOD(ctx, **kwargs) + + def __init__(self, ctx, title): + super().__init__(ctx) + self.title = title + self.movie = False + self.content_id = None + self.content_type = None + self.slug = None + self.license_url = None + self.drm_token = None + self.castleblack_token = None + + if (id_match := re.match(self.ID_RE, self.title)): + self.content_id = self.title + prefix = id_match.group("prefix") + self.content_type = {"PS": "series", "PZ": "season", "PT": "episode"}.get(prefix) + if prefix == "PT": + self.movie = True + elif (url_match := re.match(self.TITLE_RE, self.title)): + self.content_type = url_match.group("type") or "diziler" + self.slug = url_match.group("slug") + if s := url_match.group("season"): + self.slug += f"/{s}" + if e := url_match.group("episode"): + self.slug += f"/{e}" + if self.content_type == "film": + self.movie = True + else: + self.slug = self.title + self.content_type = "film" if self.movie else "diziler" + + # ──────────────────────────── Auth ──────────────────────────── # + + def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None: + super().authenticate(cookies, credential) + if not cookies: + raise EnvironmentError("TODTV Requires Cookies for Authentication...") + + self.session.headers.update({ + "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", + "Referer": "https://www.todtv.com.tr/", + "X-Requested-With": "XMLHttpRequest", + }) + self._sanitize_session() + self.log.info("Session Authenticated using Cookies...") + + # ──────────────────────────── Titles ──────────────────────────── # + + def get_titles(self) -> Titles_T: + return self._fetch_movie_titles() if self.movie else self._fetch_series_titles() + + def _fetch_movie_titles(self) -> Movies: + if self.content_id and self.content_id.startswith("PT"): + return Movies([Movie( + id_=self.content_id, service=self.__class__, + name=self.content_id, language=Language.get("tr"), + data={"contentId": self.content_id}, + )]) + + url = self._build_url(self.slug, "film") + response = self.session.get(url) + response.raise_for_status() + + data = self._extract_content_info(response.text) + if not data: + raise ValueError(f"Movie Info not Found: {self.slug}") + + return Movies([Movie( + id_=data.get("contentId", self.slug), + service=self.__class__, + name=data.get("title", self.slug), + description=data.get("description", ""), + year=data.get("year"), + language=Language.get("tr"), + data=data, + )]) + + def _fetch_series_titles(self) -> Series: + base_path = self.content_type + response = None + fallback_types = self.config.get("fallback_types", ["diziler", "dizi", "tod-studios", "bein-series", "belgesel"]) + + paths_to_try = [base_path] if base_path else [] + paths_to_try.extend([t for t in fallback_types if t != base_path]) + + for path in paths_to_try: + if not path: + continue + try: + url = self._build_url(self.slug, path) + resp = self.session.get(url) + resp.raise_for_status() + response = resp + base_path = path + break + except Exception: + continue + + if not response: + raise ValueError(f"Series Page not Found: {self.slug}") + + series_data = self._extract_series_data(response.text) + + if not series_data["episodes"] and (ep_link := re.search(rf'href="(/[^/]+/{re.escape(self.slug)}/[^"]+)"', response.text)): + self.log.debug("Trying Data from Episode Page...") + base_url = self.config.get("endpoints", {}).get("base_url", "https://www.todtv.com.tr") + if (ep_resp := self.session.get(f"{base_url}{ep_link.group(1)}")) and ep_resp.status_code == 200: + series_data = self._extract_series_data(ep_resp.text) + + existing_ids = {ep.get("contentId") for ep in series_data["episodes"]} + + sorted_metadata = sorted( + series_data.get("seasons_metadata", []), + key=lambda x: int(x["no"]) if str(x["no"]).isdigit() else 999, + ) + + for meta in sorted_metadata: + s_no, s_id, s_slug = meta["no"], meta["id"], meta.get("slug") + cached_count = sum(1 for ep in series_data["episodes"] if str(ep.get("season")) == str(s_no)) + + if cached_count > 0: + self.log.info(f"Fetching Season {s_no} data (ID: {s_id or 'Cached'})...") + self.log.info(f"Season {s_no}: {cached_count} new episodes added.") + continue + + if not s_id: + continue + + self.log.info(f"Fetching Season {s_no} data (ID: {s_id})...") + + if s_slug: + configs_to_try = [self._build_url(s_slug)] + else: + page_v_ids = set(re.findall(r'\b(v\d+)\b', response.text)) + series_base = self.slug.split('/')[0] + + configs_to_try = [] + for vid in page_v_ids: + configs_to_try.append(self._build_url(f"{series_base}/{s_no}-sezon-{vid}", base_path)) + configs_to_try.append(self._build_url(f"{series_base}/{s_no}sezon-{vid}", base_path)) + + configs_to_try.append(self._build_url(f"{series_base}/{s_no}sezon-{s_id}", base_path)) + + if len(configs_to_try) > 2: + self.log.info(f"Auto-discovering Season {s_no} URL (scanning {len(page_v_ids)} candidates)...") + + found_season = False + for url in configs_to_try: + if found_season: + break + try: + if (s_resp := self.session.get(url)) and s_resp.status_code == 200: + s_data = self._extract_series_data(s_resp.text) + added_count = 0 + for ep in s_data["episodes"]: + if str(ep.get("season")) != str(s_no): + continue + if ep["contentId"] not in existing_ids: + series_data["episodes"].append(ep) + existing_ids.add(ep["contentId"]) + added_count += 1 + if added_count: + self.log.info(f"Season {s_no}: {added_count} new episodes added.") + found_season = True + except Exception: + pass + + if not found_season: + self.log.warning(f"Could not find valid episodes for Season {s_no}") + + if not series_data["episodes"]: + raise ValueError(f"Episode not found: {self.slug}") + + show_name = series_data.get("title", self.slug) + episodes = [ + Episode( + id_=ep["contentId"], + service=self.__class__, + title=show_name, + season=ep["season"], + number=ep["episode"], + name=ep.get("name") or f"Bölüm {ep['episode']}", + description=ep.get("data", {}).get("description", ""), + year=ep.get("data", {}).get("year"), + language=Language.get("tr"), + data=ep["data"], + ) for ep in series_data["episodes"] + ] + + return Series(episodes) + + # ──────────────────────────── Tracks ──────────────────────────── # + + def get_tracks(self, title: Title_T) -> Tracks: + if isinstance(title, Movie): + page_url = title.data.get("pageUrl") or self._build_url(title.data.get("slug") or self.slug, "film") + else: + slug = (title.data or {}).get("slug") or ((title.data or {}).get("customData") or {}).get("slug") + page_url = self._build_url(slug) if slug else None + + if not page_url: + raise ValueError(f"URL not found: {title.id}") + + self.log.info(f"🔗 Content Page: {page_url}") + resp = self.session.get(page_url) + resp.raise_for_status() + + info = self._extract_stream_info(resp.text) + manifest_url = info.get("manifestUrl") + license_url = info.get("licenseUrl") + + if all(info.get(k) for k in ["contentId", "assetId", "usageSpecId"]): + try: + self.log.info("▶ Sending play request (playRequest)...") + play_data = self._play_request(resp.text, info) + + if play_data.get("CdnUrl"): + manifest_url = play_data["CdnUrl"] + self.session.get(manifest_url, allow_redirects=True) + + if play_data.get("DrmTicket"): + self.drm_token = play_data["DrmTicket"].replace("ticket=", "") + if play_data.get("manifestUrl"): + manifest_url = play_data["manifestUrl"] + if play_data.get("licenseUrl"): + license_url = play_data["licenseUrl"] + except Exception as e: + self.log.warning(f"Play request failed: {e}") + + if not manifest_url: + raise ValueError(f"No Manifest URL: {title.id}") + + self.license_url = license_url + self.log.info(f"Manifest URL: {manifest_url}") + + manifest = HLS if "HLS" in manifest_url or manifest_url.endswith(".m3u8") else DASH + tracks = manifest.from_url(manifest_url, self.session).to_tracks(language=title.language) + + parsed_manifest = urlparse(manifest_url) + manifest_query = dict(parse_qsl(parsed_manifest.query)) + + if token := manifest_query.get("hdnts"): + for track in tracks: + if isinstance(track, (Video, Audio)): + t_parsed = urlparse(track.url) + t_query = dict(parse_qsl(t_parsed.query)) + if "hdnts" not in t_query: + t_query["hdnts"] = token + track.url = t_parsed._replace(query=urlencode(t_query)).geturl() + + for track in tracks: + if isinstance(track, (Video, Audio)): + track.license_url = license_url + + self._sanitize_session() + return tracks + + # ──────────────────────────── DRM ──────────────────────────── # + + def get_widevine_service_certificate(self, **_) -> Optional[str]: + return None + + def get_widevine_license(self, *, challenge: bytes, title: Title_T, track: AnyTrack) -> Optional[Union[bytes, str]]: + endpoints = self.config.get("endpoints", {}) + + if self.castleblack_token: + license_url = endpoints.get("license_castleblack", "https://castleblack.digiturk.com.tr/api/widevine/license?version=1.0") + headers = { + "Authorization": f"Bearer {self.castleblack_token}", + "X-CB-Ticket": self.drm_token, + } + else: + if not self.license_url: + raise ValueError("No License URL.") + license_url = self.license_url + headers = {"Content-Type": "application/octet-stream"} + if self.drm_token: + headers["X-ErDRM-Message"] = self.drm_token + + resp = self.session.post(license_url, data=challenge, headers=headers) + + if "<License>" in resp.text: + if match := re.search(r"<License>(.*?)</License>", resp.text): + return base64.b64decode(match.group(1)) + + return resp.content + + def get_chapters(self, title: Title_T) -> Chapters: + return Chapters() + + # ──────────────────────────── Helpers ──────────────────────────── # + + def _sanitize_session(self): + """Normalize non-latin-1 characters in session headers and cookies. + + The requests download worker encodes headers/cookies as latin-1. + Characters like œ (\u0153) in cookie values set by the TOD server cause + UnicodeEncodeError. We strip them here at the source so core + downloaders stay untouched. + """ + for key in list(self.session.headers.keys()): + val = self.session.headers[key] + if isinstance(val, str): + try: + val.encode("latin-1") + except UnicodeEncodeError: + self.session.headers[key] = ( + unicodedata.normalize("NFKD", val) + .encode("ascii", "ignore") + .decode("ascii") + ) + + # curl_cffi.Cookies → .jar / stdlib CookieJar → iterate directly + cookie_jar = getattr(self.session.cookies, "jar", None) or self.session.cookies + for cookie in cookie_jar: + if hasattr(cookie, "value") and isinstance(cookie.value, str): + try: + cookie.value.encode("latin-1") + except UnicodeEncodeError: + cookie.value = ( + unicodedata.normalize("NFKD", cookie.value) + .encode("ascii", "ignore") + .decode("ascii") + ) + + def _play_request(self, html_content: str, info: dict) -> dict: + verify_token = "" + if match := re.search(r'name="__RequestVerificationToken"\s+type="hidden"\s+value="([^"]+)"', html_content): + verify_token = match.group(1) + + form_data = { + "__RequestVerificationToken": verify_token, + "contentId": info["contentId"], + "versionId": info.get("versionId", ""), + "assetId": info["assetId"], + "usageSpecId": info["usageSpecId"], + "contentType": info.get("contentType", "Movie"), + "assetType": info.get("assetType", "MUL"), + "videoType": "1", + "updateWatchingOptions": "True", + "restart": "False", + } + + play_request_url = self.config.get("endpoints", {}).get("play_request", "https://www.todtv.com.tr/content/playRequest") + resp = self.session.post( + play_request_url, + data=form_data, + headers={"X-Requested-With": "XMLHttpRequest"}, + ) + resp.raise_for_status() + + data = resp.json() + if data.get("MessageCode") != 0 and not data.get("CdnUrl"): + raise ValueError(f"PlayRequest error: {data.get('Message', 'Unknown error')} (Code: {data.get('MessageCode')})") + + self.drm_token = (data.get("DrmTicket") or "").replace("ticket=", "") + self.castleblack_token = data.get("CastleBlackToken") + return data + + def _extract_stream_info(self, html_content: str) -> dict: + info = {} + + patterns = { + "contentId": [r'data-cms-id=["\']([^"\']+)["\']', r'data-content-id=["\']([^"\']+)["\']'], + "assetId": [r'data-play-asset-id=["\']([^"\']+)["\']', r'data-id=["\']([^"\']+)["\']'], + "usageSpecId": [r'data-usage-spec=["\']([^"\']+)["\']', r'data-usage-id=["\']([^"\']+)["\']'], + "versionId": [r'data-version-id=["\']([^"\']+)["\']'], + "assetType": [r'data-play-asset-asset-type=["\']([^"\']+)["\']', r'data-assettype=["\']([^"\']+)["\']'], + } + + for key, regexes in patterns.items(): + for regex in regexes: + if match := re.search(regex, html_content): + val = match.group(1) + if "{{" not in val: + info[key] = val + break + + if not info.get("assetId") and (match := re.search(r'data-asset-list=["\'](\[.*?\])["\']', html_content, re.DOTALL)): + if assets := self._parse_json_safe(match.group(1).replace('"', '"')): + asset = assets[0] + info.setdefault("assetId", asset.get("AssetId")) + info.setdefault("usageSpecId", asset.get("UsageSpecId")) + + return info + + def _build_url(self, slug: str, content_type: str = None) -> str: + base_url = self.config.get("endpoints", {}).get("base_url", "https://www.todtv.com.tr") + if slug.startswith("http"): + return slug + if slug.startswith("/"): + return f"{base_url}{slug}" + base = content_type or self.content_type or "diziler" + if base in ("series", "season", "episode"): + base = "diziler" + return f"{base_url}/{base}/{slug}" + + def _clean_title(self, raw_title: str) -> str: + if not raw_title: + return "" + title = html.unescape(raw_title.split("|")[0].strip()) + title = re.sub(r'\s*-\s*\d+\.\s*Sezon.*', '', title, flags=re.IGNORECASE) + title = re.sub(r'\s*-\s*(?:TOD|beIN|Fantastik|Aksiyon|Dram|Komedi|Bilim Kurgu).*', '', title, flags=re.IGNORECASE) + return title.strip() + + @staticmethod + def _parse_json_safe(text: str) -> dict: + try: + return json.loads(re.sub(r'//[^\n]*', '', text)) + except Exception: + return {} + + def _extract_season_slugs(self, html_content: str) -> dict: + slugs = {} + for m in re.finditer(self.SEASON_HREF_RE, html_content): + slugs[int(m.group(2) or m.group(3))] = m.group(1) + return slugs + + def _process_seasons_list(self, seasons: list, info: dict, source: str, seen_ids: set) -> None: + if "seasons_metadata" not in info: + info["seasons_metadata"] = [] + + for s_data in seasons: + s_id = s_data.get("id") + s_no = int(s_data.get("no", 1)) + episodes = s_data.get("episodes") + + if not any(m["id"] == s_id for m in info["seasons_metadata"]): + info["seasons_metadata"].append({ + "id": s_id, + "no": s_no, + "slug": (s_data.get("customData") or {}).get("slug"), + "populated": bool(episodes), + }) + + for ep in episodes or []: + ep_id = ep.get("id") + if ep_id in seen_ids: + continue + seen_ids.add(ep_id) + + info["episodes"].append({ + "contentId": ep_id, + "name": ep.get("title"), + "season": s_no, + "episode": int(ep.get("no", len(info["episodes"]) + 1)), + "slug": (ep.get("customData") or {}).get("slug"), + "data": ep, + }) + + if info["episodes"]: + self.log.debug(f"{len(info['episodes'])} episodes found ({source})") + + def _extract_series_data(self, html_content: str) -> dict: + info = {"episodes": []} + seen_ids = set() + turkish_title = None + + if title_match := re.search(r'<title>([^<]+)', html_content): + turkish_title = title_match.group(1) + + if (match := re.search(self.SEASONS_RE, html_content)) and (seasons := self._parse_json_safe(match.group(1))): + self._process_seasons_list(seasons, info, "window.seasons", seen_ids) + + if match := re.search(self.PLAYER_CONFIG_RE, html_content): + config = self._parse_json_safe(match.group(1)) + if config.get("title"): + turkish_title = config["title"] + if seasons := config.get("seasons") or config.get("seriesSettings", {}).get("seasons"): + self._process_seasons_list(seasons, info, "playerConfig", seen_ids) + + found_slugs = self._extract_season_slugs(html_content) + for meta in info.get("seasons_metadata", []): + if meta["no"] in found_slugs and not meta.get("slug"): + meta["slug"] = found_slugs[meta["no"]] + + original_title = self._extract_original_title_from_breadcrumbs(html_content) + info["title"] = self._format_title(turkish_title, original_title) + return info + + def _extract_original_title_from_breadcrumbs(self, html_content: str) -> Optional[str]: + if match := re.search(r'()', html_content, re.DOTALL): + try: + json_str = match.group(1).replace('', '') + data = json.loads(json_str) + items = data.get("itemListElement", []) + if items: + return items[-1].get("item", {}).get("name") + except Exception: + pass + return None + + def _format_title(self, turkish_title: str, original_title: Optional[str]) -> str: + if not turkish_title: + return "" + turkish_title = self._clean_title(turkish_title) + + if original_title: + original_title = self._clean_title(original_title) + if original_title.lower() != turkish_title.lower(): + safe = unicodedata.normalize("NFKD", original_title).encode("ascii", "ignore").decode("ascii").strip() + return f"{turkish_title} ({safe if safe else original_title})" + + return turkish_title + + def _extract_content_info(self, html_content: str) -> dict: + info = {} + + if match := re.search(self.PLAYER_CONFIG_RE, html_content): + info.update(self._parse_json_safe(match.group(1))) + if match := re.search(r'"contentId"\s*:\s*"([^"]+)"', html_content): + info["contentId"] = match.group(1) + if match := re.search(r'var\s+videoEventObject\s*=\s*(\{[^;]+\});', html_content): + info.update(self._parse_json_safe(match.group(1))) + + turkish_title = info.get("title") + if not turkish_title: + if match := re.search(r'([^<]+)', html_content): + turkish_title = match.group(1) + + original_title = self._extract_original_title_from_breadcrumbs(html_content) + info["title"] = self._format_title(turkish_title, original_title) + return info + + # ──────────────────────────── Session ──────────────────────────── # + @staticmethod + def get_session(): + """Creates a session with retries and browser impersonation.""" + return create_curl_session("chrome124", max_retries=3, status_forcelist=[429, 500, 502, 503, 504]) diff --git a/packages/envied/src/envied/services/TOD/config.yaml b/packages/envied/src/envied/services/TOD/config.yaml new file mode 100644 index 0000000..822a5b7 --- /dev/null +++ b/packages/envied/src/envied/services/TOD/config.yaml @@ -0,0 +1,11 @@ +endpoints: + base_url: "https://www.todtv.com.tr" + play_request: "https://www.todtv.com.tr/content/playRequest" + license_castleblack: "https://castleblack.digiturk.com.tr/api/widevine/license?version=1.0" + +fallback_types: + - diziler + - dizi + - tod-studios + - bein-series + - belgesel \ No newline at end of file diff --git a/packages/envied/src/envied/services/TUBI/__init__.py b/packages/envied/src/envied/services/TUBI/__init__.py index 3a2d50c..c3df3f3 100644 --- a/packages/envied/src/envied/services/TUBI/__init__.py +++ b/packages/envied/src/envied/services/TUBI/__init__.py @@ -25,7 +25,7 @@ class TUBI(Service): Service code for TubiTV streaming service (https://tubitv.com/) \b - Version: 1.0.7 + Version: 1.0.8 Author: stabbedbybrick Authorization: Cookies (Optional) Geofence: Locked to whatever region the user is in (API only) @@ -206,10 +206,12 @@ class TUBI(Service): tracks = Tracks() for codec in ["h264", "h265"]: - resource = next(( - x for x in resources - if self.drm_system in x.get("type", "") and codec in x.get("codec", "").lower() - ), None) + codec_matches = [x for x in resources if codec in x.get("codec", "").lower()] + + resource = next(iter(sorted(codec_matches, key=lambda x: ( + self.drm_system not in x.get("type", ""), + "dash" not in x.get("type", "") + ))), None) if not resource: self.log.warning(f" - Could not find a {codec} video resource for this title") continue diff --git a/packages/envied/src/envied/services/UNXT/README.md b/packages/envied/src/envied/services/UNXT/README.md index 1f2a089..113ba1e 100644 --- a/packages/envied/src/envied/services/UNXT/README.md +++ b/packages/envied/src/envied/services/UNXT/README.md @@ -1,7 +1,7 @@ # U-NEXT(유넥스트) ``` -uv run unshackle dl --list -vl all -al orig -sl all -q 2160 -v h.265 -r HDR10 UNXT SID0087607 +uv run envied.dl --list -vl all -al orig -sl all -q 2160 -v h.265 -r HDR10 UNXT SID0087607 ``` ## Information diff --git a/packages/vinefeeder/README.md b/packages/vinefeeder/README.md index a20061d..3eaf134 100644 --- a/packages/vinefeeder/README.md +++ b/packages/vinefeeder/README.md @@ -59,7 +59,7 @@ First be sure to follow Devine or envied's install and set-up procedure and ensu **If you have installed Devine by any other way than via poetry or by 'pip install devine' then remove it and re-install using the correct method before running VineFeeder! The re-install should pick-up the last configuration. if not re-configure Devine. Make absolutely sure devine can be called from any folder on your system.** -**If you are using envied make sure you have installed the tool version that runs from the command envied. See https://github.com/envied-dl/envied for installation details** +**If you are using envied make sure you have installed the tool version that runs from the command envied.See https://github.com/envied-dl/envied for installation details** **Setup** diff --git a/packages/vinefeeder/src/vinefeeder/README.md b/packages/vinefeeder/src/vinefeeder/README.md index fd5c534..160a02b 100644 --- a/packages/vinefeeder/src/vinefeeder/README.md +++ b/packages/vinefeeder/src/vinefeeder/README.md @@ -64,7 +64,7 @@ First be sure to follow Devine or envied's install and set-up procedure and ensu **If you have installed Devine by any other way than via poetry or by 'pip install devine' then remove it and re-install using the correct method before running VineFeeder! The re-install should pick-up the last configuration. if not re-configure Devine. Make absolutely sure devine can be called from any folder on your system.** -**If you are using envied make sure you have installed the tool version that runs from the command envied. See https://github.com/envied-dl/envied for installation details** +**If you are using envied make sure you have installed the tool version that runs from the command envied.See https://github.com/envied-dl/envied for installation details** **Setup**