diff --git a/StreamingCommunity/Api/Site/altadefinizione/__init__.py b/StreamingCommunity/Api/Site/altadefinizione/__init__.py index 8d9aed57b..7f3c97d50 100644 --- a/StreamingCommunity/Api/Site/altadefinizione/__init__.py +++ b/StreamingCommunity/Api/Site/altadefinizione/__init__.py @@ -71,6 +71,7 @@ def get_user_input(string_to_search: str = None): else: return msg.ask(f"\n[purple]Insert a word to search in [green]{site_constant.SITE_NAME}").strip() + def process_search_result(select_title, selections=None): """ Handles the search result and initiates the download for either a film or series. @@ -109,6 +110,7 @@ def process_search_result(select_title, selections=None): table_show_manager.clear() return True + # search("Game of Thrones", selections={"season": "1", "episode": "1-3"}) def search(string_to_search: str = None, get_onlyDatabase: bool = False, direct_item: dict = None, selections: dict = None): """ diff --git a/StreamingCommunity/Api/Site/animeunity/__init__.py b/StreamingCommunity/Api/Site/animeunity/__init__.py index ccd42dbe5..ab55ae577 100644 --- a/StreamingCommunity/Api/Site/animeunity/__init__.py +++ b/StreamingCommunity/Api/Site/animeunity/__init__.py @@ -71,6 +71,7 @@ def get_user_input(string_to_search: str = None): else: return msg.ask(f"\n[purple]Insert a word to search in [green]{site_constant.SITE_NAME}").strip() + def process_search_result(select_title, selections=None): """ Handles the search result and initiates the download for either a film or series. @@ -108,6 +109,7 @@ def process_search_result(select_title, selections=None): table_show_manager.clear() return True + def search(string_to_search: str = None, get_onlyDatabase: bool = False, direct_item: dict = None, selections: dict = None): """ Main function of the application for search. diff --git a/StreamingCommunity/Api/Site/animeworld/__init__.py b/StreamingCommunity/Api/Site/animeworld/__init__.py index ca9f59f7d..387aa760c 100644 --- a/StreamingCommunity/Api/Site/animeworld/__init__.py +++ b/StreamingCommunity/Api/Site/animeworld/__init__.py @@ -71,6 +71,7 @@ def get_user_input(string_to_search: str = None): else: return msg.ask(f"\n[purple]Insert a word to search in [green]{site_constant.SITE_NAME}").strip() + def process_search_result(select_title, selections=None): """ Handles the search result and initiates the download for either a film or series. @@ -106,6 +107,7 @@ def process_search_result(select_title, selections=None): table_show_manager.clear() return True + def search(string_to_search: str = None, get_onlyDatabase: bool = False, direct_item: dict = None, selections: dict = None): """ Main function of the application for search. diff --git a/StreamingCommunity/Api/Site/crunchyroll/__init__.py b/StreamingCommunity/Api/Site/crunchyroll/__init__.py index 46eb80f37..664578c67 100644 --- a/StreamingCommunity/Api/Site/crunchyroll/__init__.py +++ b/StreamingCommunity/Api/Site/crunchyroll/__init__.py @@ -71,6 +71,7 @@ def get_user_input(string_to_search: str = None): else: return msg.ask(f"\n[purple]Insert a word to search in [green]{site_constant.SITE_NAME}").strip() + def process_search_result(select_title, selections=None): """ Handles the search result and initiates the download for either a film or series. @@ -109,6 +110,7 @@ def process_search_result(select_title, selections=None): table_show_manager.clear() return True + # search("Game of Thrones", selections={"season": "1", "episode": "1-3"}) def search(string_to_search: str = None, get_onlyDatabase: bool = False, direct_item: dict = None, selections: dict = None): """ diff --git a/StreamingCommunity/Api/Site/dmax/__init__.py b/StreamingCommunity/Api/Site/dmax/__init__.py new file mode 100644 index 000000000..00dc54255 --- /dev/null +++ b/StreamingCommunity/Api/Site/dmax/__init__.py @@ -0,0 +1,108 @@ +# 26.11.2025 + +# External library +from rich.console import Console +from rich.prompt import Prompt + + +# Internal utilities +from StreamingCommunity.Api.Template import get_select_title +from StreamingCommunity.Api.Template.config_loader import site_constant +from StreamingCommunity.Api.Template.Class.SearchType import MediaItem + + +# Logic class +from .site import title_search, table_show_manager, media_search_manager +from .series import download_series + + +# Variable +indice = 10 +_useFor = "Serie" +_priority = 0 +_engineDownload = "hls" +_deprecate = False + +msg = Prompt() +console = Console() + + +def get_user_input(string_to_search: str = None): + """ + Asks the user to input a search term. + Handles both Telegram bot input and direct input. + If string_to_search is provided, it's returned directly (after stripping). + """ + if string_to_search is not None: + return string_to_search.strip() + else: + return msg.ask(f"\n[purple]Insert a word to search in [green]{site_constant.SITE_NAME}").strip() + + +def process_search_result(select_title, selections=None): + """ + Handles the search result and initiates the download for either a film or series. + + Parameters: + select_title (MediaItem): The selected media item. Can be None if selection fails. + selections (dict, optional): Dictionary containing selection inputs that bypass manual input + e.g., {'season': season_selection, 'episode': episode_selection} + Returns: + bool: True if processing was successful, False otherwise + """ + if not select_title: + return False + + if select_title.type == 'tv': + season_selection = None + episode_selection = None + + if selections: + season_selection = selections.get('season') + episode_selection = selections.get('episode') + + download_series(select_title, season_selection, episode_selection) + media_search_manager.clear() + table_show_manager.clear() + return True + + +def search(string_to_search: str = None, get_onlyDatabase: bool = False, direct_item: dict = None, selections: dict = None): + """ + Main function of the application for search. + + Parameters: + string_to_search (str, optional): String to search for. Can be passed from run.py. + If 'back', special handling might occur in get_user_input. + get_onlyDatabase (bool, optional): If True, return only the database search manager object. + direct_item (dict, optional): Direct item to process (bypasses search). + selections (dict, optional): Dictionary containing selection inputs that bypass manual input + for series (season/episode). + """ + if direct_item: + select_title = MediaItem(**direct_item) + result = process_search_result(select_title, selections) + return result + + # Get the user input for the search term + actual_search_query = get_user_input(string_to_search) + + # Handle empty input + if not actual_search_query: + return False + + # Search on database + len_database = title_search(actual_search_query) + + # If only the database is needed, return the manager + if get_onlyDatabase: + return media_search_manager + + if len_database > 0: + select_title = get_select_title(table_show_manager, media_search_manager, len_database) + result = process_search_result(select_title, selections) + return result + + else: + console.print(f"\n[red]Nothing matching was found for[white]: [purple]{actual_search_query}") + return False \ No newline at end of file diff --git a/StreamingCommunity/Api/Site/dmax/series.py b/StreamingCommunity/Api/Site/dmax/series.py new file mode 100644 index 000000000..02ef25f9a --- /dev/null +++ b/StreamingCommunity/Api/Site/dmax/series.py @@ -0,0 +1,174 @@ +# 26.11.2025 + +import os +from typing import Tuple + + +# External library +from rich.console import Console +from rich.prompt import Prompt + + +# Internal utilities +from StreamingCommunity.Util.message import start_message +from StreamingCommunity.Util.config_json import config_manager + + +# Logic class +from ..realtime.util.ScrapeSerie import GetSerieInfo +from StreamingCommunity.Api.Template.Util import ( + manage_selection, + map_episode_title, + validate_selection, + validate_episode_selection, + display_episodes_list, + display_seasons_list +) +from StreamingCommunity.Api.Template.config_loader import site_constant +from StreamingCommunity.Api.Template.Class.SearchType import MediaItem + + +# Player +from StreamingCommunity import HLS_Downloader +from ..realtime.util.get_license import get_bearer_token, get_playback_url + + +# Variable +msg = Prompt() +console = Console() +extension_output = config_manager.get("M3U8_CONVERSION", "extension") + + +def download_video(index_season_selected: int, index_episode_selected: int, scrape_serie: GetSerieInfo) -> Tuple[str,bool]: + """ + Downloads a specific episode from the specified season. + + Parameters: + - index_season_selected (int): Season number + - index_episode_selected (int): Episode index + - scrape_serie (GetSerieInfo): Scraper object with series information + + Returns: + - str: Path to downloaded file + - bool: Whether download was stopped + """ + start_message() + + # Get episode information + obj_episode = scrape_serie.selectEpisode(index_season_selected, index_episode_selected-1) + console.print(f"\n[bold yellow]Download:[/bold yellow] [red]{site_constant.SITE_NAME}[/red] → [cyan]{scrape_serie.series_name}[/cyan] \\ [bold magenta]{obj_episode.name}[/bold magenta] ([cyan]S{index_season_selected}E{index_episode_selected}[/cyan]) \n") + + # Define filename and path for the downloaded video + mp4_name = f"{map_episode_title(scrape_serie.series_name, index_season_selected, index_episode_selected, obj_episode.name)}.{extension_output}" + mp4_path = os.path.join(site_constant.SERIES_FOLDER, scrape_serie.series_name, f"S{index_season_selected}") + + # Get m3u8 playlist + bearer_token = get_bearer_token() + master_playlist = get_playback_url(obj_episode.id, bearer_token) + + # Download the episode + hls_process = HLS_Downloader( + m3u8_url=master_playlist, + output_path=os.path.join(mp4_path, mp4_name) + ).start() + + if hls_process['error'] is not None: + try: + os.remove(hls_process['path']) + except Exception: + pass + + return hls_process['path'], hls_process['stopped'] + + +def download_episode(index_season_selected: int, scrape_serie: GetSerieInfo, download_all: bool = False, episode_selection: str = None) -> None: + """ + Handle downloading episodes for a specific season. + + Parameters: + - index_season_selected (int): Season number + - scrape_serie (GetSerieInfo): Scraper object with series information + - download_all (bool): Whether to download all episodes + - episode_selection (str, optional): Pre-defined episode selection that bypasses manual input + """ + # Get episodes for the selected season + episodes = scrape_serie.getEpisodeSeasons(index_season_selected) + episodes_count = len(episodes) + + if episodes_count == 0: + console.print(f"[red]No episodes found for season {index_season_selected}") + return + + if download_all: + # Download all episodes in the season + for i_episode in range(1, episodes_count + 1): + path, stopped = download_video(index_season_selected, i_episode, scrape_serie) + + if stopped: + break + + console.print(f"\n[red]End downloaded [yellow]season: [red]{index_season_selected}.") + + else: + # Display episodes list and manage user selection + if episode_selection is None: + last_command = display_episodes_list(episodes) + else: + last_command = episode_selection + console.print(f"\n[cyan]Using provided episode selection: [yellow]{episode_selection}") + + # Validate the selection + list_episode_select = manage_selection(last_command, episodes_count) + list_episode_select = validate_episode_selection(list_episode_select, episodes_count) + + # Download selected episodes if not stopped + for i_episode in list_episode_select: + path, stopped = download_video(index_season_selected, i_episode, scrape_serie) + + if stopped: + break + + +def download_series(select_season: MediaItem, season_selection: str = None, episode_selection: str = None) -> None: + """ + Handle downloading a complete series. + + Parameters: + - select_season (MediaItem): Series metadata from search + - season_selection (str, optional): Pre-defined season selection that bypasses manual input + - episode_selection (str, optional): Pre-defined episode selection that bypasses manual input + """ + start_message() + + # Init class + scrape_serie = GetSerieInfo(select_season.url) + + # Collect information about season + scrape_serie.getNumberSeason() + seasons_count = len(scrape_serie.seasons_manager) + + # If season_selection is provided, use it instead of asking for input + if season_selection is None: + index_season_selected = display_seasons_list(scrape_serie.seasons_manager) + else: + index_season_selected = season_selection + console.print(f"\n[cyan]Using provided season selection: [yellow]{season_selection}") + + # Validate the selection + list_season_select = manage_selection(index_season_selected, seasons_count) + list_season_select = validate_selection(list_season_select, seasons_count) + + # Loop through the selected seasons and download episodes + for i_season in list_season_select: + try: + season = scrape_serie.seasons_manager.seasons[i_season - 1] + except IndexError: + console.print(f"[red]Season index {i_season} not found! Available seasons: {[s.number for s in scrape_serie.seasons_manager.seasons]}") + continue + + season_number = season.number + + if len(list_season_select) > 1 or index_season_selected == "*": + download_episode(season_number, scrape_serie, download_all=True) + else: + download_episode(season_number, scrape_serie, download_all=False, episode_selection=episode_selection) \ No newline at end of file diff --git a/StreamingCommunity/Api/Site/dmax/site.py b/StreamingCommunity/Api/Site/dmax/site.py new file mode 100644 index 000000000..d2a41cbf2 --- /dev/null +++ b/StreamingCommunity/Api/Site/dmax/site.py @@ -0,0 +1,74 @@ +# 26.11.2025 + + +# External libraries +from rich.console import Console + + +# Internal utilities +from StreamingCommunity.Util.headers import get_userAgent +from StreamingCommunity.Util.http_client import create_client +from StreamingCommunity.Util.table import TVShowManager + + +# Logic class +from StreamingCommunity.Api.Template.config_loader import site_constant +from StreamingCommunity.Api.Template.Class.SearchType import MediaManager + + +# Variable +console = Console() +media_search_manager = MediaManager() +table_show_manager = TVShowManager() + + +def title_search(query: str) -> int: + """ + Search for titles based on a search query. + + Parameters: + - query (str): The query to search for. + + Returns: + int: The number of titles found. + """ + media_search_manager.clear() + table_show_manager.clear() + + search_url = f"https://public.aurora.enhanced.live/site/search/page/?include=default&filter[environment]=dmaxit&v=2&q={query}&page[number]=1&page[size]=20" + console.print(f"[cyan]Search url: [yellow]{search_url}") + + try: + response = create_client(headers={'user-agent': get_userAgent()}).get(search_url) + response.raise_for_status() + + except Exception as e: + console.print(f"[red]Site: {site_constant.SITE_NAME}, request search error: {e}") + return 0 + + # Collect json data + try: + data = response.json().get('data') + except Exception as e: + console.log(f"Error parsing JSON response: {e}") + return 0 + + for dict_title in data: + try: + # Skip non-showpage entries + if dict_title.get('type') != 'showpage': + continue + + media_search_manager.add_media({ + 'name': dict_title.get('title'), + 'type': 'tv', + 'date': dict_title.get('dateLastModified').split('T')[0], + 'image': dict_title.get('image').get('url'), + 'url': f'https://public.aurora.enhanced.live/site/page/{str(dict_title.get("slug")).lower().replace(" ", "-")}/?include=default&filter[environment]=dmaxit&v=2&parent_slug={dict_title.get("parentSlug")}', + }) + + except Exception as e: + print(f"Error parsing a film entry: {e}") + + # Return the number of titles found + return media_search_manager.get_length() \ No newline at end of file diff --git a/StreamingCommunity/Api/Site/guardaserie/__init__.py b/StreamingCommunity/Api/Site/guardaserie/__init__.py index cba609049..a520293fb 100644 --- a/StreamingCommunity/Api/Site/guardaserie/__init__.py +++ b/StreamingCommunity/Api/Site/guardaserie/__init__.py @@ -70,7 +70,8 @@ def get_user_input(string_to_search: str = None): else: return msg.ask(f"\n[purple]Insert a word to search in [green]{site_constant.SITE_NAME}").strip() - + + def process_search_result(select_title, selections=None): """ Handles the search result and initiates the download for either a film or series. @@ -103,6 +104,7 @@ def process_search_result(select_title, selections=None): table_show_manager.clear() return True + def search(string_to_search: str = None, get_onlyDatabase: bool = False, direct_item: dict = None, selections: dict = None): """ Main function of the application for search. diff --git a/StreamingCommunity/Api/Site/guardaserie/util/ScrapeSerie.py b/StreamingCommunity/Api/Site/guardaserie/util/ScrapeSerie.py index 3f19bc4be..a6f38f488 100644 --- a/StreamingCommunity/Api/Site/guardaserie/util/ScrapeSerie.py +++ b/StreamingCommunity/Api/Site/guardaserie/util/ScrapeSerie.py @@ -88,29 +88,29 @@ def get_episode_number(self, n_season: int) -> List[Dict[str, str]]: episode_content = table_content.find_all("li") list_dict_episode = [] - # Get the season from seasons_manager - season = self.seasons_manager.get_season_by_number(n_season) - - if season: - season.episodes.episodes = [] - for episode_div in episode_content: - index = episode_div.find("a").get("data-num") - link = episode_div.find("a").get("data-link") - name = episode_div.find("a").get("data-num") + episode_link = episode_div.find("a") + if not episode_link: + continue + + # Extract episode information from data attributes + data_num = episode_link.get("data-num", "") + data_link = episode_link.get("data-link", "") + data_title = episode_link.get("data-title", "") + + # Parse episode number from data-num + episode_number = data_num.split('x')[-1] if 'x' in data_num else data_num + + # Use data-title if available + episode_name = data_title if data_title else f"Episodio {episode_number}" obj_episode = { - 'number': index, - 'name': name, - 'url': link, - 'id': index + 'number': episode_number, + 'name': episode_name, + 'url': data_link, + 'id': episode_number } - list_dict_episode.append(obj_episode) - - # Add episode to the season in seasons_manager - if season: - season.episodes.add(obj_episode) return list_dict_episode @@ -132,25 +132,22 @@ def getEpisodeSeasons(self, season_number: int) -> list: """ Get all episodes for a specific season. """ - season = self.seasons_manager.get_season_by_number(season_number) + episodes = self.get_episode_number(season_number) - if not season: - logging.error(f"Season {season_number} not found") + if not episodes: + logging.error(f"No episodes found for season {season_number}") return [] - # If episodes are not loaded yet, fetch them - if not season.episodes.episodes: - self.get_episode_number(season_number) - - return season.episodes.episodes + return episodes def selectEpisode(self, season_number: int, episode_index: int) -> dict: """ Get information for a specific episode in a specific season. """ - episodes = self.getEpisodeSeasons(season_number) + episodes = self.get_episode_number(season_number) + if not episodes or episode_index < 0 or episode_index >= len(episodes): logging.error(f"Episode index {episode_index} is out of range for season {season_number}") return None - return episodes[episode_index] \ No newline at end of file + return episodes[episode_index] diff --git a/StreamingCommunity/Api/Site/mediasetinfinity/__init__.py b/StreamingCommunity/Api/Site/mediasetinfinity/__init__.py index a33a0060d..587222481 100644 --- a/StreamingCommunity/Api/Site/mediasetinfinity/__init__.py +++ b/StreamingCommunity/Api/Site/mediasetinfinity/__init__.py @@ -71,6 +71,7 @@ def get_user_input(string_to_search: str = None): else: return msg.ask(f"\n[purple]Insert a word to search in [green]{site_constant.SITE_NAME}").strip() + def process_search_result(select_title, selections=None): """ Handles the search result and initiates the download for either a film or series. @@ -109,6 +110,7 @@ def process_search_result(select_title, selections=None): table_show_manager.clear() return True + def search(string_to_search: str = None, get_onlyDatabase: bool = False, direct_item: dict = None, selections: dict = None): """ Main function of the application for search. diff --git a/StreamingCommunity/Api/Site/plutotv/__init__.py b/StreamingCommunity/Api/Site/plutotv/__init__.py new file mode 100644 index 000000000..229dc0c17 --- /dev/null +++ b/StreamingCommunity/Api/Site/plutotv/__init__.py @@ -0,0 +1,108 @@ +# 26.11.2025 + + +# External library +from rich.console import Console +from rich.prompt import Prompt + + +# Internal utilities +from StreamingCommunity.Api.Template import get_select_title +from StreamingCommunity.Api.Template.config_loader import site_constant +from StreamingCommunity.Api.Template.Class.SearchType import MediaItem + + +# Logic class +from .site import title_search, table_show_manager, media_search_manager +from .series import download_series + + +# Variable +indice = 11 +_useFor = "Serie" +_priority = 0 +_engineDownload = "dash" +_deprecate = True + +msg = Prompt() +console = Console() + + +def get_user_input(string_to_search: str = None): + """ + Asks the user to input a search term. + Handles both Telegram bot input and direct input. + If string_to_search is provided, it's returned directly (after stripping). + """ + if string_to_search is not None: + return string_to_search.strip() + else: + return msg.ask(f"\n[purple]Insert a word to search in [green]{site_constant.SITE_NAME}").strip() + +def process_search_result(select_title, selections=None): + """ + Handles the search result and initiates the download for either a film or series. + + Parameters: + select_title (MediaItem): The selected media item. Can be None if selection fails. + selections (dict, optional): Dictionary containing selection inputs that bypass manual input + e.g., {'season': season_selection, 'episode': episode_selection} + Returns: + bool: True if processing was successful, False otherwise + """ + if not select_title: + return False + + if select_title.type == 'tv': + season_selection = None + episode_selection = None + + if selections: + season_selection = selections.get('season') + episode_selection = selections.get('episode') + + download_series(select_title, season_selection, episode_selection) + media_search_manager.clear() + table_show_manager.clear() + return True + + +def search(string_to_search: str = None, get_onlyDatabase: bool = False, direct_item: dict = None, selections: dict = None): + """ + Main function of the application for search. + + Parameters: + string_to_search (str, optional): String to search for. Can be passed from run.py. + If 'back', special handling might occur in get_user_input. + get_onlyDatabase (bool, optional): If True, return only the database search manager object. + direct_item (dict, optional): Direct item to process (bypasses search). + selections (dict, optional): Dictionary containing selection inputs that bypass manual input + for series (season/episode). + """ + if direct_item: + select_title = MediaItem(**direct_item) + result = process_search_result(select_title, selections) + return result + + # Get the user input for the search term + actual_search_query = get_user_input(string_to_search) + + # Handle empty input + if not actual_search_query: + return False + + # Search on database + len_database = title_search(actual_search_query) + + # If only the database is needed, return the manager + if get_onlyDatabase: + return media_search_manager + + if len_database > 0: + select_title = get_select_title(table_show_manager, media_search_manager, len_database) + result = process_search_result(select_title, selections) + return result + + else: + console.print(f"\n[red]Nothing matching was found for[white]: [purple]{actual_search_query}") + return False \ No newline at end of file diff --git a/StreamingCommunity/Api/Site/plutotv/series.py b/StreamingCommunity/Api/Site/plutotv/series.py new file mode 100644 index 000000000..bf54a0d7b --- /dev/null +++ b/StreamingCommunity/Api/Site/plutotv/series.py @@ -0,0 +1,184 @@ +# 26.11.2025 + +import os +from typing import Tuple + + +# External library +from rich.console import Console +from rich.prompt import Prompt + + +# Internal utilities +from StreamingCommunity.Util.message import start_message +from StreamingCommunity.Util.headers import get_headers +from StreamingCommunity.Util.config_json import config_manager + + +# Logic class +from .util.ScrapeSerie import GetSerieInfo +from StreamingCommunity.Api.Template.Util import ( + manage_selection, + map_episode_title, + validate_selection, + validate_episode_selection, + display_episodes_list, + display_seasons_list +) +from StreamingCommunity.Api.Template.config_loader import site_constant +from StreamingCommunity.Api.Template.Class.SearchType import MediaItem + + +# Player +from StreamingCommunity import DASH_Downloader +from .util.get_license import get_playback_url_episode, get_bearer_token + + +# Variable +msg = Prompt() +console = Console() +extension_output = config_manager.get("M3U8_CONVERSION", "extension") + + +def download_video(index_season_selected: int, index_episode_selected: int, scrape_serie: GetSerieInfo) -> Tuple[str,bool]: + """ + Downloads a specific episode from the specified season. + + Parameters: + - index_season_selected (int): Season number + - index_episode_selected (int): Episode index + - scrape_serie (GetSerieInfo): Scraper object with series information + + Returns: + - str: Path to downloaded file + - bool: Whether download was stopped + """ + start_message() + + # Get episode information + obj_episode = scrape_serie.selectEpisode(index_season_selected, index_episode_selected-1) + console.print(f"\n[bold yellow]Download:[/bold yellow] [red]{site_constant.SITE_NAME}[/red] → [cyan]{scrape_serie.series_name}[/cyan] \\ [bold magenta]{obj_episode.name}[/bold magenta] ([cyan]S{index_season_selected}E{index_episode_selected}[/cyan]) \n") + + # Define filename and path for the downloaded video + mp4_name = f"{map_episode_title(scrape_serie.series_name, index_season_selected, index_episode_selected, obj_episode.name)}.{extension_output}" + mp4_path = os.path.join(site_constant.SERIES_FOLDER, scrape_serie.series_name, f"S{index_season_selected}") + + # Generate headers + headers = get_headers() + headers_2 = get_headers() + headers['authorization'] = f'Bearer {get_bearer_token()}' + + # Start download process + dash_process = DASH_Downloader( + license_url="https://service-concierge.clusters.pluto.tv/v1/wv/alt", + mpd_url=get_playback_url_episode(obj_episode.id) + f"?jwt={get_bearer_token()}", + output_path=os.path.join(mp4_path, mp4_name), + ) + dash_process.parse_manifest(custom_headers=headers) + + if dash_process.download_and_decrypt(custom_headers=headers_2): + dash_process.finalize_output() + + # Get final output path and status + r_proc = dash_process.get_status() + + if r_proc['error'] is not None: + try: + os.remove(r_proc['path']) + except Exception: + pass + + return r_proc['path'], r_proc['stopped'] + + +def download_episode(index_season_selected: int, scrape_serie: GetSerieInfo, download_all: bool = False, episode_selection: str = None) -> None: + """ + Handle downloading episodes for a specific season. + + Parameters: + - index_season_selected (int): Season number + - scrape_serie (GetSerieInfo): Scraper object with series information + - download_all (bool): Whether to download all episodes + - episode_selection (str, optional): Pre-defined episode selection that bypasses manual input + """ + # Get episodes for the selected season + episodes = scrape_serie.getEpisodeSeasons(index_season_selected) + episodes_count = len(episodes) + + if episodes_count == 0: + console.print(f"[red]No episodes found for season {index_season_selected}") + return + + if download_all: + # Download all episodes in the season + for i_episode in range(1, episodes_count + 1): + path, stopped = download_video(index_season_selected, i_episode, scrape_serie) + + if stopped: + break + + console.print(f"\n[red]End downloaded [yellow]season: [red]{index_season_selected}.") + + else: + # Display episodes list and manage user selection + if episode_selection is None: + last_command = display_episodes_list(episodes) + else: + last_command = episode_selection + console.print(f"\n[cyan]Using provided episode selection: [yellow]{episode_selection}") + + # Validate the selection + list_episode_select = manage_selection(last_command, episodes_count) + list_episode_select = validate_episode_selection(list_episode_select, episodes_count) + + # Download selected episodes if not stopped + for i_episode in list_episode_select: + path, stopped = download_video(index_season_selected, i_episode, scrape_serie) + + if stopped: + break + + +def download_series(select_season: MediaItem, season_selection: str = None, episode_selection: str = None) -> None: + """ + Handle downloading a complete series. + + Parameters: + - select_season (MediaItem): Series metadata from search + - season_selection (str, optional): Pre-defined season selection that bypasses manual input + - episode_selection (str, optional): Pre-defined episode selection that bypasses manual input + """ + start_message() + + # Init class + scrape_serie = GetSerieInfo(select_season.url) + + # Collect information about season + scrape_serie.getNumberSeason() + seasons_count = len(scrape_serie.seasons_manager) + + # If season_selection is provided, use it instead of asking for input + if season_selection is None: + index_season_selected = display_seasons_list(scrape_serie.seasons_manager) + else: + index_season_selected = season_selection + console.print(f"\n[cyan]Using provided season selection: [yellow]{season_selection}") + + # Validate the selection + list_season_select = manage_selection(index_season_selected, seasons_count) + list_season_select = validate_selection(list_season_select, seasons_count) + + # Loop through the selected seasons and download episodes + for i_season in list_season_select: + try: + season = scrape_serie.seasons_manager.seasons[i_season - 1] + except IndexError: + console.print(f"[red]Season index {i_season} not found! Available seasons: {[s.number for s in scrape_serie.seasons_manager.seasons]}") + continue + + season_number = season.number + + if len(list_season_select) > 1 or index_season_selected == "*": + download_episode(season_number, scrape_serie, download_all=True) + else: + download_episode(season_number, scrape_serie, download_all=False, episode_selection=episode_selection) \ No newline at end of file diff --git a/StreamingCommunity/Api/Site/plutotv/site.py b/StreamingCommunity/Api/Site/plutotv/site.py new file mode 100644 index 000000000..cdd2748ce --- /dev/null +++ b/StreamingCommunity/Api/Site/plutotv/site.py @@ -0,0 +1,76 @@ +# 26.11.2025 + + +# External libraries +from rich.console import Console + + +# Internal utilities +from StreamingCommunity.Util.headers import get_userAgent +from StreamingCommunity.Util.http_client import create_client +from StreamingCommunity.Util.table import TVShowManager + + +# Logic class +from StreamingCommunity.Api.Template.config_loader import site_constant +from StreamingCommunity.Api.Template.Class.SearchType import MediaManager +from .util.get_license import get_bearer_token + + +# Variable +console = Console() +media_search_manager = MediaManager() +table_show_manager = TVShowManager() + + +def title_search(query: str) -> int: + """ + Search for titles based on a search query. + + Parameters: + - query (str): The query to search for. + + Returns: + int: The number of titles found. + """ + media_search_manager.clear() + table_show_manager.clear() + + search_url = f"https://service-media-search.clusters.pluto.tv/v1/search?q={query}&limit=10" + console.print(f"[cyan]Search url: [yellow]{search_url}") + + try: + response = create_client(headers={'user-agent': get_userAgent(), 'Authorization': f"Bearer {get_bearer_token()}"}).get(search_url) + response.raise_for_status() + + except Exception as e: + console.print(f"[red]Site: {site_constant.SITE_NAME}, request search error: {e}") + return 0 + + # Collect json data + try: + data = response.json().get('data') + except Exception as e: + console.log(f"Error parsing JSON response: {e}") + return 0 + + for dict_title in data: + try: + if dict_title.get('type') == 'channel': + continue + + define_type = 'tv' if dict_title.get('type') == 'series' else dict_title.get('type') + + media_search_manager.add_media({ + 'id': dict_title.get('id'), + 'name': dict_title.get('name'), + 'type': define_type, + 'image': None, + 'url': f"https://service-vod.clusters.pluto.tv/v4/vod/{dict_title.get('type')}/{dict_title.get('id')}" + }) + + except Exception as e: + print(f"Error parsing a film entry: {e}") + + # Return the number of titles found + return media_search_manager.get_length() \ No newline at end of file diff --git a/StreamingCommunity/Api/Site/plutotv/util/ScrapeSerie.py b/StreamingCommunity/Api/Site/plutotv/util/ScrapeSerie.py new file mode 100644 index 000000000..2a54a729c --- /dev/null +++ b/StreamingCommunity/Api/Site/plutotv/util/ScrapeSerie.py @@ -0,0 +1,162 @@ +# 26.11.2025 + +import logging + +# Internal utilities +from StreamingCommunity.Util.headers import get_headers +from StreamingCommunity.Util.http_client import create_client +from StreamingCommunity.Api.Player.Helper.Vixcloud.util import SeasonManager + +from .get_license import get_bearer_token + + +class GetSerieInfo: + def __init__(self, url): + """ + Initialize the GetSerieInfo class for scraping TV series information. + + Args: + - url (str): The URL of the streaming site. + """ + self.url = url + "/seasons" + self.headers = get_headers() + self.series_name = None + self.seasons_manager = SeasonManager() + self.seasons_data = {} + + def collect_info_title(self) -> None: + """ + Retrieve general information about the TV series from the streaming site. + """ + try: + # Add Bearer token to headers + bearer_token = get_bearer_token() + self.headers['authorization'] = f'Bearer {bearer_token}' + + response = create_client(headers=self.headers).get(self.url) + response.raise_for_status() + + # Parse JSON response + json_response = response.json() + self.series_name = json_response.get('name', 'Unknown Series') + seasons_array = json_response.get('seasons', []) + + if not seasons_array: + logging.warning("No seasons found in JSON response") + return + + # Process each season in the array + for season_obj in seasons_array: + season_number = season_obj.get('number') + if season_number is None: + logging.warning("Season without number found, skipping") + continue + + # Store season data indexed by season number + self.seasons_data[str(season_number)] = season_obj + + # Build season structure for SeasonManager + season_info = { + 'id': f"season-{season_number}", + 'number': season_number, + 'name': f"Season {season_number}", + 'slug': f"season-{season_number}", + } + self.seasons_manager.add_season(season_info) + + except Exception as e: + logging.error(f"Error collecting series info: {e}") + raise + + def collect_info_season(self, number_season: int) -> None: + """ + Retrieve episode information for a specific season. + + Args: + number_season (int): Season number to fetch episodes for + + Raises: + Exception: If there's an error fetching episode information + """ + try: + season = self.seasons_manager.get_season_by_number(number_season) + if not season: + logging.error(f"Season {number_season} not found") + return + + # Get episodes for this season from stored data + season_key = str(number_season) + season_data = self.seasons_data.get(season_key, {}) + episodes = season_data.get('episodes', []) + + if not episodes: + logging.warning(f"No episodes found for season {number_season}") + return + + # Sort episodes by episode number in ascending order + episodes.sort(key=lambda x: x.get('number', 0), reverse=False) + + # Transform episodes to match the expected format + for episode in episodes: + duration_ms = episode.get('duration', 0) + duration_minutes = round(duration_ms / 1000 / 60) if duration_ms else 0 + + episode_data = { + 'id': episode.get('_id'), + 'number': episode.get('number'), + 'name': episode.get('name', f"Episode {episode.get('number')}"), + 'description': episode.get('description', ''), + 'duration': duration_minutes, + 'slug': episode.get('slug', '') + } + + # Add episode to the season's episode manager + season.episodes.add(episode_data) + + except Exception as e: + logging.error(f"Error collecting episodes for season {number_season}: {e}") + raise + + # ------------- FOR GUI ------------- + def getNumberSeason(self) -> int: + """ + Get the total number of seasons available for the series. + """ + if not self.seasons_manager.seasons: + self.collect_info_title() + + return len(self.seasons_manager.seasons) + + def getEpisodeSeasons(self, season_number: int) -> list: + """ + Get all episodes for a specific season. + """ + season = self.seasons_manager.get_season_by_number(season_number) + + if not season: + logging.error(f"Season {season_number} not found") + return [] + + if not season.episodes.episodes: + self.collect_info_season(season_number) + + return season.episodes.episodes + + def selectEpisode(self, season_number: int, episode_index: int) -> dict: + """ + Get information for a specific episode in a specific season. + """ + episodes = self.getEpisodeSeasons(season_number) + if not episodes or episode_index < 0 or episode_index >= len(episodes): + logging.error(f"Episode index {episode_index} is out of range for season {season_number}") + return None + + return episodes[episode_index] + + def get_series_name(self) -> str: + """ + Get the name of the series. + """ + if not self.series_name: + self.collect_info_title() + return self.series_name \ No newline at end of file diff --git a/StreamingCommunity/Api/Site/plutotv/util/get_license.py b/StreamingCommunity/Api/Site/plutotv/util/get_license.py new file mode 100644 index 000000000..6fcdde5a4 --- /dev/null +++ b/StreamingCommunity/Api/Site/plutotv/util/get_license.py @@ -0,0 +1,40 @@ +# 26.11.2025 + +import uuid +import random + + +# Internal utilities +from StreamingCommunity.Util.headers import get_headers +from StreamingCommunity.Util.http_client import create_client + + +def generate_params(): + """Generate all params automatically""" + device_makes = ['opera', 'chrome', 'firefox', 'safari', 'edge'] + + return { + 'appName': 'web', + 'appVersion': str(random.randint(100, 999)), + 'deviceVersion': str(random.randint(100, 999)), + 'deviceModel': 'web', + 'deviceMake': random.choice(device_makes), + 'deviceType': 'web', + 'clientID': str(uuid.uuid4()), + 'clientModelNumber': f"{random.randint(1, 9)}.{random.randint(0, 9)}.{random.randint(0, 9)}", + 'channelID': ''.join(random.choice('0123456789abcdef') for _ in range(24)) + } + +def get_bearer_token(): + """ + Get the Bearer token required for authentication. + + Returns: + str: Token Bearer + """ + response = create_client(headers=get_headers()).get('https://boot.pluto.tv/v4/start', params=generate_params()) + return response.json()['sessionToken'] + + +def get_playback_url_episode(id_episode): + return f"https://cfd-v4-service-stitcher-dash-use1-1.prd.pluto.tv/v2/stitch/dash/episode/{id_episode}/main.mpd" \ No newline at end of file diff --git a/StreamingCommunity/Api/Site/raiplay/__init__.py b/StreamingCommunity/Api/Site/raiplay/__init__.py index 1ade70801..a462b74f2 100644 --- a/StreamingCommunity/Api/Site/raiplay/__init__.py +++ b/StreamingCommunity/Api/Site/raiplay/__init__.py @@ -71,6 +71,7 @@ def get_user_input(string_to_search: str = None): else: return msg.ask(f"\n[purple]Insert a word to search in [green]{site_constant.SITE_NAME}").strip() + def process_search_result(select_title, selections=None): """ Handles the search result and initiates the download for either a film or series. @@ -109,6 +110,7 @@ def process_search_result(select_title, selections=None): table_show_manager.clear() return True + def search(string_to_search: str = None, get_onlyDatabase: bool = False, direct_item: dict = None, selections: dict = None): """ Main function of the application for search. diff --git a/StreamingCommunity/Api/Site/realtime/__init__.py b/StreamingCommunity/Api/Site/realtime/__init__.py new file mode 100644 index 000000000..9dd462e0a --- /dev/null +++ b/StreamingCommunity/Api/Site/realtime/__init__.py @@ -0,0 +1,108 @@ +# 26.11.2025 + + +# External library +from rich.console import Console +from rich.prompt import Prompt + + +# Internal utilities +from StreamingCommunity.Api.Template import get_select_title +from StreamingCommunity.Api.Template.config_loader import site_constant +from StreamingCommunity.Api.Template.Class.SearchType import MediaItem + + +# Logic class +from .site import title_search, table_show_manager, media_search_manager +from .series import download_series + + +# Variable +indice = 9 +_useFor = "Serie" +_priority = 0 +_engineDownload = "hls" +_deprecate = False + +msg = Prompt() +console = Console() + + +def get_user_input(string_to_search: str = None): + """ + Asks the user to input a search term. + Handles both Telegram bot input and direct input. + If string_to_search is provided, it's returned directly (after stripping). + """ + if string_to_search is not None: + return string_to_search.strip() + else: + return msg.ask(f"\n[purple]Insert a word to search in [green]{site_constant.SITE_NAME}").strip() + +def process_search_result(select_title, selections=None): + """ + Handles the search result and initiates the download for either a film or series. + + Parameters: + select_title (MediaItem): The selected media item. Can be None if selection fails. + selections (dict, optional): Dictionary containing selection inputs that bypass manual input + e.g., {'season': season_selection, 'episode': episode_selection} + Returns: + bool: True if processing was successful, False otherwise + """ + if not select_title: + return False + + if select_title.type == 'tv': + season_selection = None + episode_selection = None + + if selections: + season_selection = selections.get('season') + episode_selection = selections.get('episode') + + download_series(select_title, season_selection, episode_selection) + media_search_manager.clear() + table_show_manager.clear() + return True + + +def search(string_to_search: str = None, get_onlyDatabase: bool = False, direct_item: dict = None, selections: dict = None): + """ + Main function of the application for search. + + Parameters: + string_to_search (str, optional): String to search for. Can be passed from run.py. + If 'back', special handling might occur in get_user_input. + get_onlyDatabase (bool, optional): If True, return only the database search manager object. + direct_item (dict, optional): Direct item to process (bypasses search). + selections (dict, optional): Dictionary containing selection inputs that bypass manual input + for series (season/episode). + """ + if direct_item: + select_title = MediaItem(**direct_item) + result = process_search_result(select_title, selections) + return result + + # Get the user input for the search term + actual_search_query = get_user_input(string_to_search) + + # Handle empty input + if not actual_search_query: + return False + + # Search on database + len_database = title_search(actual_search_query) + + # If only the database is needed, return the manager + if get_onlyDatabase: + return media_search_manager + + if len_database > 0: + select_title = get_select_title(table_show_manager, media_search_manager, len_database) + result = process_search_result(select_title, selections) + return result + + else: + console.print(f"\n[red]Nothing matching was found for[white]: [purple]{actual_search_query}") + return False \ No newline at end of file diff --git a/StreamingCommunity/Api/Site/realtime/series.py b/StreamingCommunity/Api/Site/realtime/series.py new file mode 100644 index 000000000..e25065389 --- /dev/null +++ b/StreamingCommunity/Api/Site/realtime/series.py @@ -0,0 +1,174 @@ +# 26.11.2025 + +import os +from typing import Tuple + + +# External library +from rich.console import Console +from rich.prompt import Prompt + + +# Internal utilities +from StreamingCommunity.Util.message import start_message +from StreamingCommunity.Util.config_json import config_manager + + +# Logic class +from .util.ScrapeSerie import GetSerieInfo +from StreamingCommunity.Api.Template.Util import ( + manage_selection, + map_episode_title, + validate_selection, + validate_episode_selection, + display_episodes_list, + display_seasons_list +) +from StreamingCommunity.Api.Template.config_loader import site_constant +from StreamingCommunity.Api.Template.Class.SearchType import MediaItem + + +# Player +from StreamingCommunity import HLS_Downloader +from .util.get_license import get_bearer_token, get_playback_url + + +# Variable +msg = Prompt() +console = Console() +extension_output = config_manager.get("M3U8_CONVERSION", "extension") + + +def download_video(index_season_selected: int, index_episode_selected: int, scrape_serie: GetSerieInfo) -> Tuple[str,bool]: + """ + Downloads a specific episode from the specified season. + + Parameters: + - index_season_selected (int): Season number + - index_episode_selected (int): Episode index + - scrape_serie (GetSerieInfo): Scraper object with series information + + Returns: + - str: Path to downloaded file + - bool: Whether download was stopped + """ + start_message() + + # Get episode information + obj_episode = scrape_serie.selectEpisode(index_season_selected, index_episode_selected-1) + console.print(f"\n[bold yellow]Download:[/bold yellow] [red]{site_constant.SITE_NAME}[/red] → [cyan]{scrape_serie.series_name}[/cyan] \\ [bold magenta]{obj_episode.name}[/bold magenta] ([cyan]S{index_season_selected}E{index_episode_selected}[/cyan]) \n") + + # Define filename and path for the downloaded video + mp4_name = f"{map_episode_title(scrape_serie.series_name, index_season_selected, index_episode_selected, obj_episode.name)}.{extension_output}" + mp4_path = os.path.join(site_constant.SERIES_FOLDER, scrape_serie.series_name, f"S{index_season_selected}") + + # Get hls url + bearer_token = get_bearer_token() + master_playlist = get_playback_url(obj_episode.id, bearer_token) + + # Download the episode + hls_process = HLS_Downloader( + m3u8_url=master_playlist, + output_path=os.path.join(mp4_path, mp4_name) + ).start() + + if hls_process['error'] is not None: + try: + os.remove(hls_process['path']) + except Exception: + pass + + return hls_process['path'], hls_process['stopped'] + + +def download_episode(index_season_selected: int, scrape_serie: GetSerieInfo, download_all: bool = False, episode_selection: str = None) -> None: + """ + Handle downloading episodes for a specific season. + + Parameters: + - index_season_selected (int): Season number + - scrape_serie (GetSerieInfo): Scraper object with series information + - download_all (bool): Whether to download all episodes + - episode_selection (str, optional): Pre-defined episode selection that bypasses manual input + """ + # Get episodes for the selected season + episodes = scrape_serie.getEpisodeSeasons(index_season_selected) + episodes_count = len(episodes) + + if episodes_count == 0: + console.print(f"[red]No episodes found for season {index_season_selected}") + return + + if download_all: + # Download all episodes in the season + for i_episode in range(1, episodes_count + 1): + path, stopped = download_video(index_season_selected, i_episode, scrape_serie) + + if stopped: + break + + console.print(f"\n[red]End downloaded [yellow]season: [red]{index_season_selected}.") + + else: + # Display episodes list and manage user selection + if episode_selection is None: + last_command = display_episodes_list(episodes) + else: + last_command = episode_selection + console.print(f"\n[cyan]Using provided episode selection: [yellow]{episode_selection}") + + # Validate the selection + list_episode_select = manage_selection(last_command, episodes_count) + list_episode_select = validate_episode_selection(list_episode_select, episodes_count) + + # Download selected episodes if not stopped + for i_episode in list_episode_select: + path, stopped = download_video(index_season_selected, i_episode, scrape_serie) + + if stopped: + break + + +def download_series(select_season: MediaItem, season_selection: str = None, episode_selection: str = None) -> None: + """ + Handle downloading a complete series. + + Parameters: + - select_season (MediaItem): Series metadata from search + - season_selection (str, optional): Pre-defined season selection that bypasses manual input + - episode_selection (str, optional): Pre-defined episode selection that bypasses manual input + """ + start_message() + + # Init class + scrape_serie = GetSerieInfo(select_season.url) + + # Collect information about season + scrape_serie.getNumberSeason() + seasons_count = len(scrape_serie.seasons_manager) + + # If season_selection is provided, use it instead of asking for input + if season_selection is None: + index_season_selected = display_seasons_list(scrape_serie.seasons_manager) + else: + index_season_selected = season_selection + console.print(f"\n[cyan]Using provided season selection: [yellow]{season_selection}") + + # Validate the selection + list_season_select = manage_selection(index_season_selected, seasons_count) + list_season_select = validate_selection(list_season_select, seasons_count) + + # Loop through the selected seasons and download episodes + for i_season in list_season_select: + try: + season = scrape_serie.seasons_manager.seasons[i_season - 1] + except IndexError: + console.print(f"[red]Season index {i_season} not found! Available seasons: {[s.number for s in scrape_serie.seasons_manager.seasons]}") + continue + + season_number = season.number + + if len(list_season_select) > 1 or index_season_selected == "*": + download_episode(season_number, scrape_serie, download_all=True) + else: + download_episode(season_number, scrape_serie, download_all=False, episode_selection=episode_selection) \ No newline at end of file diff --git a/StreamingCommunity/Api/Site/realtime/site.py b/StreamingCommunity/Api/Site/realtime/site.py new file mode 100644 index 000000000..6f0bb9a85 --- /dev/null +++ b/StreamingCommunity/Api/Site/realtime/site.py @@ -0,0 +1,73 @@ +# 26.11.2025 + + +# External libraries +from rich.console import Console + + +# Internal utilities +from StreamingCommunity.Util.headers import get_userAgent +from StreamingCommunity.Util.http_client import create_client +from StreamingCommunity.Util.table import TVShowManager + + +# Logic class +from StreamingCommunity.Api.Template.config_loader import site_constant +from StreamingCommunity.Api.Template.Class.SearchType import MediaManager + + +# Variable +console = Console() +media_search_manager = MediaManager() +table_show_manager = TVShowManager() + + +def title_search(query: str) -> int: + """ + Search for titles based on a search query. + + Parameters: + - query (str): The query to search for. + + Returns: + int: The number of titles found. + """ + media_search_manager.clear() + table_show_manager.clear() + + search_url = f"https://public.aurora.enhanced.live/site/search/page/?include=default&filter[environment]=realtime&v=2&q={query}&page[number]=1&page[size]=20" + console.print(f"[cyan]Search url: [yellow]{search_url}") + + try: + response = create_client(headers={'user-agent': get_userAgent()}).get(search_url) + response.raise_for_status() + + except Exception as e: + console.print(f"[red]Site: {site_constant.SITE_NAME}, request search error: {e}") + return 0 + + # Collect json data + try: + data = response.json().get('data') + except Exception as e: + console.log(f"Error parsing JSON response: {e}") + return 0 + + for dict_title in data: + try: + if dict_title.get('type') != 'showpage': + continue + + media_search_manager.add_media({ + 'name': dict_title.get('title'), + 'type': 'tv', + 'date': dict_title.get('dateLastModified').split('T')[0], + 'image': dict_title.get('image').get('url'), + 'url': f'https://public.aurora.enhanced.live/site/page/{str(dict_title.get("slug")).lower().replace(" ", "-")}/?include=default&filter[environment]=realtime&v=2&parent_slug={dict_title.get("parentSlug")}', + }) + + except Exception as e: + print(f"Error parsing a film entry: {e}") + + # Return the number of titles found + return media_search_manager.get_length() \ No newline at end of file diff --git a/StreamingCommunity/Api/Site/realtime/util/ScrapeSerie.py b/StreamingCommunity/Api/Site/realtime/util/ScrapeSerie.py new file mode 100644 index 000000000..4dab4400b --- /dev/null +++ b/StreamingCommunity/Api/Site/realtime/util/ScrapeSerie.py @@ -0,0 +1,164 @@ +# 26.11.2025 + +import logging + + +# Internal utilities +from StreamingCommunity.Util.headers import get_headers +from StreamingCommunity.Util.http_client import create_client +from StreamingCommunity.Api.Player.Helper.Vixcloud.util import SeasonManager + + +class GetSerieInfo: + def __init__(self, url): + """ + Initialize the GetSerieInfo class for scraping TV series information. + + Args: + - url (str): The URL of the streaming site. + """ + self.url = url + self.headers = get_headers() + self.series_name = None + self.seasons_manager = SeasonManager() + self.all_episodes = [] + + def collect_info_title(self) -> None: + """ + Retrieve general information about the TV series from the streaming site. + """ + try: + response = create_client(headers=self.headers).get(self.url) + response.raise_for_status() + + # Parse JSON response + json_response = response.json() + + # Extract episodes from blocks[1]['items'] + items = json_response.get('blocks', [{}])[1].get('items', []) + + if not items: + logging.warning("No episodes found in response") + return + + # Store all episodes + self.all_episodes = items + + # Get show title from first episode + if items: + first_episode = items[0] + show_info = first_episode.get('show', {}) + + # Set series_name if not provided + if self.series_name is None: + self.series_name = show_info.get('title', 'Unknown Series') + + self.title_info = { + 'id': show_info.get('id', ''), + 'title': show_info.get('title', 'Unknown Series') + } + + # Group episodes by season and build season structure + seasons_dict = {} + for episode in items: + season_num = episode.get('seasonNumber', 0) + + if season_num not in seasons_dict: + seasons_dict[season_num] = { + 'id': f"season-{season_num}", + 'number': season_num, + 'name': f"Season {season_num}", + 'slug': f"season-{season_num}", + } + + # Add seasons to SeasonManager (sorted by season number) + for season_num in sorted(seasons_dict.keys()): + self.seasons_manager.add_season(seasons_dict[season_num]) + + except Exception as e: + logging.error(f"Error collecting series info: {e}") + raise + + def collect_info_season(self, number_season: int) -> None: + """ + Retrieve episode information for a specific season. + + Args: + number_season (int): Season number to fetch episodes for + + Raises: + Exception: If there's an error fetching episode information + """ + try: + season = self.seasons_manager.get_season_by_number(number_season) + if not season: + logging.error(f"Season {number_season} not found") + return + + # Filter episodes for this specific season + season_episodes = [ + ep for ep in self.all_episodes + if ep.get('seasonNumber') == number_season + ] + + # Sort episodes by episode number in ascending order + season_episodes.sort(key=lambda x: x.get('episodeNumber', 0), reverse=False) + + # Transform episodes to match the expected format + for episode in season_episodes: + + # Convert duration from milliseconds to minutes + duration_ms = episode.get('videoDuration', 0) + duration_minutes = round(duration_ms / 1000 / 60) if duration_ms else 0 + + episode_data = { + 'id': episode.get('id'), + 'number': episode.get('episodeNumber'), + 'name': episode.get('title', f"Episode {episode.get('episodeNumber')}"), + 'description': episode.get('description', ''), + 'duration': duration_minutes, + 'poster': episode.get('poster', {}).get('src', ''), + } + + # Add episode to the season's episode manager + season.episodes.add(episode_data) + + except Exception as e: + logging.error(f"Error collecting episodes for season {number_season}: {e}") + raise + + # ------------- FOR GUI ------------- + def getNumberSeason(self) -> int: + """ + Get the total number of seasons available for the series. + """ + if not self.seasons_manager.seasons: + self.collect_info_title() + + return len(self.seasons_manager.seasons) + + def getEpisodeSeasons(self, season_number: int) -> list: + """ + Get all episodes for a specific season. + """ + season = self.seasons_manager.get_season_by_number(season_number) + + if not season: + logging.error(f"Season {season_number} not found") + return [] + + if not season.episodes.episodes: + self.collect_info_season(season_number) + + return season.episodes.episodes + + def selectEpisode(self, season_number: int, episode_index: int) -> dict: + """ + Get information for a specific episode in a specific season. + """ + episodes = self.getEpisodeSeasons(season_number) + if not episodes or episode_index < 0 or episode_index >= len(episodes): + logging.error(f"Episode index {episode_index} is out of range for season {season_number}") + return None + + return episodes[episode_index] \ No newline at end of file diff --git a/StreamingCommunity/Api/Site/realtime/util/get_license.py b/StreamingCommunity/Api/Site/realtime/util/get_license.py new file mode 100644 index 000000000..73967c03d --- /dev/null +++ b/StreamingCommunity/Api/Site/realtime/util/get_license.py @@ -0,0 +1,44 @@ +# 26.11.2025 + + +# Internal utilities +from StreamingCommunity.Util.headers import get_userAgent, get_headers +from StreamingCommunity.Util.http_client import create_client + + + +def get_playback_url(video_id: str, bearer_token: str, get_dash=False) -> str: + """ + Get the playback URL (HLS or DASH) for a given video ID. + + Parameters: + - video_id (str): ID of the video. + """ + headers = { + 'authorization': f'Bearer {bearer_token}', + 'user-agent': get_userAgent() + } + + json_data = { + 'videoId': video_id, + } + + response = create_client().post('https://public.aurora.enhanced.live/playback/v3/videoPlaybackInfo', headers=headers, json=json_data) + + if not get_dash: + return response.json()['data']['attributes']['streaming'][0]['url'] + else: + return response.json()['data']['attributes']['streaming'][1]['url'] + + +def get_bearer_token(): + """ + Get the Bearer token required for authentication. + + Returns: + str: Token Bearer + """ + response = create_client(headers=get_headers()).get('https://public.aurora.enhanced.live/site/page/homepage/?include=default&filter[environment]=realtime&v=2') + + # response.json()['userMeta']['realm']['X-REALM-DPLAY'] + return response.json()['userMeta']['realm']['X-REALM-IT'] \ No newline at end of file diff --git a/StreamingCommunity/Api/Site/streamingcommunity/__init__.py b/StreamingCommunity/Api/Site/streamingcommunity/__init__.py index 671082bf9..a19b705bf 100644 --- a/StreamingCommunity/Api/Site/streamingcommunity/__init__.py +++ b/StreamingCommunity/Api/Site/streamingcommunity/__init__.py @@ -70,6 +70,7 @@ def get_user_input(string_to_search: str = None): else: return msg.ask(f"\n[purple]Insert a word to search in [green]{site_constant.SITE_NAME}").strip() + def process_search_result(select_title, selections=None): """ Handles the search result and initiates the download for either a film or series. diff --git a/StreamingCommunity/Api/Site/streamingcommunity/site.py b/StreamingCommunity/Api/Site/streamingcommunity/site.py index f8d497662..82ffe682e 100644 --- a/StreamingCommunity/Api/Site/streamingcommunity/site.py +++ b/StreamingCommunity/Api/Site/streamingcommunity/site.py @@ -130,4 +130,4 @@ def title_search(query: str) -> int: bot.send_message("Lista dei risultati:", choices) # Return the number of titles found - return media_search_manager.get_length() + return media_search_manager.get_length() \ No newline at end of file diff --git a/StreamingCommunity/Api/Site/streamingwatch/__init__.py b/StreamingCommunity/Api/Site/streamingwatch/__init__.py index e9a6180c3..380c0d90d 100644 --- a/StreamingCommunity/Api/Site/streamingwatch/__init__.py +++ b/StreamingCommunity/Api/Site/streamingwatch/__init__.py @@ -71,6 +71,7 @@ def get_user_input(string_to_search: str = None): else: return msg.ask(f"\n[purple]Insert a word to search in [green]{site_constant.SITE_NAME}").strip() + def process_search_result(select_title, selections=None): """ Handles the search result and initiates the download for either a film or series. @@ -109,6 +110,7 @@ def process_search_result(select_title, selections=None): table_show_manager.clear() return True + def search(string_to_search: str = None, get_onlyDatabase: bool = False, direct_item: dict = None, selections: dict = None): """ Main function of the application for search. diff --git a/StreamingCommunity/Api/Template/Util/manage_ep.py b/StreamingCommunity/Api/Template/Util/manage_ep.py index 87baace7b..915553d5b 100644 --- a/StreamingCommunity/Api/Template/Util/manage_ep.py +++ b/StreamingCommunity/Api/Template/Util/manage_ep.py @@ -97,6 +97,10 @@ def manage_selection(cmd_insert: str, max_count: int) -> List[int]: list_selection = list(range(1, max_count + 1)) break + elif cmd_insert.lower() in ("q", "quit"): + console.print("\n[red]Quit ...") + sys.exit(0) + cmd_insert = msg.ask("[red]Invalid input. Please enter a valid command") logging.info(f"List return: {list_selection}") @@ -157,14 +161,11 @@ def validate_selection(list_season_select: List[int], seasons_count: int) -> Lis # If the list is empty, the input was completely invalid if not valid_seasons: - logging.error(f"Invalid selection: The selected seasons are outside the available range (1-{seasons_count}). Please try again.") - - # Re-prompt for valid input - input_seasons = input(f"Enter valid season numbers (1-{seasons_count}): ") + input_seasons = msg.ask(f"[red]Enter valid season numbers (1-{seasons_count})") list_season_select = list(map(int, input_seasons.split(','))) - continue # Re-prompt the user if the selection is invalid + continue - return valid_seasons # Return the valid seasons if the input is correct + return valid_seasons except ValueError: logging.error("Error: Please enter valid integers separated by commas.") @@ -229,8 +230,13 @@ def display_seasons_list(seasons_manager) -> str: table_show_manager = TVShowManager() # Check if 'type' and 'id' attributes exist in the first season - has_type = hasattr(seasons_manager.seasons[0], 'type') and (seasons_manager.seasons[0].type) is not None and str(seasons_manager.seasons[0].type) != '' - has_id = hasattr(seasons_manager.seasons[0], 'id') and (seasons_manager.seasons[0].id) is not None and str(seasons_manager.seasons[0].id) != '' + try: + has_type = hasattr(seasons_manager.seasons[0], 'type') and (seasons_manager.seasons[0].type) is not None and str(seasons_manager.seasons[0].type) != '' + has_id = hasattr(seasons_manager.seasons[0], 'id') and (seasons_manager.seasons[0].id) is not None and str(seasons_manager.seasons[0].id) != '' + + except IndexError: + has_type = False + has_id = False # Add columns to the table column_info = { diff --git a/StreamingCommunity/Lib/Downloader/DASH/cdm_helpher.py b/StreamingCommunity/Lib/Downloader/DASH/cdm_helpher.py index fe086e193..2113d21ac 100644 --- a/StreamingCommunity/Lib/Downloader/DASH/cdm_helpher.py +++ b/StreamingCommunity/Lib/Downloader/DASH/cdm_helpher.py @@ -29,6 +29,10 @@ def get_widevine_keys(pssh, license_url, cdm_device_path, headers=None, payload= Returns: list: List of dicts {'kid': ..., 'key': ...} (only CONTENT keys) or None if error. """ + if not cdm_device_path: + console.print("[bold red]Invalid CDM device path.[/bold red]") + return None + try: device = Device.load(cdm_device_path) cdm = Cdm.from_device(device) diff --git a/StreamingCommunity/Lib/Downloader/DASH/decrypt.py b/StreamingCommunity/Lib/Downloader/DASH/decrypt.py index 61369b9f9..924b15611 100644 --- a/StreamingCommunity/Lib/Downloader/DASH/decrypt.py +++ b/StreamingCommunity/Lib/Downloader/DASH/decrypt.py @@ -21,6 +21,8 @@ # Variable console = Console() extension_output = config_manager.get("M3U8_CONVERSION", "extension") +CLEANUP_TMP = config_manager.get_bool('M3U8_DOWNLOAD', 'cleanup_tmp_folder') +SHOW_DECRYPT_PROGRESS = False def decrypt_with_mp4decrypt(type, encrypted_path, kid, key, output_path=None, cleanup=True): @@ -62,61 +64,65 @@ def decrypt_with_mp4decrypt(type, encrypted_path, kid, key, output_path=None, cl cmd = [get_mp4decrypt_path(), "--key", key_format, encrypted_path, output_path] logging.info(f"Running command: {' '.join(cmd)}") - # Create progress bar with custom format - bar_format = ( - f"{Colors.YELLOW}DECRYPT{Colors.CYAN} {type}{Colors.WHITE}: " - f"{Colors.MAGENTA}{{bar:40}} " - f"{Colors.LIGHT_GREEN}{{n_fmt}}{Colors.WHITE}/{Colors.CYAN}{{total_fmt}} " - f"{Colors.DARK_GRAY}[{Colors.YELLOW}{{elapsed}}{Colors.WHITE} < {Colors.CYAN}{{remaining}}{Colors.DARK_GRAY}] " - f"{Colors.WHITE}{{postfix}}" - ) + progress_bar = None + monitor_thread = None - progress_bar = tqdm( - total=100, - bar_format=bar_format, - unit="", - ncols=150 - ) - - def monitor_output_file(): - """Monitor output file growth and update progress bar.""" - last_size = 0 - while True: - if os.path.exists(output_path): - current_size = os.path.getsize(output_path) - if current_size > 0: - progress_percent = min(int((current_size / file_size) * 100), 100) - progress_bar.n = progress_percent - progress_bar.refresh() - - if current_size == last_size and current_size > 0: - # File stopped growing, likely finished - break - - last_size = current_size - - time.sleep(0.1) - - # Start monitoring thread - monitor_thread = threading.Thread(target=monitor_output_file, daemon=True) - monitor_thread.start() + if SHOW_DECRYPT_PROGRESS: + bar_format = ( + f"{Colors.YELLOW}DECRYPT{Colors.CYAN} {type}{Colors.WHITE}: " + f"{Colors.MAGENTA}{{bar:40}} " + f"{Colors.LIGHT_GREEN}{{n_fmt}}{Colors.WHITE}/{Colors.CYAN}{{total_fmt}} " + f"{Colors.DARK_GRAY}[{Colors.YELLOW}{{elapsed}}{Colors.WHITE} < {Colors.CYAN}{{remaining}}{Colors.DARK_GRAY}] " + f"{Colors.WHITE}{{postfix}}" + ) + + progress_bar = tqdm( + total=100, + bar_format=bar_format, + unit="", + ncols=150 + ) + + def monitor_output_file(): + """Monitor output file growth and update progress bar.""" + last_size = 0 + while True: + if os.path.exists(output_path): + current_size = os.path.getsize(output_path) + if current_size > 0: + progress_percent = min(int((current_size / file_size) * 100), 100) + progress_bar.n = progress_percent + progress_bar.refresh() + + if current_size == last_size and current_size > 0: + # File stopped growing, likely finished + break + + last_size = current_size + + time.sleep(0.1) + + # Start monitoring thread + monitor_thread = threading.Thread(target=monitor_output_file, daemon=True) + monitor_thread.start() try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) except Exception as e: - progress_bar.close() + if progress_bar: + progress_bar.close() console.print(f"[bold red] mp4decrypt execution failed: {e}[/bold red]") return None - # Ensure progress bar reaches 100% - progress_bar.n = 100 - progress_bar.refresh() - progress_bar.close() + if progress_bar: + progress_bar.n = 100 + progress_bar.refresh() + progress_bar.close() if result.returncode == 0 and os.path.exists(output_path): - # Cleanup temporary files if requested - if cleanup: + # Cleanup temporary files + if cleanup and CLEANUP_TMP: if os.path.exists(encrypted_path): os.remove(encrypted_path) diff --git a/StreamingCommunity/Lib/Downloader/DASH/downloader.py b/StreamingCommunity/Lib/Downloader/DASH/downloader.py index 6531cf0d0..f45300b09 100644 --- a/StreamingCommunity/Lib/Downloader/DASH/downloader.py +++ b/StreamingCommunity/Lib/Downloader/DASH/downloader.py @@ -59,8 +59,11 @@ def __init__(self, license_url, mpd_url, mpd_sub_list: list = None, output_path: self.license_url = license_url self.mpd_url = mpd_url self.mpd_sub_list = mpd_sub_list or [] - self.out_path = os.path.splitext(os.path.abspath(os_manager.get_sanitize_path(output_path)))[0] - self.original_output_path = output_path + + # Sanitize the output path to remove invalid characters + sanitized_output_path = os_manager.get_sanitize_path(output_path) + self.out_path = os.path.splitext(os.path.abspath(sanitized_output_path))[0] + self.original_output_path = sanitized_output_path self.file_already_exists = os.path.exists(self.original_output_path) self.parser = None @@ -519,15 +522,23 @@ def finalize_output(self): use_shortest = False # Merge video and audio - if os.path.exists(video_file) and os.path.exists(audio_file): - audio_tracks = [{"path": audio_file}] - merged_file, use_shortest = join_audios(video_file, audio_tracks, output_file) - - elif os.path.exists(video_file): - merged_file = join_video(video_file, output_file, codec=None) - - else: - console.print("[red]Video file missing, cannot export[/red]") + merged_file = None + try: + if os.path.exists(video_file) and os.path.exists(audio_file): + audio_tracks = [{"path": audio_file}] + merged_file, use_shortest = join_audios(video_file, audio_tracks, output_file) + + elif os.path.exists(video_file): + merged_file = join_video(video_file, output_file, codec=None) + + else: + console.print("[red]Video file missing, cannot export[/red]") + self.error = "Video file missing, cannot export" + return None + + except Exception as e: + console.print(f"[red]Error during merge: {e}[/red]") + self.error = f"Merge failed: {e}" return None # Merge subtitles if available @@ -583,23 +594,27 @@ def finalize_output(self): console.print(f"[yellow]Output [red]{os.path.abspath(output_file)} [cyan]with size [red]{file_size} [cyan]and duration [red]{duration}") else: console.print(f"[red]Output file not found: {output_file}") + self.error = f"Output file not found: {output_file}" + return None - # Clean up: delete only the tmp directory, not the main directory - if os.path.exists(self.tmp_dir): - shutil.rmtree(self.tmp_dir, ignore_errors=True) + if CLEANUP_TMP: + + # Clean up: delete only the tmp directory, not the main directory + if os.path.exists(self.tmp_dir): + shutil.rmtree(self.tmp_dir, ignore_errors=True) - # Only remove the temp base directory if it was created specifically for this download - # and if the final output is NOT inside this directory - output_dir = os.path.dirname(self.original_output_path) - - # Check if out_path is different from the actual output directory - # and if it's empty, then it's safe to remove - if (self.out_path != output_dir and os.path.exists(self.out_path) and not os.listdir(self.out_path)): - try: - os.rmdir(self.out_path) + # Only remove the temp base directory if it was created specifically for this download + # and if the final output is NOT inside this directory + output_dir = os.path.dirname(self.original_output_path) + + # Check if out_path is different from the actual output directory + # and if it's empty, then it's safe to remove + if (self.out_path != output_dir and os.path.exists(self.out_path) and not os.listdir(self.out_path)): + try: + os.rmdir(self.out_path) - except Exception as e: - console.print(f"[red]Cannot remove directory {self.out_path}: {e}") + except Exception as e: + pass # Verify the final file exists before returning if os.path.exists(output_file): diff --git a/StreamingCommunity/Lib/Downloader/DASH/parser.py b/StreamingCommunity/Lib/Downloader/DASH/parser.py index 9299bdd52..c6783b1ba 100644 --- a/StreamingCommunity/Lib/Downloader/DASH/parser.py +++ b/StreamingCommunity/Lib/Downloader/DASH/parser.py @@ -138,7 +138,7 @@ class SegmentTimelineParser: def __init__(self, namespace: Dict[str, str]): self.ns = namespace - def parse(self, seg_timeline_element) -> Tuple[List[int], List[int]]: + def parse(self, seg_timeline_element, start_number: int = 1) -> Tuple[List[int], List[int]]: """ Parse SegmentTimeline and return (number_list, time_list) """ @@ -148,8 +148,8 @@ def parse(self, seg_timeline_element) -> Tuple[List[int], List[int]]: if seg_timeline_element is None: return number_list, time_list - current_time = None - start_number = 1 # Default start number + current_time = 0 + current_number = start_number for s_element in seg_timeline_element.findall('mpd:S', self.ns): d = s_element.get('d') @@ -157,19 +157,23 @@ def parse(self, seg_timeline_element) -> Tuple[List[int], List[int]]: continue d = int(d) - r = int(s_element.get('r', 0)) - - # Handle 't' attribute + + # Handle 't' attribute (explicit time) if s_element.get('t') is not None: current_time = int(s_element.get('t')) - elif current_time is None: - current_time = 0 - - # Append (r+1) times and numbers + + # Get repeat count (default 0 means 1 segment) + r = int(s_element.get('r', 0)) + + # Special case: r=-1 means repeat until end of Period + if r == -1: + r = 0 + + # Add (r+1) segments for i in range(r + 1): - number_list.append(start_number) + number_list.append(current_number) time_list.append(current_time) - start_number += 1 + current_number += 1 current_time += d return number_list, time_list @@ -183,6 +187,21 @@ def __init__(self, mpd_url: str, namespace: Dict[str, str]): self.ns = namespace self.timeline_parser = SegmentTimelineParser(namespace) + def _resolve_adaptation_base_url(self, adapt_set, initial_base: str) -> str: + """Resolve base URL at AdaptationSet level""" + base = initial_base + + # Check for BaseURL at AdaptationSet level + adapt_base = adapt_set.find('mpd:BaseURL', self.ns) + if adapt_base is not None and adapt_base.text: + base_text = adapt_base.text.strip() + if base_text.startswith('http'): + base = base_text + else: + base = urljoin(base, base_text) + + return base + def parse_adaptation_set(self, adapt_set, base_url: str) -> List[Dict[str, Any]]: """ Parse all representations in an adaptation set @@ -193,9 +212,16 @@ def parse_adaptation_set(self, adapt_set, base_url: str) -> List[Dict[str, Any]] # Find SegmentTemplate at AdaptationSet level adapt_seg_template = adapt_set.find('mpd:SegmentTemplate', self.ns) + + # Risolvi il BaseURL a livello di AdaptationSet + adapt_base_url = self._resolve_adaptation_base_url(adapt_set, base_url) for rep_element in adapt_set.findall('mpd:Representation', self.ns): - representation = self._parse_representation(rep_element, adapt_set, adapt_seg_template, base_url, mime_type, lang) + representation = self._parse_representation( + rep_element, adapt_set, adapt_seg_template, + adapt_base_url, + mime_type, lang + ) if representation: representations.append(representation) @@ -257,28 +283,14 @@ def _parse_representation(self, rep_element, adapt_set, adapt_seg_template, base } def _resolve_base_url(self, rep_element, adapt_set, initial_base: str) -> str: - """Resolve base URL by concatenating MPD -> Period/AdaptationSet -> Representation BaseURLs""" + """Resolve base URL at Representation level (AdaptationSet already resolved)""" base = initial_base - - # Adaptation-level BaseURL - if adapt_set is not None: - adapt_base = adapt_set.find('mpd:BaseURL', self.ns) - if adapt_base is not None and adapt_base.text: - base_text = adapt_base.text.strip() - - # Handle BaseURL that might already be absolute - if base_text.startswith('http'): - base = base_text - else: - base = urljoin(base, base_text) - - # Representation-level BaseURL + + # Representation-level BaseURL only if rep_element is not None: rep_base = rep_element.find('mpd:BaseURL', self.ns) if rep_base is not None and rep_base.text: base_text = rep_base.text.strip() - - # Handle BaseURL that might already be absolute if base_text.startswith('http'): base = base_text else: @@ -301,10 +313,12 @@ def _build_segment_urls(self, seg_tmpl, rep_id: str, bandwidth: str, base_url: s # Parse segment timeline seg_timeline = seg_tmpl.find('mpd:SegmentTimeline', self.ns) - number_list, time_list = self.timeline_parser.parse(seg_timeline) + number_list, time_list = self.timeline_parser.parse(seg_timeline, start_number) - if not number_list: + # Fallback solo se non c'è SegmentTimeline + if not number_list and not time_list: number_list = list(range(start_number, start_number + 100)) + time_list = [] # Build media URLs media_urls = self._build_media_urls(media, base_url, rep_id, bandwidth, number_list, time_list) @@ -341,6 +355,32 @@ def _build_media_urls(self, media_template: str, base_url: str, rep_id: str, ban class MPDParser: + @staticmethod + def _is_ad_period(period_id: str, base_url: str) -> bool: + """ + Detect if a Period is an advertisement or bumper. + Returns True if it's an ad, False if it's main content. + """ + ad_indicators = [ + '_ad/', # Generic ad marker in URL + 'ad_bumper', # Ad bumper + '/creative/', # Ad creative folder + '_OandO/', # Pluto TV bumpers + ] + + # Check BaseURL for ad indicators + for indicator in ad_indicators: + if indicator in base_url: + return True + + # Check Period ID for patterns + if period_id: + if '_subclip_' in period_id: + return False + # Short periods (< 60s) are usually ads/bumpers + + return False + @staticmethod def _deduplicate_videos(representations: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """ @@ -449,23 +489,11 @@ def parse(self, custom_headers: Dict[str, str]) -> None: def _fetch_and_parse_mpd(self, custom_headers: Dict[str, str]) -> None: """Fetch MPD content and parse XML""" - for attempt in range(max_retry + 1): - try: - response = requests.get( - self.mpd_url, headers=custom_headers, timeout=max_timeout, - allow_redirects=True, impersonate="chrome124" - ) - - response.raise_for_status() - logging.info(f"Successfully fetched MPD: {response.content}") - self.root = ET.fromstring(response.content) - break - - except Exception as e: - if attempt == max_retry: - raise e - - console.print(f"[bold yellow]Retrying manifest request ... ({attempt + 1}/{max_retry})[/bold yellow]") + response = requests.get(self.mpd_url, headers=custom_headers, timeout=max_timeout, impersonate="chrome124") + response.raise_for_status() + + logging.info(f"Successfully fetched MPD: {response.content}") + self.root = ET.fromstring(response.content) def _extract_namespace(self) -> None: """Extract and register namespaces from the root element""" @@ -475,21 +503,81 @@ def _extract_namespace(self) -> None: self.ns['cenc'] = 'urn:mpeg:cenc:2013' def _extract_pssh(self) -> None: - """Extract PSSH from ContentProtection elements""" + """Extract Widevine PSSH from ContentProtection elements""" + # Try to find Widevine PSSH first (preferred) + for protection in self.root.findall('.//mpd:ContentProtection', self.ns): + scheme_id = protection.get('schemeIdUri', '') + + # Check if this is Widevine ContentProtection + if 'edef8ba9-79d6-4ace-a3c8-27dcd51d21ed' in scheme_id: + pssh_element = protection.find('cenc:pssh', self.ns) + if pssh_element is not None and pssh_element.text: + self.pssh = pssh_element.text.strip() + return + + # Fallback: try any PSSH (for compatibility with other services) for protection in self.root.findall('.//mpd:ContentProtection', self.ns): pssh_element = protection.find('cenc:pssh', self.ns) if pssh_element is not None and pssh_element.text: - self.pssh = pssh_element.text - break + self.pssh = pssh_element.text.strip() + print(f"Found PSSH (fallback): {self.pssh}") + return + + def _get_period_base_url(self, period, initial_base: str) -> str: + """Get base URL at Period level""" + base = initial_base + + period_base = period.find('mpd:BaseURL', self.ns) + if period_base is not None and period_base.text: + base_text = period_base.text.strip() + if base_text.startswith('http'): + base = base_text + else: + base = urljoin(base, base_text) + + return base def _parse_representations(self) -> None: - """Parse all representations from the MPD""" + """Parse all representations from the MPD, filtering out ads and aggregating main content""" base_url = self._get_initial_base_url() representation_parser = RepresentationParser(self.mpd_url, self.ns) + + # Dictionary to aggregate representations by ID + rep_aggregator = {} + periods = self.root.findall('.//mpd:Period', self.ns) - for adapt_set in self.root.findall('.//mpd:AdaptationSet', self.ns): - representations = representation_parser.parse_adaptation_set(adapt_set, base_url) - self.representations.extend(representations) + for period_idx, period in enumerate(periods): + period_id = period.get('id', f'period_{period_idx}') + period_base_url = self._get_period_base_url(period, base_url) + + # CHECK IF THIS IS AN AD PERIOD + is_ad = self._is_ad_period(period_id, period_base_url) + + # Skip ad periods + if is_ad: + continue + + for adapt_set in period.findall('mpd:AdaptationSet', self.ns): + representations = representation_parser.parse_adaptation_set(adapt_set, period_base_url) + + for rep in representations: + rep_id = rep['id'] + + if rep_id not in rep_aggregator: + rep_aggregator[rep_id] = rep + #print(f" ✨ New Rep {rep_id} ({rep['type']}): {len(rep['segment_urls'])} segments") + else: + existing = rep_aggregator[rep_id] + + # Concatenate segment URLs + if rep['segment_urls']: + existing['segment_urls'].extend(rep['segment_urls']) + # Update init_url only if it wasn't set before + if not existing['init_url'] and rep['init_url']: + existing['init_url'] = rep['init_url'] + + # Convert aggregated dict back to list + self.representations = list(rep_aggregator.values()) def _deduplicate_representations(self) -> None: """Remove duplicate video and audio representations""" diff --git a/StreamingCommunity/Lib/Downloader/DASH/segments.py b/StreamingCommunity/Lib/Downloader/DASH/segments.py index 1a4e4578d..f0b8118a1 100644 --- a/StreamingCommunity/Lib/Downloader/DASH/segments.py +++ b/StreamingCommunity/Lib/Downloader/DASH/segments.py @@ -26,6 +26,7 @@ SEGMENT_MAX_TIMEOUT = config_manager.get_int("M3U8_DOWNLOAD", "segment_timeout") LIMIT_SEGMENT = config_manager.get_int('M3U8_DOWNLOAD', 'limit_segment') ENABLE_RETRY = config_manager.get_bool('M3U8_DOWNLOAD', 'enable_retry') +CLEANUP_TMP = config_manager.get_bool('M3U8_DOWNLOAD', 'cleanup_tmp_folder') # Variable @@ -456,7 +457,7 @@ def _cleanup_resources(self, temp_dir, progress_bar: tqdm) -> None: progress_bar.close() # Delete temp segment files - if temp_dir and os.path.exists(temp_dir): + if CLEANUP_TMP and temp_dir and os.path.exists(temp_dir): try: for idx in range(len(self.selected_representation.get('segment_urls', []))): temp_file = os.path.join(temp_dir, f"seg_{idx:06d}.tmp") @@ -465,8 +466,8 @@ def _cleanup_resources(self, temp_dir, progress_bar: tqdm) -> None: os.rmdir(temp_dir) except Exception as e: - print(f"[yellow]Warning: Could not clean temp directory: {e}") - + console.print(f"[yellow]Warning: Could not clean temp directory: {e}") + if getattr(self, 'info_nFailed', 0) > 0: self._display_error_summary() diff --git a/StreamingCommunity/Upload/version.py b/StreamingCommunity/Upload/version.py index 388030695..275f353a8 100644 --- a/StreamingCommunity/Upload/version.py +++ b/StreamingCommunity/Upload/version.py @@ -1,5 +1,5 @@ __title__ = 'StreamingCommunity' -__version__ = '3.4.5' +__version__ = '3.4.6' __author__ = 'Arrowar' __description__ = 'A command-line program to download film' __copyright__ = 'Copyright 2025' diff --git a/config.json b/config.json index a3283e10d..0e4d8d5a2 100644 --- a/config.json +++ b/config.json @@ -60,4 +60,4 @@ "etp_rt": "" } } -} +} \ No newline at end of file